mirror of
https://github.com/diangogav/EDOpro-server-ts
synced 2026-08-24 00:23:05 -04:00
Compare commits
103 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
447d93df48 | ||
|
|
6e7baf2cc7 | ||
|
|
46e353c588 | ||
|
|
c028fcd495 | ||
|
|
21f81774c9 | ||
|
|
07b5f4d06a | ||
|
|
83e8cd5a73 | ||
|
|
7462eee8bf | ||
|
|
7a801b141d | ||
|
|
8a2a43050d | ||
|
|
200aec0be2 | ||
|
|
d52dfd7408 | ||
|
|
4f706da2e0 | ||
|
|
7850081571 | ||
|
|
3afaec3f92 | ||
|
|
e2054d15bb | ||
|
|
2fa9923eb3 | ||
|
|
816f30513e | ||
|
|
aa7c96bd46 | ||
|
|
0337addef3 | ||
|
|
2385260d7a | ||
|
|
a7308204b4 | ||
|
|
a720913390 | ||
|
|
98d5ff0e17 | ||
|
|
b3b51e5bc8 | ||
|
|
224f140706 | ||
|
|
9013963d58 | ||
|
|
178107edc8 | ||
|
|
a2dc475169 | ||
|
|
69a7b52b3e | ||
|
|
050ae582e9 | ||
|
|
afa8110777 | ||
|
|
29809f1453 | ||
|
|
f63602b80b | ||
|
|
5af3b416eb | ||
|
|
c8f82ff311 | ||
|
|
02de10d33b | ||
|
|
3668a51436 | ||
|
|
6aad541835 | ||
|
|
a73fc849a2 | ||
|
|
e9e225aa0b | ||
|
|
2ce758b755 | ||
|
|
d7d222e229 | ||
|
|
b8bbc32ab6 | ||
|
|
52279ffe28 | ||
|
|
d527fee620 | ||
|
|
58ccaf2061 | ||
|
|
d708d2be9c | ||
|
|
2477327141 | ||
|
|
b9dc5a27a9 | ||
|
|
a52c6c2ce4 | ||
|
|
69176dfc51 | ||
|
|
6cb347c9d9 | ||
|
|
dba554971c | ||
|
|
c31cd170d4 | ||
|
|
d425ad9780 | ||
|
|
b549c59af0 | ||
|
|
e64e2c0814 | ||
|
|
b2502b71cd | ||
|
|
a90c12fb16 | ||
|
|
45c7d7810e | ||
|
|
01bf460fb2 | ||
|
|
c1d7cb6c0d | ||
|
|
c5357a8c39 | ||
|
|
afb8b1b8e8 | ||
|
|
ff347973df | ||
|
|
cba2f95d24 | ||
|
|
bfb096b4eb | ||
|
|
a8c26c0cdb | ||
|
|
95369ac557 | ||
|
|
3e2a083610 | ||
|
|
ff44e400d6 | ||
|
|
41a6e7e16b | ||
|
|
9dc33e811e | ||
|
|
77aae5da68 | ||
|
|
61d4f8f51c | ||
|
|
7822d6af19 | ||
|
|
ec1aa86a56 | ||
|
|
62b7e697b8 | ||
|
|
9f3e0385a0 | ||
|
|
729e105aab | ||
|
|
0e6f7335e4 | ||
|
|
a2f3f7243b | ||
|
|
6a9be1ffa0 | ||
|
|
b248bbd790 | ||
|
|
a4d3d1f15b | ||
|
|
47c6fc716e | ||
|
|
301282d409 | ||
|
|
b5fc9a151e | ||
|
|
8b8b026f33 | ||
|
|
d3051549da | ||
|
|
9eac87c8b4 | ||
|
|
2a94ae1c48 | ||
|
|
19ea97d78f | ||
|
|
c2aac4021a | ||
|
|
8380879611 | ||
|
|
f86d185b6a | ||
|
|
052a050d27 | ||
|
|
d4d000ea9b | ||
|
|
cce58150be | ||
|
|
ddecdbfb9a | ||
|
|
24378a793e | ||
|
|
b7abb6ffaa |
455 changed files with 45076 additions and 28360 deletions
|
|
@ -61,8 +61,9 @@ mercury/pre-releases/tcg/*
|
|||
# Git Submodules & Repositories
|
||||
*repositories/
|
||||
|
||||
# Scripts
|
||||
# Scripts — exclude the directory but re-include the .sh files for the build
|
||||
scripts/
|
||||
!scripts/*.sh
|
||||
|
||||
# Miscellaneous Directories
|
||||
core/dependencies/
|
||||
|
|
@ -75,5 +76,11 @@ Dockerfile
|
|||
docker-compose*.yaml
|
||||
README.md
|
||||
CHANGELOG.md
|
||||
CONTRIBUTING.md
|
||||
CODE_OF_CONDUCT.md
|
||||
.github/CONTRIBUTING.md
|
||||
.github/CODE_OF_CONDUCT.md
|
||||
|
||||
# Private manifest override — NOT baked; provided at runtime via a volume mount.
|
||||
# Keeping it out of the build context makes the seed public-only (no token at build).
|
||||
resources.manifest.private.json
|
||||
# generated by the private-override merge (resources-lib.sh)
|
||||
resources.manifest.effective.json
|
||||
|
|
|
|||
13
.editorconfig
Normal file
13
.editorconfig
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
root = true
|
||||
|
||||
[*]
|
||||
charset = utf-8
|
||||
end_of_line = lf
|
||||
insert_final_newline = true
|
||||
trim_trailing_whitespace = true
|
||||
indent_style = tab
|
||||
indent_size = 2
|
||||
|
||||
# Markdown: trailing spaces are significant (line breaks)
|
||||
[*.md]
|
||||
trim_trailing_whitespace = false
|
||||
|
|
@ -5,10 +5,9 @@
|
|||
WEBSOCKET_PORT=4000
|
||||
|
||||
# ── YGOPro Card Resources ──
|
||||
YGOPRO_FOLDERS=./resources/ygopro/base,./resources/ygopro/ocg,./resources/ygopro/a
|
||||
lternatives
|
||||
YGOPRO_EXTRA_DB_FOLDERS=./resources/ygopro/prereleases-cdb,./resources/ygopro/card
|
||||
s-art
|
||||
# Resources live under resources/current (a symlink to the active release,
|
||||
# maintained by setup_resources.sh / the in-container refresh loop).
|
||||
RESOURCES_DIR=./resources/current
|
||||
|
||||
# ── Database ──
|
||||
POSTGRES_HOST=localhost
|
||||
|
|
|
|||
|
|
@ -1,2 +0,0 @@
|
|||
# Resources (external git repos)
|
||||
resources/
|
||||
8
.git-blame-ignore-revs
Normal file
8
.git-blame-ignore-revs
Normal file
|
|
@ -0,0 +1,8 @@
|
|||
# Bulk reformat with Biome (2-space indentation -> tabs).
|
||||
# Squash commit of the ESLint+Prettier -> Biome migration (#283): most of
|
||||
# its diff is the mechanical reflow, safe to ignore for `git blame`.
|
||||
#
|
||||
# Enable locally with:
|
||||
# git config blame.ignoreRevsFile .git-blame-ignore-revs
|
||||
# GitHub applies this file automatically.
|
||||
b8bbc32ab6fd89f1ab955b6426cd13cd1095a772
|
||||
6
CONTRIBUTING.md → .github/CONTRIBUTING.md
vendored
6
CONTRIBUTING.md → .github/CONTRIBUTING.md
vendored
|
|
@ -8,14 +8,14 @@ Thank you for your interest in contributing to Evolution Server! Your help is we
|
|||
2. **Describe your changes** clearly in your Pull Request (PR).
|
||||
3. **Link related issues** in your PR description if applicable.
|
||||
4. **Ensure your code passes linting** by running `npm run lint` before submitting.
|
||||
5. **Write clear, maintainable code** following the TypeScript and ESLint (Codely) standards used in this project.
|
||||
6. **Add or update tests** if your changes affect logic or features.
|
||||
5. **Write clear, maintainable code** following the TypeScript and Biome standards used in this project.
|
||||
6. **Add or update tests** if your changes affect logic or features. Follow the [testing conventions](./docs/testing.md).
|
||||
7. **Update documentation** if your changes require it.
|
||||
|
||||
## Code Style
|
||||
|
||||
- Use TypeScript for all new code.
|
||||
- Follow the ESLint rules defined in `.eslintrc.js` (Codely TypeScript config).
|
||||
- Follow the Biome rules defined in `biome.json`. Run `npm run lint:fix` (lint + format) before submitting.
|
||||
- Use clear, descriptive variable and function names.
|
||||
- Keep functions and files focused and modular.
|
||||
- Prefer immutability and pure functions when possible.
|
||||
28
.github/workflows/pipeline.yaml
vendored
28
.github/workflows/pipeline.yaml
vendored
|
|
@ -11,7 +11,7 @@ jobs:
|
|||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
|
@ -19,18 +19,36 @@ jobs:
|
|||
- name: Update submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- uses: actions/setup-node@v3
|
||||
- uses: actions/setup-node@v6
|
||||
with:
|
||||
node-version: 22
|
||||
node-version: 24
|
||||
|
||||
- name: Install dependencies
|
||||
run: npm ci
|
||||
|
||||
- name: Lint
|
||||
run: npm run lint
|
||||
- name: Lint & format check
|
||||
run: npx biome ci
|
||||
|
||||
- name: Build
|
||||
run: npm run build
|
||||
|
||||
- name: Test
|
||||
run: npm test
|
||||
|
||||
bash-resources-tests:
|
||||
runs-on: ubuntu-latest
|
||||
|
||||
steps:
|
||||
- uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
submodules: true
|
||||
|
||||
- name: Update submodules
|
||||
run: git submodule update --init --recursive
|
||||
|
||||
- name: Install jq and bats
|
||||
run: sudo apt-get update && sudo apt-get install -y jq bats
|
||||
|
||||
- name: Run bash resource tests
|
||||
run: bats test/*.bats
|
||||
|
|
|
|||
2
.github/workflows/release-please.yaml
vendored
2
.github/workflows/release-please.yaml
vendored
|
|
@ -19,7 +19,7 @@ jobs:
|
|||
release-type: node
|
||||
- name: Checkout 🛬
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
- name: Tag major and minor versions 🏷
|
||||
if: ${{ steps.release.outputs.release_created }}
|
||||
run: |
|
||||
|
|
|
|||
15
.gitignore
vendored
15
.gitignore
vendored
|
|
@ -25,6 +25,9 @@ postgres_data/
|
|||
# Project configuration
|
||||
*.conf
|
||||
|
||||
# Agent tooling (local skill-registry cache)
|
||||
.atl/
|
||||
|
||||
# VS Code & IDEs
|
||||
.vscode/
|
||||
.idea/
|
||||
|
|
@ -63,4 +66,14 @@ dependencies/
|
|||
build/
|
||||
|
||||
mycard
|
||||
resources/
|
||||
resources/
|
||||
|
||||
# Local bats-core installation (not a repo dependency)
|
||||
tools/
|
||||
resources.manifest.private.json
|
||||
resources.manifest.effective.json
|
||||
resources.manifest.private.json.*
|
||||
|
||||
# Local session + fetched core artifact (not versioned)
|
||||
.claude/
|
||||
/ocgcore-worker
|
||||
|
|
|
|||
33
AGENTS.md
33
AGENTS.md
|
|
@ -26,7 +26,7 @@ As an AI agent, you must strictly adhere to these rules. **Violation of these ru
|
|||
|
||||
- **DDD is Mandatory**: Business logic lives in `domain/`. Never import `infrastructure/` or `application/`. Dependencies point inward.
|
||||
- **Hexagonal Architecture**: Dependencies point inward. The core doesn't know about the database or sockets.
|
||||
- **Mother Pattern**: ALWAYS use `*Mother` classes (Object Factories) for test data. Never manually instantiate complex entities in tests.
|
||||
- **Test data**: Use `*Mother` classes for shared domain entities; local `make*` factories for suite-specific stubs. Tests are **co-located** in `src/` next to their source. See [testing conventions](./docs/testing.md).
|
||||
- **Chain of Responsibility**: Use this pattern for complex validations (e.g., Deck Rules).
|
||||
- **Dependency Injection**: Use `diod` or constructor injection to manage dependencies.
|
||||
|
||||
|
|
@ -62,7 +62,7 @@ When performing these actions, **ALWAYS** follow the corresponding Standard Oper
|
|||
| Action | Skill / SOP |
|
||||
| --------------------------------------------------- | ----------------------------------------------- |
|
||||
| "Implement feature...", "Create use case..." | **[SOP-001] Feature Implementation (DDD)** |
|
||||
| "Write tests...", "Fix bug...", "Add unit test" | **[SOP-002] Testing Strategy (Mother Pattern)** |
|
||||
| "Write tests...", "Fix bug...", "Add unit test" | **[SOP-002] Testing Strategy** |
|
||||
| "Add field to DB", "Update schema", "New entity" | **[SOP-003] Database Migration** |
|
||||
| "Create new module", "New architecture component" | **[SOP-004] Module Creation** |
|
||||
| "Fix complex type error", "Optimize TS build" | **typescript-expert** |
|
||||
|
|
@ -91,38 +91,35 @@ When performing these actions, **ALWAYS** follow the corresponding Standard Oper
|
|||
4. **Interface Layer**:
|
||||
- Expose via Controller (HTTP) or Event Handler (Socket).
|
||||
|
||||
### [SOP-002] Testing Strategy (Mother Pattern)
|
||||
### [SOP-002] Testing Strategy
|
||||
|
||||
**Goal**: Ensure robust testing using consistent data factories.
|
||||
**Goal**: Consistent, co-located tests. Full conventions in [docs/testing.md](./docs/testing.md).
|
||||
|
||||
1. **Locate/Create Mother**:
|
||||
- Check `tests/modules/[module]/domain/[Entity]Mother.ts`.
|
||||
- If missing, create it using `@faker-js/faker`.
|
||||
- _Template_:
|
||||
1. **Co-locate the test**: create `[Thing].test.ts` next to `src/[module]/[Thing].ts`. Do **not** add to the root `tests/` folder (legacy, being migrated out).
|
||||
2. **Build test data**:
|
||||
- Shared domain entities → use/create a `*Mother` (static `create(overrides?)`, faker defaults):
|
||||
```typescript
|
||||
export class UserMother {
|
||||
static create(overrides?: Partial<UserPrimitives>): User {
|
||||
const primitives = {
|
||||
return User.fromPrimitives({
|
||||
id: UuidMother.create(),
|
||||
name: faker.person.firstName(),
|
||||
...overrides,
|
||||
};
|
||||
return User.fromPrimitives(primitives);
|
||||
});
|
||||
}
|
||||
}
|
||||
```
|
||||
2. **Write Test Spec**:
|
||||
- Create `[UseCase].test.ts` in `tests/modules/[module]/application/`.
|
||||
- **Rule**: Describe the test case in **Spanish**.
|
||||
- _Template_:
|
||||
- Suite-local stubs (fake repo/provider/socket) → inline `make*` factory with an `overrides` param.
|
||||
3. **Naming**: `describe` the unit, `it` the behavior in **English**, present tense, no "should":
|
||||
```typescript
|
||||
describe("CrearSala", () => {
|
||||
it("debe permitir crear una sala pública correctamente", async () => {
|
||||
describe("CreateRoom", () => {
|
||||
it("creates a public room", async () => {
|
||||
const user = UserMother.create();
|
||||
// ...
|
||||
});
|
||||
});
|
||||
```
|
||||
4. **Mocks**: shared Mock classes for infra (Logger/Socket/MessageRepository), `jest.mock()` for module singletons, `mock<T>()` (jest-mock-extended) for one-off interfaces. Reset singletons in `afterEach`.
|
||||
|
||||
### [SOP-003] Database Migration
|
||||
|
||||
|
|
@ -146,8 +143,8 @@ When performing these actions, **ALWAYS** follow the corresponding Standard Oper
|
|||
1. **Structure**:
|
||||
```bash
|
||||
mkdir -p src/new-module/{domain,application,infrastructure}
|
||||
mkdir -p tests/modules/new-module
|
||||
```
|
||||
Tests are co-located next to their source inside these folders — no separate `tests/` tree.
|
||||
2. **Registration**:
|
||||
- Register new entities in TypeORM config.
|
||||
- Register new controllers/handlers in the dependency injection container (`diod`).
|
||||
|
|
|
|||
78
CHANGELOG.md
78
CHANGELOG.md
|
|
@ -1,5 +1,83 @@
|
|||
# Changelog
|
||||
|
||||
## [2.14.0](https://github.com/diangogav/EDOpro-server-ts/compare/v2.13.2...v2.14.0) (2026-08-05)
|
||||
|
||||
|
||||
### Features
|
||||
|
||||
* **admission:** add pure admission domain + credential resolver ([#269](https://github.com/diangogav/EDOpro-server-ts/issues/269)) ([e64e2c0](https://github.com/diangogav/EDOpro-server-ts/commit/e64e2c08144cc7a9ee2a0301f8ece89990048f70))
|
||||
* **admission:** close spectator->player escalation ([#272](https://github.com/diangogav/EDOpro-server-ts/issues/272)) ([c31cd17](https://github.com/diangogav/EDOpro-server-ts/commit/c31cd170d4a847332fbdb5eb46285a5510f382b0))
|
||||
* **admission:** determine room league + AdmitToRoom use case ([#270](https://github.com/diangogav/EDOpro-server-ts/issues/270)) ([b549c59](https://github.com/diangogav/EDOpro-server-ts/commit/b549c59af0bc244549501a863f2687e3a1f8a78e))
|
||||
* **admission:** let verified players sit in External rooms (one-way cross-league) ([#277](https://github.com/diangogav/EDOpro-server-ts/issues/277)) ([b9dc5a2](https://github.com/diangogav/EDOpro-server-ts/commit/b9dc5a27a9c2df568daedd39de5b48ff31fd7bd0))
|
||||
* **admission:** tell PIN users their credentials are invalid on rejection ([#279](https://github.com/diangogav/EDOpro-server-ts/issues/279)) ([2477327](https://github.com/diangogav/EDOpro-server-ts/commit/24773271412f254d25996acb4de0b10d6d881266))
|
||||
* **admission:** wire AdmitToRoom into the join flow ([#271](https://github.com/diangogav/EDOpro-server-ts/issues/271)) ([d425ad9](https://github.com/diangogav/EDOpro-server-ts/commit/d425ad9780d3598c5dd177195bb458e060e73fab))
|
||||
* **bootstrap:** hot-reload ban lists without a server restart ([#306](https://github.com/diangogav/EDOpro-server-ts/issues/306)) ([816f305](https://github.com/diangogav/EDOpro-server-ts/commit/816f30513e2420be825cd138c8da0ae3038d2dd6))
|
||||
* **botlist:** add Yugi to the botlist example configuration ([afb8b1b](https://github.com/diangogav/EDOpro-server-ts/commit/afb8b1b8e810496b06c763fd3e63742b86496bdd))
|
||||
* **card:** refresh the inspection index on a TTL ([#295](https://github.com/diangogav/EDOpro-server-ts/issues/295)) ([#299](https://github.com/diangogav/EDOpro-server-ts/issues/299)) ([a2dc475](https://github.com/diangogav/EDOpro-server-ts/commit/a2dc475169c7b452156437f456a14c3d63df8ec6))
|
||||
* **disconnect:** implement hasNoConnectedPlayers check and refactor cleanup logic ([c5357a8](https://github.com/diangogav/EDOpro-server-ts/commit/c5357a8c3943f4068fdf922c2fe547961184f31d))
|
||||
* **edison:** MR1 (2010) format — forked core, pre-errata pool, resource pipeline ([#316](https://github.com/diangogav/EDOpro-server-ts/issues/316)) ([83e8cd5](https://github.com/diangogav/EDOpro-server-ts/commit/83e8cd5a73e0129a41a77731daca752c63bf04b0))
|
||||
* **emotes:** relay a dedicated emote opcode to the room ([#313](https://github.com/diangogav/EDOpro-server-ts/issues/313)) ([8a2a430](https://github.com/diangogav/EDOpro-server-ts/commit/8a2a43050d623536b5ac64269a003ec7e093e0cd))
|
||||
* **genesys:** add new cards and adjust points for existing cards ([c1d7cb6](https://github.com/diangogav/EDOpro-server-ts/commit/c1d7cb6c0d03e1b05b6585c117ff8430c0ffc21e))
|
||||
* **genesys:** add new cards with unique codes and point values ([b7abb6f](https://github.com/diangogav/EDOpro-server-ts/commit/b7abb6ffaa2c4d8b5d049325ac8f55fdffd8312c))
|
||||
* **genesys:** move point list to lflist.conf, validate on both paths ([#303](https://github.com/diangogav/EDOpro-server-ts/issues/303)) ([b3b51e5](https://github.com/diangogav/EDOpro-server-ts/commit/b3b51e5bc8ac99bf1c8ae4bc0fdbcbe748c0c2f3))
|
||||
* **genesys:** show point list with card costs in inspect view ([2385260](https://github.com/diangogav/EDOpro-server-ts/commit/2385260d7a2ca0251c04ab98c64584f4bb04658e))
|
||||
* hot-reload the EDOPro card DB via atomic datasource swap ([#297](https://github.com/diangogav/EDOpro-server-ts/issues/297)) ([050ae58](https://github.com/diangogav/EDOpro-server-ts/commit/050ae582e9270aa740fd348a06621514fdaf6449))
|
||||
* **lobby:** expose room league in the lobby DTO and broadcast ([#274](https://github.com/diangogav/EDOpro-server-ts/issues/274)) ([6cb347c](https://github.com/diangogav/EDOpro-server-ts/commit/6cb347c9d989dddcf4f2fc99f3c3dd0e2b651ab5))
|
||||
* **matchmaking:** auto-pairing queue with ranked + windbot fallback ([#308](https://github.com/diangogav/EDOpro-server-ts/issues/308)) ([e2054d1](https://github.com/diangogav/EDOpro-server-ts/commit/e2054d15bb9ae0b474751f499574d28f193ed1f8))
|
||||
* **matchmaking:** multi-format queue with per-format bot roster (tcg, jtp) ([#309](https://github.com/diangogav/EDOpro-server-ts/issues/309)) ([3afaec3](https://github.com/diangogav/EDOpro-server-ts/commit/3afaec3f92a20ac898fe3d54de720881f63e2c81))
|
||||
* **matchmaking:** open the ranked TCG room pool via the tt token ([#311](https://github.com/diangogav/EDOpro-server-ts/issues/311)) ([4f706da](https://github.com/diangogav/EDOpro-server-ts/commit/4f706da2e006aa88b0bcf7022ef21506c6d224b8))
|
||||
* **matchmaking:** ranked human pairs play best-of-3 matches ([#314](https://github.com/diangogav/EDOpro-server-ts/issues/314)) ([200aec0](https://github.com/diangogav/EDOpro-server-ts/commit/200aec0be2123b5b0c3d81cca241a1d5910fde8e))
|
||||
* public card & banlist inspection page ([#294](https://github.com/diangogav/EDOpro-server-ts/issues/294)) ([29809f1](https://github.com/diangogav/EDOpro-server-ts/commit/29809f145301e442d599c646859774168948f88c))
|
||||
* **ranked-join:** add RankedUserResolver for ticket-first credential resolution ([729e105](https://github.com/diangogav/EDOpro-server-ts/commit/729e105aab8282c1213f575a23bbaebfdae9f222))
|
||||
* **ranked:** add RankedUserResolver with ticket-first identity resolution ([#255](https://github.com/diangogav/EDOpro-server-ts/issues/255)) ([729e105](https://github.com/diangogav/EDOpro-server-ts/commit/729e105aab8282c1213f575a23bbaebfdae9f222))
|
||||
* **ranked:** wire ticket-authenticated ranked join end-to-end ([#256](https://github.com/diangogav/EDOpro-server-ts/issues/256)) ([9f3e038](https://github.com/diangogav/EDOpro-server-ts/commit/9f3e0385a0be3b67fab9d05ad8da634ab18c7db8))
|
||||
* **reconnect:** add reusable token reconnection layer in shared ([01bf460](https://github.com/diangogav/EDOpro-server-ts/commit/01bf460fb27a191983d6927ec792c9e92be28ada))
|
||||
* **reconnect:** harden mid-duel reconnect against identity hijack ([#273](https://github.com/diangogav/EDOpro-server-ts/issues/273)) ([dba5549](https://github.com/diangogav/EDOpro-server-ts/commit/dba554971c9d75ec471cbc9267e8d1b06596e715))
|
||||
* **replay:** add STOC_EVRP_EXPORT 0xF0, EvrpSerializer, and omniscient broadcast ([#267](https://github.com/diangogav/EDOpro-server-ts/issues/267)) ([95369ac](https://github.com/diangogav/EDOpro-server-ts/commit/95369ac5577bf114e3fce50969e3ad5481501fd6))
|
||||
* **resources:** declarative manifest-driven cdb/banlist sources + ygopro pool derivation ([#305](https://github.com/diangogav/EDOpro-server-ts/issues/305)) ([aa7c96b](https://github.com/diangogav/EDOpro-server-ts/commit/aa7c96bd46b76f03a051fdf410feb2b03209644d))
|
||||
* **room:** support casual token and expose ranked flag in room list ([cba2f95](https://github.com/diangogav/EDOpro-server-ts/commit/cba2f95d2447047abb32c42e9db2738ace9105d8))
|
||||
* **rule-mappings:** add support for JTP Advanced March 2007 format ([7462eee](https://github.com/diangogav/EDOpro-server-ts/commit/7462eee8bfc9c861df0bca0f7b679eef33e1c7a5))
|
||||
* runtime-updatable resources (releases/current) with in-container refresh ([#296](https://github.com/diangogav/EDOpro-server-ts/issues/296)) ([afa8110](https://github.com/diangogav/EDOpro-server-ts/commit/afa81107776b7708892cf65d9daf2c146bc1bc68))
|
||||
* **startup:** structured logs, Redis connection observability and fail-closed consume ([#258](https://github.com/diangogav/EDOpro-server-ts/issues/258)) ([ec1aa86](https://github.com/diangogav/EDOpro-server-ts/commit/ec1aa86a564c649e4518d8ff78db8fddcf5b1145))
|
||||
* **ticket:** add socket resolvedUserId field and single-use ticket repository ([#253](https://github.com/diangogav/EDOpro-server-ts/issues/253)) ([a2f3f72](https://github.com/diangogav/EDOpro-server-ts/commit/a2f3f7243b7f852cf88dc1a66c353e42a0457877))
|
||||
* **ticket:** log consume rejections for ranked-auth observability ([#264](https://github.com/diangogav/EDOpro-server-ts/issues/264)) ([ff44e40](https://github.com/diangogav/EDOpro-server-ts/commit/ff44e400d6d12b2b7b7e1a19e92ee91bb8a0f27a))
|
||||
* **version:** add GET /api/resources/version endpoint ([#307](https://github.com/diangogav/EDOpro-server-ts/issues/307)) ([2fa9923](https://github.com/diangogav/EDOpro-server-ts/commit/2fa9923eb3abd2e7eff6ed8938c7a58dcfc6253e))
|
||||
* **windbot:** shorten TCG botlist names to fit the join wire budget ([#312](https://github.com/diangogav/EDOpro-server-ts/issues/312)) ([d52dfd7](https://github.com/diangogav/EDOpro-server-ts/commit/d52dfd7408f02e61426edfc08f5f2ad4c7a82a4f))
|
||||
* **ws:** accept game-ticket via ?ticket= query param on the WS handshake ([#259](https://github.com/diangogav/EDOpro-server-ts/issues/259)) ([7822d6a](https://github.com/diangogav/EDOpro-server-ts/commit/7822d6af195977ec14871463d1247e0212c63fdc))
|
||||
* **ws:** validate single-use ticket on the WebSocket handshake ([#254](https://github.com/diangogav/EDOpro-server-ts/issues/254)) ([0e6f733](https://github.com/diangogav/EDOpro-server-ts/commit/0e6f7335e4d811e193b1408330a6772f3335936b))
|
||||
* **ygopro/windbot:** add provider use cases, botlist repository and domain types ([#244](https://github.com/diangogav/EDOpro-server-ts/issues/244)) ([8380879](https://github.com/diangogav/EDOpro-server-ts/commit/83808796118e5f0588b0603e5d8a9297349937ea))
|
||||
* **ygopro/windbot:** add WindbotTokenStore for bot reverse-connection auth ([#242](https://github.com/diangogav/EDOpro-server-ts/issues/242)) ([ddecdbf](https://github.com/diangogav/EDOpro-server-ts/commit/ddecdbfb9ad202b27369111b6344b8363c58afad))
|
||||
* **ygopro:** add isInternal flag and deck-check bypass for bot clients ([#243](https://github.com/diangogav/EDOpro-server-ts/issues/243)) ([cce5815](https://github.com/diangogav/EDOpro-server-ts/commit/cce58150be54ec741066d78ab662ad03bed407a9))
|
||||
* **ygopro:** add ws heartbeat and app-level ping echo ([#290](https://github.com/diangogav/EDOpro-server-ts/issues/290)) ([02de10d](https://github.com/diangogav/EDOpro-server-ts/commit/02de10d33bc85f3282c07afdfeae0005319bec26))
|
||||
* **ygopro:** support token reconnection across all duel phases ([a90c12f](https://github.com/diangogav/EDOpro-server-ts/commit/a90c12fb16149dab8b876b039dfe58b7e5d2a356))
|
||||
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
* **banlist:** source JTP whitelist from evolution-assets ([a8c26c0](https://github.com/diangogav/EDOpro-server-ts/commit/a8c26c0cdb8f7d896f4d455af5e424995d9ac233))
|
||||
* **card:** disable EDOPro card DB hot-reload crashing the core ([#301](https://github.com/diangogav/EDOpro-server-ts/issues/301)) ([9013963](https://github.com/diangogav/EDOpro-server-ts/commit/9013963d58c2672e2063a8561b17c3293b46aadf))
|
||||
* **card:** refresh EDOPro card DB in place so the C++ core sees hot-reloads ([#302](https://github.com/diangogav/EDOpro-server-ts/issues/302)) ([224f140](https://github.com/diangogav/EDOpro-server-ts/commit/224f14070683880d3cd477ae2889d2ecf0c01868))
|
||||
* **chat:** include spectator name in Mercury chat messages ([#262](https://github.com/diangogav/EDOpro-server-ts/issues/262)) ([9dc33e8](https://github.com/diangogav/EDOpro-server-ts/commit/9dc33e811e25ce30fcf6d5b9bbd04ccc5d0222ab))
|
||||
* **core:** resolve EDOPro scripts under RESOURCES_DIR (resources/current) ([#300](https://github.com/diangogav/EDOpro-server-ts/issues/300)) ([178107e](https://github.com/diangogav/EDOpro-server-ts/commit/178107edc8bb0530ee762c31df69805296429610))
|
||||
* **deck:** encode deck error code so the client shows the specific error ([#263](https://github.com/diangogav/EDOpro-server-ts/issues/263)) ([41a6e7e](https://github.com/diangogav/EDOpro-server-ts/commit/41a6e7e16bb0cd8e33c02e27dafe8776e8258a18))
|
||||
* **deps:** bump evolution-types submodule to fix postgres startup crash ([#252](https://github.com/diangogav/EDOpro-server-ts/issues/252)) ([a4d3d1f](https://github.com/diangogav/EDOpro-server-ts/commit/a4d3d1f15b9721c440b975194cc8393e9354eba7))
|
||||
* **deps:** resolve two moderate npm audit vulnerabilities ([0337add](https://github.com/diangogav/EDOpro-server-ts/commit/0337addef34e5250c2ef91cbf9b3395c380c70d1))
|
||||
* **emotes:** reject spectator emotes server-side ([#315](https://github.com/diangogav/EDOpro-server-ts/issues/315)) ([7a801b1](https://github.com/diangogav/EDOpro-server-ts/commit/7a801b141d6b03adef0ea6cf01993cdb316a6567))
|
||||
* **genesys:** copy edopro genesys list as lowercase to overwrite in place ([a720913](https://github.com/diangogav/EDOpro-server-ts/commit/a720913390df0ff2856ed9131a28ab782eb726ce))
|
||||
* **join:** close socket on join rejections that send a message ([#261](https://github.com/diangogav/EDOpro-server-ts/issues/261)) ([77aae5d](https://github.com/diangogav/EDOpro-server-ts/commit/77aae5da68ba22228a073f19d59136de8975dd6e))
|
||||
* **join:** route blank join password to a default room instead of AI ([#278](https://github.com/diangogav/EDOpro-server-ts/issues/278)) ([d708d2b](https://github.com/diangogav/EDOpro-server-ts/commit/d708d2be9c8eb1aceffee7fab14f8ba3d3239dcd))
|
||||
* **join:** send ranked-reject JOINERROR with the ygopro serializer ([#265](https://github.com/diangogav/EDOpro-server-ts/issues/265)) ([3e2a083](https://github.com/diangogav/EDOpro-server-ts/commit/3e2a0836106467bb16577d9c953c92043471ff20))
|
||||
* **matchmaking:** abort incomplete room reservations ([#310](https://github.com/diangogav/EDOpro-server-ts/issues/310)) ([7850081](https://github.com/diangogav/EDOpro-server-ts/commit/785008157110519c811e2add7c2f7616f348b41b))
|
||||
* **ranked:** require room password on ticket-authenticated joins ([#260](https://github.com/diangogav/EDOpro-server-ts/issues/260)) ([61d4f8f](https://github.com/diangogav/EDOpro-server-ts/commit/61d4f8f51ca4a679686c72fe70285de7634da237))
|
||||
* **ranking:** key rank by banlist name to unify formats across paths ([#318](https://github.com/diangogav/EDOpro-server-ts/issues/318)) ([21f8177](https://github.com/diangogav/EDOpro-server-ts/commit/21f81774c90932d7c4f8ab43c106e19597c31b31))
|
||||
* **reconnect:** allow ranked by-name reconnect over half-open sockets ([#291](https://github.com/diangogav/EDOpro-server-ts/issues/291)) ([c8f82ff](https://github.com/diangogav/EDOpro-server-ts/commit/c8f82ff3117733db3d806c3b17cbb9136d8010e3))
|
||||
* **reconnect:** revoke reconnection tokens on room teardown to stop TokenIndex leak ([#268](https://github.com/diangogav/EDOpro-server-ts/issues/268)) ([b2502b7](https://github.com/diangogav/EDOpro-server-ts/commit/b2502b71cdc4ba24e310cc62159bf2cbbec2419f))
|
||||
* **setup:** re-clone repository when directory exists without .git ([a730820](https://github.com/diangogav/EDOpro-server-ts/commit/a7308204b4af5658d8b4d5ef3ff564b63fabf90c))
|
||||
* update permissions for libocgcore.so to executable ([052a050](https://github.com/diangogav/EDOpro-server-ts/commit/052a050d273a41edd1be414f14bfca4e43b21f84))
|
||||
* update ygopro-scripts repository source in Dockerfile ([f86d185](https://github.com/diangogav/EDOpro-server-ts/commit/f86d185b6a2b4ecf4a3da5ff6e2719ef66a9899a))
|
||||
* **ygopro:** resolve genesys ban list by alias and reject join when missing ([#304](https://github.com/diangogav/EDOpro-server-ts/issues/304)) ([98d5ff0](https://github.com/diangogav/EDOpro-server-ts/commit/98d5ff0e179dbc22db3be93383d6276cce920dfc))
|
||||
* **ygopro:** send the banlist hash in STOC_JOIN_GAME, not the in-memory index ([ff34797](https://github.com/diangogav/EDOpro-server-ts/commit/ff347973dfb773c75514514a1ca34864d25b8a22))
|
||||
|
||||
## [2.13.2](https://github.com/diangogav/EDOpro-server-ts/compare/v2.13.1...v2.13.2) (2026-05-01)
|
||||
|
||||
|
||||
|
|
|
|||
96
Dockerfile
96
Dockerfile
|
|
@ -1,64 +1,24 @@
|
|||
# syntax=docker/dockerfile:1
|
||||
# Stage 1: Clone repositories and assemble resources
|
||||
FROM public.ecr.aws/docker/library/node:24.11.0-bullseye-slim AS resources-builder
|
||||
|
||||
RUN apt-get update -y && \
|
||||
apt-get install -y --no-install-recommends wget git ca-certificates && \
|
||||
apt-get install -y --no-install-recommends wget git ca-certificates jq && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /repositories
|
||||
WORKDIR /build
|
||||
|
||||
# Clone required repositories
|
||||
RUN git clone --depth 1 --branch master https://github.com/ProjectIgnis/CardScripts.git edopro-card-scripts && \
|
||||
git clone --depth 1 --branch master https://github.com/ProjectIgnis/BabelCDB.git edopro-card-databases && \
|
||||
git clone --depth 1 --branch master https://github.com/ProjectIgnis/LFLists edopro-banlists-ignis && \
|
||||
git clone --depth 1 --branch main https://github.com/termitaklk/lflist edopro-banlists-evolution && \
|
||||
git clone --depth 1 https://code.moenext.com/nanahira/ygopro-scripts ygopro-scripts && \
|
||||
git clone --depth 1 --branch master https://github.com/evolutionygo/pre-release-database-cdb ygopro-prereleases-cdb && \
|
||||
git clone --depth 1 --branch main https://github.com/evolutionygo/cards-art-server ygopro-cards-art && \
|
||||
git clone --depth 1 --branch main https://github.com/evolutionygo/server-formats-cdb.git ygopro-format-alternatives && \
|
||||
wget -O ygopro-lflist.conf https://cdntx.moecube.com/ygopro-database/zh-CN/lflist.conf && \
|
||||
wget -O ygopro-cards.cdb https://cdntx.moecube.com/ygopro-database/zh-CN/cards.cdb
|
||||
|
||||
# Copy selected banlists into corresponding alternative folders
|
||||
RUN bash -c 'set -e; \
|
||||
declare -A MAP=( \
|
||||
["2010.03 Edison(Pre Errata)"]="edison" \
|
||||
["2014.04 HAT (Pre Errata)"]="hat" \
|
||||
["jtp-oficial"]="jtp" \
|
||||
["GOAT"]="goat" \
|
||||
["Rush"]="rush" \
|
||||
["Speed"]="speed" \
|
||||
["Tengu.Plant"]="tengu" \
|
||||
["World"]="world" \
|
||||
["MD.2025.03"]="md" \
|
||||
["Genesys"]="genesys" \
|
||||
); \
|
||||
for name in "${!MAP[@]}"; do \
|
||||
src="./edopro-banlists-evolution/${name}.lflist.conf"; \
|
||||
[ -f "$src" ] || src="./edopro-banlists-ignis/${name}.lflist.conf"; \
|
||||
cp "$src" "./ygopro-format-alternatives/${MAP[$name]}/lflist.conf"; \
|
||||
done'
|
||||
|
||||
# Assemble final resources structure and strip .git dirs
|
||||
RUN find . -name ".git" -type d -exec rm -rf {} + 2>/dev/null; \
|
||||
mkdir -p /resources/edopro \
|
||||
/resources/ygopro/base \
|
||||
/resources/ygopro/ocg && \
|
||||
\
|
||||
# ── edopro ── \
|
||||
cp -r edopro-card-scripts /resources/edopro/scripts && \
|
||||
cp -r edopro-card-databases /resources/edopro/databases && \
|
||||
cp -r edopro-banlists-ignis /resources/edopro/banlists-ignis && \
|
||||
cp -r edopro-banlists-evolution /resources/edopro/banlists-evolution && \
|
||||
\
|
||||
# ── ygopro (each repo as its own independent folder) ── \
|
||||
cp -r ygopro-scripts /resources/ygopro/base/script && \
|
||||
cp ygopro-lflist.conf /resources/ygopro/base/lflist.conf && \
|
||||
cp ygopro-cards.cdb /resources/ygopro/base/cards.cdb && \
|
||||
cp -r ygopro-prereleases-cdb /resources/ygopro/prereleases-cdb && \
|
||||
cp -r ygopro-cards-art /resources/ygopro/cards-art && \
|
||||
cp -r ygopro-format-alternatives /resources/ygopro/alternatives && \
|
||||
cp edopro-banlists-ignis/OCG.lflist.conf /resources/ygopro/ocg/lflist.conf
|
||||
# Resource layout is owned by scripts/clone_repositories.sh + scripts/setup_resources.sh — the
|
||||
# single source of truth, shared with local dev (README) and the runtime refresh
|
||||
# loop (entrypoint). This produces /build/resources/releases/<id> and a current symlink.
|
||||
COPY scripts/ ./scripts/
|
||||
# resources.manifest.json = public base (+ the shipped example). The private override is
|
||||
# NOT part of the build — it is provided at runtime, so the seed is public-only.
|
||||
COPY resources.manifest*.json ./
|
||||
# Assemble the PUBLIC resource seed so the server boots immediately. Private sources are
|
||||
# fetched at runtime by the entrypoint's updater (mounted private override + a token from
|
||||
# the container env); no token ever touches the build.
|
||||
RUN bash scripts/clone_repositories.sh && bash scripts/setup_resources.sh
|
||||
|
||||
|
||||
# Stage 2: Build CoreIntegrator (C++)
|
||||
|
|
@ -102,7 +62,7 @@ RUN npm run build && \
|
|||
FROM public.ecr.aws/docker/library/node:24.11.0-slim
|
||||
|
||||
RUN apt-get update && \
|
||||
apt-get install -y --no-install-recommends curl liblua5.3-dev libsqlite3-dev libevent-dev dumb-init && \
|
||||
apt-get install -y --no-install-recommends curl wget git ca-certificates jq liblua5.3-dev libsqlite3-dev libevent-dev dumb-init && \
|
||||
rm -rf /var/lib/apt/lists/*
|
||||
|
||||
WORKDIR /app
|
||||
|
|
@ -112,11 +72,31 @@ COPY --from=server-builder /server/dist ./
|
|||
COPY --from=server-builder /server/package.json ./package.json
|
||||
COPY --from=server-builder /server/node_modules ./node_modules
|
||||
|
||||
# WindBot botlist (read at boot by FileBotlistRepository when ENABLE_WINDBOT=true).
|
||||
# tsc only emits dist/, so config/ must be copied explicitly or the server crashes
|
||||
# at boot with ENOENT when windbot is enabled. Replace botlist.example.json with a
|
||||
# curated botlist whose deck names match the WindBot image's bots.json.
|
||||
# IMPORTANT: every entry MUST also carry a "format" tag ("tcg" / "jtp" / "edison")
|
||||
# matching resolveBotPool's pools. Format-scoped random join commands (e.g.
|
||||
# "pre,ai", "ed,ai", "jtp,ai") call pickRandom(format) — a curated botlist without
|
||||
# "format" tags makes that lookup return null for every user of those commands
|
||||
# (JOINERROR). See config/botlist.example.json for the reference shape.
|
||||
COPY --from=server-builder /server/config ./config
|
||||
|
||||
# CoreIntegrator binaries
|
||||
COPY --from=core-builder /app/libocgcore.so ./core/libocgcore.so
|
||||
COPY --from=core-builder /app/CoreIntegrator ./core/CoreIntegrator
|
||||
|
||||
# All resources (assembled in Stage 1)
|
||||
COPY --from=resources-builder /resources ./resources
|
||||
# All resources (assembled in Stage 1): releases/<id> + current symlink — the
|
||||
# baked seed so the server boots immediately. The entrypoint's background loop
|
||||
# then refreshes resources/current in place and the in-memory reload picks it up.
|
||||
COPY --from=resources-builder /build/resources ./resources
|
||||
|
||||
CMD ["dumb-init", "node", "./src/index.js"]
|
||||
# Provisioning scripts (scripts/) + the PUBLIC manifest — reused by the runtime updater loop.
|
||||
# The private override is not baked in: mount it at runtime (-v .../resources.manifest.private.json
|
||||
# :/app/resources.manifest.private.json) and pass a read-only token via the container env
|
||||
# (--env-file); the entrypoint sets up git-credentials before the loop clones private sources.
|
||||
COPY scripts/ ./scripts/
|
||||
COPY resources.manifest*.json ./
|
||||
|
||||
CMD ["dumb-init", "bash", "scripts/entrypoint.sh"]
|
||||
|
|
|
|||
89
README.md
89
README.md
|
|
@ -50,13 +50,16 @@ For when you want full control, or Docker isn't an option.
|
|||
- [Node.js](https://nodejs.org) >= 24
|
||||
- [CMake](https://cmake.org/download/) >= 3.18
|
||||
- A C++ compiler (g++ or clang++)
|
||||
- [jq](https://jqlang.github.io/jq/) >= 1.6 — required by `scripts/clone_repositories.sh` and `scripts/setup_resources.sh` to read the resource manifest
|
||||
|
||||
On Ubuntu/Debian, the provided script installs everything you need:
|
||||
|
||||
```bash
|
||||
sudo bash install_dependencies.sh
|
||||
sudo bash scripts/install_dependencies.sh
|
||||
```
|
||||
|
||||
> 💡 To install `jq` manually: `sudo apt-get install -y jq` (Debian/Ubuntu) or `brew install jq` (macOS).
|
||||
|
||||
### 📦 Step by step
|
||||
|
||||
```bash
|
||||
|
|
@ -65,13 +68,13 @@ git clone https://github.com/diangogav/EDOpro-server-ts
|
|||
cd EDOpro-server-ts
|
||||
|
||||
# 2️⃣ Clone card scripts, databases, and banlists
|
||||
bash clone_repositories.sh
|
||||
bash scripts/clone_repositories.sh
|
||||
|
||||
# 3️⃣ Organize everything into resources/
|
||||
bash setup_resources.sh
|
||||
bash scripts/setup_resources.sh
|
||||
|
||||
# 4️⃣ Build the C++ duel core (used by the EDOPro engine)
|
||||
bash build_core_integrator.sh
|
||||
bash scripts/build_core_integrator.sh
|
||||
|
||||
# 5️⃣ Install Node.js dependencies
|
||||
npm install
|
||||
|
|
@ -80,6 +83,8 @@ npm install
|
|||
cp .env.example .env
|
||||
```
|
||||
|
||||
> 📁 `scripts/setup_resources.sh` assembles each run into `resources/releases/<id>/` and points `resources/current` (a symlink) at it. Everything is read through `resources/current/…`, so refreshing resources is an atomic symlink swap — no restart needed. In Docker the container runs this refresh loop in the background (see `scripts/entrypoint.sh` + `scripts/resources-updater.sh`), so card/banlist updates are picked up live.
|
||||
|
||||
Now choose which engine(s) you want to run 👇
|
||||
|
||||
---
|
||||
|
|
@ -104,7 +109,7 @@ WEBSOCKET_PORT=4000
|
|||
**Resource structure used:**
|
||||
|
||||
```
|
||||
📂 resources/edopro/
|
||||
📂 resources/current/edopro/
|
||||
├── 📜 scripts/ # ProjectIgnis/CardScripts
|
||||
├── 🗄️ databases/ # ProjectIgnis/BabelCDB
|
||||
├── 📋 banlists-ignis/ # ProjectIgnis/LFLists
|
||||
|
|
@ -126,8 +131,7 @@ The YGOPro engine uses srvpro2-compatible protocol. Players connect using Koishi
|
|||
**What you need:**
|
||||
- ✅ Card scripts and databases from ygopro-scripts
|
||||
- ✅ Ban lists and alternative format resources
|
||||
- ✅ The `YGOPRO_FOLDERS` environment variable pointing to your resource directories
|
||||
- ✅ (Optional) The `YGOPRO_EXTRA_FOLDERS` environment variable for pre-release and art card resource folders (cdbs + scripts)
|
||||
- ✅ `resources.manifest.json` at repo root (already present — the server derives card paths from it automatically)
|
||||
|
||||
**Minimum `.env` configuration:**
|
||||
|
||||
|
|
@ -135,28 +139,23 @@ The YGOPro engine uses srvpro2-compatible protocol. Players connect using Koishi
|
|||
YGOPRO_PORT=7711
|
||||
HTTP_PORT=7922
|
||||
WEBSOCKET_PORT=4000
|
||||
YGOPRO_FOLDERS=./resources/ygopro/base
|
||||
RESOURCES_DIR=./resources/current
|
||||
```
|
||||
|
||||
**Resource structure used:**
|
||||
|
||||
```
|
||||
📂 resources/ygopro/
|
||||
├── 📜 base/ # Core scripts + lflist + cards.cdb (loaded by all modes)
|
||||
├── 🌏 ocg/ # OCG-specific banlist
|
||||
├── 🃏 alternatives/ # Format variants (Edison, GOAT, HAT, etc.)
|
||||
├── 🆕 prereleases-cdb/ # Pre-release card databases + scripts (extra folder)
|
||||
└── 🎨 cards-art/ # Custom card art databases (extra folder)
|
||||
📂 resources/current/ygopro/
|
||||
├── 📜 base/ # Core scripts + lflist + cards.cdb (loaded by all modes)
|
||||
├── 🌏 formats/ocg/ # OCG-specific banlist
|
||||
├── 🃏 formats/<name>/ # Format variants (Edison, HAT, JTP, MD, Tengu, World, Genesys, …)
|
||||
├── 🆕 extensions/prereleases/ # Pre-release card databases + scripts (extra folder)
|
||||
└── 🎨 extensions/custom-cards/ # Custom card art databases (extra folder)
|
||||
```
|
||||
|
||||
**Standard card pool** (`YGOPRO_FOLDERS`) is loaded for all rooms. **Extra folders** (`YGOPRO_EXTRA_FOLDERS`) — which can contain cdbs, scripts, and other card assets — are only available in rooms that use PRE or ART formats. Standard rooms cannot use those cards.
|
||||
**Standard card pool** (base + all served formats) is loaded for all rooms. **Extended pool** (standard + extension dirs) is only available in rooms that use PRE or ART formats. Standard rooms cannot use those cards.
|
||||
|
||||
To enable all formats and pre-releases, set:
|
||||
|
||||
```env
|
||||
YGOPRO_FOLDERS=./resources/ygopro/base,./resources/ygopro/ocg,./resources/ygopro/alternatives
|
||||
YGOPRO_EXTRA_FOLDERS=./resources/ygopro/prereleases-cdb,./resources/ygopro/cards-art
|
||||
```
|
||||
Both pools are **derived automatically** from `resources.manifest.json` (`runtime.ygopro.standard` / `.extended`). No environment variable is needed or supported for pool membership — the manifest is the sole source.
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
|
|
@ -175,8 +174,7 @@ HOST_PORT=7911
|
|||
YGOPRO_PORT=7711
|
||||
HTTP_PORT=7922
|
||||
WEBSOCKET_PORT=4000
|
||||
YGOPRO_FOLDERS=./resources/ygopro/base,./resources/ygopro/ocg,./resources/ygopro/alternatives
|
||||
YGOPRO_EXTRA_FOLDERS=./resources/ygopro/prereleases-cdb,./resources/ygopro/cards-art
|
||||
RESOURCES_DIR=./resources/current
|
||||
```
|
||||
|
||||
```bash
|
||||
|
|
@ -193,8 +191,8 @@ The YGOPro engine maintains **two separate card pools** in memory:
|
|||
|
||||
| Pool | Loaded from | Available to |
|
||||
|------|-------------|--------------|
|
||||
| **Standard** | `YGOPRO_FOLDERS` | All rooms |
|
||||
| **Extended** | `YGOPRO_FOLDERS` + `YGOPRO_EXTRA_FOLDERS` | PRE/ART rooms only |
|
||||
| **Standard** | `runtime.ygopro.standard` in `resources.manifest.json` | All rooms |
|
||||
| **Extended** | standard + `runtime.ygopro.extended` in `resources.manifest.json` | PRE/ART rooms only |
|
||||
|
||||
When a player creates a room with a format like `PRE`, `TCGPRE`, `OCGPRE`, `TCGART`, or `OCGART`, the server uses the **extended** card pool for both deck validation and the duel engine. Standard rooms (`M`, `TCG`, `OT`, `GOAT`, etc.) use only the **standard** pool — any card not in that pool is rejected as unknown.
|
||||
|
||||
|
|
@ -210,8 +208,8 @@ Both pools are loaded at startup and refreshed every 10 minutes if the underlyin
|
|||
| `YGOPRO_PORT` | YGOPro server port | `7711` |
|
||||
| `HTTP_PORT` | HTTP API port | `7922` |
|
||||
| `WEBSOCKET_PORT` | WebSocket port | `4000` |
|
||||
| `YGOPRO_FOLDERS` | Comma-separated resource directories (standard card pool) | *(empty)* |
|
||||
| `YGOPRO_EXTRA_FOLDERS` | Comma-separated extra resource directories (cdbs + scripts for pre-releases, art cards) — only loaded for PRE/ART room formats | *(empty)* |
|
||||
| `RESOURCES_DIR` | Root of the assembled resource tree (symlink target) | `./resources/current` |
|
||||
| `MANIFEST_PATH` | Path to `resources.manifest.json` used for pool derivation | `./resources.manifest.json` |
|
||||
| `RANK_ENABLED` | Enable ranking system (requires PostgreSQL) | `false` |
|
||||
| `POSTGRES_HOST` | PostgreSQL host | `localhost` |
|
||||
| `POSTGRES_PORT` | PostgreSQL port | `5432` |
|
||||
|
|
@ -223,6 +221,43 @@ Both pools are loaded at startup and refreshed every 10 minutes if the underlyin
|
|||
|
||||
---
|
||||
|
||||
## 🔥 Pre-deploy Smoke Check (RFD-008)
|
||||
|
||||
Run this manually before deploying to production to confirm the derived pools match the expected baselines (network required):
|
||||
|
||||
```bash
|
||||
# 1. Assemble resources (must be done at least once)
|
||||
bash scripts/clone_repositories.sh && bash scripts/setup_resources.sh
|
||||
|
||||
# 2. Start the server (RESOURCES_DIR and MANIFEST_PATH use their defaults)
|
||||
npm run dev
|
||||
```
|
||||
|
||||
Watch the startup log for lines like:
|
||||
```
|
||||
Merged standard database from N databases with M cards
|
||||
Merged extended database from N databases with M cards
|
||||
Total LFLists loaded: K
|
||||
```
|
||||
|
||||
These counts should match the pre-change production baseline. Any significant difference (e.g. M cards drops to 0) indicates a pool derivation or container manifest issue.
|
||||
|
||||
You can also inspect the derived paths at any time:
|
||||
|
||||
```bash
|
||||
node -e "
|
||||
const { resolvePools } = require('./dist/src/ygopro/ygopro/ResourcePoolResolver');
|
||||
const { config } = require('./dist/src/config');
|
||||
const pools = resolvePools({ manifestPath: config.resources.manifestPath, resourcesDir: config.resources.dir, env: process.env, logger: console });
|
||||
console.log('standard paths:', pools.standard.length);
|
||||
console.log('extended paths:', pools.extended.length);
|
||||
pools.standard.forEach(p => console.log(' S', p));
|
||||
pools.extended.slice(pools.standard.length).forEach(p => console.log(' E', p));
|
||||
"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ Project Architecture
|
||||
|
||||
```
|
||||
|
|
|
|||
165
biome.json
Normal file
165
biome.json
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
{
|
||||
"$schema": "https://biomejs.dev/schemas/2.5.0/schema.json",
|
||||
"vcs": { "enabled": true, "clientKind": "git", "useIgnoreFile": true },
|
||||
"files": {
|
||||
"includes": [
|
||||
"**",
|
||||
"!node_modules/**",
|
||||
"!dist/**",
|
||||
"!build/**",
|
||||
"!coverage/**",
|
||||
"!core/**",
|
||||
"!databases/**",
|
||||
"!repositories/**",
|
||||
"!.agents/**",
|
||||
"!.claude/**",
|
||||
"!resources/**",
|
||||
"!src/evolution-types/**",
|
||||
"!**/*.config.js",
|
||||
"!**/*.config.mjs",
|
||||
"!test/fixtures/**"
|
||||
]
|
||||
},
|
||||
"formatter": {
|
||||
"enabled": true,
|
||||
"indentStyle": "tab",
|
||||
"lineEnding": "lf",
|
||||
"lineWidth": 100
|
||||
},
|
||||
"linter": {
|
||||
"enabled": true,
|
||||
"rules": {
|
||||
"preset": "none",
|
||||
"complexity": {
|
||||
"noAdjacentSpacesInRegex": "error",
|
||||
"noExtraBooleanCast": "error",
|
||||
"noUselessCatch": "error",
|
||||
"noUselessEscapeInRegex": "error",
|
||||
"noUselessTypeConstraint": "error"
|
||||
},
|
||||
"correctness": {
|
||||
"noConstAssign": "error",
|
||||
"noConstantCondition": "error",
|
||||
"noEmptyCharacterClassInRegex": "error",
|
||||
"noEmptyPattern": "error",
|
||||
"noGlobalObjectCalls": "error",
|
||||
"noInvalidBuiltinInstantiation": "error",
|
||||
"noInvalidConstructorSuper": "error",
|
||||
"noNonoctalDecimalEscape": "error",
|
||||
"noPrecisionLoss": "error",
|
||||
"noSelfAssign": "error",
|
||||
"noSetterReturn": "error",
|
||||
"noSwitchDeclarations": "error",
|
||||
"noUndeclaredVariables": "error",
|
||||
"noUnreachable": "error",
|
||||
"noUnreachableSuper": "error",
|
||||
"noUnsafeFinally": "error",
|
||||
"noUnsafeOptionalChaining": "error",
|
||||
"noUnusedLabels": "error",
|
||||
"noUnusedPrivateClassMembers": "error",
|
||||
"noUnusedVariables": "error",
|
||||
"useIsNan": "error",
|
||||
"useValidForDirection": "error",
|
||||
"useValidTypeof": "error",
|
||||
"useYield": "error"
|
||||
},
|
||||
"style": {
|
||||
"noCommonJs": "error",
|
||||
"noNamespace": "error",
|
||||
"useArrayLiterals": "error",
|
||||
"useAsConstAssertion": "error"
|
||||
},
|
||||
"suspicious": {
|
||||
"noAsyncPromiseExecutor": "error",
|
||||
"noCatchAssign": "error",
|
||||
"noClassAssign": "error",
|
||||
"noCompareNegZero": "error",
|
||||
"noConstantBinaryExpressions": "error",
|
||||
"noControlCharactersInRegex": "error",
|
||||
"noDebugger": "error",
|
||||
"noDuplicateCase": "error",
|
||||
"noDuplicateClassMembers": "error",
|
||||
"noDuplicateElseIf": "error",
|
||||
"noDuplicateEnumValues": "error",
|
||||
"noDuplicateObjectKeys": "error",
|
||||
"noDuplicateParameters": "error",
|
||||
"noEmptyBlockStatements": "error",
|
||||
"noExplicitAny": "error",
|
||||
"noExtraNonNullAssertion": "error",
|
||||
"noFallthroughSwitchClause": "error",
|
||||
"noFunctionAssign": "error",
|
||||
"noGlobalAssign": "error",
|
||||
"noImportAssign": "error",
|
||||
"noIrregularWhitespace": "error",
|
||||
"noMisleadingCharacterClass": "error",
|
||||
"noMisleadingInstantiator": "error",
|
||||
"noNonNullAssertedOptionalChain": "error",
|
||||
"noPrototypeBuiltins": "error",
|
||||
"noRedeclare": "error",
|
||||
"noShadowRestrictedNames": "error",
|
||||
"noSparseArray": "error",
|
||||
"noUnsafeDeclarationMerging": "error",
|
||||
"noUnsafeNegation": "error",
|
||||
"noUnusedExpressions": "off",
|
||||
"noUselessRegexBackrefs": "error",
|
||||
"noWith": "error",
|
||||
"useGetterReturn": "error",
|
||||
"useNamespaceKeyword": "error"
|
||||
}
|
||||
}
|
||||
},
|
||||
"javascript": {
|
||||
"parser": { "unsafeParameterDecoratorsEnabled": true },
|
||||
"formatter": { "quoteStyle": "double" },
|
||||
"globals": ["exports"]
|
||||
},
|
||||
"overrides": [
|
||||
{
|
||||
"includes": ["**/*.ts", "**/*.tsx", "**/*.mts", "**/*.cts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"complexity": { "noArguments": "error" },
|
||||
"correctness": {
|
||||
"noConstAssign": "off",
|
||||
"noGlobalObjectCalls": "off",
|
||||
"noInvalidBuiltinInstantiation": "off",
|
||||
"noInvalidConstructorSuper": "off",
|
||||
"noSetterReturn": "off",
|
||||
"noUndeclaredVariables": "off",
|
||||
"noUnreachable": "off",
|
||||
"noUnreachableSuper": "off",
|
||||
"noUnusedVariables": "off"
|
||||
},
|
||||
"style": { "useConst": "error", "useSpreadOverApply": "error" },
|
||||
"suspicious": {
|
||||
"noClassAssign": "off",
|
||||
"noDuplicateClassMembers": "off",
|
||||
"noDuplicateObjectKeys": "off",
|
||||
"noDuplicateParameters": "off",
|
||||
"noExplicitAny": "off",
|
||||
"noFunctionAssign": "off",
|
||||
"noImportAssign": "off",
|
||||
"noRedeclare": "off",
|
||||
"noUnsafeNegation": "off",
|
||||
"noVar": "error",
|
||||
"noWith": "off",
|
||||
"useGetterReturn": "off"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"includes": ["**/*.test.ts", "**/*.spec.ts", "tests/**/*.ts"],
|
||||
"linter": {
|
||||
"rules": {
|
||||
"style": { "noDoneCallback": "error" },
|
||||
"suspicious": { "noExplicitAny": "off" }
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"assist": {
|
||||
"enabled": true,
|
||||
"actions": { "source": { "organizeImports": "off" } }
|
||||
}
|
||||
}
|
||||
|
|
@ -1,22 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "Cloning repositories..."
|
||||
|
||||
rm -rf repositories
|
||||
mkdir repositories
|
||||
cd repositories
|
||||
|
||||
git clone --depth 1 --branch master https://github.com/ProjectIgnis/CardScripts.git edopro-card-scripts
|
||||
git clone --depth 1 --branch master https://github.com/ProjectIgnis/BabelCDB.git edopro-card-databases
|
||||
git clone --depth 1 --branch master https://github.com/ProjectIgnis/LFLists edopro-banlists-ignis
|
||||
git clone --depth 1 --branch main https://github.com/termitaklk/lflist edopro-banlists-evolution
|
||||
git clone --depth 1 https://code.moenext.com/nanahira/ygopro-scripts ygopro-scripts
|
||||
git clone --depth 1 --branch master https://github.com/evolutionygo/pre-release-database-cdb ygopro-prereleases-cdb
|
||||
git clone --depth 1 --branch main https://github.com/evolutionygo/cards-art-server ygopro-cards-art
|
||||
git clone --depth 1 --branch main https://github.com/evolutionygo/server-formats-cdb.git ygopro-format-alternatives
|
||||
wget -O ygopro-lflist.conf https://cdntx.moecube.com/ygopro-database/zh-CN/lflist.conf
|
||||
wget -O ygopro-cards.cdb https://cdntx.moecube.com/ygopro-database/zh-CN/cards.cdb
|
||||
|
||||
echo "Repositories cloned successfully."
|
||||
65
config/botlist.example.json
Normal file
65
config/botlist.example.json
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
[
|
||||
{
|
||||
"name": "Joey",
|
||||
"deck": "JTP",
|
||||
"format": "jtp"
|
||||
},
|
||||
{
|
||||
"name": "Yugi",
|
||||
"deck": "Yugi",
|
||||
"format": "jtp"
|
||||
},
|
||||
{
|
||||
"name": "Salamangreat",
|
||||
"deck": "Salamangreat",
|
||||
"format": "tcg"
|
||||
},
|
||||
{
|
||||
"name": "Sky Striker",
|
||||
"deck": "SkyStriker",
|
||||
"format": "tcg"
|
||||
},
|
||||
{
|
||||
"name": "Labrynth",
|
||||
"deck": "Labrynth",
|
||||
"format": "tcg"
|
||||
},
|
||||
{
|
||||
"name": "Yubel",
|
||||
"deck": "Yubel",
|
||||
"format": "tcg"
|
||||
},
|
||||
{
|
||||
"name": "Swordsoul",
|
||||
"deck": "Swordsoul",
|
||||
"format": "tcg"
|
||||
},
|
||||
{
|
||||
"name": "Ryzeal",
|
||||
"deck": "Ryzeal",
|
||||
"format": "tcg"
|
||||
},
|
||||
{
|
||||
"name": "Maliss",
|
||||
"deck": "Maliss",
|
||||
"format": "tcg"
|
||||
},
|
||||
{
|
||||
"name": "Blackwing",
|
||||
"deck": "EdisonBlackwing",
|
||||
"hidden": true,
|
||||
"format": "edison"
|
||||
},
|
||||
{
|
||||
"name": "Lightsworn",
|
||||
"deck": "EdisonLightsworn",
|
||||
"hidden": true,
|
||||
"format": "edison"
|
||||
},
|
||||
{
|
||||
"name": "Machina",
|
||||
"deck": "EdisonMachina",
|
||||
"hidden": true,
|
||||
"format": "edison"
|
||||
}
|
||||
]
|
||||
Binary file not shown.
|
|
@ -1,5 +1,6 @@
|
|||
#include "../includes/duel.h"
|
||||
#include <stdexcept>
|
||||
#include <cstdlib>
|
||||
|
||||
template <>
|
||||
constexpr LocInfo Read(const uint8_t *&ptr) noexcept
|
||||
|
|
@ -60,7 +61,11 @@ void Duel::destroy()
|
|||
void Duel::load_scripts()
|
||||
{
|
||||
std::filesystem::path current_path = std::filesystem::current_path();
|
||||
std::filesystem::path scripts_path = current_path / "resources/edopro/scripts";
|
||||
// Resources live under RESOURCES_DIR (default resources/current — the symlink
|
||||
// maintained by the resource refresh), matching the TS config.resources.dir.
|
||||
const char *resources_dir = std::getenv("RESOURCES_DIR");
|
||||
std::filesystem::path scripts_path =
|
||||
current_path / (resources_dir ? resources_dir : "resources/current") / "edopro/scripts";
|
||||
const char *path = scripts_path.c_str();
|
||||
|
||||
std::vector<char> constants_buffer = this->file_reader.read(path, "constant.lua");
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
#include "ScriptReader.h"
|
||||
#include "FileReader.h"
|
||||
#include <filesystem>
|
||||
#include <cstdlib>
|
||||
namespace fs = std::filesystem;
|
||||
|
||||
ScriptReader::ScriptReader(OCGRepository repository) : repository(repository) {}
|
||||
|
|
@ -16,7 +17,11 @@ int ScriptReader::read(void *duel, const char *name)
|
|||
{
|
||||
FileReader reader;
|
||||
std::filesystem::path currentPath = std::filesystem::current_path();
|
||||
std::filesystem::path scriptsPath = currentPath / "resources/edopro/scripts";
|
||||
// Resources live under RESOURCES_DIR (default resources/current — the symlink
|
||||
// maintained by the resource refresh), matching the TS config.resources.dir.
|
||||
const char *resourcesDir = std::getenv("RESOURCES_DIR");
|
||||
std::filesystem::path scriptsPath =
|
||||
currentPath / (resourcesDir ? resourcesDir : "resources/current") / "edopro/scripts";
|
||||
const char* path = scriptsPath.c_str();
|
||||
|
||||
std::vector<char> buffer = reader.read(path, name);
|
||||
|
|
|
|||
|
|
@ -57,8 +57,14 @@ services:
|
|||
MERCURY_PORT: ${MERCURY_PORT:-7711}
|
||||
HTTP_PORT: ${HTTP_PORT:-7922}
|
||||
WEBSOCKET_PORT: ${WEBSOCKET_PORT:-4000}
|
||||
YGOPRO_FOLDERS: ./resources/ygopro/base,./resources/ygopro/ocg,./resources/ygopro/alternatives
|
||||
YGOPRO_EXTRA_FOLDERS: ./resources/ygopro/prereleases-cdb,./resources/ygopro/cards-art
|
||||
RESOURCES_DIR: ./resources/current
|
||||
# WindBot (opt-in). The WindBot container is run separately (independent docker
|
||||
# command). Off by default — set these in .env to enable. WINDBOT_MY_IP must be a
|
||||
# host/IP the externally-run bot can reach back on; WINDBOT_ENDPOINT is the bot's HTTP API.
|
||||
ENABLE_WINDBOT: ${ENABLE_WINDBOT:-false}
|
||||
WINDBOT_ENDPOINT: ${WINDBOT_ENDPOINT:-}
|
||||
WINDBOT_MY_IP: ${WINDBOT_MY_IP:-windbot}
|
||||
WINDBOT_BOTLIST: ${WINDBOT_BOTLIST:-config/botlist.example.json}
|
||||
ports:
|
||||
- "${HOST_PORT:-7911}:${HOST_PORT:-7911}"
|
||||
- "${MERCURY_PORT:-7711}:${MERCURY_PORT:-7711}"
|
||||
|
|
|
|||
|
|
@ -19,5 +19,21 @@ services:
|
|||
timeout: 5s
|
||||
retries: 5
|
||||
|
||||
valkey:
|
||||
image: valkey/valkey:9.0-alpine
|
||||
container_name: evolution-valkey
|
||||
ports:
|
||||
- "6379:6379"
|
||||
volumes:
|
||||
- valkey_data:/data
|
||||
restart: unless-stopped
|
||||
healthcheck:
|
||||
test: [ "CMD", "valkey-cli", "ping" ]
|
||||
interval: 10s
|
||||
timeout: 5s
|
||||
retries: 5
|
||||
command: valkey-server --appendonly yes
|
||||
|
||||
volumes:
|
||||
postgres_data:
|
||||
valkey_data:
|
||||
|
|
|
|||
|
|
@ -1,53 +0,0 @@
|
|||
# Add `command` field to `GET /api/rooms`
|
||||
|
||||
## What
|
||||
|
||||
Add a `command` string field to each room in the `GET /api/rooms` response.
|
||||
|
||||
## Why
|
||||
|
||||
The Evolution client needs the room's command string to join via WebSocket.
|
||||
The join protocol (`CTOS_JOIN_GAME`) requires the room name as the `pass` field.
|
||||
Without `command`, the client has no way to join a room from the browser.
|
||||
|
||||
## Source
|
||||
|
||||
```typescript
|
||||
// YGOProRoom already has this as a public readonly field:
|
||||
YGOProRoom.name // e.g. "M,tcg,lp8000,tm5"
|
||||
```
|
||||
|
||||
This is the command string that created the room, **without** the `#password` suffix.
|
||||
It's already stripped of the password during room creation.
|
||||
|
||||
## Change
|
||||
|
||||
In the `toRoomListDTO()` method (or wherever the room list response is built),
|
||||
add one field:
|
||||
|
||||
```typescript
|
||||
{
|
||||
id: this.id,
|
||||
command: this.name, // <-- ADD THIS LINE
|
||||
status: ...,
|
||||
// ... rest of fields
|
||||
}
|
||||
```
|
||||
|
||||
## Response example (before → after)
|
||||
|
||||
```diff
|
||||
{
|
||||
"id": 7659,
|
||||
+ "command": "M,tcg,lp8000",
|
||||
"status": "waiting",
|
||||
"started": false,
|
||||
...
|
||||
}
|
||||
```
|
||||
|
||||
## Security
|
||||
|
||||
`command` does NOT contain the password. The password portion after `#` is
|
||||
stripped during `YGOProRoom.create()` and stored separately in `YGOProRoom.password`
|
||||
(which is never exposed in any API response).
|
||||
|
|
@ -1,171 +0,0 @@
|
|||
# Room List API — Specification
|
||||
|
||||
## Context
|
||||
|
||||
Evolution's client Room Browser needs to list Mercury rooms with all labels
|
||||
pre-resolved by the server. The client only renders — no mapping, no lookups.
|
||||
|
||||
---
|
||||
|
||||
## Endpoint
|
||||
|
||||
```
|
||||
GET /api/rooms
|
||||
```
|
||||
|
||||
### Query Parameters (all optional)
|
||||
|
||||
| Param | Type | Default | Description |
|
||||
|----------|--------|---------|-------------|
|
||||
| `status` | `string` | — | Comma-separated: `waiting`, `dueling`, `rps`, `choosingOrder`, `sideDecking` |
|
||||
| `open` | `bool` | — | `true` = no password only, `false` = password only |
|
||||
| `mode` | `number` | — | `0` Single, `1` Match, `2` Tag |
|
||||
|
||||
No params → all Mercury rooms.
|
||||
|
||||
---
|
||||
|
||||
### Response
|
||||
|
||||
```jsonc
|
||||
{
|
||||
"rooms": [
|
||||
{
|
||||
"id": 1234,
|
||||
"command": "M,tcg,lp8000", // join key — pass this as CTOS_JOIN_GAME.pass (not displayed)
|
||||
"status": "waiting", // DuelState value
|
||||
"started": false, // false=waiting, true=any other state
|
||||
"private": false,
|
||||
"canPlay": true, // waiting AND open slots
|
||||
"canWatch": true, // always true
|
||||
|
||||
"banlist": "2026.04 TCG", // resolved name from BanList.name (via hash lookup)
|
||||
"rule": "TCG", // resolved label: "OCG" | "TCG" | "OCG/TCG" | "Pre-release" | "Anything Goes"
|
||||
|
||||
"mode": 0, // 0=Single, 1=Match, 2=Tag
|
||||
"bestOf": 1,
|
||||
"duelRule": 5, // Master Rule 1-5
|
||||
"startLp": 8000,
|
||||
"timeLimit": 180,
|
||||
|
||||
"players": [
|
||||
{ "name": "DarkMagician", "position": 0, "team": 0 },
|
||||
{ "name": "BlueEyes99", "position": 1, "team": 1 }
|
||||
],
|
||||
"maxPlayers": 2,
|
||||
"spectators": 0
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
### Field Reference
|
||||
|
||||
| Field | Type | Source | Description |
|
||||
|-------------|-----------|--------|-------------|
|
||||
| `id` | `number` | `YgoRoom.id` | Room ID |
|
||||
| `command` | `string` | `YGOProRoom.name` | Join key — client sends this as `CTOS_JOIN_GAME.pass`. Not displayed in UI. |
|
||||
| `status` | `string` | `YgoRoom.duelState` | Granular lifecycle state |
|
||||
| `started` | `boolean` | computed | `duelState !== "waiting"` — quick filter for UI |
|
||||
| `private` | `boolean` | `password.length > 0` | Password required |
|
||||
| `canPlay` | `boolean` | computed | `!started && players.length < maxPlayers` |
|
||||
| `canWatch` | `boolean` | computed | Always `true` |
|
||||
| `banlist` | `string` | `BanListRepo.findByHash(hash).name` | Human-readable banlist name |
|
||||
| `rule` | `string` | resolved from `HostInfo.rule` numeric | `"OCG"`, `"TCG"`, `"OCG/TCG"`, `"Pre-release"`, `"Anything Goes"` |
|
||||
| `mode` | `number` | `HostInfo.mode` | `0`=Single, `1`=Match, `2`=Tag |
|
||||
| `bestOf` | `number` | `YgoRoom.bestOf` | Match count |
|
||||
| `duelRule` | `number` | `HostInfo.duel_rule` | Master Rule version |
|
||||
| `startLp` | `number` | `HostInfo.start_lp` | Starting LP |
|
||||
| `timeLimit` | `number` | `HostInfo.time_limit` | Seconds per turn |
|
||||
| `players` | `array` | `YgoRoom._players` | Player list |
|
||||
| `maxPlayers`| `number` | `team0 + team1` | Total player slots |
|
||||
| `spectators`| `number` | `YgoRoom._spectators.length` | Spectator count |
|
||||
|
||||
#### `players[]`
|
||||
|
||||
| Field | Type | Source |
|
||||
|------------|----------|--------|
|
||||
| `name` | `string` | `YgoClient.name` (null bytes stripped) |
|
||||
| `position` | `number` | `YgoClient.position` |
|
||||
| `team` | `number` | `YgoClient.team` (0 or 1) |
|
||||
|
||||
#### Rule label resolution (server-side)
|
||||
|
||||
```
|
||||
0 → "OCG"
|
||||
1 → "TCG"
|
||||
2 → "OCG/TCG"
|
||||
3 → "Pre-release"
|
||||
4 → "Anything Goes"
|
||||
5 → "Anything Goes"
|
||||
```
|
||||
|
||||
#### Banlist name resolution (server-side)
|
||||
|
||||
```typescript
|
||||
YGOProBanListMemoryRepository.findByHash(room.banListHash)?.name ?? "No banlist"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Implementation
|
||||
|
||||
### Server
|
||||
|
||||
1. **New controller**: `src/http-server/controllers/RoomListController.ts`
|
||||
- `YGOProRoomList.getRooms()` → filter by query params → map `toRoomListDTO()`
|
||||
|
||||
2. **New route**: `GET /api/rooms` in routes
|
||||
|
||||
3. **`toRoomListDTO()` on `YGOProRoom`** — builds the response shape, resolves:
|
||||
- `banlist`: lookup `BanListMemoryRepository.findByHash(hash).name`
|
||||
- `rule`: map numeric → string label
|
||||
- `started`: `duelState !== DuelState.WAITING`
|
||||
- `canPlay`: `!started && players.length < maxPlayers`
|
||||
- `canWatch`: `true`
|
||||
|
||||
### Data available (no model changes)
|
||||
|
||||
| Need | Source |
|
||||
|------|--------|
|
||||
| `duelState` | `YgoRoom.duelState` |
|
||||
| `team0/team1` | `YgoRoom.team0/team1` |
|
||||
| `players` with position, team | `YgoRoom._players` → `YgoClient` |
|
||||
| `spectators.length` | `YgoRoom._spectators` |
|
||||
| `password` | `YGOProRoom.password` |
|
||||
| `HostInfo` fields | `YGOProRoom._hostInfo` |
|
||||
| `banListHash` | `YGOProRoom._edoBanListHash ?? banListHash` |
|
||||
| BanList name lookup | `YGOProBanListMemoryRepository.findByHash()` |
|
||||
|
||||
### Security
|
||||
|
||||
- Passwords NEVER in response
|
||||
- Player IPs NEVER in response
|
||||
|
||||
---
|
||||
|
||||
## Client Usage
|
||||
|
||||
1. `GET /api/rooms` — all rooms, or `?status=waiting` for joinable only
|
||||
2. Render room list — data is display-ready, no mapping needed
|
||||
3. User clicks room:
|
||||
- `canPlay` → **Join** button
|
||||
- `canWatch` (and `started`) → **Spectate** button
|
||||
- `private` → prompt password first
|
||||
4. Connect WebSocket → `CTOS_PLAYER_INFO` + `CTOS_JOIN_GAME`
|
||||
|
||||
---
|
||||
|
||||
## DuelState Reference
|
||||
|
||||
| Value | `started` | `canPlay` | `canWatch` |
|
||||
|------------------|-----------|------------------|------------|
|
||||
| `waiting` | `false` | if slots open | Yes |
|
||||
| `rps` | `true` | No | Yes |
|
||||
| `choosingOrder` | `true` | No | Yes |
|
||||
| `dueling` | `true` | No | Yes |
|
||||
| `sideDecking` | `true` | No | Yes |
|
||||
|
||||
Joining a `started` room → server assigns spectator role automatically.
|
||||
287
docs/join-commands.md
Normal file
287
docs/join-commands.md
Normal file
|
|
@ -0,0 +1,287 @@
|
|||
# YGOPro Join Commands — Structure and Handling
|
||||
|
||||
How the server interprets the `CTOS_JOIN_GAME` password ("join command"), how rooms
|
||||
are matched and created from it, and where player/spectator decisions happen.
|
||||
Evidence references point at the current source; update them if files move.
|
||||
|
||||
## 1. Command wire format
|
||||
|
||||
```
|
||||
<config-tokens>[#<room-password>]
|
||||
```
|
||||
|
||||
- The whole string rides the `CTOS_JOIN_GAME` `pass` field: **UTF-16LE, fixed
|
||||
`utf16[20]` — silently truncated past 20 characters**. Exactly-20 round-trips
|
||||
(no NUL terminator required by `ygopro-msg-encode`).
|
||||
- `YGOProJoinHandler` splits on `#` and keeps only the first two segments
|
||||
(`src/ygopro/room/application/YGOProJoinHandler.ts:51`):
|
||||
- `command` = raw segment before the first `#` (case preserved).
|
||||
- `password` = segment after the first `#`, or `""`.
|
||||
- Config tokens are comma-separated inside `command`. For rule resolution they
|
||||
are trimmed and lowercased; for room identity they are **not** (see §4).
|
||||
|
||||
Examples: `tcg`, `edison#myroom`, `nc,ns,ai#Blackwing`, `m,tt#duel1`.
|
||||
|
||||
## 2. Join pipeline
|
||||
|
||||
```
|
||||
socket → MessageEmitter (Commands.JOIN_GAME = 18) → YGOProJoinHandler
|
||||
→ JoinStrategyRegistry.resolve(ctx) → strategy.handle(ctx)
|
||||
```
|
||||
|
||||
Strategy chain, first `matches()` wins
|
||||
(`src/ygopro/room/application/join-strategies/composeJoinStrategies.ts`):
|
||||
|
||||
| # | Strategy | Matches when | Behavior |
|
||||
|---|----------|--------------|----------|
|
||||
| 1 | `AIJoinTokenStrategy` | windbot enabled AND `rawPass` starts with `AIJOIN#` | The bot itself connecting back: consumes a one-shot token, finds the room **by id**, marks the client internal. |
|
||||
| 2 | `WindBotJoinStrategy` | windbot enabled AND `ai` among config tokens | Creates an AI room, resolves the bot (by name after `#`, or format-scoped random via `resolveBotPool`), fires the bot request. Rejects tag mode. |
|
||||
| 3 | `TicketJoinStrategy` | socket authenticated via WS ticket (`resolvedUserId`) | Shared `findOrCreateRoom` helper (`rankedOverride=true`) — see §5 for the pairing-vs-non-pairing lookup it performs. |
|
||||
| 4 | `DefaultJoinStrategy` | always | Shared `findOrCreateRoom` helper (`rankedOverride=undefined`) — see §5 for the pairing-vs-non-pairing lookup it performs. Admission is delegated to the room state (§6). |
|
||||
|
||||
On strategy error: `JOINERROR` frame + `socket.close()`.
|
||||
|
||||
`TicketJoinStrategy` and `DefaultJoinStrategy` share one function,
|
||||
`findOrCreateRoom` (`src/ygopro/room/application/join-strategies/findOrCreateRoom.ts`),
|
||||
parameterized by `{ rankedOverride }`. The two strategies differ ONLY in that
|
||||
parameter — the find-or-create logic itself, including the pairing-join
|
||||
branch described in §5, is identical for both.
|
||||
|
||||
## 3. Rule resolution (token catalog)
|
||||
|
||||
`YGOProRoom.create` (`src/ygopro/room/domain/YGOProRoom.ts:198-233`) lowercases
|
||||
each token and applies three tiers from
|
||||
`src/ygopro/room/domain/RuleMappings.ts`, in order — later tiers overwrite
|
||||
earlier ones for the same option; **two matches inside one tier throw**:
|
||||
|
||||
1. **`ruleMappings`** (mode): `m`/`match`, `t`/`tag`.
|
||||
2. **`formatRuleMappings`** (format presets): `edison`/`ed`, `hat`, `tengu`,
|
||||
`md`, `jtp`, `jtp-2007-03`, `jm`, `gx`, `mdc`, `goat`,
|
||||
`genesys`/`g`/`g<N>`/`genesys<N>`, `rush`, `rushpre`, `speed`, `world`,
|
||||
`pre`, `ocg`, `tcgpre`, `ocgpre`, `tcgart`, `ocgart`.
|
||||
3. **`priorityRuleMappings`** (modifiers, win over format tokens): `bo<N>`,
|
||||
`lp<N>`, `tm<N>`/`time<N>`, `mr<N>`/`duelrule<N>`, `ot`/`tcg`, `otto`,
|
||||
`toot`/`tt`, `ns`, `nc`, `dr<N>`, `st<N>`, `to`/`tcgonly`/`tor`,
|
||||
`lf*`/`lflist*`, `nf`/`nolflist`, `oor`/`oo`/`ocgonly`, `or`, `tr`, `oomr`,
|
||||
`omr`, `tomr`, `tmr`.
|
||||
|
||||
Notes worth knowing:
|
||||
|
||||
- **`tcg` is an alias of `ot`** (priority tier): payload is `{ rule: 5 }` only —
|
||||
banlist and duel rule stay at the host defaults (first TCG banlist,
|
||||
duel_rule 5).
|
||||
- `ocg` → `{ rule: 0, lflist: 0, time_limit: 450 }`;
|
||||
`md` → `{ rule: 5, lflist: alias("md"), duel_rule: 5, time_limit: 450 }`;
|
||||
`tcgpre` → `{ rule: 5, duel_rule: 5, time_limit: 450 }` (default TCG lflist);
|
||||
`ocgpre` → `{ rule: 5, duel_rule: 5, lflist: 0, time_limit: 450 }`;
|
||||
`tcgart`/`ocgart` are byte-identical to `tcgpre`/`ocgpre` in ruleset, but
|
||||
kept as separate tokens on purpose — token = room identity (§4/§5), so
|
||||
merging them would silently merge two distinct pairing pools.
|
||||
- `g` resolves genesys (`rule: 1`, genesys banlist, `max_deck_points`
|
||||
default 100) and **throws if the genesys banlist is not loaded**.
|
||||
- `pre`, `tcgpre`, `ocgpre`, `tcgart`, `ocgart`, `rushpre` additionally enable
|
||||
the **extended card pool** (`extendedCardPoolFormats`,
|
||||
`RuleMappings.ts:707`) — an effect separate from the tier payloads.
|
||||
- Banlist aliases resolve by **normalized substring inclusion**
|
||||
(`MercuryBanListMemoryRepository.findIndexByAlias`), not exact match.
|
||||
- Unrecognized tokens are silently ignored (host defaults apply:
|
||||
`rule: 1`, `mode: SINGLE`, `duel_rule: 5`, first TCG banlist).
|
||||
- AI-pool resolution for random windbots mirrors the tier precedence
|
||||
(`src/ygopro/windbot/domain/resolveBotPool.ts`): priority TCG tokens win
|
||||
over format tokens regardless of position.
|
||||
|
||||
## 4. Room identity and matching
|
||||
|
||||
- `room.name` = the **raw, case-preserved** config segment of whichever string
|
||||
created the room. `room.password` = the raw segment after `#`.
|
||||
- For **non-pairing** joins, room identity is the exact **(name, password)
|
||||
pair**: `YGOProRoomList.findByNameAndPassword`
|
||||
(`src/ygopro/room/infrastructure/YGOProRoomList.ts`) does plain `===` on
|
||||
both `room.name` and `room.password` against the joiner's `command` and
|
||||
`password`, and returns the **first** room matching both. A miss creates a
|
||||
new room under that exact pair — it never rejects the join. Two rooms
|
||||
sharing a name but differing in password are therefore always
|
||||
distinguishable and independently reachable.
|
||||
- `YGOProRoomList.findByName` (name-only match) remains available for callers
|
||||
that need a name-only lookup (e.g. `MatchmakingRoomFactory`'s
|
||||
name-collision check when minting a fresh matchmaking room name); the join
|
||||
pipeline's non-pairing lookup uses `findByNameAndPassword` instead.
|
||||
- Consequences:
|
||||
- **Case-sensitive identity**: `tcg` and `TCG` are two different rooms that
|
||||
resolve to the same ruleset.
|
||||
- **A bare token is both config AND room identity**: two strangers sending
|
||||
exactly `tcg` land in the same room and get seated as the two players.
|
||||
This started as an emergent side effect of `findByName`'s first-match
|
||||
lookup; §5 formalizes it into the designed pairing feature (still keyed
|
||||
on the exact command string, now state- and seat-aware).
|
||||
- `findByNameAndPassword` returns the **first** room matching the pair and
|
||||
**ignores room state** — a room stays in the list (and stays matchable)
|
||||
through `waiting → rps → choosingOrder → dueling → sideDecking` until
|
||||
`FinalizeYGOProRoom.run` removes it at match end. A joiner who supplies
|
||||
the correct password for a room mid-duel still resolves to that room
|
||||
(spectating decided downstream — see §6); a joiner who mistypes the
|
||||
password resolves to no existing room and gets a fresh, empty one of
|
||||
their own instead of a rejection.
|
||||
|
||||
## 5. Pairing joins
|
||||
|
||||
Any client can pair players by having them send the same bare token command
|
||||
(e.g. `TCG`, `edison`) with no password — the server matches them into the
|
||||
same room or gives each side a fresh one.
|
||||
|
||||
Added on top of the pipeline in §2 — `findOrCreateRoom`
|
||||
(`src/ygopro/room/application/join-strategies/findOrCreateRoom.ts`) branches
|
||||
into a **pairing lookup** instead of the non-pairing `findByNameAndPassword`
|
||||
lookup when the join qualifies as a pairing join.
|
||||
|
||||
**Placement in the pipeline:** this branch lives entirely inside step 4 of §2
|
||||
(`JoinStrategyRegistry.resolve(ctx) → strategy.handle(ctx)`), specifically
|
||||
inside the shared `findOrCreateRoom` helper called by both `TicketJoinStrategy`
|
||||
and `DefaultJoinStrategy`. It runs AFTER strategy resolution (so `AIJoinTokenStrategy`
|
||||
and `WindBotJoinStrategy` are never affected) and BEFORE room admission (§6) —
|
||||
it only decides WHICH room object the join is routed to, never who gets
|
||||
seated once there.
|
||||
|
||||
**Predicate** (`isPairingJoin`,
|
||||
`src/ygopro/room/application/join-strategies/isPairingJoin.ts`): a join is a
|
||||
pairing join when ALL of the following hold:
|
||||
|
||||
- the password segment is empty (no `#` in the command, or nothing after it);
|
||||
- the command is non-empty;
|
||||
- every comma-separated token, trimmed and lowercased, is **recognized**:
|
||||
`isRecognizedToken(token)` (exported from `RuleMappings.ts` — true when the
|
||||
token matches at least one tier's `validate()`, per §3) **or** the literal
|
||||
token `"casual"`.
|
||||
|
||||
Concretely NOT pairing joins: `ai` (intercepted earlier by
|
||||
`WindBotJoinStrategy` when windbot is enabled; when disabled it falls through
|
||||
here and is simply unrecognized — conservative, not a special case),
|
||||
`mm<...>` (the matchmaking queue's generated marker token), any arbitrary
|
||||
unrecognized room name, and the blank command.
|
||||
|
||||
**Pairing key is the exact raw command string** — case and token order
|
||||
matter. `edison,ns` and `ns,edison` are two different pairing pools and will
|
||||
NOT pair with each other; `TCG` and `tcg` are likewise two different pools
|
||||
(consistent with the case-sensitive room identity already described in §4).
|
||||
This is a deliberate product decision: players expect to pair only with
|
||||
someone who typed the exact same command.
|
||||
|
||||
**Lookup, inside `findOrCreateRoom`:**
|
||||
|
||||
- Pairing join → `YGOProRoomList.findJoinableByName(command, options)`. Every
|
||||
candidate with a matching name is scanned (not just the first), and ALL of
|
||||
the following are evaluated **per candidate, inside the same scan** — a
|
||||
candidate that fails any one of them is simply skipped, it can never
|
||||
shadow a later qualifying candidate:
|
||||
- `duelState === WAITING` and has a free seat (state- and seat-aware —
|
||||
see §4);
|
||||
- `password === ""` (`requireEmptyPassword`) — a passworded same-named
|
||||
room (e.g. `tcg#secret`) is skipped, not treated as a match-then-reject;
|
||||
- league compatibility for **guest** joiners only (`excludeRankedForGuest`):
|
||||
a joiner with no resolved ticket identity and no PIN skips any candidate
|
||||
whose league is ranked (Verified/External), because `RoomAdmission`
|
||||
hard-rejects a guest in a ranked room (JOINERROR + close, no spectator
|
||||
fallback) rather than seating or spectating it. Non-guest joiners
|
||||
(ticket or PIN) are not filtered by league — they keep the pre-existing
|
||||
behavior.
|
||||
- Found → join it.
|
||||
- Not found (no same-named room at all, or every same-named room is
|
||||
dueling/full/passworded/league-incompatible for this guest) → **create a
|
||||
new room** with the same name (`rankedOverride` per the calling
|
||||
strategy, same as any other creation).
|
||||
- Non-pairing join → `findByNameAndPassword(command, password)` (first-match
|
||||
on the exact pair, state-blind), exactly as in §4 — a match joins whatever
|
||||
state that room is in (spectating included); no match **creates a new
|
||||
room** under that name and password rather than rejecting. See §8 for the
|
||||
trade-off this implies for a mistyped password.
|
||||
|
||||
**Never-spectate guarantee (mostly — see the seat-race caveat):** because a
|
||||
pairing join either lands in a room that `findJoinableByName` just confirmed
|
||||
is `WAITING` with a free seat, or creates a brand-new `WAITING` room, a
|
||||
pairing join can **never** be routed into a dueling/mid-match room. The
|
||||
unconditional-spectator path in `YGOProDuelingState.handleJoin` (§6) is
|
||||
therefore unreachable for pairing joins — it only ever fires for non-pairing
|
||||
joins (passworded rooms, or an unrecognized/arbitrary room name), where the
|
||||
behavior is unchanged from before this feature.
|
||||
|
||||
Caveat: `hasFreeSeat()` (used by `findJoinableByName`) is an explicitly
|
||||
**lock-free routing hint** — it does not hold the room's mutex. Two pairing
|
||||
joiners can both observe the same last free seat as available and both get
|
||||
routed to the same room; real admission (`RoomAdmission.decide`, inside
|
||||
`room.mutex.runExclusive`) is the actual arbiter, and the loser of that race
|
||||
degrades to **spectator** (no free seat left → `RoomAdmission` returns
|
||||
`spectator`, per §6), not a rejection. This is the one gap in the
|
||||
"never-spectate" guarantee: it holds for the routing decision, not for the
|
||||
sub-mutex admission race on the very last seat.
|
||||
|
||||
## 6. Player vs spectator
|
||||
|
||||
Admission depends on the room state active when `JOIN` fires:
|
||||
|
||||
- **WAITING** (`YGOProWaitingState.handleJoin`): duplicate name → reject;
|
||||
otherwise `RoomAdmission.decide`
|
||||
(`src/shared/room/admission/domain/RoomAdmission.ts:32`):
|
||||
ranked + guest → reject; league doesn't admit → spectator;
|
||||
**no free seat → spectator**; else seated as player. A spectator can later be
|
||||
promoted via `TO_DUEL` (waiting state only).
|
||||
- **RPS / ChoosingOrder / SideDecking / Dueling**: joiners are matched against
|
||||
existing players by `findReconnectingPlayer` (name, and for unranked rooms
|
||||
also remote address + original socket closed). Reconnect on match; otherwise
|
||||
**unconditionally a spectator** — mid-match joins never create players, and
|
||||
there is no "room is busy → go elsewhere" path.
|
||||
|
||||
## 7. What join does NOT use
|
||||
|
||||
- The HTTP room listings (`GET /api/getrooms`, `GET /api/rooms`) are read-only
|
||||
browse endpoints; the join pipeline never consults them.
|
||||
- The HTTP matchmaking queue (`MatchmakingQueue`, `MatchmakingRoomFactory`) is
|
||||
a separate engine: it pairs players per format, creates a room with a
|
||||
generated `"<token>,mm<id>#<pass>"` string, and hands both players that exact
|
||||
string to send through the normal socket join pipeline. The only room-side
|
||||
marker is `room.isMatchmaking = true`. That generated string always carries
|
||||
an `mm<id>` token, which `isRecognizedToken` does not recognize — so
|
||||
matchmaking joins are never pairing joins (§5); they always go through the
|
||||
`findByNameAndPassword` lookup (§4/§5), resolving to their room by the exact
|
||||
generated `(name, password)` pair.
|
||||
|
||||
## 8. Known caveats (current behavior)
|
||||
|
||||
1. ~~Bare-token pairing works for the first two players, but a **third**
|
||||
sender of the same token while the duel is running silently becomes a
|
||||
spectator of two strangers.~~ **Fixed by the pairing feature (§5)** for any
|
||||
command where every token is recognized (or `casual`): a third sender gets
|
||||
a brand-new room instead of spectating, and a fourth sender pairs with the
|
||||
third's room rather than the still-dueling first one. The caveat still
|
||||
applies verbatim to commands with an unrecognized token or a password
|
||||
segment — those keep using `findByNameAndPassword` (state-blind,
|
||||
first-match on the pair) and can still land a joiner as a spectator of a
|
||||
dueling room when the pair matches.
|
||||
2. Room identity is case-sensitive while rule resolution is not; clients that
|
||||
uppercase their commands partition themselves from clients that lowercase.
|
||||
The pairing feature (§5) inherits this exactly — `TCG` and `tcg` are two
|
||||
distinct pairing pools, not one.
|
||||
3. `YGOProRoomList.findByName` (name-only, ignoring password) returns the
|
||||
first name match regardless of state; a caller routing through it alone
|
||||
can only ever reach the first same-named room. The join pipeline does not
|
||||
have this problem: non-pairing joins resolve by the full `(name,
|
||||
password)` pair via `findByNameAndPassword`, so a second, third, etc.
|
||||
same-named room under a different password is always independently
|
||||
reachable; pairing joins (§5) scan every same-named room via
|
||||
`findJoinableByName` and skip dueling/full/passworded ones. The caveat
|
||||
still applies to any other caller that uses plain `findByName` directly
|
||||
(e.g. `MatchmakingRoomFactory`'s name-collision check when minting a new
|
||||
matchmaking room name).
|
||||
4. Because room identity for a non-pairing join is the exact `(name,
|
||||
password)` pair, there is no "wrong password" rejection on this path: a
|
||||
joiner who mistypes the password of an existing room does not get an
|
||||
error — they get a brand-new, empty room under that name and their typo'd
|
||||
password, indistinguishable in kind from any other room. This is a
|
||||
deliberate trade-off (see §4/§5): treating the full command string as the
|
||||
identity avoids `findByName`'s first-match collision with same-named
|
||||
pairing/other rooms, at the cost of silent typo-driven room creation
|
||||
instead of an explicit reject. Because there is no reject on this path, a
|
||||
mismatched password also no longer terminates the connection: the socket
|
||||
is never destroyed, and the joiner instead lands in the newly created room.
|
||||
5. The 20-char `pass` ceiling applies to the entire command string; bot names
|
||||
are boot-validated against a 13-char budget that assumes short (≤3 chars +
|
||||
comma) format tokens.
|
||||
|
|
@ -1,185 +0,0 @@
|
|||
# Revisión de arquitectura Node.js ↔ C++ (child_process)
|
||||
|
||||
## Resumen ejecutivo
|
||||
|
||||
El diseño actual funciona, pero tiene cuellos de botella y riesgos de robustez en el canal IPC:
|
||||
|
||||
1. Se mezclan **dos protocolos distintos** (entrada con JSON por línea y salida con JSON prefijado por longitud).
|
||||
2. Hay manejo incompleto de **backpressure** en `stdin` desde Node.js.
|
||||
3. El parser de salida en Node.js procesa **solo un mensaje por evento**, lo que puede dejar mensajes acumulados.
|
||||
4. En C++ se emiten logs por `stdout` fuera del framing, lo que puede corromper el stream.
|
||||
5. Se envía la configuración inicial por `argv[1]`, con riesgo de límite de tamaño y sin validación de `argc`.
|
||||
|
||||
---
|
||||
|
||||
## Hallazgos técnicos
|
||||
|
||||
### 1) Protocolo de entrada/salida asimétrico
|
||||
|
||||
- Node inicia el core con un JSON grande en los argumentos de proceso (`argv[1]`).
|
||||
- Luego envía comandos por `stdin` como JSON terminado en `\n`.
|
||||
- C++ responde por `stdout` con framing binario: `uint32_le + json`.
|
||||
|
||||
Esto obliga a mantener parsers distintos en ambos sentidos y complica diagnóstico, retries y evolución de versión de protocolo.
|
||||
|
||||
### 2) Backpressure: retries con `setTimeout` en vez de cola + `drain`
|
||||
|
||||
`writeToCppProcess` reintenta en 100ms si `stdin.write()` devuelve `false`, pero no espera evento `drain` ni serializa una cola explícita. En picos puede causar:
|
||||
|
||||
- Reintentos redundantes.
|
||||
- Latencia artificial.
|
||||
- Riesgo de duplicación/reordenamiento si cambian timings.
|
||||
|
||||
### 3) Consumo parcial de mensajes del core
|
||||
|
||||
En `DuelingState`, al recibir `stdout` se llama `processMessage()` una sola vez por chunk, y `processMessage()` no drena en bucle mientras haya mensajes listos. Si un chunk trae múltiples frames, se procesa el primero y los demás dependen de un evento futuro.
|
||||
|
||||
### 4) Posible corrupción del canal de salida del core
|
||||
|
||||
El core envía mensajes de protocolo por `stdout` con framing binario (`send_message`), pero también hay código que escribe logs a `stdout` (`Timer expired...`). Cualquier byte no enmarcado rompe el parser Node.
|
||||
|
||||
### 5) Inicialización frágil por argumentos de proceso
|
||||
|
||||
`main.cpp` usa `argv[1]` sin validar `argc`. Además, serializar toda la configuración y decks en la línea de comandos aumenta riesgo por límites del sistema operativo y dificulta observabilidad segura.
|
||||
|
||||
### 6) Mercury: bootstrap de puerto con `stdout.once("data")`
|
||||
|
||||
En Mercury se toma el puerto del primer chunk de `stdout` (`once("data")`). Si llegan bytes parciales/extra en el primer chunk, el parseo se vuelve frágil.
|
||||
|
||||
---
|
||||
|
||||
## Plan de mejora recomendado (priorizado)
|
||||
|
||||
## Fase 1 (alto impacto, bajo riesgo)
|
||||
|
||||
1. **Unificar logs fuera del canal IPC**
|
||||
- Regla: protocolo exclusivamente por `stdout`; logs exclusivamente por `stderr`.
|
||||
- Mover cualquier `std::cout` de diagnóstico a `std::cerr` en C++.
|
||||
|
||||
2. **Drenar completamente frames en Node**
|
||||
- Cambiar `processMessage()` para iterar `while (isMessageReady())`.
|
||||
- Mantener límite de seguridad por tick para evitar starvation (ej. 1k mensajes).
|
||||
|
||||
3. **Backpressure correcto**
|
||||
- Implementar cola FIFO de comandos a C++.
|
||||
- Escribir hasta que `write()` devuelva `false`, pausar y continuar en `duel.stdin.once("drain")`.
|
||||
|
||||
## Fase 2 (robustez de protocolo)
|
||||
|
||||
4. **Handshake/versionado de protocolo**
|
||||
- Mensaje inicial `HELLO { protocolVersion, features }` en ambos sentidos.
|
||||
- Rechazar versiones incompatibles explícitamente.
|
||||
|
||||
5. **Unificar framing en ambas direcciones**
|
||||
- Opción recomendada: `length-prefixed JSON` para `stdin` y `stdout`.
|
||||
- Evitar parser por saltos de línea para comandos entrantes.
|
||||
|
||||
6. **Mover bootstrap de configuración a `stdin`**
|
||||
- En vez de `argv[1]`, enviar `INIT` por el mismo canal framed.
|
||||
- Añadir validaciones de esquema y respuesta de `ACK_INIT`.
|
||||
|
||||
## Fase 3 (performance y operación)
|
||||
|
||||
7. **Codificación binaria para mensajes calientes**
|
||||
- Mantener JSON para control-plane.
|
||||
- Usar MessagePack/CBOR (o binario propio) para data-plane de alta frecuencia.
|
||||
|
||||
8. **Pool de workers C++ por matchmaker**
|
||||
- Evaluar proceso por duelo vs. worker pool según throughput objetivo.
|
||||
|
||||
9. **Observabilidad de IPC**
|
||||
- Métricas: cola IPC, `drain wait`, frames/s, parse errors, tamaño de frame p95/p99.
|
||||
|
||||
---
|
||||
|
||||
## ¿Se puede cambiar JSON por otro protocolo más rápido?
|
||||
|
||||
Sí. Para este caso (Node + C++ con mensajes frecuentes), las opciones más prácticas son:
|
||||
|
||||
### 1) MessagePack (recomendado)
|
||||
|
||||
- **Ventajas**: payload más pequeño que JSON, parseo más rápido, esquema flexible, librerías maduras en Node y C++.
|
||||
- **Costo de migración**: medio.
|
||||
- **Uso sugerido**: reemplazo directo para mensajes actuales (`START`, `TIME`, `CORE`, etc.) con framing por longitud.
|
||||
|
||||
### 2) FlatBuffers / Cap’n Proto
|
||||
|
||||
- **Ventajas**: muy alto rendimiento, acceso casi zero-copy.
|
||||
- **Costo de migración**: alto (IDL, generación de código, versionado estricto).
|
||||
- **Uso sugerido**: si el cuello de botella IPC ya está probado en profiling y se requiere latencia ultra baja.
|
||||
|
||||
### 3) Protobuf
|
||||
|
||||
- **Ventajas**: ecosistema excelente, buen versionado, rendimiento sólido.
|
||||
- **Costo de migración**: medio/alto por definición de `.proto` y mapeo de tipos.
|
||||
- **Uso sugerido**: si se prioriza interoperabilidad y contratos muy estables.
|
||||
|
||||
### Decisión práctica sugerida
|
||||
|
||||
1. Corto plazo: **JSON length-prefixed bidireccional** (homogeneizar primero).
|
||||
2. Mediano plazo: migrar a **MessagePack length-prefixed** para data/control plane.
|
||||
3. Largo plazo: evaluar **FlatBuffers/Cap’n Proto** solo con métricas que justifiquen la complejidad.
|
||||
|
||||
---
|
||||
|
||||
## ¿Hay un canal de comunicación mejor que `child_process` stdio?
|
||||
|
||||
Sí, dependiendo del objetivo.
|
||||
|
||||
### Opción A) Unix Domain Socket (UDS) / Named Pipe (recomendado si siguen procesos separados)
|
||||
|
||||
- **Pros**: canal dedicado, menor overhead que TCP local, fácil multiplexar, control más fino de reconexión/healthcheck.
|
||||
- **Contras**: más complejidad operativa que stdio.
|
||||
- **Cuándo usar**: cuando necesitan robustez, observabilidad y posibilidad de reinicio independiente del core.
|
||||
|
||||
### Opción B) TCP loopback (127.0.0.1)
|
||||
|
||||
- **Pros**: simple, portable, útil si ya hay arquitectura tipo Mercury con puertos.
|
||||
- **Contras**: overhead mayor que UDS.
|
||||
|
||||
### Opción C) Node-API addon (in-process)
|
||||
|
||||
- **Pros**: máxima performance (sin serialización IPC entre procesos).
|
||||
- **Contras**: riesgo de tumbar todo el proceso Node ante fallo nativo; despliegue y debugging más complejos.
|
||||
- **Cuándo usar**: solo si priorizan latencia extrema y aceptan costo operacional alto.
|
||||
|
||||
### Opción D) gRPC local
|
||||
|
||||
- **Pros**: contratos claros, observabilidad, tooling.
|
||||
- **Contras**: overhead y complejidad mayor para este tipo de motor de duelo de alta frecuencia.
|
||||
|
||||
### Recomendación de canal
|
||||
|
||||
1. Mantener procesos separados (aislamiento de fallos).
|
||||
2. Migrar de stdio a **UDS + framing binario (MessagePack)**.
|
||||
3. Reservar addon in-process para una fase posterior, solo si benchmarks reales lo exigen.
|
||||
|
||||
---
|
||||
|
||||
## Riesgos actuales visibles
|
||||
|
||||
- Deadlocks/lags intermitentes bajo carga por backpressure incompleto.
|
||||
- Corrupción de stream por logs en `stdout` del proceso C++.
|
||||
- Mensajes pendientes en buffer Node sin drenar de inmediato.
|
||||
- Falla de arranque por `argv[1]` ausente o demasiado grande.
|
||||
|
||||
---
|
||||
|
||||
## Quick wins concretos (1–2 días)
|
||||
|
||||
1. Mover logs C++ de `stdout` a `stderr` y auditar todo uso de `std::cout` fuera de `send_message`.
|
||||
2. Refactor de `DuelingState.processMessage()` para drenar en bucle.
|
||||
3. Reemplazar retries temporizados por cola+`drain` en `Room.writeToCppProcess`.
|
||||
4. Validar `argc` en `main.cpp` y emitir error estructurado por `stderr`.
|
||||
|
||||
---
|
||||
|
||||
## Referencias de código revisadas
|
||||
|
||||
- `src/edopro/room/domain/states/dueling/DuelingState.ts`
|
||||
- `src/edopro/room/domain/Room.ts`
|
||||
- `src/edopro/messages/JSONMessageProcessor.ts`
|
||||
- `src/mercury/room/domain/MercuryRoom.ts`
|
||||
- `core/src/main.cpp`
|
||||
- `core/src/app/duel.cpp`
|
||||
- `core/src/modules/shared/DuelTurnTimer.cpp`
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
# Node.js ↔ C++ IPC performance testing
|
||||
|
||||
This document adds reproducible local benchmarks for the child-process IPC channel.
|
||||
|
||||
## Included scripts
|
||||
|
||||
- `npm run perf:protocol`
|
||||
- Microbenchmark for JSON serialization/parsing and length-prefixed frame encode/decode.
|
||||
- `npm run perf:ipc`
|
||||
- End-to-end IPC benchmark using a child-process echo worker that mimics current transport:
|
||||
- parent writes line-delimited JSON commands,
|
||||
- child replies with `uint32_le + json` frames.
|
||||
|
||||
## Environment variables
|
||||
|
||||
### `perf:protocol`
|
||||
|
||||
- `BENCH_ITERATIONS` (default `200000`)
|
||||
- `BENCH_PAYLOAD_BYTES` (default `256`)
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
BENCH_ITERATIONS=500000 BENCH_PAYLOAD_BYTES=512 npm run perf:protocol
|
||||
```
|
||||
|
||||
### `perf:ipc`
|
||||
|
||||
- `BENCH_MESSAGES` (default `20000`)
|
||||
- `BENCH_CONCURRENCY` (default `512`)
|
||||
- `BENCH_PAYLOAD_BYTES` (default `128`)
|
||||
|
||||
Example:
|
||||
|
||||
```bash
|
||||
BENCH_MESSAGES=50000 BENCH_CONCURRENCY=1024 BENCH_PAYLOAD_BYTES=256 npm run perf:ipc
|
||||
```
|
||||
|
||||
## Metrics reported
|
||||
|
||||
- Throughput (`throughputMsgPerSec`)
|
||||
- End-to-end latency (`p50`, `p95`, `p99`)
|
||||
- Backpressure stats (`drainCount`, `totalDrainWaitMs`)
|
||||
|
||||
## Suggested baseline workflow
|
||||
|
||||
1. Run both scripts on current branch.
|
||||
2. Save JSON output artifacts.
|
||||
3. Apply protocol changes (e.g. bidirectional length-prefix framing).
|
||||
4. Repeat benchmarks with same env vars.
|
||||
5. Compare p95/p99 latency and throughput deltas.
|
||||
|
||||
## Production metrics capture
|
||||
|
||||
You can enable periodic IPC metrics logs per room by setting:
|
||||
|
||||
```bash
|
||||
IPC_METRICS_ENABLED=true
|
||||
```
|
||||
|
||||
When enabled, each active EDO room logs a structured `IPC_METRICS` event every 60 seconds with:
|
||||
|
||||
- queue depth and max queue depth
|
||||
- commands enqueued/written and stdin write errors
|
||||
- drain count and total drain wait time
|
||||
- stdout chunk/bytes counts
|
||||
- processed frames, parse errors, and deferred ticks
|
||||
|
||||
Use this in production/staging to collect real IPC behavior and share snapshots for analysis.
|
||||
107
docs/testing.md
Normal file
107
docs/testing.md
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
# Testing conventions
|
||||
|
||||
How we write tests in EDOpro-server-ts: where they live, how we build test data, how we mock, and how we format them. The goal is one consistent pattern so any test reads like the one next to it.
|
||||
|
||||
> **Status:** Active standard. All tests are co-located under `src/` — the legacy `tests/` folder has been fully migrated and removed. New tests **must** follow this doc.
|
||||
|
||||
## Quick path — writing a new test
|
||||
|
||||
1. **Co-locate it.** Put `Thing.test.ts` next to `Thing.ts` inside `src/`. Never add tests to the root `tests/` folder.
|
||||
2. **Build domain objects with a Mother.** Use (or create) an Object Mother for shared domain entities. Use a local `make*` factory only for stubs specific to that one suite.
|
||||
3. **Mock infra with the shared doubles.** `LoggerMock`, `SocketMock`, `MessageRepositoryMock` for ubiquitous interfaces; `jest.mock()` for module singletons; `mock<T>()` (jest-mock-extended) for one-off interface mocks.
|
||||
4. **Reset singletons** in `afterEach` (e.g. `WindbotModule.resetForTests()`, `JoinStrategyRegistry.reset()`).
|
||||
5. **Format:** tab indentation — Biome applies it (`npm run format`), and your editor via `.editorconfig`.
|
||||
|
||||
## Where tests live
|
||||
|
||||
| Rule | Detail |
|
||||
|------|--------|
|
||||
| Co-location | `src/<feature>/Thing.test.ts` sits next to `src/<feature>/Thing.ts`. The test travels with the code it tests. |
|
||||
| Shared test support | Mothers and mocks live in `src/test-support/` (`mothers/`, `mocks/`), importable from any co-located test. |
|
||||
| Legacy `tests/` | Being phased out. Do not add to it. When you touch a module, migrate its legacy test to co-location. |
|
||||
|
||||
**Why co-location:** a mirrored `tests/` tree drifts from `src/` (it already did). The test next to the source is discovered together, refactored together, and never goes stale.
|
||||
|
||||
## Building test data
|
||||
|
||||
Decide by **what** you are building:
|
||||
|
||||
| You are building… | Use | Why |
|
||||
|-------------------|-----|-----|
|
||||
| A shared **domain entity** (Client, Player, Room, YGOProRoom, UserProfile, Game) | **Object Mother** | One canonical builder. Faker defaults expose hidden coupling; override only the field under test. |
|
||||
| A **local stub** used by one suite (a fake repo, provider, socket) | **Inline `make*` factory** | Lightweight, self-contained, fresh per test. No need to share. |
|
||||
|
||||
### Object Mother (domain entities)
|
||||
|
||||
A static `create(params?: Partial<Props>)` returning a real domain object, with sensible faker-backed defaults:
|
||||
|
||||
```ts
|
||||
export class ClientMother {
|
||||
static create(params?: Partial<ClientMotherProps>): Client {
|
||||
return new Client({
|
||||
name: params?.name ?? faker.person.firstName(),
|
||||
team: params?.team ?? faker.number.int({ min: 0, max: 1 }),
|
||||
// ...other faker defaults
|
||||
...params,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// usage — override only what the test cares about
|
||||
const client = ClientMother.create({ id: "1" });
|
||||
```
|
||||
|
||||
Rule: **one Mother per shared domain entity.** If an entity is built more than one way across the suite, that is a bug to consolidate (see Migration targets).
|
||||
|
||||
### Inline factory (local stubs)
|
||||
|
||||
For doubles that only one suite needs — compose with an `overrides` parameter:
|
||||
|
||||
```ts
|
||||
const makeRepo = (overrides: Partial<BotlistRepository> = {}): BotlistRepository => ({
|
||||
findAll: jest.fn().mockReturnValue([]),
|
||||
findByName: jest.fn().mockReturnValue(null),
|
||||
...overrides,
|
||||
});
|
||||
```
|
||||
|
||||
## Mocking
|
||||
|
||||
| Need | Use |
|
||||
|------|-----|
|
||||
| Ubiquitous infra interface (Logger, Socket, MessageRepository) | Shared **Mock class** from `src/test-support/mocks/` |
|
||||
| A module-level singleton (e.g. `WebSocketSingleton`) | `jest.mock("...path...")` at the top of the file |
|
||||
| A one-off interface mock | `mock<T>()` from **jest-mock-extended** (type-safe, no hand-rolled `jest.fn()` objects) |
|
||||
| A spy on a real method | `jest.spyOn(obj, "method")` |
|
||||
|
||||
**Pick one for one-off interface mocks: `mock<T>()`.** Do not hand-roll ad-hoc `jest.fn()` stub objects for interfaces a Mock class or `mock<T>()` already covers.
|
||||
|
||||
Always reset shared singleton state in `afterEach` so suites don't leak into each other.
|
||||
|
||||
## Formatting & naming
|
||||
|
||||
| Topic | Decision |
|
||||
|-------|----------|
|
||||
| Indentation | **Tabs**, enforced by Biome (`biome.json`) and auto-applied on commit via lint-staged (`biome check --write`). |
|
||||
| `describe` | The unit under test: `describe("WindbotModule")` or `describe("WindbotModule.requestBot()")`. |
|
||||
| `it` | Behavior, present tense, no "should": `it("throws when the token is missing")`. |
|
||||
| No scaffolding | No `PR-N` / `REQ-XXX` labels in comments or test names. Comments explain *why*, not *what*. |
|
||||
|
||||
## PR checklist
|
||||
|
||||
- [ ] Test is co-located in `src/`, next to its source.
|
||||
- [ ] Shared domain objects built via a Mother; local stubs via `make*` factories.
|
||||
- [ ] Infra mocked via shared Mock classes / `mock<T>()`; singletons reset in `afterEach`.
|
||||
- [ ] Tab indentation; `describe`/`it` follow the naming convention.
|
||||
- [ ] `npm run lint` and the test suite pass.
|
||||
|
||||
## Migration targets (incremental)
|
||||
|
||||
Known consolidation work, done opportunistically as modules are touched:
|
||||
|
||||
| Target | Status |
|
||||
|--------|--------|
|
||||
| `YGOProRoom` built two ways | ✅ Consolidated onto `YGOProRoomMother`. |
|
||||
| Mothers & mocks under `tests/` | ✅ Moved to `src/test-support/`. |
|
||||
| Tab formatting | ✅ Enforced by Biome (`biome check --write` on pre-commit via lint-staged). |
|
||||
| 26 legacy tests in `tests/` | ✅ Migrated to co-location; `tests/` removed and dropped from `jest roots`. |
|
||||
|
|
@ -1,132 +0,0 @@
|
|||
import eslint from "@eslint/js";
|
||||
import tseslint from "typescript-eslint";
|
||||
import importPlugin from "eslint-plugin-import";
|
||||
import jestPlugin from "eslint-plugin-jest";
|
||||
import globals from "globals";
|
||||
|
||||
export default tseslint.config(
|
||||
// Base ESLint recommended config
|
||||
eslint.configs.recommended,
|
||||
|
||||
// TypeScript ESLint recommended configs (only recommended, not stylistic)
|
||||
...tseslint.configs.recommended,
|
||||
|
||||
// Global configuration
|
||||
{
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.node,
|
||||
...globals.es2022,
|
||||
},
|
||||
parserOptions: {
|
||||
project: "./tsconfig.eslint.json",
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Disable empty interface/object check globally
|
||||
"@typescript-eslint/no-empty-interface": "off",
|
||||
"@typescript-eslint/no-empty-object-type": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// TypeScript files configuration
|
||||
{
|
||||
files: ["**/*.ts", "**/*.tsx"],
|
||||
plugins: {
|
||||
import: importPlugin,
|
||||
},
|
||||
settings: {
|
||||
"import/parsers": {
|
||||
"@typescript-eslint/parser": [".ts", ".tsx"],
|
||||
},
|
||||
"import/resolver": {
|
||||
typescript: {
|
||||
alwaysTryTypes: true,
|
||||
project: "./tsconfig.eslint.json",
|
||||
},
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
// Import rules
|
||||
"import/no-unresolved": "error",
|
||||
|
||||
// TypeScript rules - more permissive to match previous config
|
||||
"@typescript-eslint/explicit-function-return-type": "off",
|
||||
"@typescript-eslint/explicit-module-boundary-types": "off",
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unused-vars": "off", // Completely disable to match previous behavior
|
||||
"@typescript-eslint/no-empty-interface": "off", // Allow empty interfaces
|
||||
|
||||
// Disable stylistic rules that were causing errors
|
||||
"@typescript-eslint/array-type": "off",
|
||||
"@typescript-eslint/consistent-type-definitions": "off",
|
||||
"@typescript-eslint/consistent-type-assertions": "off",
|
||||
"@typescript-eslint/consistent-indexed-object-style": "off",
|
||||
"@typescript-eslint/prefer-nullish-coalescing": "off",
|
||||
"@typescript-eslint/prefer-regexp-exec": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"@typescript-eslint/no-unsafe-member-access": "off",
|
||||
"@typescript-eslint/no-unsafe-return": "off",
|
||||
"@typescript-eslint/no-unsafe-argument": "off",
|
||||
"@typescript-eslint/no-unsafe-enum-comparison": "off",
|
||||
"@typescript-eslint/no-unused-expressions": "off",
|
||||
"@typescript-eslint/restrict-template-expressions": "off",
|
||||
"@typescript-eslint/no-unnecessary-condition": "off",
|
||||
|
||||
// Keep important rules enabled
|
||||
"@typescript-eslint/no-floating-promises": "off",
|
||||
"@typescript-eslint/no-misused-promises": "off",
|
||||
"@typescript-eslint/await-thenable": "off",
|
||||
"@typescript-eslint/require-await": "off",
|
||||
"@typescript-eslint/no-unnecessary-type-assertion": "off",
|
||||
|
||||
// General code quality rules
|
||||
"no-console": "off",
|
||||
"no-debugger": "error",
|
||||
"prefer-const": "error",
|
||||
"no-var": "error",
|
||||
"no-await-in-loop": "off",
|
||||
"no-use-before-define": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// Test files configuration
|
||||
{
|
||||
files: ["**/*.test.ts", "**/*.spec.ts", "tests/**/*.ts"],
|
||||
plugins: {
|
||||
jest: jestPlugin,
|
||||
},
|
||||
languageOptions: {
|
||||
globals: {
|
||||
...globals.jest,
|
||||
},
|
||||
},
|
||||
rules: {
|
||||
...jestPlugin.configs.recommended.rules,
|
||||
"@typescript-eslint/no-explicit-any": "off",
|
||||
"@typescript-eslint/no-unsafe-assignment": "off",
|
||||
"@typescript-eslint/no-unsafe-call": "off",
|
||||
"jest/no-identical-title": "off",
|
||||
"jest/valid-title": "off",
|
||||
},
|
||||
},
|
||||
|
||||
// Ignore patterns
|
||||
{
|
||||
ignores: [
|
||||
"node_modules/**",
|
||||
"dist/**",
|
||||
"build/**",
|
||||
"coverage/**",
|
||||
"*.config.js",
|
||||
"*.config.mjs",
|
||||
"core/**",
|
||||
"databases/**",
|
||||
"repositories/**",
|
||||
".agents/**",
|
||||
"resources/**",
|
||||
],
|
||||
},
|
||||
);
|
||||
3207
genesys.json
3207
genesys.json
File diff suppressed because it is too large
Load diff
|
|
@ -6,9 +6,9 @@ module.exports = {
|
|||
testEnvironment: "node",
|
||||
modulePaths: [compilerOptions.baseUrl],
|
||||
moduleNameMapper: pathsToModuleNameMapper(compilerOptions.paths),
|
||||
roots: ["<rootDir>/src", "<rootDir>/tests"],
|
||||
roots: ["<rootDir>/src"],
|
||||
maxWorkers: "50%",
|
||||
transform: {
|
||||
"^.+\\.tsx?$": "ts-jest",
|
||||
"^.+\\.tsx?$": ["ts-jest", { tsconfig: "tsconfig.test.json" }],
|
||||
},
|
||||
};
|
||||
24384
package-lock.json
generated
24384
package-lock.json
generated
File diff suppressed because it is too large
Load diff
184
package.json
184
package.json
|
|
@ -1,99 +1,89 @@
|
|||
{
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
},
|
||||
"name": "edopro",
|
||||
"version": "2.13.2",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"dev": "ts-node-dev --inspect=0.0.0.0:9229 -r tsconfig-paths/register --respawn --poll --require dotenv/config src/index.ts ",
|
||||
"build": "tsc && tsc-alias -p tsconfig.json",
|
||||
"start": "npm run build && node --env-file=.env ./dist/src/index.js",
|
||||
"lint": "eslint .",
|
||||
"lint:fix": "eslint . --fix",
|
||||
"prepare": "husky",
|
||||
"create-user": "ts-node-dev -r tsconfig-paths/register --respawn --poll --require dotenv/config src/utils/create-user.ts",
|
||||
"genesys": "ts-node-dev -r tsconfig-paths/register --respawn --poll --require dotenv/config src/utils/generate-genesys-format-json.ts",
|
||||
"generate-mercury-pre-releases-cdb": "ts-node -r tsconfig-paths/register --require dotenv/config src/utils/generate-mercury-pre-releases-cdb.ts",
|
||||
"migrate-users": "ts-node-dev -r tsconfig-paths/register --respawn --poll --require dotenv/config src/utils/migrate-user-from-redis-to-postgres.ts",
|
||||
"migration:generate": "ts-node -r tsconfig-paths/register --require dotenv/config ./node_modules/typeorm/cli.js migration:generate -d ./src/evolution-types/src/data-source.ts ./src/evolution-types/src/migrations/$npm_config_name",
|
||||
"migration:run": "ts-node -r tsconfig-paths/register --require dotenv/config ./node_modules/typeorm/cli.js migration:run -d ./src/evolution-types/src/data-source.ts",
|
||||
"migration:revert": "ts-node -r tsconfig-paths/register --require dotenv/config ./node_modules/typeorm/cli.js migration:revert -d ./src/evolution-types/src/data-source.ts",
|
||||
"perf:protocol": "ts-node -r tsconfig-paths/register src/utils/perf/benchmark-protocol.ts",
|
||||
"perf:ipc": "ts-node -r tsconfig-paths/register src/utils/perf/benchmark-node-cpp-ipc.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
"@commitlint/config-conventional": "^20.0.0",
|
||||
"@eslint/compat": "^2.0.0",
|
||||
"@eslint/eslintrc": "^3.3.1",
|
||||
"@eslint/js": "^9.39.1",
|
||||
"@faker-js/faker": "^9.2.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"@typescript-eslint/eslint-plugin": "^8.48.0",
|
||||
"@typescript-eslint/parser": "^8.48.0",
|
||||
"eslint": "^9.39.1",
|
||||
"eslint-import-resolver-typescript": "^4.4.4",
|
||||
"eslint-plugin-import": "^2.32.0",
|
||||
"eslint-plugin-jest": "^29.2.1",
|
||||
"globals": "^16.5.0",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^30.2.0",
|
||||
"jest-mock-extended": "^4.0.0",
|
||||
"lint-staged": "^16.2.7",
|
||||
"prettier": "^3.6.2",
|
||||
"swc-loader": "^0.2.6",
|
||||
"ts-jest": "^29.4.5",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^5.9.3",
|
||||
"typescript-eslint": "^8.48.0"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/shuffle-array": "^1.0.5",
|
||||
"array-shuffle": "^4.0.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-lock": "^3.2.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"cheerio": "^1.1.2",
|
||||
"diod": "^3.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"koishipro-core.js": "^1.4.4",
|
||||
"load-json-file": "^7.0.1",
|
||||
"lzma-native": "^8.0.6",
|
||||
"nfkit": "^1.0.37",
|
||||
"pg": "^8.16.3",
|
||||
"pino": "^10.1.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"shuffle-array": "^1.0.1",
|
||||
"simple-git": "^3.30.0",
|
||||
"typeorm": "^0.3.27",
|
||||
"winston": "^3.18.3",
|
||||
"ws": "^8.18.3",
|
||||
"ygopro-deck-encode": "^1.0.16",
|
||||
"ygopro-lflist-encode": "^1.0.3",
|
||||
"ygopro-msg-encode": "^1.2.2",
|
||||
"yuzuthread": "^1.0.10",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.(js|ts)": "npm run lint:fix"
|
||||
}
|
||||
"engines": {
|
||||
"node": ">=24.11.0"
|
||||
},
|
||||
"name": "edopro",
|
||||
"version": "2.14.0",
|
||||
"description": "",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"test": "jest",
|
||||
"test:watch": "jest --watch",
|
||||
"dev": "ts-node-dev --inspect=0.0.0.0:9229 -r tsconfig-paths/register --respawn --poll --require dotenv/config src/index.ts ",
|
||||
"clean": "node -e \"require('fs').rmSync('dist', { recursive: true, force: true })\"",
|
||||
"build": "npm run clean && tsc && tsc-alias -p tsconfig.json",
|
||||
"start": "npm run build && node --env-file=.env ./dist/src/index.js",
|
||||
"lint": "biome lint",
|
||||
"lint:fix": "biome check --write",
|
||||
"format": "biome format --write",
|
||||
"check": "biome check",
|
||||
"prepare": "husky",
|
||||
"create-user": "ts-node-dev -r tsconfig-paths/register --respawn --poll --require dotenv/config src/utils/create-user.ts",
|
||||
"migrate-users": "ts-node-dev -r tsconfig-paths/register --respawn --poll --require dotenv/config src/utils/migrate-user-from-redis-to-postgres.ts",
|
||||
"migration:generate": "ts-node -r tsconfig-paths/register --require dotenv/config ./node_modules/typeorm/cli.js migration:generate -d ./src/evolution-types/src/data-source.ts ./src/evolution-types/src/migrations/$npm_config_name",
|
||||
"migration:run": "ts-node -r tsconfig-paths/register --require dotenv/config ./node_modules/typeorm/cli.js migration:run -d ./src/evolution-types/src/data-source.ts",
|
||||
"migration:revert": "ts-node -r tsconfig-paths/register --require dotenv/config ./node_modules/typeorm/cli.js migration:revert -d ./src/evolution-types/src/data-source.ts",
|
||||
"perf:protocol": "ts-node -r tsconfig-paths/register src/utils/perf/benchmark-protocol.ts",
|
||||
"perf:ipc": "ts-node -r tsconfig-paths/register src/utils/perf/benchmark-node-cpp-ipc.ts"
|
||||
},
|
||||
"keywords": [],
|
||||
"author": "",
|
||||
"license": "ISC",
|
||||
"devDependencies": {
|
||||
"@biomejs/biome": "2.5.0",
|
||||
"@commitlint/cli": "^20.1.0",
|
||||
"@commitlint/config-conventional": "^20.0.0",
|
||||
"@faker-js/faker": "^9.2.0",
|
||||
"@types/bcrypt": "^6.0.0",
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/express": "^5.0.5",
|
||||
"@types/jest": "^30.0.0",
|
||||
"@types/node": "^24.10.1",
|
||||
"@types/ws": "^8.18.1",
|
||||
"husky": "^9.1.7",
|
||||
"jest": "^30.2.0",
|
||||
"jest-mock-extended": "^4.0.1",
|
||||
"lint-staged": "^16.2.7",
|
||||
"swc-loader": "^0.2.6",
|
||||
"ts-jest": "^29.4.11",
|
||||
"ts-node-dev": "^2.0.0",
|
||||
"tsc-alias": "^1.8.16",
|
||||
"tsconfig-paths": "^4.2.0",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@types/shuffle-array": "^1.0.5",
|
||||
"array-shuffle": "^4.0.0",
|
||||
"async-mutex": "^0.5.0",
|
||||
"bcrypt": "^6.0.0",
|
||||
"better-lock": "^3.2.0",
|
||||
"better-sqlite3": "^12.6.2",
|
||||
"cheerio": "^1.1.2",
|
||||
"diod": "^3.0.0",
|
||||
"dotenv": "^17.2.3",
|
||||
"express": "^5.1.0",
|
||||
"ioredis": "^5.8.2",
|
||||
"koishipro-core.js": "^1.5.2",
|
||||
"load-json-file": "^7.0.1",
|
||||
"lzma-native": "^8.0.6",
|
||||
"nfkit": "^1.0.37",
|
||||
"pg": "^8.16.3",
|
||||
"pino": "^10.1.0",
|
||||
"pino-pretty": "^13.1.2",
|
||||
"reflect-metadata": "^0.2.2",
|
||||
"rxjs": "^7.8.2",
|
||||
"shuffle-array": "^1.0.1",
|
||||
"simple-git": "^3.30.0",
|
||||
"typeorm": "^0.3.27",
|
||||
"winston": "^3.18.3",
|
||||
"ws": "^8.18.3",
|
||||
"ygopro-deck-encode": "^1.0.16",
|
||||
"ygopro-lflist-encode": "^1.0.3",
|
||||
"ygopro-msg-encode": "^1.3.0",
|
||||
"yuzuthread": "^1.0.10",
|
||||
"zod": "^4.3.6"
|
||||
},
|
||||
"lint-staged": {
|
||||
"*.{js,ts,mjs,cjs,json,jsonc}": "biome check --write --no-errors-on-unmatched"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
215
resources.manifest.json
Normal file
215
resources.manifest.json
Normal file
|
|
@ -0,0 +1,215 @@
|
|||
{
|
||||
"comment": "Declarative resource manifest. Edit this file to add, remove, or change sources. See docs for schema.",
|
||||
"sources": [
|
||||
{
|
||||
"id": "edopro-cdbs",
|
||||
"type": "git",
|
||||
"url": "https://github.com/ProjectIgnis/BabelCDB.git",
|
||||
"branch": "master"
|
||||
},
|
||||
{
|
||||
"id": "edopro-scripts",
|
||||
"type": "git",
|
||||
"url": "https://github.com/ProjectIgnis/CardScripts.git",
|
||||
"branch": "master"
|
||||
},
|
||||
{
|
||||
"id": "edopro-lflists",
|
||||
"type": "git",
|
||||
"url": "https://github.com/ProjectIgnis/LFLists",
|
||||
"branch": "master"
|
||||
},
|
||||
{
|
||||
"id": "evolution-lflists",
|
||||
"type": "git",
|
||||
"url": "https://github.com/termitaklk/lflist",
|
||||
"branch": "main"
|
||||
},
|
||||
{
|
||||
"id": "evolution-assets",
|
||||
"type": "git",
|
||||
"url": "https://github.com/diangogav/evolution-assets",
|
||||
"branch": "main"
|
||||
},
|
||||
{
|
||||
"id": "ygopro-moecube-cdb",
|
||||
"type": "http",
|
||||
"url": "https://cdntx.moecube.com/ygopro-database/zh-CN/cards.cdb",
|
||||
"filename": "ygopro-moecube-cards.cdb"
|
||||
},
|
||||
{
|
||||
"id": "ygopro-fluorohydride-scripts",
|
||||
"type": "git",
|
||||
"url": "https://github.com/Fluorohydride/ygopro-scripts"
|
||||
},
|
||||
{
|
||||
"id": "moecube-lflist",
|
||||
"type": "http",
|
||||
"url": "https://cdntx.moecube.com/ygopro-database/zh-CN/lflist.conf",
|
||||
"filename": "moecube-lflist.conf"
|
||||
},
|
||||
{
|
||||
"id": "ygopro-moecube-prereleases",
|
||||
"type": "git",
|
||||
"url": "https://github.com/evolutionygo/pre-release-database-cdb",
|
||||
"branch": "master"
|
||||
},
|
||||
{
|
||||
"id": "custom-cards",
|
||||
"type": "git",
|
||||
"url": "https://github.com/evolutionygo/cards-art-server",
|
||||
"branch": "main"
|
||||
},
|
||||
{
|
||||
"comment": "ocgcore fork WASM — runtime add-on; the loader logs whether the fork or the stock core is active",
|
||||
"id": "edison-core",
|
||||
"type": "http",
|
||||
"url": "https://github.com/diangogav/evolution-ygopro-core/releases/download/v1.0.0-edison/libocgcore-edison-fork.wasm",
|
||||
"filename": "ocgcore-worker"
|
||||
}
|
||||
],
|
||||
"assembly": [
|
||||
{
|
||||
"comment": "EDOPro lflists",
|
||||
"target": "edopro/lflists",
|
||||
"from": "edopro-lflists"
|
||||
},
|
||||
{
|
||||
"comment": "EDOPro evolution lflists",
|
||||
"target": "edopro/evolution-lflists",
|
||||
"from": "evolution-lflists"
|
||||
},
|
||||
{
|
||||
"comment": "EDOPro databases",
|
||||
"target": "edopro/databases",
|
||||
"from": "edopro-cdbs"
|
||||
},
|
||||
{
|
||||
"comment": "EDOPro card scripts",
|
||||
"target": "edopro/scripts",
|
||||
"from": "edopro-scripts"
|
||||
},
|
||||
{
|
||||
"comment": "YGOPro base lflist",
|
||||
"target": "ygopro/base/lflist.conf",
|
||||
"from": "moecube-lflist",
|
||||
"file": "moecube-lflist.conf"
|
||||
},
|
||||
{
|
||||
"comment": "JTP lflist from evolution-assets",
|
||||
"target": "ygopro/formats/jtp/lflist.conf",
|
||||
"from": "evolution-assets",
|
||||
"file": "lflist/jtp.lflist.conf"
|
||||
},
|
||||
{
|
||||
"comment": "JTP Advanced March 2007 lflist from evolution-assets",
|
||||
"target": "ygopro/formats/jtp-adv-2007-03/lflist.conf",
|
||||
"from": "evolution-assets",
|
||||
"file": "lflist/jtp-advanced-marzo-2007.lflist.conf"
|
||||
},
|
||||
{
|
||||
"comment": "Genesys lflist from evolution-assets",
|
||||
"target": "ygopro/formats/genesys/lflist.conf",
|
||||
"from": "evolution-assets",
|
||||
"file": "lflist/genesys.lflist.conf"
|
||||
},
|
||||
{
|
||||
"comment": "Edison lflist from evolution-assets",
|
||||
"target": "ygopro/formats/edison/lflist.conf",
|
||||
"from": "evolution-assets",
|
||||
"file": "lflist/edison.lflist.conf"
|
||||
},
|
||||
{
|
||||
"comment": "ocgcore fork WASM — placed at ygopro/core/ocgcore-worker; read by the card-load worker when (re)building card storage",
|
||||
"target": "ygopro/core/ocgcore-worker",
|
||||
"from": "edison-core",
|
||||
"file": "ocgcore-worker"
|
||||
},
|
||||
{
|
||||
"target": "ygopro/formats/md/lflist.conf",
|
||||
"from": "evolution-lflists",
|
||||
"file": "MD.2025.03.lflist.conf"
|
||||
},
|
||||
{
|
||||
"target": "ygopro/formats/world/lflist.conf",
|
||||
"from": "edopro-lflists",
|
||||
"file": "World.lflist.conf"
|
||||
},
|
||||
{
|
||||
"target": "ygopro/formats/tengu/lflist.conf",
|
||||
"from": "evolution-lflists",
|
||||
"file": "Tengu.Plant.lflist.conf"
|
||||
},
|
||||
{
|
||||
"target": "ygopro/formats/speed/lflist.conf",
|
||||
"from": "edopro-lflists",
|
||||
"file": "Speed.lflist.conf"
|
||||
},
|
||||
{
|
||||
"target": "ygopro/formats/rush/lflist.conf",
|
||||
"from": "edopro-lflists",
|
||||
"file": "Rush.lflist.conf"
|
||||
},
|
||||
{
|
||||
"target": "ygopro/formats/goat/lflist.conf",
|
||||
"from": "edopro-lflists",
|
||||
"file": "GOAT.lflist.conf"
|
||||
},
|
||||
{
|
||||
"target": "ygopro/formats/ocg/lflist.conf",
|
||||
"from": "edopro-lflists",
|
||||
"file": "OCG.lflist.conf"
|
||||
},
|
||||
{
|
||||
"comment": "YGOPro base cards.cdb",
|
||||
"target": "ygopro/base/cards.cdb",
|
||||
"from": "ygopro-moecube-cdb",
|
||||
"file": "ygopro-moecube-cards.cdb"
|
||||
},
|
||||
{
|
||||
"comment": "YGOPro base scripts",
|
||||
"target": "ygopro/base/script",
|
||||
"from": "ygopro-fluorohydride-scripts"
|
||||
},
|
||||
{
|
||||
"comment": "Classic (pre-errata) variants for edison/hat — alt-coded cards, format legality via lflists",
|
||||
"target": "ygopro/classic/classic.cdb",
|
||||
"from": "evolution-assets",
|
||||
"file": "cdb/classic.cdb"
|
||||
},
|
||||
{
|
||||
"comment": "Edison pre-errata pool (bilingual .es/.en; server loads .es — engine is language-neutral)",
|
||||
"target": "ygopro/formats/edison/pre-errata.es.cdb",
|
||||
"from": "evolution-assets",
|
||||
"file": "cdb/pre-errata.es.cdb"
|
||||
},
|
||||
{
|
||||
"comment": "YGOPro Moecube prereleases (extended pool only)",
|
||||
"target": "ygopro/extensions/prereleases",
|
||||
"from": "ygopro-moecube-prereleases"
|
||||
},
|
||||
{
|
||||
"comment": "YGOPro cards art (extended pool only)",
|
||||
"target": "ygopro/extensions/custom-cards",
|
||||
"from": "custom-cards"
|
||||
}
|
||||
],
|
||||
"runtime": {
|
||||
"comment": "TS loader pool membership. standard = base + served formats (load order; base first for FIRST-occurrence dedup). extended = extra members appended after standard. Formats assembled but omitted here (goat, rush, speed, gx, mdc) are assembled-but-not-served by design.",
|
||||
"ygopro": {
|
||||
"standard": [
|
||||
"base",
|
||||
"formats/jtp",
|
||||
"formats/jtp-adv-2007-03",
|
||||
"formats/genesys",
|
||||
"formats/md",
|
||||
"formats/world",
|
||||
"formats/tengu",
|
||||
"formats/edison",
|
||||
"formats/hat",
|
||||
"formats/ocg"
|
||||
],
|
||||
"extended": ["extensions/prereleases", "extensions/custom-cards"]
|
||||
}
|
||||
}
|
||||
}
|
||||
25
resources.manifest.private.example.json
Normal file
25
resources.manifest.private.example.json
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
{
|
||||
"comment": "PRIVATE manifest override — TEMPLATE. Copy to resources.manifest.private.json (gitignored) and set the real repo url(s). resources-lib.sh merges this over the public base manifest (append sources + assembly), so PRIVATE git sources never appear in the public base. Add ANY private source here — not just pre-errata scripts. Not baked into the image: mount this file into the running container (-v <host>/resources.manifest.private.json:/app/resources.manifest.private.json:ro) and pass a read-only GH_PRIVATE_TOKEN via the container env (--env-file), or use an SSH url + key. The entrypoint's updater clones the private sources on start.",
|
||||
"sources": [
|
||||
{
|
||||
"id": "my-private-source",
|
||||
"type": "git",
|
||||
"url": "https://github.com/YOUR_ORG/YOUR_PRIVATE_REPO.git",
|
||||
"branch": "main"
|
||||
}
|
||||
],
|
||||
"assembly": [
|
||||
{
|
||||
"comment": "Example: Edison pre-errata card scripts served from the private repo.",
|
||||
"target": "ygopro/formats/edison/script",
|
||||
"from": "my-private-source",
|
||||
"dir": "card-scripts/edison"
|
||||
},
|
||||
{
|
||||
"comment": "Example: classic pre-errata card scripts served from the private repo.",
|
||||
"target": "ygopro/classic/script",
|
||||
"from": "my-private-source",
|
||||
"dir": "card-scripts/classic"
|
||||
}
|
||||
]
|
||||
}
|
||||
58
scripts/clone_repositories.sh
Executable file
58
scripts/clone_repositories.sh
Executable file
|
|
@ -0,0 +1,58 @@
|
|||
#!/usr/bin/env bash
|
||||
# clone_repositories.sh — FETCH stage: validate manifest, then fetch all sources.
|
||||
#
|
||||
# Stage: VALIDATE (RSM-005) then FETCH (RSM-006, RSM-007).
|
||||
# Runs from repo root (D1 cwd invariant — no cd into subdirs).
|
||||
# All checkout dirs are keyed by source id: repositories/<id> (D8).
|
||||
# Sources are read exclusively from resources.manifest.json (RSM-010).
|
||||
#
|
||||
# Usage: bash clone_repositories.sh
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/resources-lib.sh"
|
||||
|
||||
echo "Validating manifest..."
|
||||
validate_manifest "$MANIFEST_PATH" || exit 1
|
||||
|
||||
echo "Fetching sources..."
|
||||
|
||||
# Support test override for repos root; default matches setup_resources.sh.
|
||||
REPOS="${REPOS_ROOT:-./repositories}"
|
||||
|
||||
mkdir -p "$REPOS"
|
||||
|
||||
# Iterate all sources from the manifest
|
||||
source_count=$(manifest_get '.sources | length')
|
||||
|
||||
i=0
|
||||
while [ "$i" -lt "$source_count" ]; do
|
||||
src_id=$(manifest_get ".sources[$i].id")
|
||||
src_type=$(manifest_get ".sources[$i].type")
|
||||
src_url=$(manifest_get ".sources[$i].url")
|
||||
|
||||
case "$src_type" in
|
||||
git)
|
||||
# Optional branch — jq returns "null" when the field is absent
|
||||
src_branch=$(manifest_get ".sources[$i].branch // \"\"")
|
||||
echo " [git] $src_id"
|
||||
sync_repo "$src_id" "$src_url" "$src_branch"
|
||||
;;
|
||||
http)
|
||||
src_filename=$(manifest_get ".sources[$i].filename")
|
||||
local_path="$REPOS/$src_filename"
|
||||
echo " [http] $src_id -> $local_path"
|
||||
wget -qO "$local_path" "$src_url" || fail "wget failed for $src_id ($src_url)"
|
||||
http_integrity_check "$local_path" "$src_id" "$src_url" \
|
||||
|| fail "Integrity check failed for $src_id — aborting fetch stage"
|
||||
;;
|
||||
*)
|
||||
fail "Unknown source type '$src_type' for source '$src_id'"
|
||||
;;
|
||||
esac
|
||||
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
echo "Repositories synced."
|
||||
18
scripts/entrypoint.sh
Normal file
18
scripts/entrypoint.sh
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Container entrypoint: run the resource updater loop in the background so card
|
||||
# databases / banlists refresh live (picked up by the in-memory reload), and run
|
||||
# the server as the main foreground process. The image ships a baked resource
|
||||
# seed, so the server boots immediately while the first refresh runs in the
|
||||
# background. dumb-init (PID 1) forwards signals to the server and reaps the loop.
|
||||
|
||||
set -u
|
||||
|
||||
# Configure git to authenticate private manifest sources (read-only token from
|
||||
# GH_PRIVATE_TOKEN in the container env / --env-file). No-op when unset. Must run
|
||||
# before the updater loop, which re-clones sources.
|
||||
bash scripts/setup-git-credentials.sh
|
||||
|
||||
bash scripts/resources-updater.sh &
|
||||
|
||||
exec node ./src/index.js
|
||||
444
scripts/resources-lib.sh
Normal file
444
scripts/resources-lib.sh
Normal file
|
|
@ -0,0 +1,444 @@
|
|||
#!/usr/bin/env bash
|
||||
# resources-lib.sh — Shared library for clone_repositories.sh and setup_resources.sh
|
||||
#
|
||||
# Source this file; do NOT execute it directly.
|
||||
# Resolved manifest path is cwd-invariant: callers may set MANIFEST_PATH to override.
|
||||
#
|
||||
# Usage:
|
||||
# source "$(dirname "${BASH_SOURCE[0]}")/resources-lib.sh"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# fail — log an error to stderr and exit 1
|
||||
# ---------------------------------------------------------------------------
|
||||
fail() {
|
||||
echo "[resources][ERROR] $*" >&2
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Default manifest path — resolved relative to CWD (repo root).
|
||||
# All resource scripts must run from repo root (D1 cwd invariant).
|
||||
# The manifest lives at repo root; scripts now live in scripts/ but the
|
||||
# manifest stays at root, so a CWD-relative default is correct here.
|
||||
# Callers may override by setting MANIFEST_PATH before sourcing.
|
||||
# ---------------------------------------------------------------------------
|
||||
MANIFEST_PATH="${MANIFEST_PATH:-resources.manifest.json}"
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# jq preflight — fail fast with a clear message if jq is not available
|
||||
# ---------------------------------------------------------------------------
|
||||
_check_jq() {
|
||||
command -v jq >/dev/null 2>&1 || fail "jq is required but not found in PATH. Install jq before running resource scripts."
|
||||
}
|
||||
_check_jq
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Private override merge — keeps PRIVATE git sources (private repos, e.g. the
|
||||
# pre-errata scripts) OUT of the public base manifest. When the caller uses
|
||||
# the default base manifest AND a private override file exists, merge base +
|
||||
# override (append sources + assembly) into an effective manifest and use it.
|
||||
# Prod/CI provide resources.manifest.private.json (gitignored) + a read-only
|
||||
# GH_PRIVATE_TOKEN (or SSH) for the private repos. Generic: add any private
|
||||
# source here. See resources.manifest.private.example.json.
|
||||
# An explicitly-set MANIFEST_PATH (e.g. tests) skips the merge.
|
||||
# ---------------------------------------------------------------------------
|
||||
MANIFEST_OVERRIDE="${MANIFEST_OVERRIDE:-resources.manifest.private.json}"
|
||||
if [ "$MANIFEST_PATH" = "resources.manifest.json" ] && [ -f "$MANIFEST_OVERRIDE" ]; then
|
||||
MANIFEST_EFFECTIVE="${MANIFEST_EFFECTIVE:-resources.manifest.effective.json}"
|
||||
jq -s '.[0] as $b | .[1] as $o | $b
|
||||
| .sources = ($b.sources + ($o.sources // []))
|
||||
| .assembly = ($b.assembly + ($o.assembly // []))' \
|
||||
"$MANIFEST_PATH" "$MANIFEST_OVERRIDE" > "$MANIFEST_EFFECTIVE" \
|
||||
|| fail "failed to merge private manifest override into $MANIFEST_EFFECTIVE"
|
||||
MANIFEST_PATH="$MANIFEST_EFFECTIVE"
|
||||
fi
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# manifest_get — wrapper around jq queries on the manifest
|
||||
# Usage: manifest_get <jq-filter> [manifest-path]
|
||||
# ---------------------------------------------------------------------------
|
||||
manifest_get() {
|
||||
local filter="$1"
|
||||
local path="${2:-$MANIFEST_PATH}"
|
||||
jq -r "$filter" "$path"
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# validate_manifest — RSM-005 a-e fail-fast validation
|
||||
# Usage: validate_manifest <manifest-path>
|
||||
# Exits 0 on success, 1 on any violation (with message to stderr).
|
||||
# ---------------------------------------------------------------------------
|
||||
validate_manifest() {
|
||||
local manifest="${1:-$MANIFEST_PATH}"
|
||||
|
||||
# (a) Well-formed JSON
|
||||
if ! jq empty "$manifest" 2>/dev/null; then
|
||||
echo "[resources][ERROR] Manifest validation failed (a): malformed JSON in $manifest" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (structural guard) sources and assembly must be arrays
|
||||
local sources_type assembly_type
|
||||
sources_type=$(jq -r '(.sources | type)' "$manifest" 2>/dev/null)
|
||||
if [ $? -ne 0 ] || [ "$sources_type" != "array" ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (structural): .sources must be an array, got ${sources_type:-error}" >&2
|
||||
return 1
|
||||
fi
|
||||
assembly_type=$(jq -r '(.assembly | type)' "$manifest" 2>/dev/null)
|
||||
if [ $? -ne 0 ] || [ "$assembly_type" != "array" ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (structural): .assembly must be an array, got ${assembly_type:-error}" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (structural guard) every assembly[] element must be an object — a bare string,
|
||||
# number, or null would pass the array check but silently produce wrong behaviour
|
||||
# when later jq queries assume object keys (.target, .from, etc.). Name the index.
|
||||
local non_object_elements
|
||||
non_object_elements=$(jq -r '[.assembly | to_entries[] | select((.value | type) != "object") | .key] | .[]' "$manifest" 2>/dev/null)
|
||||
if [ -n "$non_object_elements" ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (structural): assembly element(s) at index ${non_object_elements} must be objects, not $(jq -r ".assembly[${non_object_elements}] | type" "$manifest" 2>/dev/null)" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (RSM-001) assembly[] target must be non-empty — a rule with a missing/empty
|
||||
# target resolves to a literal "null/" (or bare) publish dir. Name the rule index.
|
||||
local empty_targets
|
||||
empty_targets=$(jq -r '[.assembly | to_entries[] | select((.value.target // "") == "") | .key] | .[]' "$manifest" 2>/dev/null)
|
||||
if [ -n "$empty_targets" ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (RSM-001): assembly rule(s) with empty/missing target at index: $empty_targets" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (RSM-001) sources[] field validation
|
||||
# (1) every source id must be non-empty
|
||||
local empty_ids
|
||||
empty_ids=$(jq -r '[.sources[] | select((.id // "") == "")] | length' "$manifest" 2>/dev/null)
|
||||
if [ "$empty_ids" != "0" ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (RSM-001): source with empty/missing id in $manifest" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (2) source ids must be unique
|
||||
local dup_ids
|
||||
dup_ids=$(jq -r '[.sources[].id] | (group_by(.) | map(select(length > 1) | .[0])) | .[]' "$manifest" 2>/dev/null)
|
||||
if [ -n "$dup_ids" ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (RSM-001): duplicate source id(s): $dup_ids" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (3) every source url must be non-empty
|
||||
local empty_urls
|
||||
empty_urls=$(jq -r '[.sources[] | select((.url // "") == "") | .id] | .[]' "$manifest" 2>/dev/null)
|
||||
if [ -n "$empty_urls" ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (RSM-001): empty/missing url in source(s): $empty_urls" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (4) http sources must include a non-empty filename
|
||||
local missing_filename
|
||||
missing_filename=$(jq -r '[.sources[] | select(.type == "http") | select((.filename // "") == "") | .id] | .[]' "$manifest" 2>/dev/null)
|
||||
if [ -n "$missing_filename" ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (RSM-001): http source(s) missing filename: $missing_filename" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (5) whole-source fallback is unsupported in this slice — an assembly rule
|
||||
# whose from is an array MUST specify a file
|
||||
local array_no_file
|
||||
array_no_file=$(jq -r '[.assembly[] | select((.from | type) == "array") | select(has("file") | not) | .target] | .[]' "$manifest" 2>/dev/null)
|
||||
if [ -n "$array_no_file" ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (RSM-001): array from without file (whole-source fallback unsupported) in rule(s): $array_no_file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (6) An http source resolves to a flat file under repositories/ (its "source
|
||||
# dir" is repositories/ itself), so a rule referencing an http source
|
||||
# without a `file` key would copy the whole repositories/ tree. Reject any
|
||||
# rule whose from (string or any array element) points at an http source
|
||||
# when the rule has no `file`. Name the offending rule target.
|
||||
local http_no_file
|
||||
http_no_file=$(jq -r '
|
||||
(.sources | map(select(.type == "http") | .id)) as $http |
|
||||
[
|
||||
.assembly[] |
|
||||
select(has("file") | not) |
|
||||
.target as $t |
|
||||
.from as $from |
|
||||
(if ($from | type) == "array" then $from[] else $from end) |
|
||||
select(. as $f | ($http | index($f)) != null) |
|
||||
$t
|
||||
] | unique | .[]
|
||||
' "$manifest" 2>/dev/null)
|
||||
if [ -n "$http_no_file" ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (RSM-001): rule(s) referencing an http source without a file key (whole-source/dir/only over a flat http file unsupported): $http_no_file" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (7) An http source lands as a flat file named <filename> under repositories/,
|
||||
# so a rule whose resolved from (string or array element) is an http source
|
||||
# MUST reference that exact file via `file` == source.filename. A mismatch
|
||||
# would look for a non-existent file. Name the rule target + source id.
|
||||
local http_file_mismatch
|
||||
http_file_mismatch=$(jq -r '
|
||||
(.sources | map(select(.type == "http") | {id, filename})) as $http |
|
||||
($http | map(.id)) as $http_ids |
|
||||
[
|
||||
.assembly[] |
|
||||
.target as $t |
|
||||
(.file // "") as $rfile |
|
||||
.from as $from |
|
||||
(if ($from | type) == "array" then $from[] else $from end) |
|
||||
select(. as $f | ($http_ids | index($f)) != null) as $sid |
|
||||
($http[] | select(.id == $sid)) as $src |
|
||||
select($rfile != $src.filename) |
|
||||
($t + " (source " + $sid + ": file=\"" + $rfile + "\" != filename=\"" + ($src.filename // "") + "\")")
|
||||
] | unique | .[]
|
||||
' "$manifest" 2>/dev/null)
|
||||
if [ -n "$http_file_mismatch" ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (RSM-001): rule file must equal http source filename: $http_file_mismatch" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (b) No unknown source types — only "git" and "http" are allowed.
|
||||
# Use (.type|tostring) so non-string type values (e.g. integers) are safely
|
||||
# converted rather than causing jq to error. Capture jq exit status explicitly
|
||||
# so a jq crash (non-zero exit) is treated as a hard validation failure
|
||||
# instead of silently passing (fail-open prevention).
|
||||
local bad_types jq_rc
|
||||
bad_types=$(jq -r '[.sources[] | select((.type|tostring) != "git" and (.type|tostring) != "http")] | map(.id + " (type=" + (.type|tostring) + ")") | .[]' "$manifest" 2>/dev/null)
|
||||
jq_rc=$?
|
||||
if [ $jq_rc -ne 0 ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (b): jq error while checking source types (exit $jq_rc)" >&2
|
||||
return 1
|
||||
fi
|
||||
if [ -n "$bad_types" ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (b): unknown source type(s): $bad_types" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (c) No dangling from-ids — every from value (string or array) must reference a declared source id
|
||||
local dangling
|
||||
dangling=$(jq -r '
|
||||
(.sources | map(.id)) as $ids |
|
||||
[
|
||||
.assembly[] |
|
||||
.from as $from |
|
||||
if ($from | type) == "array" then $from[] else $from end |
|
||||
select(. as $f | ($ids | index($f)) == null)
|
||||
] | unique | .[]
|
||||
' "$manifest" 2>/dev/null)
|
||||
if [ -n "$dangling" ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (c): dangling from-id(s): $dangling" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (d) file must be mutually exclusive with dir and only
|
||||
local bad_combos
|
||||
bad_combos=$(jq -r '
|
||||
[
|
||||
.assembly[] |
|
||||
select(
|
||||
(has("file") and (has("dir") or has("only")))
|
||||
) |
|
||||
.target
|
||||
] | .[]
|
||||
' "$manifest" 2>/dev/null)
|
||||
if [ -n "$bad_combos" ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (d): file+dir or file+only combo in rule(s): $bad_combos" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (e) Target collision — two rules with the same target are only legal if the
|
||||
# later rule (higher array index) has overwrite:true
|
||||
local collision_targets
|
||||
collision_targets=$(jq -r '
|
||||
[
|
||||
.assembly | to_entries |
|
||||
group_by(.value.target) |
|
||||
.[] |
|
||||
select(length > 1) |
|
||||
. as $group |
|
||||
# All entries after the first must carry overwrite:true
|
||||
# If any entry after the first does NOT have overwrite:true, report the target
|
||||
if ($group[1:] | map(select(.value.overwrite != true)) | length) > 0
|
||||
then $group[0].value.target
|
||||
else empty
|
||||
end
|
||||
] | .[]
|
||||
' "$manifest" 2>/dev/null)
|
||||
if [ -n "$collision_targets" ]; then
|
||||
echo "[resources][ERROR] Manifest validation failed (e): target collision without overwrite: $collision_targets" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# http_integrity_check — RSM-006
|
||||
# Usage: http_integrity_check <filepath> <source-id> <url>
|
||||
# Checks: non-empty AND (for *.cdb) SQLite magic header.
|
||||
# ---------------------------------------------------------------------------
|
||||
http_integrity_check() {
|
||||
local filepath="$1"
|
||||
local src_id="$2"
|
||||
local url="$3"
|
||||
|
||||
# (a) Non-empty
|
||||
if [ ! -s "$filepath" ]; then
|
||||
echo "[resources][ERROR] Integrity check failed for $src_id ($url): file is empty" >&2
|
||||
return 1
|
||||
fi
|
||||
|
||||
# (b) For .cdb files: first 16 bytes must be the SQLite3 magic header.
|
||||
# Case-insensitive match (${filepath,,}) so both .cdb and .CDB trigger the check.
|
||||
# (D4) Binary comparison via cmp — command substitution strips the trailing
|
||||
# NUL byte, so string comparison would only check 15 of 16 magic bytes and
|
||||
# accept a file truncated to exactly "SQLite format 3". cmp compares the raw
|
||||
# first 16 bytes; a file shorter than 16 bytes fails because cmp reports EOF.
|
||||
case "${filepath,,}" in
|
||||
*.cdb)
|
||||
if ! head -c 16 "$filepath" | cmp -s - <(printf 'SQLite format 3\000'); then
|
||||
echo "[resources][ERROR] Integrity check failed for $src_id ($url): not a valid SQLite3 .cdb (bad magic header)" >&2
|
||||
return 1
|
||||
fi
|
||||
;;
|
||||
esac
|
||||
|
||||
return 0
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# apply_rule — RSM-002/003 assembly rule executor
|
||||
# Usage: apply_rule <src_dir> <dir> <file> <only> <target>
|
||||
# src_dir : resolved source directory (repositories/<id>)
|
||||
# dir : subdirectory within source (may be empty)
|
||||
# file : single file path within source (may be empty)
|
||||
# only : glob pattern for find -name (may be empty)
|
||||
# target : destination path relative to staging root (caller provides full path)
|
||||
#
|
||||
# All four rule shapes (whole-source / dir / file / only-glob) are handled.
|
||||
# find -name is used for glob to handle filenames with spaces and parentheses.
|
||||
# Overwrite legality is enforced at validate time (RSM-004); at copy time cp
|
||||
# overwrites unconditionally, so no per-rule overwrite flag is consumed here.
|
||||
# A missing single-file, or a missing named subdirectory, is a hard failure
|
||||
# (exit 1) — distinct from an empty glob match in an existing dir, which is a
|
||||
# non-fatal exit 0 per RSM-002.
|
||||
# ---------------------------------------------------------------------------
|
||||
apply_rule() {
|
||||
local src_dir="$1"
|
||||
local dir="$2"
|
||||
local file="$3"
|
||||
local only="$4"
|
||||
local target="$5"
|
||||
|
||||
if [ -n "$file" ]; then
|
||||
# Single-file copy — target is the full destination file path (including name)
|
||||
local src_file="$src_dir/$file"
|
||||
if [ ! -f "$src_file" ]; then
|
||||
echo "[resources][ERROR] apply_rule: file '$file' not found in source '$src_dir'" >&2
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$(dirname "$target")"
|
||||
cp "$src_file" "$target"
|
||||
|
||||
elif [ -n "$only" ]; then
|
||||
# Glob via find -name — handles spaces and parentheses safely
|
||||
local search_root="$src_dir"
|
||||
if [ -n "$dir" ]; then
|
||||
search_root="$src_dir/$dir"
|
||||
# Missing named subdirectory is a hard failure (distinct from empty glob)
|
||||
if [ ! -d "$search_root" ]; then
|
||||
echo "[resources][ERROR] apply_rule: subdirectory '$dir' not found in source '$src_dir'" >&2
|
||||
return 1
|
||||
fi
|
||||
fi
|
||||
mkdir -p "$target"
|
||||
# find copies nothing when there are no matches — non-fatal per RSM-002
|
||||
find "$search_root" -maxdepth 1 -type f -name "$only" -exec cp -t "$target/" {} +
|
||||
|
||||
elif [ -n "$dir" ]; then
|
||||
# Directory-only copy
|
||||
local src_subdir="$src_dir/$dir"
|
||||
if [ ! -d "$src_subdir" ]; then
|
||||
echo "[resources][ERROR] apply_rule: subdirectory '$dir' not found in source '$src_dir'" >&2
|
||||
return 1
|
||||
fi
|
||||
mkdir -p "$target"
|
||||
cp -r "$src_subdir/." "$target/"
|
||||
|
||||
else
|
||||
# Whole-source copy
|
||||
mkdir -p "$target"
|
||||
cp -r "$src_dir/." "$target/"
|
||||
fi
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# apply_rule_with_fallback — RSM-003 ordered from[] fallback chain
|
||||
# Usage: apply_rule_with_fallback <file> <target> <src_dir1> [<src_dir2> ...]
|
||||
# file : file name to look for in each source dir
|
||||
# target : destination file path (full path)
|
||||
# src_dirN: one or more source directories to try in order
|
||||
#
|
||||
# Uses the first source that contains the file. Aborts if no source has it.
|
||||
# ---------------------------------------------------------------------------
|
||||
apply_rule_with_fallback() {
|
||||
local file="$1"
|
||||
local target="$2"
|
||||
shift 2
|
||||
local sources=("$@")
|
||||
|
||||
for src_dir in "${sources[@]}"; do
|
||||
local candidate="$src_dir/$file"
|
||||
if [ -f "$candidate" ]; then
|
||||
mkdir -p "$(dirname "$target")"
|
||||
cp "$candidate" "$target"
|
||||
return 0
|
||||
fi
|
||||
done
|
||||
|
||||
echo "[resources][ERROR] Fallback chain exhausted: '$file' not found in any of: ${sources[*]}" >&2
|
||||
return 1
|
||||
}
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# sync_repo — RSM-007 git source behavior (moved verbatim from clone_repositories.sh)
|
||||
# Usage: sync_repo <id> <url> <branch>
|
||||
# id : source id — used as checkout directory name (repositories/<id>)
|
||||
# url : git remote url
|
||||
# branch : branch name, or "" to use remote default branch
|
||||
#
|
||||
# Checkout dir is repositories/<id> relative to repo root (D1/D8: id-keyed, cwd-invariant).
|
||||
# Caller must run from repo root.
|
||||
# ---------------------------------------------------------------------------
|
||||
sync_repo() {
|
||||
local id="$1"
|
||||
local url="$2"
|
||||
local branch="$3"
|
||||
# Checkout root honors REPOS_ROOT (default ./repositories), matching
|
||||
# setup_resources.sh, so both stages agree on where sources land.
|
||||
local repos_root="${REPOS_ROOT:-./repositories}"
|
||||
local dir="$repos_root/$id"
|
||||
|
||||
if [ -d "$dir/.git" ]; then
|
||||
if [ -n "$branch" ]; then
|
||||
git -C "$dir" fetch --depth 1 origin "$branch" >/dev/null 2>&1 &&
|
||||
git -C "$dir" reset --hard FETCH_HEAD >/dev/null 2>&1 && return 0
|
||||
else
|
||||
git -C "$dir" fetch --depth 1 >/dev/null 2>&1 &&
|
||||
git -C "$dir" reset --hard "@{u}" >/dev/null 2>&1 && return 0
|
||||
fi
|
||||
echo "[resources] update failed for $id — re-cloning"
|
||||
rm -rf "$dir"
|
||||
fi
|
||||
|
||||
# Directory exists without .git (partial checkout, etc.) — start clean
|
||||
[ -d "$dir" ] && rm -rf "$dir"
|
||||
|
||||
if [ -n "$branch" ]; then
|
||||
git clone --depth 1 --branch "$branch" "$url" "$dir"
|
||||
else
|
||||
git clone --depth 1 "$url" "$dir"
|
||||
fi
|
||||
}
|
||||
24
scripts/resources-updater.sh
Normal file
24
scripts/resources-updater.sh
Normal file
|
|
@ -0,0 +1,24 @@
|
|||
#!/bin/bash
|
||||
|
||||
# Runtime resource updater (sidecar). Periodically re-clones the upstream card
|
||||
# databases / banlists and publishes a fresh release via setup_resources.sh,
|
||||
# which atomically repoints resources/current. The server reads resources/current
|
||||
# read-only and picks up changes through its in-memory reload — no restart, no
|
||||
# dropped connections.
|
||||
#
|
||||
# A failed refresh leaves the previous release live (clone/assemble run with
|
||||
# `set -e`; on non-zero we keep going and retry next interval).
|
||||
|
||||
set -u
|
||||
|
||||
INTERVAL="${RESOURCES_REFRESH_SECONDS:-600}"
|
||||
|
||||
while true; do
|
||||
echo "[updater] refreshing resources..."
|
||||
if bash scripts/clone_repositories.sh && bash scripts/setup_resources.sh; then
|
||||
echo "[updater] refresh ok"
|
||||
else
|
||||
echo "[updater] refresh FAILED — keeping previous resources/current" >&2
|
||||
fi
|
||||
sleep "$INTERVAL"
|
||||
done
|
||||
19
scripts/setup-git-credentials.sh
Executable file
19
scripts/setup-git-credentials.sh
Executable file
|
|
@ -0,0 +1,19 @@
|
|||
#!/usr/bin/env bash
|
||||
# setup-git-credentials.sh — authenticate github.com HTTPS clones of PRIVATE
|
||||
# repos declared in the private manifest override, using a read-only token.
|
||||
#
|
||||
# Generic add-on: any private GitHub source (pre-errata scripts today, other
|
||||
# private repos in the future) is fetched with GH_PRIVATE_TOKEN. Reads the token
|
||||
# from the container env (--env-file) at runtime — the entrypoint runs this before
|
||||
# the resource updater loop. No-op when the token is absent (only public sources
|
||||
# are cloned). The token is NEVER written to disk — an env-based credential helper
|
||||
# reads it at clone time; public HTTPS clones don't challenge auth, so the token
|
||||
# is only ever sent to private repos.
|
||||
set -u
|
||||
if [ -n "${GH_PRIVATE_TOKEN:-}" ]; then
|
||||
git config --global credential."https://github.com".helper \
|
||||
'!f() { echo "username=x-access-token"; echo "password=${GH_PRIVATE_TOKEN}"; }; f'
|
||||
echo "[git-credentials] github.com HTTPS token helper configured (private sources)."
|
||||
else
|
||||
echo "[git-credentials] GH_PRIVATE_TOKEN not set — skipping (public sources only)."
|
||||
fi
|
||||
113
scripts/setup_resources.sh
Executable file
113
scripts/setup_resources.sh
Executable file
|
|
@ -0,0 +1,113 @@
|
|||
#!/usr/bin/env bash
|
||||
# setup_resources.sh — ASSEMBLE + PUBLISH stage.
|
||||
#
|
||||
# Builds a fresh resource set into resources/releases/<id>, then atomically
|
||||
# repoints resources/current at it. Safe to run while the server is reading
|
||||
# resources/current: the swap is a single atomic rename, and in-flight reads
|
||||
# keep their old release via open file handles (POSIX). Old releases are GC'd.
|
||||
#
|
||||
# Single source of truth for the resource layout — used by local dev, the
|
||||
# Docker build (seed), and the runtime updater sidecar.
|
||||
#
|
||||
# CWD invariant (D1): runs from repo root; all paths are repo-root-relative.
|
||||
# No cd into subdirs. Sources are read exclusively from resources.manifest.json
|
||||
# (RSM-010). Assembly rules from manifest assembly[] (RSM-002/003/004).
|
||||
#
|
||||
# Usage: bash setup_resources.sh
|
||||
|
||||
set -e
|
||||
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
source "$SCRIPT_DIR/resources-lib.sh"
|
||||
|
||||
# Support test overrides for repos root and releases root.
|
||||
REPOS="${REPOS_ROOT:-./repositories}"
|
||||
RELEASES="${RELEASES_ROOT:-./resources/releases}"
|
||||
|
||||
ID="$(date +%Y%m%d-%H%M%S-%N)"
|
||||
STAGING="$RELEASES/$ID"
|
||||
KEEP="${RESOURCES_KEEP_RELEASES:-2}"
|
||||
|
||||
echo "Assembling resources into $STAGING ..."
|
||||
|
||||
mkdir -p "$STAGING"
|
||||
|
||||
# === ASSEMBLE: iterate assembly[] rules from the manifest ===
|
||||
|
||||
rule_count=$(manifest_get '.assembly | length')
|
||||
|
||||
# Resolve source directory for a given source id.
|
||||
# git sources land at repositories/<id>; http sources land as a flat file
|
||||
# at repositories/<filename>, so their "source dir" is repositories/ itself.
|
||||
# Defined once before the loop; src_id is passed via jq --arg to avoid string
|
||||
# interpolation into the filter.
|
||||
_resolve_src_dir() {
|
||||
local src_id="$1"
|
||||
local src_type
|
||||
src_type=$(jq -r --arg id "$src_id" '.sources[] | select(.id == $id) | .type' "$MANIFEST_PATH")
|
||||
case "$src_type" in
|
||||
http) echo "$REPOS" ;;
|
||||
*) echo "$REPOS/$src_id" ;;
|
||||
esac
|
||||
}
|
||||
|
||||
i=0
|
||||
while [ "$i" -lt "$rule_count" ]; do
|
||||
rule_target=$(manifest_get ".assembly[$i].target")
|
||||
rule_from=$(manifest_get ".assembly[$i].from")
|
||||
rule_from_type=$(manifest_get ".assembly[$i].from | type")
|
||||
rule_dir=$(manifest_get ".assembly[$i].dir // \"\"")
|
||||
rule_file=$(manifest_get ".assembly[$i].file // \"\"")
|
||||
rule_only=$(manifest_get ".assembly[$i].only // \"\"")
|
||||
|
||||
full_target="$STAGING/$rule_target"
|
||||
|
||||
if [ "$rule_from_type" = "array" ]; then
|
||||
# RSM-003 fallback chain — from is an array; a file key is required
|
||||
# (validated at manifest-validation time).
|
||||
# Build list of source dirs in order.
|
||||
from_count=$(manifest_get ".assembly[$i].from | length")
|
||||
src_dirs=()
|
||||
j=0
|
||||
while [ "$j" -lt "$from_count" ]; do
|
||||
from_id=$(manifest_get ".assembly[$i].from[$j]")
|
||||
src_dirs+=("$(_resolve_src_dir "$from_id")")
|
||||
j=$((j + 1))
|
||||
done
|
||||
apply_rule_with_fallback "$rule_file" "$full_target" "${src_dirs[@]}" \
|
||||
|| fail "Assembly rule $i (target=$rule_target): fallback chain exhausted"
|
||||
else
|
||||
# Single source — from is a string source id
|
||||
src_dir="$(_resolve_src_dir "$rule_from")"
|
||||
apply_rule "$src_dir" "$rule_dir" "$rule_file" "$rule_only" "$full_target" \
|
||||
|| fail "Assembly rule $i (target=$rule_target) failed"
|
||||
fi
|
||||
|
||||
i=$((i + 1))
|
||||
done
|
||||
|
||||
# === PUBLISH: strip .git, atomic swap, GC ===
|
||||
|
||||
# Strip .git from the assembled release (keep it in repositories/ so
|
||||
# refreshes can pull deltas).
|
||||
find "$STAGING" -name ".git" -type d -exec rm -rf {} +
|
||||
|
||||
# Atomic publish: repoint resources/current -> releases/<id>.
|
||||
# Build the symlink under a temp name, then rename over the live one.
|
||||
# rename(2) is atomic on the same filesystem, so readers never observe a
|
||||
# missing or half-updated current.
|
||||
# If a previous build left `current` as a real directory (e.g. a Docker COPY
|
||||
# that dereferenced the symlink), drop it so the atomic symlink swap can land.
|
||||
RESOURCES_DIR="$(dirname "$RELEASES")"
|
||||
if [ -d "$RESOURCES_DIR/current" ] && [ ! -L "$RESOURCES_DIR/current" ]; then
|
||||
rm -rf "$RESOURCES_DIR/current"
|
||||
fi
|
||||
ln -sfn "$(basename "$RELEASES")/$ID" "$RESOURCES_DIR/.current.tmp"
|
||||
mv -T "$RESOURCES_DIR/.current.tmp" "$RESOURCES_DIR/current"
|
||||
echo "Published $RESOURCES_DIR/current -> releases/$ID"
|
||||
|
||||
# === GC: keep only the newest $KEEP releases ===
|
||||
# The just-published release is the newest, so current is always retained.
|
||||
ls -1dt "$RELEASES"/*/ 2>/dev/null | tail -n +"$((KEEP + 1))" | xargs -r rm -rf
|
||||
|
||||
echo "Resources assembled successfully."
|
||||
|
|
@ -1,55 +0,0 @@
|
|||
#!/bin/bash
|
||||
|
||||
set -e
|
||||
|
||||
echo "Assembling resources structure..."
|
||||
|
||||
REPOS=./repositories
|
||||
|
||||
# Strip .git dirs
|
||||
find "$REPOS" -name ".git" -type d -exec rm -rf {} + 2>/dev/null || true
|
||||
|
||||
# === EDOPro ===
|
||||
mkdir -p ./resources/edopro
|
||||
cp -r "$REPOS/edopro-card-scripts" ./resources/edopro/scripts
|
||||
cp -r "$REPOS/edopro-card-databases" ./resources/edopro/databases
|
||||
cp -r "$REPOS/edopro-banlists-ignis" ./resources/edopro/banlists-ignis
|
||||
cp -r "$REPOS/edopro-banlists-evolution" ./resources/edopro/banlists-evolution
|
||||
|
||||
# === YGOPro Base (scripts + cards.cdb + lflist propagate to all variants) ===
|
||||
mkdir -p ./resources/ygopro/base
|
||||
cp -r "$REPOS/ygopro-scripts" ./resources/ygopro/base/script
|
||||
cp "$REPOS/ygopro-lflist.conf" ./resources/ygopro/base/lflist.conf
|
||||
cp "$REPOS/ygopro-cards.cdb" ./resources/ygopro/base/cards.cdb
|
||||
|
||||
# === YGOPro Prereleases (each repo as independent folder) ===
|
||||
cp -r "$REPOS/ygopro-prereleases-cdb" ./resources/ygopro/prereleases-cdb
|
||||
cp -r "$REPOS/ygopro-cards-art" ./resources/ygopro/cards-art
|
||||
|
||||
# === YGOPro OCG (only own lflist) ===
|
||||
mkdir -p ./resources/ygopro/ocg
|
||||
cp "$REPOS/edopro-banlists-ignis/OCG.lflist.conf" ./resources/ygopro/ocg/lflist.conf
|
||||
|
||||
# === YGOPro Alternatives (repo as-is + banlists) ===
|
||||
cp -r "$REPOS/ygopro-format-alternatives" ./resources/ygopro/alternatives
|
||||
|
||||
declare -A MAP=(
|
||||
["2010.03 Edison(Pre Errata)"]="edison"
|
||||
["2014.04 HAT (Pre Errata)"]="hat"
|
||||
["jtp-oficial"]="jtp"
|
||||
["GOAT"]="goat"
|
||||
["Rush"]="rush"
|
||||
["Speed"]="speed"
|
||||
["Tengu.Plant"]="tengu"
|
||||
["World"]="world"
|
||||
["MD.2025.03"]="md"
|
||||
["Genesys"]="genesys"
|
||||
)
|
||||
|
||||
for name in "${!MAP[@]}"; do
|
||||
src="$REPOS/edopro-banlists-evolution/${name}.lflist.conf"
|
||||
[ -f "$src" ] || src="$REPOS/edopro-banlists-ignis/${name}.lflist.conf"
|
||||
cp "$src" "./resources/ygopro/alternatives/${MAP[$name]}/lflist.conf"
|
||||
done
|
||||
|
||||
echo "Resources assembled successfully."
|
||||
160
src/bootstrap/bootstrapBanListLoaders.test.ts
Normal file
160
src/bootstrap/bootstrapBanListLoaders.test.ts
Normal file
|
|
@ -0,0 +1,160 @@
|
|||
// Tests for the re-callable loader functions extracted from bootstrap.
|
||||
// The functions are pure builders: they parse into a local temp array and do NOT touch
|
||||
// the live BanListMemoryRepository. Mocking at the class level to avoid fs / ygopro deps.
|
||||
|
||||
jest.mock("@edopro/ban-list/infrastructure/BanListLoader", () => ({
|
||||
EdoProBanListLoader: jest.fn(),
|
||||
}));
|
||||
jest.mock("@ygopro/ban-list/infrastructure/YGOProBanListLoader", () => ({
|
||||
YGOProBanListLoader: jest.fn(),
|
||||
}));
|
||||
// Prevent config from loading env that doesn't exist in test env
|
||||
jest.mock("src/config", () => ({
|
||||
config: {
|
||||
resources: { dir: "/fake/resources" },
|
||||
},
|
||||
}));
|
||||
|
||||
import { EdoProBanListLoader } from "@edopro/ban-list/infrastructure/BanListLoader";
|
||||
import { YGOProBanListLoader } from "@ygopro/ban-list/infrastructure/YGOProBanListLoader";
|
||||
import { EdoproBanList } from "@edopro/ban-list/domain/BanList";
|
||||
import { YGOProBanList } from "@ygopro/ban-list/domain/YGOProBanList";
|
||||
|
||||
// Import the functions AFTER mocks are set up
|
||||
import { loadEdoproBanLists, loadYgoproBanLists } from "./bootstrapBanListLoaders";
|
||||
|
||||
// Cast to jest.Mock so we can control the constructor's returned instance via mockImplementation.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const MockEdoProBanListLoader = EdoProBanListLoader as jest.Mock<any>;
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const MockYGOProBanListLoader = YGOProBanListLoader as jest.Mock<any>;
|
||||
|
||||
function makeEdoList(name: string): EdoproBanList {
|
||||
const list = new EdoproBanList();
|
||||
list.setName(name);
|
||||
return list;
|
||||
}
|
||||
|
||||
function makeYgoList(name: string): YGOProBanList {
|
||||
const list = new YGOProBanList();
|
||||
list.setName(name);
|
||||
return list;
|
||||
}
|
||||
|
||||
describe("loadEdoproBanLists", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls loadDirectory for both evolution-lflists and lflists paths", async () => {
|
||||
const mockLoadDirectory = jest.fn().mockResolvedValue(undefined);
|
||||
MockEdoProBanListLoader.mockImplementation(() => ({
|
||||
loadDirectory: mockLoadDirectory,
|
||||
getLoaded: () => [],
|
||||
}));
|
||||
|
||||
await loadEdoproBanLists();
|
||||
|
||||
expect(mockLoadDirectory).toHaveBeenCalledTimes(2);
|
||||
expect(mockLoadDirectory).toHaveBeenCalledWith(
|
||||
expect.stringContaining("edopro/evolution-lflists"),
|
||||
);
|
||||
expect(mockLoadDirectory).toHaveBeenCalledWith(expect.stringContaining("edopro/lflists"));
|
||||
});
|
||||
|
||||
it("resolves without throwing when loader succeeds", async () => {
|
||||
MockEdoProBanListLoader.mockImplementation(() => ({
|
||||
loadDirectory: jest.fn().mockResolvedValue(undefined),
|
||||
getLoaded: () => [],
|
||||
}));
|
||||
|
||||
await expect(loadEdoproBanLists()).resolves.not.toThrow();
|
||||
});
|
||||
|
||||
it("propagates the error when loadDirectory throws — caller is responsible for error handling", async () => {
|
||||
MockEdoProBanListLoader.mockImplementation(() => ({
|
||||
loadDirectory: jest.fn().mockRejectedValue(new Error("parse error")),
|
||||
getLoaded: () => [],
|
||||
}));
|
||||
|
||||
await expect(loadEdoproBanLists()).rejects.toThrow("parse error");
|
||||
});
|
||||
|
||||
it("returns loaded EdoproBanList array from the loader", async () => {
|
||||
const listA = makeEdoList("List A");
|
||||
const listB = makeEdoList("List B");
|
||||
const mockLoadDirectory = jest.fn().mockResolvedValue(undefined);
|
||||
MockEdoProBanListLoader.mockImplementation(() => ({
|
||||
loadDirectory: mockLoadDirectory,
|
||||
getLoaded: () => [listA, listB],
|
||||
}));
|
||||
|
||||
const result = await loadEdoproBanLists();
|
||||
|
||||
expect(result).toHaveLength(2);
|
||||
expect(result).toContain(listA);
|
||||
expect(result).toContain(listB);
|
||||
});
|
||||
});
|
||||
|
||||
describe("loadYgoproBanLists", () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it("calls load() once on YGOProBanListLoader", async () => {
|
||||
const mockLoad = jest.fn().mockResolvedValue(undefined);
|
||||
MockYGOProBanListLoader.mockImplementation(() => ({
|
||||
load: mockLoad,
|
||||
getLoaded: () => [],
|
||||
}));
|
||||
|
||||
await loadYgoproBanLists();
|
||||
|
||||
expect(mockLoad).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("returns loaded YGOProBanList array from the loader", async () => {
|
||||
const listX = makeYgoList("TCG 2026.04");
|
||||
const mockLoad = jest.fn().mockResolvedValue(undefined);
|
||||
MockYGOProBanListLoader.mockImplementation(() => ({
|
||||
load: mockLoad,
|
||||
getLoaded: () => [listX],
|
||||
}));
|
||||
|
||||
const result = await loadYgoproBanLists();
|
||||
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe(listX);
|
||||
});
|
||||
});
|
||||
|
||||
describe("edopro loader called before ygopro loader", () => {
|
||||
it("edopro load completes before ygopro load begins", async () => {
|
||||
const callOrder: string[] = [];
|
||||
|
||||
MockEdoProBanListLoader.mockImplementation(() => ({
|
||||
loadDirectory: jest.fn().mockImplementation(async () => {
|
||||
callOrder.push("edopro:loadDirectory");
|
||||
}),
|
||||
getLoaded: () => [],
|
||||
}));
|
||||
|
||||
MockYGOProBanListLoader.mockImplementation(() => ({
|
||||
load: jest.fn().mockImplementation(async () => {
|
||||
callOrder.push("ygopro:load");
|
||||
}),
|
||||
getLoaded: () => [],
|
||||
}));
|
||||
|
||||
// Caller must always call edopro first, then ygopro.
|
||||
await loadEdoproBanLists();
|
||||
await loadYgoproBanLists();
|
||||
|
||||
expect(callOrder[0]).toBe("edopro:loadDirectory");
|
||||
// Both edopro loadDirectory calls complete before ygopro:load
|
||||
expect(callOrder.indexOf("ygopro:load")).toBeGreaterThan(
|
||||
callOrder.lastIndexOf("edopro:loadDirectory"),
|
||||
);
|
||||
});
|
||||
});
|
||||
40
src/bootstrap/bootstrapBanListLoaders.ts
Normal file
40
src/bootstrap/bootstrapBanListLoaders.ts
Normal file
|
|
@ -0,0 +1,40 @@
|
|||
// Re-callable pure builders for edopro and ygopro ban lists.
|
||||
//
|
||||
// These functions parse ban lists into a local temporary array and do NOT touch
|
||||
// the live BanListMemoryRepository or YGOProBanListMemoryRepository. The caller
|
||||
// (bootstrapBanListReloader) is responsible for atomically swapping the repo via
|
||||
// replaceAll() once both arrays are successfully built.
|
||||
|
||||
import { EdoProBanListLoader } from "@edopro/ban-list/infrastructure/BanListLoader";
|
||||
import { EdoproBanList } from "@edopro/ban-list/domain/BanList";
|
||||
import { YGOProBanListLoader } from "@ygopro/ban-list/infrastructure/YGOProBanListLoader";
|
||||
import { YGOProBanList } from "@ygopro/ban-list/domain/YGOProBanList";
|
||||
import { config } from "src/config";
|
||||
|
||||
/**
|
||||
* Loads edopro ban lists into a fresh temporary array.
|
||||
* Does NOT write to BanListMemoryRepository.
|
||||
* Throws on parse error — callers are responsible for error handling.
|
||||
*/
|
||||
export async function loadEdoproBanLists(): Promise<EdoproBanList[]> {
|
||||
const tmp: EdoproBanList[] = [];
|
||||
const loader = new EdoProBanListLoader(tmp);
|
||||
await loader.loadDirectory(`${config.resources.dir}/edopro/evolution-lflists`);
|
||||
await loader.loadDirectory(`${config.resources.dir}/edopro/lflists`);
|
||||
return loader.getLoaded();
|
||||
}
|
||||
|
||||
/**
|
||||
* Loads ygopro ban lists into a fresh temporary array.
|
||||
* Does NOT write to YGOProBanListMemoryRepository.
|
||||
* Throws on parse error — callers are responsible for error handling.
|
||||
*
|
||||
* Precondition: edopro ban lists must already be present in BanListMemoryRepository
|
||||
* before this is called — YGOProRoom cross-references them by name at construction.
|
||||
*/
|
||||
export async function loadYgoproBanLists(): Promise<YGOProBanList[]> {
|
||||
const tmp: YGOProBanList[] = [];
|
||||
const loader = new YGOProBanListLoader(tmp);
|
||||
await loader.load();
|
||||
return loader.getLoaded();
|
||||
}
|
||||
186
src/bootstrap/bootstrapBanListReloader.test.ts
Normal file
186
src/bootstrap/bootstrapBanListReloader.test.ts
Normal file
|
|
@ -0,0 +1,186 @@
|
|||
// Tests for the ban-list reloader core cycle. reloadBanListsOnce is exercised
|
||||
// directly with injected ports so no filesystem, timers, or singletons are touched.
|
||||
|
||||
jest.mock("src/config", () => ({
|
||||
config: { resources: { dir: "/fake/resources" } },
|
||||
}));
|
||||
jest.mock("./bootstrapBanListLoaders", () => ({
|
||||
loadEdoproBanLists: jest.fn(),
|
||||
loadYgoproBanLists: jest.fn(),
|
||||
}));
|
||||
|
||||
import { EdoproBanList } from "@edopro/ban-list/domain/BanList";
|
||||
import { Logger } from "@shared/logger/domain/Logger";
|
||||
import { YGOProBanList } from "@ygopro/ban-list/domain/YGOProBanList";
|
||||
|
||||
import {
|
||||
type BanListReloaderPorts,
|
||||
getBanListReloadedAt,
|
||||
reloadBanListsOnce,
|
||||
} from "./bootstrapBanListReloader";
|
||||
|
||||
function fakeLogger(): Logger {
|
||||
return {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
} as unknown as Logger;
|
||||
}
|
||||
|
||||
function makeEdoList(name: string): EdoproBanList {
|
||||
const list = new EdoproBanList();
|
||||
list.setName(name);
|
||||
return list;
|
||||
}
|
||||
|
||||
function makeYgoList(name: string): YGOProBanList {
|
||||
const list = new YGOProBanList();
|
||||
list.setName(name);
|
||||
return list;
|
||||
}
|
||||
|
||||
interface Recorder {
|
||||
calls: string[];
|
||||
edoproReplaced: EdoproBanList[] | null;
|
||||
ygoproReplaced: YGOProBanList[] | null;
|
||||
}
|
||||
|
||||
function makePorts(overrides: Partial<BanListReloaderPorts> & { recorder?: Recorder } = {}): {
|
||||
ports: BanListReloaderPorts;
|
||||
recorder: Recorder;
|
||||
} {
|
||||
const recorder: Recorder = overrides.recorder ?? {
|
||||
calls: [],
|
||||
edoproReplaced: null,
|
||||
ygoproReplaced: null,
|
||||
};
|
||||
const ports: BanListReloaderPorts = {
|
||||
fingerprint: overrides.fingerprint ?? jest.fn().mockResolvedValue("fp-new"),
|
||||
loadEdopro:
|
||||
overrides.loadEdopro ??
|
||||
jest.fn().mockImplementation(async () => {
|
||||
recorder.calls.push("loadEdopro");
|
||||
return [makeEdoList("Edo A")];
|
||||
}),
|
||||
loadYgopro:
|
||||
overrides.loadYgopro ??
|
||||
jest.fn().mockImplementation(async () => {
|
||||
recorder.calls.push("loadYgopro");
|
||||
return [makeYgoList("Ygo A")];
|
||||
}),
|
||||
replaceEdopro:
|
||||
overrides.replaceEdopro ??
|
||||
((next) => {
|
||||
recorder.calls.push("replaceEdopro");
|
||||
recorder.edoproReplaced = next;
|
||||
}),
|
||||
replaceYgopro:
|
||||
overrides.replaceYgopro ??
|
||||
((next) => {
|
||||
recorder.calls.push("replaceYgopro");
|
||||
recorder.ygoproReplaced = next;
|
||||
}),
|
||||
now: overrides.now ?? (() => "2026-07-14T12:00:00.000Z"),
|
||||
};
|
||||
return { ports, recorder };
|
||||
}
|
||||
|
||||
describe("reloadBanListsOnce — change detection", () => {
|
||||
it("skips the rebuild when the fingerprint is unchanged", async () => {
|
||||
const { ports, recorder } = makePorts({
|
||||
fingerprint: jest.fn().mockResolvedValue("fp-same"),
|
||||
});
|
||||
|
||||
const outcome = await reloadBanListsOnce(ports, fakeLogger(), "fp-same");
|
||||
|
||||
expect(outcome.changed).toBe(false);
|
||||
expect(outcome.fingerprint).toBe("fp-same");
|
||||
expect(recorder.calls).toEqual([]); // no load, no replace
|
||||
});
|
||||
|
||||
it("rebuilds and swaps when the fingerprint changed", async () => {
|
||||
const { ports, recorder } = makePorts();
|
||||
|
||||
const outcome = await reloadBanListsOnce(ports, fakeLogger(), "fp-old");
|
||||
|
||||
expect(outcome.changed).toBe(true);
|
||||
expect(outcome.fingerprint).toBe("fp-new");
|
||||
expect(recorder.edoproReplaced).toHaveLength(1);
|
||||
expect(recorder.ygoproReplaced).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadBanListsOnce — atomic swap ordering", () => {
|
||||
it("replaces edopro before ygopro", async () => {
|
||||
const { ports, recorder } = makePorts();
|
||||
|
||||
await reloadBanListsOnce(ports, fakeLogger(), "fp-old");
|
||||
|
||||
expect(recorder.calls).toEqual(["loadEdopro", "loadYgopro", "replaceEdopro", "replaceYgopro"]);
|
||||
expect(recorder.calls.indexOf("replaceEdopro")).toBeLessThan(
|
||||
recorder.calls.indexOf("replaceYgopro"),
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadBanListsOnce — empty-result safety", () => {
|
||||
it("keeps previous lists and does NOT swap when edopro rebuild is empty", async () => {
|
||||
const { ports, recorder } = makePorts({
|
||||
loadEdopro: jest.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
const outcome = await reloadBanListsOnce(ports, fakeLogger(), "fp-old");
|
||||
|
||||
expect(outcome.changed).toBe(false);
|
||||
// old fingerprint retained so the next cycle retries
|
||||
expect(outcome.fingerprint).toBe("fp-old");
|
||||
expect(recorder.edoproReplaced).toBeNull();
|
||||
expect(recorder.ygoproReplaced).toBeNull();
|
||||
});
|
||||
|
||||
it("keeps previous lists and does NOT swap when ygopro rebuild is empty", async () => {
|
||||
const { ports, recorder } = makePorts({
|
||||
loadYgopro: jest.fn().mockResolvedValue([]),
|
||||
});
|
||||
|
||||
const outcome = await reloadBanListsOnce(ports, fakeLogger(), "fp-old");
|
||||
|
||||
expect(outcome.changed).toBe(false);
|
||||
expect(recorder.edoproReplaced).toBeNull();
|
||||
expect(recorder.ygoproReplaced).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("reloadBanListsOnce — error propagation", () => {
|
||||
it("propagates loader errors so the scheduler can keep previous lists", async () => {
|
||||
const { ports } = makePorts({
|
||||
loadEdopro: jest.fn().mockRejectedValue(new Error("parse boom")),
|
||||
});
|
||||
|
||||
await expect(reloadBanListsOnce(ports, fakeLogger(), "fp-old")).rejects.toThrow("parse boom");
|
||||
});
|
||||
});
|
||||
|
||||
describe("getBanListReloadedAt — timestamp", () => {
|
||||
it("advances after a successful reload", async () => {
|
||||
const { ports } = makePorts({ now: () => "2026-07-14T15:30:00.000Z" });
|
||||
|
||||
await reloadBanListsOnce(ports, fakeLogger(), "fp-old");
|
||||
|
||||
expect(getBanListReloadedAt()).toBe("2026-07-14T15:30:00.000Z");
|
||||
});
|
||||
|
||||
it("does not advance when the rebuild is skipped (unchanged fingerprint)", async () => {
|
||||
const { ports: first } = makePorts({ now: () => "2026-07-14T15:30:00.000Z" });
|
||||
await reloadBanListsOnce(first, fakeLogger(), "fp-old"); // sets a known value
|
||||
|
||||
const { ports: second } = makePorts({
|
||||
fingerprint: jest.fn().mockResolvedValue("fp-same"),
|
||||
now: () => "2026-07-14T99:99:99.000Z",
|
||||
});
|
||||
await reloadBanListsOnce(second, fakeLogger(), "fp-same");
|
||||
|
||||
expect(getBanListReloadedAt()).toBe("2026-07-14T15:30:00.000Z");
|
||||
});
|
||||
});
|
||||
193
src/bootstrap/bootstrapBanListReloader.ts
Normal file
193
src/bootstrap/bootstrapBanListReloader.ts
Normal file
|
|
@ -0,0 +1,193 @@
|
|||
// Periodic ban-list hot-reload.
|
||||
//
|
||||
// Ban lists are loaded once at boot (bootstrapEdoproResources / bootstrapYgoproResources).
|
||||
// The resources sidecar refreshes the underlying .conf files on disk, but the Node
|
||||
// process never re-reads them — so a new ban list historically required a full restart.
|
||||
// This reloader closes that gap: on an interval it detects on-disk changes via a
|
||||
// size+mtime fingerprint and, when something changed, rebuilds both ban-list arrays
|
||||
// into local temporaries and atomically swaps them into the live repositories.
|
||||
//
|
||||
// Safety properties:
|
||||
// - Double-buffer swap (replaceAll): the swap is synchronous with no await between the
|
||||
// two repositories, so no concurrent HTTP read can observe an empty or half-updated list.
|
||||
// - edopro BEFORE ygopro: ygopro rooms cross-reference edopro ban lists by name to resolve
|
||||
// _edoBanListHash (see bootstrapYgoproResources), so edopro must be current first.
|
||||
// - In-flight rooms are unaffected: they snapshot their ban list at construction
|
||||
// (YGOProRoom), not a live repository reference.
|
||||
// - Never swaps in an empty parse result: if a rebuild yields zero lists the previous
|
||||
// in-memory lists are kept and the change is retried next cycle.
|
||||
|
||||
import { readdir, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import { EdoproBanList } from "@edopro/ban-list/domain/BanList";
|
||||
import BanListMemoryRepository from "@edopro/ban-list/infrastructure/BanListMemoryRepository";
|
||||
import { Logger } from "@shared/logger/domain/Logger";
|
||||
import { YGOProBanList } from "@ygopro/ban-list/domain/YGOProBanList";
|
||||
import YGOProBanListMemoryRepository from "@ygopro/ban-list/infrastructure/YGOProBanListMemoryRepository";
|
||||
import { config } from "src/config";
|
||||
|
||||
import { loadEdoproBanLists, loadYgoproBanLists } from "./bootstrapBanListLoaders";
|
||||
|
||||
const DEFAULT_INTERVAL_MS = (Number(process.env.RESOURCES_REFRESH_SECONDS) || 600) * 1000;
|
||||
|
||||
// Timestamp of when the in-memory ban lists were last (re)loaded. Set at reloader
|
||||
// start (lists are current as of boot) and updated on every successful reload.
|
||||
// Exposed via getBanListReloadedAt() for a future resource-version endpoint.
|
||||
let reloadedAt: string | null = null;
|
||||
|
||||
export function getBanListReloadedAt(): string | null {
|
||||
return reloadedAt;
|
||||
}
|
||||
|
||||
/** Injectable seam so the reload logic can be tested without the filesystem or timers. */
|
||||
export interface BanListReloaderPorts {
|
||||
loadEdopro(): Promise<EdoproBanList[]>;
|
||||
loadYgopro(): Promise<YGOProBanList[]>;
|
||||
replaceEdopro(next: EdoproBanList[]): void;
|
||||
replaceYgopro(next: YGOProBanList[]): void;
|
||||
fingerprint(): Promise<string>;
|
||||
now(): string;
|
||||
}
|
||||
|
||||
export interface ReloadOutcome {
|
||||
changed: boolean;
|
||||
fingerprint: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Runs a single reload cycle. Pure with respect to timers — the scheduler calls this.
|
||||
* Skips the rebuild when the fingerprint is unchanged; keeps the previous lists when a
|
||||
* rebuild yields an empty result (returns the OLD fingerprint so the next cycle retries).
|
||||
*/
|
||||
export async function reloadBanListsOnce(
|
||||
ports: BanListReloaderPorts,
|
||||
logger: Logger,
|
||||
lastFingerprint: string,
|
||||
): Promise<ReloadOutcome> {
|
||||
const fingerprint = await ports.fingerprint();
|
||||
if (fingerprint === lastFingerprint) {
|
||||
return { changed: false, fingerprint };
|
||||
}
|
||||
|
||||
const edoproNext = await ports.loadEdopro();
|
||||
const ygoproNext = await ports.loadYgopro();
|
||||
|
||||
if (edoproNext.length === 0 || ygoproNext.length === 0) {
|
||||
logger.error(
|
||||
`[banlist-reloader] rebuild produced empty ban lists (edopro=${edoproNext.length}, ygopro=${ygoproNext.length}) — keeping previous lists`,
|
||||
);
|
||||
// Do not adopt the new fingerprint: retry on the next cycle.
|
||||
return { changed: false, fingerprint: lastFingerprint };
|
||||
}
|
||||
|
||||
// Double-buffer swap — edopro before ygopro, no await between the two.
|
||||
ports.replaceEdopro(edoproNext);
|
||||
ports.replaceYgopro(ygoproNext);
|
||||
reloadedAt = ports.now();
|
||||
|
||||
logger.info(
|
||||
`[banlist-reloader] reloaded ${edoproNext.length} edopro + ${ygoproNext.length} ygopro ban lists`,
|
||||
);
|
||||
return { changed: true, fingerprint };
|
||||
}
|
||||
|
||||
export interface BanListReloaderOptions {
|
||||
intervalMs?: number;
|
||||
ports?: BanListReloaderPorts;
|
||||
}
|
||||
|
||||
export interface BanListReloaderHandle {
|
||||
stop(): void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Starts the periodic ban-list reloader. Captures the current on-disk fingerprint as the
|
||||
* baseline (boot already loaded the lists) and only reloads when it changes thereafter.
|
||||
* Returns a handle whose stop() clears the timer — the clean rollback boundary.
|
||||
*/
|
||||
export async function bootstrapBanListReloader(
|
||||
logger: Logger,
|
||||
options: BanListReloaderOptions = {},
|
||||
): Promise<BanListReloaderHandle> {
|
||||
const ports = options.ports ?? createDefaultPorts();
|
||||
const intervalMs = options.intervalMs ?? DEFAULT_INTERVAL_MS;
|
||||
|
||||
let lastFingerprint = await ports.fingerprint().catch(() => "");
|
||||
reloadedAt = ports.now();
|
||||
let running = false;
|
||||
|
||||
const tick = async (): Promise<void> => {
|
||||
if (running) {
|
||||
return; // overlap guard: never run two reloads concurrently
|
||||
}
|
||||
running = true;
|
||||
try {
|
||||
const outcome = await reloadBanListsOnce(ports, logger, lastFingerprint);
|
||||
lastFingerprint = outcome.fingerprint;
|
||||
} catch (error) {
|
||||
logger.error("[banlist-reloader] reload cycle failed — keeping previous lists");
|
||||
logger.error(error);
|
||||
} finally {
|
||||
running = false;
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setInterval(() => void tick(), intervalMs);
|
||||
timer.unref();
|
||||
|
||||
logger.info(`🕒 Ban-list reloader started (every ${Math.round(intervalMs / 1000)}s)`);
|
||||
return { stop: (): void => clearInterval(timer) };
|
||||
}
|
||||
|
||||
function createDefaultPorts(): BanListReloaderPorts {
|
||||
return {
|
||||
loadEdopro: loadEdoproBanLists,
|
||||
loadYgopro: loadYgoproBanLists,
|
||||
replaceEdopro: (next) => BanListMemoryRepository.replaceAll(next),
|
||||
replaceYgopro: (next) => YGOProBanListMemoryRepository.replaceAll(next),
|
||||
fingerprint: () => fingerprintLflists(config.resources.dir),
|
||||
now: () => new Date().toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Fingerprint every lflist .conf under the resource directories that feed the two loaders,
|
||||
* using size + mtime (never reads/hashes contents on the event loop). Mirrors
|
||||
* EdoProCardDbPorts.fingerprint(). Missing directories are skipped, not fatal.
|
||||
*/
|
||||
async function fingerprintLflists(baseDir: string): Promise<string> {
|
||||
const roots = [
|
||||
join(baseDir, "edopro", "evolution-lflists"),
|
||||
join(baseDir, "edopro", "lflists"),
|
||||
join(baseDir, "ygopro", "formats"),
|
||||
];
|
||||
const parts: string[] = [];
|
||||
for (const root of roots) {
|
||||
await collectConfFingerprints(root, parts);
|
||||
}
|
||||
parts.sort();
|
||||
return parts.join("|");
|
||||
}
|
||||
|
||||
async function collectConfFingerprints(dir: string, out: string[]): Promise<void> {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // directory absent — skip
|
||||
}
|
||||
for (const entry of entries) {
|
||||
const full = join(dir, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
await collectConfFingerprints(full, out);
|
||||
} else if (entry.name.endsWith(".conf")) {
|
||||
try {
|
||||
const { size, mtimeMs } = await stat(full);
|
||||
out.push(`${full}:${size}:${mtimeMs}`);
|
||||
} catch {
|
||||
// file vanished between readdir and stat — skip it
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
13
src/bootstrap/bootstrapEdoproResources.ts
Normal file
13
src/bootstrap/bootstrapEdoproResources.ts
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
import { Logger } from "@shared/logger/domain/Logger";
|
||||
import BanListMemoryRepository from "@edopro/ban-list/infrastructure/BanListMemoryRepository";
|
||||
import { loadEdoproBanLists } from "./bootstrapBanListLoaders";
|
||||
|
||||
// Loads edopro ban lists into BanListMemoryRepository via loadEdoproBanLists().
|
||||
// Note: these are also read by the ygopro path (see bootstrapYgoproResources),
|
||||
// not only by edopro.
|
||||
export async function bootstrapEdoproResources(logger: Logger): Promise<void> {
|
||||
const tmp = await loadEdoproBanLists();
|
||||
BanListMemoryRepository.replaceAll(tmp);
|
||||
|
||||
logger.info("🎴 EdoPro ban lists loaded");
|
||||
}
|
||||
137
src/bootstrap/bootstrapMatchmaking.test.ts
Normal file
137
src/bootstrap/bootstrapMatchmaking.test.ts
Normal file
|
|
@ -0,0 +1,137 @@
|
|||
// Verifies the composition-root wiring: the spawnBot dependency injected into
|
||||
// MatchmakingQueue must request a bot using a (name, deck) identity pair from the
|
||||
// per-format roster. The bot is requested with an EXPLICIT name and a deckOverride
|
||||
// equal to the roster pair's deck.
|
||||
//
|
||||
// All singleton collaborators are mocked so no timers, sockets, or real windbot
|
||||
// are touched. We capture the deps object passed to MatchmakingQueue.init and
|
||||
// invoke its spawnBot directly.
|
||||
|
||||
import { Logger } from "@shared/logger/domain/Logger";
|
||||
|
||||
import {
|
||||
MatchmakingQueue,
|
||||
MatchmakingQueueDeps,
|
||||
} from "@ygopro/matchmaking/domain/MatchmakingQueue";
|
||||
import { MATCHMAKING_BOT_ROSTER } from "@ygopro/matchmaking/domain/MatchmakingBotRoster";
|
||||
import { MatchmakingFormat } from "@ygopro/matchmaking/domain/QueueEntry";
|
||||
import YGOProRoomList from "@ygopro/room/infrastructure/YGOProRoomList";
|
||||
import { WindbotModule } from "@ygopro/windbot/application/WindbotModule";
|
||||
|
||||
import { bootstrapMatchmaking } from "./bootstrapMatchmaking";
|
||||
|
||||
jest.mock("@ygopro/matchmaking/application/MatchmakingRoomFactory", () => ({
|
||||
createMatchmakingRoom: jest.fn(),
|
||||
}));
|
||||
jest.mock("@ygopro/room/application/FinalizeYGOProRoom", () => ({
|
||||
FinalizeYGOProRoom: { run: jest.fn() },
|
||||
}));
|
||||
|
||||
function fakeLogger(): Logger {
|
||||
const logger = {
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
warn: jest.fn(),
|
||||
debug: jest.fn(),
|
||||
child: jest.fn(),
|
||||
} as unknown as Logger;
|
||||
(logger.child as jest.Mock).mockReturnValue(logger);
|
||||
return logger;
|
||||
}
|
||||
|
||||
describe("bootstrapMatchmaking — spawnBot roster identity-pair wiring", () => {
|
||||
afterEach(() => {
|
||||
jest.restoreAllMocks();
|
||||
});
|
||||
|
||||
function captureSpawnBot(): (roomId: number, format: MatchmakingFormat) => void {
|
||||
const captured: { spawnBot?: (roomId: number, format: MatchmakingFormat) => void } = {};
|
||||
jest.spyOn(MatchmakingQueue, "init").mockImplementation((d) => {
|
||||
captured.spawnBot = (
|
||||
d as unknown as { spawnBot: (roomId: number, format: MatchmakingFormat) => void }
|
||||
).spawnBot;
|
||||
});
|
||||
jest
|
||||
.spyOn(MatchmakingQueue, "getInstance")
|
||||
.mockReturnValue({ start: jest.fn() } as unknown as MatchmakingQueue);
|
||||
|
||||
bootstrapMatchmaking(fakeLogger());
|
||||
|
||||
if (!captured.spawnBot) throw new Error("MatchmakingQueue.init was not called");
|
||||
return captured.spawnBot;
|
||||
}
|
||||
|
||||
it("requests a TCG bot with explicit name and deckOverride from the TCG roster", () => {
|
||||
const requestBot = jest
|
||||
.fn()
|
||||
.mockResolvedValue({ bot: { name: "Salamangreat", deck: "Salamangreat" } });
|
||||
jest.spyOn(WindbotModule, "isInitialized").mockReturnValue(true);
|
||||
jest.spyOn(WindbotModule, "getInstance").mockReturnValue({
|
||||
isEnabled: () => true,
|
||||
requestBot,
|
||||
} as unknown as WindbotModule);
|
||||
jest
|
||||
.spyOn(YGOProRoomList, "findById")
|
||||
.mockReturnValue({ finalizing: false } as unknown as ReturnType<
|
||||
typeof YGOProRoomList.findById
|
||||
>);
|
||||
|
||||
const spawnBot = captureSpawnBot();
|
||||
spawnBot(123, "tcg");
|
||||
|
||||
expect(requestBot).toHaveBeenCalledTimes(1);
|
||||
const [roomId, botName, isFinalizing, deckOverride] = requestBot.mock.calls[0];
|
||||
expect(roomId).toBe(123);
|
||||
// Explicit name from the roster (not null)
|
||||
expect(typeof botName).toBe("string");
|
||||
expect(botName.length).toBeGreaterThan(0);
|
||||
expect(typeof isFinalizing).toBe("function");
|
||||
// deckOverride must be from the TCG roster
|
||||
const tcgDecks = MATCHMAKING_BOT_ROSTER.tcg.map((p) => p.deck);
|
||||
expect(tcgDecks).toContain(deckOverride);
|
||||
// name and deck must come from the SAME pair
|
||||
const pair = MATCHMAKING_BOT_ROSTER.tcg.find((p) => p.name === botName);
|
||||
expect(pair).toBeDefined();
|
||||
expect(pair?.deck).toBe(deckOverride);
|
||||
});
|
||||
|
||||
it("requests a JTP bot with explicit name and deckOverride from the JTP roster", () => {
|
||||
const requestBot = jest.fn().mockResolvedValue({ bot: { name: "Joey", deck: "JTP" } });
|
||||
jest.spyOn(WindbotModule, "isInitialized").mockReturnValue(true);
|
||||
jest.spyOn(WindbotModule, "getInstance").mockReturnValue({
|
||||
isEnabled: () => true,
|
||||
requestBot,
|
||||
} as unknown as WindbotModule);
|
||||
jest
|
||||
.spyOn(YGOProRoomList, "findById")
|
||||
.mockReturnValue({ finalizing: false } as unknown as ReturnType<
|
||||
typeof YGOProRoomList.findById
|
||||
>);
|
||||
|
||||
const spawnBot = captureSpawnBot();
|
||||
spawnBot(456, "jtp");
|
||||
|
||||
expect(requestBot).toHaveBeenCalledTimes(1);
|
||||
const [roomId, botName, isFinalizing, deckOverride] = requestBot.mock.calls[0];
|
||||
expect(roomId).toBe(456);
|
||||
expect(typeof botName).toBe("string");
|
||||
expect(typeof isFinalizing).toBe("function");
|
||||
// deckOverride must be from the JTP roster
|
||||
const jtpDecks = MATCHMAKING_BOT_ROSTER.jtp.map((p) => p.deck);
|
||||
expect(jtpDecks).toContain(deckOverride);
|
||||
// name and deck must come from the SAME pair
|
||||
const pair = MATCHMAKING_BOT_ROSTER.jtp.find((p) => p.name === botName);
|
||||
expect(pair).toBeDefined();
|
||||
expect(pair?.deck).toBe(deckOverride);
|
||||
});
|
||||
|
||||
it("is a no-op when windbot is not initialized", () => {
|
||||
jest.spyOn(WindbotModule, "isInitialized").mockReturnValue(false);
|
||||
const getInstance = jest.spyOn(WindbotModule, "getInstance");
|
||||
|
||||
const spawnBot = captureSpawnBot();
|
||||
spawnBot(123, "tcg");
|
||||
|
||||
expect(getInstance).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
117
src/bootstrap/bootstrapMatchmaking.ts
Normal file
117
src/bootstrap/bootstrapMatchmaking.ts
Normal file
|
|
@ -0,0 +1,117 @@
|
|||
import { EventEmitter } from "stream";
|
||||
|
||||
import { Logger } from "@shared/logger/domain/Logger";
|
||||
|
||||
import { createMatchmakingRoom } from "@ygopro/matchmaking/application/MatchmakingRoomFactory";
|
||||
import { MatchmakingRoomReaper } from "@ygopro/matchmaking/application/MatchmakingRoomReaper";
|
||||
import { AbortMatchmakingRoom } from "@ygopro/matchmaking/application/AbortMatchmakingRoom";
|
||||
import { pickBotFromRoster } from "@ygopro/matchmaking/domain/MatchmakingBotRoster";
|
||||
import { CLEANUP_INTERVAL_MS, MatchmakingFormat } from "@ygopro/matchmaking/domain/QueueEntry";
|
||||
import { MatchmakingQueue } from "@ygopro/matchmaking/domain/MatchmakingQueue";
|
||||
import YGOProRoomList from "@ygopro/room/infrastructure/YGOProRoomList";
|
||||
import { WindbotModule } from "@ygopro/windbot/application/WindbotModule";
|
||||
|
||||
/**
|
||||
* Wires the matchmaking queue's ports to concrete infrastructure and starts the
|
||||
* background sweep. Kept in the composition root so the queue domain stays free
|
||||
* of YGOProRoom, windbot, and Date.now dependencies.
|
||||
*
|
||||
* - createRankedRoom(format) / createBotRoom(format) → additive YGOProRoom factory
|
||||
* (matchmaking seam), now format-aware via FORMAT_ROOM_TOKEN.
|
||||
* - spawnBot(roomId, format) → windbot fire-and-forget, using a (name, deck) identity
|
||||
* pair from MATCHMAKING_BOT_ROSTER so name and deck always come from the same pair.
|
||||
*/
|
||||
export function bootstrapMatchmaking(logger: Logger): void {
|
||||
const mmLogger = logger.child({ file: "Matchmaking" });
|
||||
|
||||
// Reaps matchmaking-created rooms that are never joined (rage-quit before join,
|
||||
// ticket expiry between match and WS handshake, network drop). Reuses the SAME
|
||||
// canonical teardown as every other reap path.
|
||||
const reaper = new MatchmakingRoomReaper({
|
||||
now: () => Date.now(),
|
||||
finalize: (room) => AbortMatchmakingRoom.run(room),
|
||||
});
|
||||
|
||||
MatchmakingQueue.init({
|
||||
now: () => Date.now(),
|
||||
|
||||
createRankedRoom: (format: MatchmakingFormat) => {
|
||||
const { room, roomPassword } = createMatchmakingRoom({
|
||||
format,
|
||||
// Ranked human pairs play best-of-3 (MATCH room with side-decking).
|
||||
matchMode: true,
|
||||
rankedOverride: true,
|
||||
logger: mmLogger,
|
||||
emitter: new EventEmitter(),
|
||||
onRoomCreated: (room) => reaper.track(room),
|
||||
});
|
||||
return { roomId: room.id, roomPassword };
|
||||
},
|
||||
|
||||
createBotRoom: (format: MatchmakingFormat) => {
|
||||
const { room, roomPassword } = createMatchmakingRoom({
|
||||
format,
|
||||
// Bot fallback stays best-of-1: windbot has no side-deck support,
|
||||
// so a MATCH room would stall in side-decking until the timeout.
|
||||
matchMode: false,
|
||||
rankedOverride: false,
|
||||
logger: mmLogger,
|
||||
emitter: new EventEmitter(),
|
||||
onRoomCreated: (room) => reaper.track(room),
|
||||
});
|
||||
return { roomPassword, roomId: room.id };
|
||||
},
|
||||
|
||||
spawnBot: (roomId: number, format: MatchmakingFormat) => {
|
||||
if (!WindbotModule.isInitialized() || !WindbotModule.getInstance().isEnabled()) {
|
||||
return;
|
||||
}
|
||||
const room = YGOProRoomList.findById(roomId);
|
||||
if (!room) return;
|
||||
|
||||
// Pick an identity pair from the per-format roster. Name and deck always
|
||||
// come from the same pair (identity coherence). Pass the explicit name so
|
||||
// requestBot finds the right bot by name, and pass deck as deckOverride so
|
||||
// windbot uses the correct deck and deckcode is cleared.
|
||||
const pair = pickBotFromRoster(format);
|
||||
|
||||
// Fire-and-forget, mirroring WindBotJoinStrategy: abort retries once the
|
||||
// room begins teardown. On failure, tear the empty bot room down so it
|
||||
// does not linger in the lobby.
|
||||
void WindbotModule.getInstance()
|
||||
.requestBot(roomId, pair.name, () => room.finalizing, pair.deck)
|
||||
.then(({ bot }) => {
|
||||
room.windbot = { name: bot.name, deck: bot.deck };
|
||||
})
|
||||
.catch((error: unknown) => {
|
||||
mmLogger.error(
|
||||
`Matchmaking bot spawn failed for room ${roomId}: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
AbortMatchmakingRoom.run(room);
|
||||
});
|
||||
},
|
||||
|
||||
// Bot fallback only makes sense when windbot is up; otherwise entries keep
|
||||
// waiting for a human (or TTL-drop) instead of dead-ending on a bot game.
|
||||
botAvailable: () => WindbotModule.isInitialized() && WindbotModule.getInstance().isEnabled(),
|
||||
|
||||
// A synchronous room-creation failure is caught inside the queue's sweep so
|
||||
// it never aborts the sweep or 500s an unrelated poller; log it here.
|
||||
onRoomCreationError: (error: unknown) => {
|
||||
mmLogger.error(
|
||||
`Matchmaking room creation failed during sweep: ${
|
||||
error instanceof Error ? error.message : String(error)
|
||||
}`,
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
MatchmakingQueue.getInstance().start();
|
||||
|
||||
// Drive the empty-room sweep on its own unref'd timer so it never keeps the
|
||||
// process alive. Reuses the queue's cleanup cadence.
|
||||
const sweepTimer = setInterval(() => reaper.sweep(), CLEANUP_INTERVAL_MS);
|
||||
sweepTimer.unref();
|
||||
}
|
||||
29
src/bootstrap/bootstrapPersistence.ts
Normal file
29
src/bootstrap/bootstrapPersistence.ts
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
import { Redis } from "@shared/db/redis/infrastructure/Redis";
|
||||
import { EdoProCardDbHotReload } from "@edopro/card/infrastructure/sqlite/EdoProCardDbHotReload";
|
||||
import { EdoProSQLiteTypeORM } from "@edopro/card/infrastructure/sqlite/EdoProSQLiteTypeORM";
|
||||
import { Logger } from "@shared/logger/domain/Logger";
|
||||
|
||||
import { config } from "src/config";
|
||||
import { PostgresTypeORM } from "src/evolution-types/src/PostgresTypeORM";
|
||||
|
||||
// Opens every datastore connection the server depends on. Postgres is only
|
||||
// touched when ranking is enabled; SQLite and Redis are always required.
|
||||
export async function bootstrapPersistence(logger: Logger): Promise<void> {
|
||||
const sqlite = new EdoProSQLiteTypeORM();
|
||||
await sqlite.connect();
|
||||
await sqlite.initialize();
|
||||
logger.info("🗄️ SQLite connected");
|
||||
|
||||
// Hot-reload the EDOPro card DB when the .cdb files change at runtime, refreshing
|
||||
// evolution_cards.db in place so the C++ core (which opens that fixed path) sees it.
|
||||
await new EdoProCardDbHotReload().start();
|
||||
|
||||
if (config.ranking.enabled) {
|
||||
const postgres = new PostgresTypeORM();
|
||||
await postgres.connect();
|
||||
logger.info("🗄️ Postgres connected · ranking ON");
|
||||
}
|
||||
|
||||
const redis = new Redis();
|
||||
await redis.connect();
|
||||
}
|
||||
12
src/bootstrap/bootstrapResources.ts
Normal file
12
src/bootstrap/bootstrapResources.ts
Normal file
|
|
@ -0,0 +1,12 @@
|
|||
import { Logger } from "@shared/logger/domain/Logger";
|
||||
|
||||
import { bootstrapEdoproResources } from "./bootstrapEdoproResources";
|
||||
import { bootstrapYgoproResources } from "./bootstrapYgoproResources";
|
||||
|
||||
// Order is mandatory: ygopro resources cross-reference edopro ban lists by name
|
||||
// (YGOProRoom resolves _edoBanListHash from BanListMemoryRepository), so edopro
|
||||
// ban lists must load first. Do not reorder these two calls.
|
||||
export async function bootstrapResources(logger: Logger): Promise<void> {
|
||||
await bootstrapEdoproResources(logger);
|
||||
await bootstrapYgoproResources(logger);
|
||||
}
|
||||
20
src/bootstrap/bootstrapYgoproResources.ts
Normal file
20
src/bootstrap/bootstrapYgoproResources.ts
Normal file
|
|
@ -0,0 +1,20 @@
|
|||
import { Logger } from "@shared/logger/domain/Logger";
|
||||
import { YGOProResourceLoader } from "@ygopro/ygopro/YGOProResourceLoader";
|
||||
import YGOProBanListMemoryRepository from "@ygopro/ban-list/infrastructure/YGOProBanListMemoryRepository";
|
||||
import { loadYgoproBanLists } from "./bootstrapBanListLoaders";
|
||||
|
||||
// Loads ygopro card resources and ban lists.
|
||||
//
|
||||
// Precondition: edopro ban lists must already be loaded. YGOProRoom cross-
|
||||
// references them by name (BanListMemoryRepository) to resolve _edoBanListHash,
|
||||
// so calling this before bootstrapEdoproResources yields empty hashes. The
|
||||
// order is enforced by bootstrapResources.
|
||||
export async function bootstrapYgoproResources(logger: Logger): Promise<void> {
|
||||
await YGOProResourceLoader.start();
|
||||
await YGOProResourceLoader.get().logLFLists();
|
||||
|
||||
const tmp = await loadYgoproBanLists();
|
||||
YGOProBanListMemoryRepository.replaceAll(tmp);
|
||||
|
||||
logger.info("🎴 YGOPro resources & ban lists loaded");
|
||||
}
|
||||
|
|
@ -1,49 +1,53 @@
|
|||
import { parseWindbotConfig } from "../ygopro/windbot/infrastructure/WindbotConfig";
|
||||
|
||||
export const config = {
|
||||
redis: {
|
||||
use: process.env.USE_REDIS === "true",
|
||||
uri: process.env.REDIS_URI,
|
||||
},
|
||||
env: process.env.NODE_ENV,
|
||||
adminApiKey: process.env.ADMIN_API_KEY,
|
||||
postgres: {
|
||||
username: process.env.POSTGRES_USER,
|
||||
password: process.env.POSTGRES_PASSWORD,
|
||||
database: process.env.POSTGRES_DB,
|
||||
host: process.env.POSTGRES_HOST ?? "localhost",
|
||||
port: process.env.POSTGRES_PORT ? Number(process.env.POSTGRES_PORT) : 5432,
|
||||
},
|
||||
ranking: {
|
||||
enabled: process.env.RANK_ENABLED === "true",
|
||||
},
|
||||
season: Number(process.env.SEASON),
|
||||
allowedOrigins: process.env.ALLOWED_ORIGINS?.split(",") ?? ["*"],
|
||||
rateLimit: {
|
||||
enabled: process.env.RATE_LIMIT_ENABLED === "true",
|
||||
limit: Number(process.env.RATE_LIMIT),
|
||||
window: Number(process.env.RATE_LIMIT_WINDOW),
|
||||
},
|
||||
servers: {
|
||||
host: {
|
||||
port: Number(process.env.HOST_PORT),
|
||||
},
|
||||
mercury: {
|
||||
port: Number(process.env.YGOPRO_PORT),
|
||||
wsPort: Number(process.env.YGOPRO_WEBSOCKET_PORT) || 4002,
|
||||
},
|
||||
http: {
|
||||
port: Number(process.env.HTTP_PORT),
|
||||
},
|
||||
websocket: {
|
||||
port: Number(process.env.WEBSOCKET_PORT),
|
||||
duelPort: Number(process.env.WEBSOCKET_DUEL_PORT) || 4001,
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
ygopro: {
|
||||
folders: process.env?.YGOPRO_FOLDERS?.split(",") ?? [],
|
||||
extraFolders: process.env?.YGOPRO_EXTRA_FOLDERS?.split(",") ?? [],
|
||||
extraScripts: process.env?.YGOPRO_EXTRA_SCRIPTS?.split(",") ?? [],
|
||||
}
|
||||
},
|
||||
sideTimeoutMinutes: Number(process.env.SIDE_TIMEOUT_MINUTES) || 3,
|
||||
redis: {
|
||||
use: process.env.USE_REDIS === "true",
|
||||
uri: process.env.REDIS_URI,
|
||||
},
|
||||
env: process.env.NODE_ENV,
|
||||
adminApiKey: process.env.ADMIN_API_KEY,
|
||||
postgres: {
|
||||
username: process.env.POSTGRES_USER,
|
||||
password: process.env.POSTGRES_PASSWORD,
|
||||
database: process.env.POSTGRES_DB,
|
||||
host: process.env.POSTGRES_HOST ?? "localhost",
|
||||
port: process.env.POSTGRES_PORT ? Number(process.env.POSTGRES_PORT) : 5432,
|
||||
},
|
||||
ranking: {
|
||||
enabled: process.env.RANK_ENABLED === "true",
|
||||
},
|
||||
season: Number(process.env.SEASON),
|
||||
allowedOrigins: process.env.ALLOWED_ORIGINS?.split(",") ?? ["*"],
|
||||
rateLimit: {
|
||||
enabled: process.env.RATE_LIMIT_ENABLED === "true",
|
||||
limit: Number(process.env.RATE_LIMIT),
|
||||
window: Number(process.env.RATE_LIMIT_WINDOW),
|
||||
},
|
||||
servers: {
|
||||
host: {
|
||||
port: Number(process.env.HOST_PORT),
|
||||
},
|
||||
mercury: {
|
||||
port: Number(process.env.YGOPRO_PORT),
|
||||
wsPort: Number(process.env.YGOPRO_WEBSOCKET_PORT) || 4002,
|
||||
wsHeartbeatIntervalMs: Number(process.env.YGOPRO_WEBSOCKET_HEARTBEAT_MS) || 30000,
|
||||
},
|
||||
http: {
|
||||
port: Number(process.env.HTTP_PORT),
|
||||
},
|
||||
websocket: {
|
||||
port: Number(process.env.WEBSOCKET_PORT),
|
||||
duelPort: Number(process.env.WEBSOCKET_DUEL_PORT) || 4001,
|
||||
},
|
||||
},
|
||||
resources: {
|
||||
dir: process.env.RESOURCES_DIR ?? "./resources/current",
|
||||
manifestPath: process.env.MANIFEST_PATH ?? "./resources.manifest.json",
|
||||
ygopro: {
|
||||
extraScripts: process.env?.YGOPRO_EXTRA_SCRIPTS?.split(",") ?? [],
|
||||
},
|
||||
},
|
||||
sideTimeoutMinutes: Number(process.env.SIDE_TIMEOUT_MINUTES) || 3,
|
||||
windbot: parseWindbotConfig(process.env),
|
||||
};
|
||||
|
|
|
|||
|
|
@ -11,7 +11,7 @@ export class MessageEmitter {
|
|||
private readonly logger: Logger,
|
||||
private readonly eventEmitter: EventEmitter,
|
||||
private readonly createGameListener: (roomId: number) => void,
|
||||
private readonly joinGameListener: () => void
|
||||
private readonly joinGameListener: () => void,
|
||||
) {
|
||||
this.messageProcessor = new MessageProcessor();
|
||||
this.logger = logger.child({ file: "MessageEmitter" });
|
||||
|
|
@ -39,7 +39,7 @@ export class MessageEmitter {
|
|||
if (this.messageProcessor.command === Commands.PLAYER_INFO) {
|
||||
this.eventEmitter.emit(
|
||||
this.messageProcessor.command as unknown as string,
|
||||
this.messageProcessor.payload
|
||||
this.messageProcessor.payload,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -48,7 +48,7 @@ export class MessageEmitter {
|
|||
this.createGameListener(roomId);
|
||||
this.eventEmitter.emit(
|
||||
this.messageProcessor.command as unknown as string,
|
||||
this.messageProcessor.payload
|
||||
this.messageProcessor.payload,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -56,7 +56,7 @@ export class MessageEmitter {
|
|||
this.joinGameListener();
|
||||
this.eventEmitter.emit(
|
||||
this.messageProcessor.command as unknown as string,
|
||||
this.messageProcessor.payload
|
||||
this.messageProcessor.payload,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -64,7 +64,7 @@ export class MessageEmitter {
|
|||
this.logger.info(`Emitting RECONNECT event for command ${Commands.RECONNECT}`);
|
||||
this.eventEmitter.emit(
|
||||
this.messageProcessor.command as unknown as string,
|
||||
this.messageProcessor.payload
|
||||
this.messageProcessor.payload,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -8,7 +8,10 @@ const PONG_COMMAND = 0xfe;
|
|||
export class RoomMessageEmitter {
|
||||
private readonly messageProcessor: MessageProcessor;
|
||||
|
||||
constructor(private readonly client: Client, private readonly room: Room) {
|
||||
constructor(
|
||||
private readonly client: Client,
|
||||
private readonly room: Room,
|
||||
) {
|
||||
this.messageProcessor = new MessageProcessor();
|
||||
}
|
||||
|
||||
|
|
@ -26,7 +29,7 @@ export class RoomMessageEmitter {
|
|||
this.room.emitRoomEvent(
|
||||
this.messageProcessor.command as unknown as string,
|
||||
this.messageProcessor.payload,
|
||||
this.client
|
||||
this.client,
|
||||
);
|
||||
this.processMessage();
|
||||
}
|
||||
|
|
|
|||
99
src/edopro/ban-list/domain/BanList.test.ts
Normal file
99
src/edopro/ban-list/domain/BanList.test.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
import { EdoproBanList } from "./BanList";
|
||||
|
||||
describe("EdoproBanList", () => {
|
||||
let banList: EdoproBanList;
|
||||
|
||||
beforeEach(() => {
|
||||
banList = new EdoproBanList();
|
||||
});
|
||||
|
||||
describe("add", () => {
|
||||
it("should ignore NaN cardId", () => {
|
||||
banList.add(NaN, 1);
|
||||
expect(banList.limited).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should add to forbidden list when quantity is 0", () => {
|
||||
const cardId = 123;
|
||||
banList.add(cardId, 0);
|
||||
expect(banList.forbidden).toContain(cardId);
|
||||
expect(banList.limited).not.toContain(cardId);
|
||||
expect(banList.semiLimited).not.toContain(cardId);
|
||||
expect(banList.all).not.toContain(cardId);
|
||||
});
|
||||
|
||||
it("should add to limited list when quantity is 1", () => {
|
||||
const cardId = 456;
|
||||
banList.add(cardId, 1);
|
||||
expect(banList.forbidden).not.toContain(cardId);
|
||||
expect(banList.limited).toContain(cardId);
|
||||
expect(banList.semiLimited).not.toContain(cardId);
|
||||
expect(banList.all).not.toContain(cardId);
|
||||
});
|
||||
|
||||
it("should add to semiLimited list when quantity is 2", () => {
|
||||
const cardId = 789;
|
||||
banList.add(cardId, 2);
|
||||
expect(banList.forbidden).not.toContain(cardId);
|
||||
expect(banList.limited).not.toContain(cardId);
|
||||
expect(banList.semiLimited).toContain(cardId);
|
||||
expect(banList.all).not.toContain(cardId);
|
||||
});
|
||||
|
||||
it("should add to all list when quantity is 3", () => {
|
||||
const cardId = 101112;
|
||||
banList.add(cardId, 3);
|
||||
expect(banList.forbidden).not.toContain(cardId);
|
||||
expect(banList.limited).not.toContain(cardId);
|
||||
expect(banList.semiLimited).not.toContain(cardId);
|
||||
expect(banList.all).toContain(cardId);
|
||||
});
|
||||
|
||||
it("should update hash when adding a card", () => {
|
||||
const initialHash = banList.hash;
|
||||
banList.add(123, 1);
|
||||
expect(banList.hash).not.toBe(initialHash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("points (Genesys third column)", () => {
|
||||
it("should store the point cost when provided", () => {
|
||||
const cardId = 21044178;
|
||||
banList.add(cardId, 3, 100);
|
||||
expect(banList.points.get(cardId)).toBe(100);
|
||||
expect(banList.all).toContain(cardId);
|
||||
});
|
||||
|
||||
it("should not store points when the third column is absent", () => {
|
||||
const cardId = 456;
|
||||
banList.add(cardId, 3);
|
||||
expect(banList.points.has(cardId)).toBe(false);
|
||||
});
|
||||
|
||||
it("should ignore a non-numeric point value", () => {
|
||||
const cardId = 789;
|
||||
banList.add(cardId, 3, NaN);
|
||||
expect(banList.points.has(cardId)).toBe(false);
|
||||
});
|
||||
|
||||
it("should not let points affect the hash", () => {
|
||||
const withPoints = new EdoproBanList();
|
||||
const withoutPoints = new EdoproBanList();
|
||||
withPoints.add(123, 3, 50);
|
||||
withoutPoints.add(123, 3);
|
||||
expect(withPoints.hash).toBe(withoutPoints.hash);
|
||||
});
|
||||
});
|
||||
|
||||
describe("isGenesys", () => {
|
||||
it("should return true if name is Genesys", () => {
|
||||
banList.setName("Genesys");
|
||||
expect(banList.isGenesys()).toBe(true);
|
||||
});
|
||||
|
||||
it("should return false if name is not Genesys", () => {
|
||||
banList.setName("OCG");
|
||||
expect(banList.isGenesys()).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { BanList } from "src/shared/ban-list/BanList";
|
||||
|
||||
export class EdoproBanList extends BanList {
|
||||
add(cardId: number, quantity: number): void {
|
||||
add(cardId: number, quantity: number, points?: number): void {
|
||||
if (isNaN(cardId)) {
|
||||
return;
|
||||
}
|
||||
|
|
@ -22,6 +22,10 @@ export class EdoproBanList extends BanList {
|
|||
this.all.push(cardId);
|
||||
}
|
||||
|
||||
if (points !== undefined && !isNaN(points)) {
|
||||
this.points.set(cardId, points);
|
||||
}
|
||||
|
||||
this._hash =
|
||||
this._hash ^
|
||||
((((cardId >>> 0) << 18) >> 0) | (cardId >> 14)) ^
|
||||
|
|
|
|||
|
|
@ -5,8 +5,22 @@ import { join } from "path";
|
|||
import { EdoproBanList } from "../domain/BanList";
|
||||
import BanListMemoryRepository from "./BanListMemoryRepository";
|
||||
import { BanListLoader } from "src/shared/ban-list/BanListLoader";
|
||||
import { parseBanListEntry } from "src/shared/ban-list/parseBanListEntry";
|
||||
|
||||
export class EdoProBanListLoader extends BanListLoader {
|
||||
/**
|
||||
* When a target array is provided, parsed banlists are pushed into it instead
|
||||
* of the shared BanListMemoryRepository. This enables the re-callable pure-builder
|
||||
* pattern used by loadEdoproBanLists() (bootstrapBanListLoaders.ts) for hot-reload.
|
||||
*/
|
||||
private readonly _target: EdoproBanList[] | null;
|
||||
private readonly _loaded: EdoproBanList[] = [];
|
||||
|
||||
constructor(target?: EdoproBanList[]) {
|
||||
super();
|
||||
this._target = target ?? null;
|
||||
}
|
||||
|
||||
async loadDirectory(path: string): Promise<void> {
|
||||
const directoryPath = path;
|
||||
const files = await readdir(directoryPath);
|
||||
|
|
@ -17,6 +31,11 @@ export class EdoProBanListLoader extends BanListLoader {
|
|||
}
|
||||
}
|
||||
|
||||
/** Returns all banlists parsed by this loader instance. */
|
||||
getLoaded(): EdoproBanList[] {
|
||||
return this._loaded;
|
||||
}
|
||||
|
||||
private load(path: string): void {
|
||||
const banList = new EdoproBanList();
|
||||
|
||||
|
|
@ -38,18 +57,24 @@ export class EdoProBanListLoader extends BanListLoader {
|
|||
banList.setName(line.substring(1));
|
||||
}
|
||||
|
||||
if (!line.includes(" ")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
if (banList.name === null) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const [cardId, quantity] = line.split(" ");
|
||||
banList.add(Number(cardId), Number(quantity));
|
||||
const entry = parseBanListEntry(line);
|
||||
if (!entry) {
|
||||
continue;
|
||||
}
|
||||
|
||||
banList.add(entry.code, entry.limit, entry.points);
|
||||
}
|
||||
|
||||
BanListMemoryRepository.add(banList);
|
||||
this._loaded.push(banList);
|
||||
|
||||
if (this._target !== null) {
|
||||
this._target.push(banList);
|
||||
} else {
|
||||
BanListMemoryRepository.add(banList);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,108 @@
|
|||
import { EdoproBanList } from "../domain/BanList";
|
||||
import BanListMemoryRepository from "./BanListMemoryRepository";
|
||||
|
||||
function makeList(name: string, hash?: number): EdoproBanList {
|
||||
const list = new EdoproBanList();
|
||||
list.setName(name);
|
||||
if (hash !== undefined) {
|
||||
list.add(hash, 1); // adds a card so hash is non-zero
|
||||
}
|
||||
return list;
|
||||
}
|
||||
|
||||
describe("EdoproBanListMemoryRepository.replaceAll", () => {
|
||||
beforeEach(() => {
|
||||
// Clear the repository between tests using the internal array trick.
|
||||
// replaceAll([]) empties it — once it exists; before it does we rely on the
|
||||
// fact that the module array starts empty at process start and Jest isolates
|
||||
// modules per test file only when configured with resetModules. We clear it
|
||||
// via replaceAll once the method is available, but for the red-bar tests we
|
||||
// seed and then call replaceAll.
|
||||
//
|
||||
// NOTE: The module-level array is shared across tests within one file.
|
||||
// Use replaceAll([]) in afterEach once the method exists.
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
// Reset to a clean state after each test.
|
||||
// This call is intentionally calling the method under test — if it does not
|
||||
// exist yet the teardown will also fail, which is correct for the red bar.
|
||||
BanListMemoryRepository.replaceAll([]);
|
||||
});
|
||||
|
||||
describe("basic replacement", () => {
|
||||
it("replaceAll([a, b]) → get() returns [a, b]", () => {
|
||||
const a = makeList("List A");
|
||||
const b = makeList("List B");
|
||||
|
||||
BanListMemoryRepository.replaceAll([a, b]);
|
||||
|
||||
expect(BanListMemoryRepository.get()).toEqual([a, b]);
|
||||
});
|
||||
|
||||
it("replaceAll([]) on a non-empty repo → get() returns []", () => {
|
||||
const a = makeList("List A");
|
||||
BanListMemoryRepository.replaceAll([a]);
|
||||
|
||||
BanListMemoryRepository.replaceAll([]);
|
||||
|
||||
expect(BanListMemoryRepository.get()).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("replaceAll called twice — second call overwrites first", () => {
|
||||
const a = makeList("List A");
|
||||
const b = makeList("List B");
|
||||
|
||||
BanListMemoryRepository.replaceAll([a]);
|
||||
BanListMemoryRepository.replaceAll([b]);
|
||||
|
||||
expect(BanListMemoryRepository.get()).toEqual([b]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("atomicity — synchronous swap invariant", () => {
|
||||
it("get() returns the new list immediately after replaceAll returns — no empty window", () => {
|
||||
// This test asserts the synchronous contract.
|
||||
// replaceAll MUST NOT contain any await between emptying and refilling the array.
|
||||
// Because JS is single-threaded, reading get() right after replaceAll()
|
||||
// returns MUST yield the new list, never [].
|
||||
const initial = makeList("Initial");
|
||||
BanListMemoryRepository.replaceAll([initial]);
|
||||
|
||||
const next = makeList("Next");
|
||||
BanListMemoryRepository.replaceAll([next]);
|
||||
|
||||
// Immediately after the call, the list is the new one — never empty.
|
||||
const result = BanListMemoryRepository.get();
|
||||
expect(result).toHaveLength(1);
|
||||
expect(result[0]).toBe(next);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findByHash / findByName operate on new list after replaceAll", () => {
|
||||
it("findByName returns item from the new list", () => {
|
||||
const old = makeList("Old List");
|
||||
BanListMemoryRepository.replaceAll([old]);
|
||||
|
||||
const fresh = makeList("Fresh List");
|
||||
BanListMemoryRepository.replaceAll([fresh]);
|
||||
|
||||
expect(BanListMemoryRepository.findByName("Fresh List")).toBe(fresh);
|
||||
expect(BanListMemoryRepository.findByName("Old List")).toBeNull();
|
||||
});
|
||||
|
||||
it("findByHash returns item from the new list", () => {
|
||||
const a = makeList("A");
|
||||
BanListMemoryRepository.replaceAll([a]);
|
||||
const aHash = a.hash;
|
||||
|
||||
const b = new EdoproBanList();
|
||||
b.setName("B");
|
||||
b.add(99999, 1); // different card → different hash
|
||||
BanListMemoryRepository.replaceAll([b]);
|
||||
|
||||
expect(BanListMemoryRepository.findByHash(aHash)).toBeNull();
|
||||
expect(BanListMemoryRepository.findByHash(b.hash)).toBe(b);
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -32,4 +32,14 @@ export default {
|
|||
getOnlyWithName(): string[] {
|
||||
return banLists.filter((banList) => banList.name).map((item) => item.name as string);
|
||||
},
|
||||
|
||||
/**
|
||||
* Atomically replaces the entire banlist array with a new one.
|
||||
* Uses a synchronous in-place swap (no await between truncation and fill)
|
||||
* so no concurrent HTTP request can observe an empty-list window.
|
||||
*/
|
||||
replaceAll(next: EdoproBanList[]): void {
|
||||
banLists.length = 0;
|
||||
banLists.push(...next);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
36
src/edopro/card/infrastructure/EdoProCardSearchRepository.ts
Normal file
36
src/edopro/card/infrastructure/EdoProCardSearchRepository.ts
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { readdir, readFile } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
|
||||
import {
|
||||
CdbCardSearchRepository,
|
||||
CdbFile,
|
||||
} from "@shared/card/infrastructure/cdb/CdbCardSearchRepository";
|
||||
import { config } from "src/config";
|
||||
|
||||
export class EdoProCardSearchRepository extends CdbCardSearchRepository {
|
||||
constructor(
|
||||
private readonly directoryPaths: string[] = [`${config.resources.dir}/edopro/databases`],
|
||||
) {
|
||||
super({ lastSourceWins: true });
|
||||
}
|
||||
|
||||
protected async *cdbFiles(): AsyncIterable<CdbFile> {
|
||||
for (const directoryPath of this.directoryPaths) {
|
||||
let files: string[];
|
||||
try {
|
||||
files = await readdir(directoryPath);
|
||||
} catch {
|
||||
continue;
|
||||
}
|
||||
|
||||
for (const file of files) {
|
||||
if (!file.endsWith(".cdb")) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const path = join(directoryPath, file);
|
||||
yield { path, read: () => readFile(path) };
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,11 @@
|
|||
import { dataSource } from "../../../../shared/db/sqlite/infrastructure/data-source";
|
||||
import { getCardDataSource } from "../../../../shared/db/sqlite/infrastructure/data-source";
|
||||
import { Card } from "../../../../shared/card/domain/Card";
|
||||
import { CardRepository } from "../../../../shared/card/domain/CardRepository";
|
||||
import { CardEntity } from "./CardEntity";
|
||||
import { CardEntity } from "@shared/db/sqlite/infrastructure/CardEntity";
|
||||
|
||||
export class CardSQLiteTYpeORMRepository implements CardRepository {
|
||||
async findByCode(code: string): Promise<Card | null> {
|
||||
const repository = dataSource.getRepository(CardEntity);
|
||||
const repository = getCardDataSource().getRepository(CardEntity);
|
||||
const card = await repository.findOneBy({ id: code });
|
||||
if (!card) {
|
||||
return null;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,48 @@
|
|||
import { CardDbReloader } from "@shared/db/sqlite/infrastructure/CardDbReloader";
|
||||
import { Logger } from "@shared/logger/domain/Logger";
|
||||
import LoggerFactory from "@shared/logger/infrastructure/LoggerFactory";
|
||||
import { config } from "src/config";
|
||||
|
||||
import { EdoProCardDbPorts } from "./EdoProCardDbPorts";
|
||||
import { EdoProSQLiteTypeORM } from "./EdoProSQLiteTypeORM";
|
||||
|
||||
const RELOAD_INTERVAL_MS = 10 * 60 * 1000;
|
||||
|
||||
// Reload mechanics (atomic in-place replace) live in EdoProCardDbPorts. Mirrors
|
||||
// the YGOPro reload timer.
|
||||
export class EdoProCardDbHotReload {
|
||||
private static shared: EdoProCardDbHotReload | null = null;
|
||||
|
||||
private readonly logger: Logger = LoggerFactory.getLogger();
|
||||
private readonly reloader: CardDbReloader;
|
||||
|
||||
constructor(directory: string = `${config.resources.dir}/edopro/databases`) {
|
||||
this.reloader = new CardDbReloader(
|
||||
new EdoProCardDbPorts(new EdoProSQLiteTypeORM([directory]), directory),
|
||||
);
|
||||
}
|
||||
|
||||
// The running instance, or null before start() runs. Lets the resource-version
|
||||
// endpoint reach the card-db fingerprint without threading the instance through DI.
|
||||
static getShared(): EdoProCardDbHotReload | null {
|
||||
return EdoProCardDbHotReload.shared;
|
||||
}
|
||||
|
||||
// The fingerprint of the currently loaded EDOPro card DB, or null before the first prime().
|
||||
get fingerprint(): string | null {
|
||||
return this.reloader.currentFingerprintValue;
|
||||
}
|
||||
|
||||
// Record the boot fingerprint, then poll for changes. The boot datasource is
|
||||
// already built/merged by bootstrapPersistence, so we only prime here.
|
||||
async start(): Promise<void> {
|
||||
EdoProCardDbHotReload.shared = this;
|
||||
await this.reloader.prime();
|
||||
setInterval(() => {
|
||||
this.reloader.reloadIfChanged().catch((error) => {
|
||||
this.logger.error("Failed reloading EDOPro card DB");
|
||||
this.logger.error(error);
|
||||
});
|
||||
}, RELOAD_INTERVAL_MS);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,83 @@
|
|||
import { rename, rm } from "node:fs/promises";
|
||||
import type { DataSource } from "typeorm";
|
||||
|
||||
import { CARD_DB_FILE } from "@shared/db/sqlite/infrastructure/data-source";
|
||||
|
||||
import { EdoProCardDbPorts } from "./EdoProCardDbPorts";
|
||||
import type { EdoProSQLiteTypeORM } from "./EdoProSQLiteTypeORM";
|
||||
|
||||
jest.mock("node:fs/promises", () => ({
|
||||
rename: jest.fn(async () => undefined),
|
||||
rm: jest.fn(async () => undefined),
|
||||
readdir: jest.fn(async () => []),
|
||||
stat: jest.fn(async () => ({ size: 0, mtimeMs: 0 })),
|
||||
}));
|
||||
|
||||
const renameMock = rename as jest.Mock;
|
||||
const rmMock = rm as jest.Mock;
|
||||
|
||||
const fakeOrm = (build: jest.Mock): EdoProSQLiteTypeORM =>
|
||||
({ build }) as unknown as EdoProSQLiteTypeORM;
|
||||
|
||||
describe("EdoProCardDbPorts", () => {
|
||||
beforeEach(() => {
|
||||
renameMock.mockClear();
|
||||
renameMock.mockResolvedValue(undefined);
|
||||
rmMock.mockClear();
|
||||
rmMock.mockResolvedValue(undefined);
|
||||
});
|
||||
|
||||
it("builds into a temp file then renames it onto the canonical evolution_cards.db", async () => {
|
||||
const builtDs = { isInitialized: true } as unknown as DataSource;
|
||||
const build = jest.fn((_file: string) => Promise.resolve(builtDs));
|
||||
const ports = new EdoProCardDbPorts(fakeOrm(build), "dir");
|
||||
|
||||
const result = await ports.build();
|
||||
|
||||
expect(build).toHaveBeenCalledTimes(1);
|
||||
const tempFile = build.mock.calls[0][0] as string;
|
||||
expect(tempFile).not.toBe(CARD_DB_FILE);
|
||||
expect(renameMock).toHaveBeenCalledWith(tempFile, CARD_DB_FILE);
|
||||
expect(result).toBe(builtDs);
|
||||
});
|
||||
|
||||
it("propagates an orm.build failure and never renames a failed build onto the canonical file", async () => {
|
||||
const build = jest.fn((_file: string) => Promise.reject(new Error("merge failed")));
|
||||
const ports = new EdoProCardDbPorts(fakeOrm(build), "dir");
|
||||
|
||||
await expect(ports.build()).rejects.toThrow("merge failed");
|
||||
expect(renameMock).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("never deletes the canonical file when disposing the previous datasource", async () => {
|
||||
jest.useFakeTimers();
|
||||
const previous = { destroy: jest.fn(async () => undefined) } as unknown as DataSource;
|
||||
const ports = new EdoProCardDbPorts(fakeOrm(jest.fn()), "dir", 1000);
|
||||
|
||||
await ports.destroy(previous);
|
||||
expect(previous.destroy).not.toHaveBeenCalled(); // deferred by the grace
|
||||
|
||||
await jest.advanceTimersByTimeAsync(1000);
|
||||
|
||||
expect(previous.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(rmMock).not.toHaveBeenCalled(); // the C++ core depends on evolution_cards.db
|
||||
jest.useRealTimers();
|
||||
});
|
||||
|
||||
it("cleans up the temp and rethrows if the rename fails, leaving the canonical file untouched", async () => {
|
||||
const builtDs = {
|
||||
isInitialized: true,
|
||||
destroy: jest.fn(async () => undefined),
|
||||
} as unknown as DataSource;
|
||||
const build = jest.fn((_file: string) => Promise.resolve(builtDs));
|
||||
renameMock.mockRejectedValueOnce(new Error("EXDEV"));
|
||||
const ports = new EdoProCardDbPorts(fakeOrm(build), "dir");
|
||||
|
||||
await expect(ports.build()).rejects.toThrow("EXDEV");
|
||||
|
||||
const tempFile = build.mock.calls[0][0] as string;
|
||||
expect(builtDs.destroy).toHaveBeenCalledTimes(1);
|
||||
expect(rmMock).toHaveBeenCalledWith(tempFile, { force: true });
|
||||
expect(rmMock).not.toHaveBeenCalledWith(CARD_DB_FILE, expect.anything());
|
||||
});
|
||||
});
|
||||
86
src/edopro/card/infrastructure/sqlite/EdoProCardDbPorts.ts
Normal file
86
src/edopro/card/infrastructure/sqlite/EdoProCardDbPorts.ts
Normal file
|
|
@ -0,0 +1,86 @@
|
|||
import { readdir, rename, rm, stat } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { CardDbReloaderPorts } from "@shared/db/sqlite/infrastructure/CardDbReloader";
|
||||
import { CARD_DB_FILE, swapCardDataSource } from "@shared/db/sqlite/infrastructure/data-source";
|
||||
import { Logger } from "@shared/logger/domain/Logger";
|
||||
import LoggerFactory from "@shared/logger/infrastructure/LoggerFactory";
|
||||
|
||||
import type { EdoProSQLiteTypeORM } from "./EdoProSQLiteTypeORM";
|
||||
|
||||
const DISPOSE_GRACE_MS = 60 * 1000;
|
||||
const TEMP_DB_FILE = `${CARD_DB_FILE}.tmp`;
|
||||
|
||||
// CardDbReloader ports for the EDOPro card DB. The C++ core (CardSqliteRepository)
|
||||
// opens the fixed path CARD_DB_FILE fresh per duel, so the reload must keep that
|
||||
// file as the single canonical artifact: build into a temp, then atomically rename
|
||||
// it onto CARD_DB_FILE. A running duel keeps its already-open inode (untouched); a
|
||||
// new duel opens the replaced file. The file is never a sidecar the core can't see
|
||||
// and is never deleted.
|
||||
export class EdoProCardDbPorts implements CardDbReloaderPorts {
|
||||
private readonly logger: Logger = LoggerFactory.getLogger();
|
||||
|
||||
constructor(
|
||||
private readonly orm: EdoProSQLiteTypeORM,
|
||||
private readonly directory: string,
|
||||
private readonly graceMs: number = DISPOSE_GRACE_MS,
|
||||
) {}
|
||||
|
||||
// Fingerprint from each file's size + mtime instead of hashing contents, so the
|
||||
// periodic check never reads/hashes hundreds of MB on the shared event loop.
|
||||
async fingerprint(): Promise<string> {
|
||||
const files = (await readdir(this.directory)).filter((file) => file.endsWith(".cdb")).sort();
|
||||
const parts: string[] = [];
|
||||
for (const file of files) {
|
||||
try {
|
||||
const { size, mtimeMs } = await stat(join(this.directory, file));
|
||||
parts.push(`${file}:${size}:${mtimeMs}`);
|
||||
} catch {
|
||||
// file vanished between readdir and stat — skip it
|
||||
}
|
||||
}
|
||||
|
||||
return parts.join("|");
|
||||
}
|
||||
|
||||
async build(): Promise<DataSource> {
|
||||
// Drop any temp left by a crashed earlier build so we merge from a clean slate.
|
||||
await rm(TEMP_DB_FILE, { force: true }).catch(() => undefined);
|
||||
const dataSource = await this.orm.build(TEMP_DB_FILE);
|
||||
try {
|
||||
// Atomic on the same filesystem: the just-built datasource's open fd follows
|
||||
// the inode, so it becomes CARD_DB_FILE; the previous file's inode lives on
|
||||
// (held by the old datasource) until it is disposed.
|
||||
await rename(TEMP_DB_FILE, CARD_DB_FILE);
|
||||
|
||||
return dataSource;
|
||||
} catch (error) {
|
||||
await dataSource.destroy().catch(() => undefined);
|
||||
await rm(TEMP_DB_FILE, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
swap(next: DataSource): DataSource {
|
||||
return swapCardDataSource(next);
|
||||
}
|
||||
|
||||
// Defer the close so in-flight findByCode calls on the old datasource finish.
|
||||
// Only the connection is closed — CARD_DB_FILE is never removed (build() already
|
||||
// replaced it in place, and the C++ core opens that path).
|
||||
destroy(previous: DataSource): Promise<void> {
|
||||
setTimeout(() => void this.retire(previous), this.graceMs).unref();
|
||||
|
||||
return Promise.resolve();
|
||||
}
|
||||
|
||||
private async retire(previous: DataSource): Promise<void> {
|
||||
try {
|
||||
await previous.destroy();
|
||||
} catch (error) {
|
||||
this.logger.error("Failed disposing the previous EDOPro card datasource");
|
||||
this.logger.error(error);
|
||||
}
|
||||
}
|
||||
}
|
||||
74
src/edopro/card/infrastructure/sqlite/EdoProSQLiteTypeORM.ts
Normal file
74
src/edopro/card/infrastructure/sqlite/EdoProSQLiteTypeORM.ts
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
import { readdir, rm } from "fs/promises";
|
||||
import { join } from "path";
|
||||
import { DataSource } from "typeorm";
|
||||
|
||||
import { Database } from "../../../../evolution-types/src/Database";
|
||||
import {
|
||||
buildCardDataSource,
|
||||
getCardDataSource,
|
||||
} from "@shared/db/sqlite/infrastructure/data-source";
|
||||
import { config } from "src/config";
|
||||
|
||||
export class EdoProSQLiteTypeORM implements Database {
|
||||
private readonly directoryPaths: string[];
|
||||
|
||||
constructor(directoryPaths?: string[]) {
|
||||
this.directoryPaths = directoryPaths ?? [`${config.resources.dir}/edopro/databases`];
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
await getCardDataSource().initialize();
|
||||
}
|
||||
|
||||
async initialize(): Promise<void> {
|
||||
await this.mergeAll(getCardDataSource());
|
||||
}
|
||||
|
||||
// Build a fresh datasource backed by `databaseFile`, merge every .cdb into it,
|
||||
// and return it ready to be swapped in. Never touches the live datasource, so a
|
||||
// rebuild can run while the current one is still serving lookups.
|
||||
async build(databaseFile: string): Promise<DataSource> {
|
||||
const dataSource = buildCardDataSource(databaseFile);
|
||||
try {
|
||||
await dataSource.initialize();
|
||||
await this.mergeAll(dataSource);
|
||||
|
||||
return dataSource;
|
||||
} catch (error) {
|
||||
// A half-built datasource would otherwise leak its connection + file.
|
||||
if (dataSource.isInitialized) {
|
||||
await dataSource.destroy().catch(() => undefined);
|
||||
}
|
||||
await rm(databaseFile, { force: true }).catch(() => undefined);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
private async mergeAll(dataSource: DataSource): Promise<void> {
|
||||
for (const directoryPath of this.directoryPaths) {
|
||||
const files = await readdir(directoryPath);
|
||||
const cdbFiles = files.filter((file) => file.endsWith(".cdb"));
|
||||
for (const file of cdbFiles) {
|
||||
await this.merge(dataSource, join(directoryPath, file));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private async merge(dataSource: DataSource, path: string): Promise<void> {
|
||||
const queryRunner = dataSource.createQueryRunner();
|
||||
await queryRunner.connect();
|
||||
await queryRunner.startTransaction();
|
||||
try {
|
||||
await queryRunner.query(`ATTACH DATABASE '${path}' AS toMerge`);
|
||||
await queryRunner.query("INSERT OR REPLACE INTO datas SELECT * FROM toMerge.datas");
|
||||
await queryRunner.query("INSERT OR REPLACE INTO texts SELECT * FROM toMerge.texts");
|
||||
await queryRunner.commitTransaction();
|
||||
await queryRunner.query("DETACH toMerge");
|
||||
} catch (error) {
|
||||
await queryRunner.rollbackTransaction();
|
||||
throw error;
|
||||
} finally {
|
||||
await queryRunner.release();
|
||||
}
|
||||
}
|
||||
}
|
||||
168
src/edopro/client/domain/Client.test.ts
Normal file
168
src/edopro/client/domain/Client.test.ts
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
import { Logger } from "@shared/logger/domain/Logger";
|
||||
import { ISocket } from "@shared/socket/domain/ISocket";
|
||||
import { Client } from "./Client";
|
||||
import { Deck } from "@shared/deck/domain/Deck";
|
||||
import { Choose } from "@edopro/rock-paper-scissor/RockPaperScissor";
|
||||
import { Room } from "@edopro/room/domain/Room";
|
||||
import { RoomMessageEmitter } from "@edopro/RoomMessageEmitter";
|
||||
import { MessageProcessor, ClientMessage } from "@shared/messages/MessageProcessor";
|
||||
|
||||
jest.mock("@edopro/RoomMessageEmitter");
|
||||
jest.mock("@shared/messages/MessageProcessor");
|
||||
|
||||
describe("Client", () => {
|
||||
let client: Client;
|
||||
let mockSocket: jest.Mocked<ISocket>;
|
||||
let mockLogger: jest.Mocked<Logger>;
|
||||
let mockRoom: jest.Mocked<Room>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockSocket = {
|
||||
id: "socket-id",
|
||||
send: jest.fn(),
|
||||
onMessage: jest.fn(),
|
||||
onClose: jest.fn(),
|
||||
close: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
remoteAddress: "127.0.0.1",
|
||||
roomId: 1,
|
||||
closed: false,
|
||||
removeAllListeners: jest.fn(),
|
||||
};
|
||||
|
||||
mockLogger = {
|
||||
debug: jest.fn(),
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
child: jest.fn().mockReturnThis(),
|
||||
} as unknown as jest.Mocked<Logger>;
|
||||
|
||||
mockRoom = {} as unknown as jest.Mocked<Room>;
|
||||
|
||||
client = new Client({
|
||||
socket: mockSocket,
|
||||
host: true,
|
||||
name: "TestPlayer",
|
||||
position: 1,
|
||||
roomId: 100,
|
||||
team: 0,
|
||||
logger: mockLogger,
|
||||
id: "user-id",
|
||||
});
|
||||
});
|
||||
|
||||
it("should initialize correctly", () => {
|
||||
expect(client.name).toBe("TestPlayer");
|
||||
expect(client.position).toBe(1);
|
||||
expect(client.roomId).toBe(100);
|
||||
expect(client.team).toBe(0);
|
||||
expect(client.host).toBe(true);
|
||||
expect(mockLogger.child).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should set socket and attach listeners", () => {
|
||||
const newSocket = { ...mockSocket, id: "new-socket-id" };
|
||||
const mockHandleMessage = jest.fn();
|
||||
const mockRead = jest.fn();
|
||||
|
||||
(RoomMessageEmitter as jest.Mock).mockImplementation(() => ({
|
||||
handleMessage: mockHandleMessage,
|
||||
}));
|
||||
|
||||
(MessageProcessor as jest.Mock).mockImplementation(() => ({
|
||||
read: mockRead,
|
||||
}));
|
||||
|
||||
client.setSocket(newSocket, [], mockRoom);
|
||||
|
||||
expect(client.socket.id).toBe("new-socket-id");
|
||||
expect(newSocket.onMessage).toHaveBeenCalled();
|
||||
|
||||
// Simulate message
|
||||
const messageHandler = (newSocket.onMessage as jest.Mock).mock.calls[0][0];
|
||||
const data = Buffer.from("test");
|
||||
messageHandler(data);
|
||||
|
||||
expect(mockHandleMessage).toHaveBeenCalledWith(data);
|
||||
expect(mockRead).toHaveBeenCalledWith(data);
|
||||
});
|
||||
|
||||
it("should handle socket without remote address", () => {
|
||||
const socketWithoutIp = { ...mockSocket, remoteAddress: undefined };
|
||||
client.setSocket(socketWithoutIp, [], mockRoom);
|
||||
expect((client as any)._ipAddress).toBeNull();
|
||||
});
|
||||
|
||||
it("should manage RPS choice", () => {
|
||||
expect(client.rpsChoise).toBeNull();
|
||||
|
||||
client.setRpsChosen("SCISSOR");
|
||||
expect(client.rpsChoise).toBe("SCISSOR");
|
||||
|
||||
client.clearRpsChoise();
|
||||
expect(client.rpsChoise).toBeNull();
|
||||
});
|
||||
|
||||
it("should set readiness", () => {
|
||||
client.ready();
|
||||
expect(client.isReady).toBe(true);
|
||||
|
||||
client.notReady();
|
||||
expect(client.isReady).toBe(false);
|
||||
});
|
||||
|
||||
it("should manage deck", () => {
|
||||
const mockDeck = {} as Deck;
|
||||
client.setDeck(mockDeck);
|
||||
expect(client.deck).toBe(mockDeck);
|
||||
});
|
||||
|
||||
it("should manage duel position", () => {
|
||||
client.setDuelPosition(5);
|
||||
expect(client.duelPosition).toBe(5);
|
||||
});
|
||||
|
||||
it("should manage turn state", () => {
|
||||
client.turn();
|
||||
expect(client.inTurn).toBe(true);
|
||||
|
||||
client.clearTurn();
|
||||
expect(client.inTurn).toBe(false);
|
||||
});
|
||||
|
||||
it("should manage reconnect flag", () => {
|
||||
client.setCanReconnect(true);
|
||||
expect(client.canReconnect).toBe(true);
|
||||
|
||||
client.setCanReconnect(false);
|
||||
expect(client.canReconnect).toBe(false);
|
||||
});
|
||||
|
||||
it("should send message via socket", () => {
|
||||
const message = Buffer.from("hello");
|
||||
client.sendMessage(message);
|
||||
expect(mockSocket.send).toHaveBeenCalledWith(message);
|
||||
});
|
||||
|
||||
it("should manage deck update state", () => {
|
||||
expect(client.isUpdatingDeck).toBeFalsy();
|
||||
|
||||
client.updatingDeck();
|
||||
expect(client.isUpdatingDeck).toBe(true);
|
||||
|
||||
client.deckUpdated();
|
||||
expect(client.isUpdatingDeck).toBe(false);
|
||||
});
|
||||
|
||||
it("should manage ready command", () => {
|
||||
const mockMessage = {} as ClientMessage;
|
||||
expect(client.haveReadyCommand).toBeFalsy();
|
||||
|
||||
client.saveReadyCommand(mockMessage);
|
||||
expect(client.haveReadyCommand).toBe(true);
|
||||
expect(client.readyMessage).toBe(mockMessage);
|
||||
|
||||
client.clearReadyCommand();
|
||||
expect(client.haveReadyCommand).toBe(false);
|
||||
});
|
||||
});
|
||||
|
|
@ -7,7 +7,7 @@ import { Choose } from "../../rock-paper-scissor/RockPaperScissor";
|
|||
import { Room } from "../../room/domain/Room";
|
||||
import { RoomMessageEmitter } from "../../RoomMessageEmitter";
|
||||
|
||||
export class Listener { }
|
||||
export class Listener {}
|
||||
|
||||
export class Client extends YgoClient {
|
||||
public readonly listener: Listener;
|
||||
|
|
|
|||
176
src/edopro/deck/application/DeckCreator.test.ts
Normal file
176
src/edopro/deck/application/DeckCreator.test.ts
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
import { EdoproBanList } from "@edopro/ban-list/domain/BanList";
|
||||
import BanListMemoryRepository from "@edopro/ban-list/infrastructure/BanListMemoryRepository";
|
||||
import { Card } from "@shared/card/domain/Card";
|
||||
import { CardRepository } from "@shared/card/domain/CardRepository";
|
||||
import { CardTypes } from "@shared/card/domain/CardTypes";
|
||||
import { Rule } from "@shared/deck/domain/Rule";
|
||||
import { DeckCreator } from "./DeckCreator";
|
||||
import { DeckRules } from "@shared/room/domain/YgoRoom";
|
||||
|
||||
// Mock dependencies
|
||||
const mockCardRepository: jest.Mocked<CardRepository> = {
|
||||
findByCode: jest.fn(),
|
||||
};
|
||||
|
||||
jest.mock("@edopro/ban-list/infrastructure/BanListMemoryRepository");
|
||||
|
||||
describe("DeckCreator", () => {
|
||||
let deckCreator: DeckCreator;
|
||||
const duelFlags = 0n;
|
||||
const deckRules = new DeckRules({
|
||||
mainMin: 40,
|
||||
mainMax: 60,
|
||||
extraMin: 0,
|
||||
extraMax: 15,
|
||||
sideMin: 0,
|
||||
sideMax: 15,
|
||||
rule: Rule.OCG_TCG,
|
||||
maxDeckPoints: 100,
|
||||
});
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
deckCreator = new DeckCreator(mockCardRepository, deckRules, duelFlags);
|
||||
});
|
||||
|
||||
const createCard = (code: string, type: number): Card => {
|
||||
return new Card({
|
||||
code,
|
||||
type,
|
||||
alias: "0",
|
||||
category: 0,
|
||||
variant: 0,
|
||||
});
|
||||
};
|
||||
|
||||
it("should build a deck correctly with main and side cards", async () => {
|
||||
const mainCodes = [123, 456];
|
||||
const sideCodes = [789];
|
||||
const banListHash = 111;
|
||||
|
||||
const card1 = createCard("123", CardTypes.TYPE_MONSTER);
|
||||
const card2 = createCard("456", CardTypes.TYPE_SPELL);
|
||||
const card3 = createCard("789", CardTypes.TYPE_TRAP);
|
||||
|
||||
mockCardRepository.findByCode.mockResolvedValueOnce(card1);
|
||||
mockCardRepository.findByCode.mockResolvedValueOnce(card2);
|
||||
mockCardRepository.findByCode.mockResolvedValueOnce(card3);
|
||||
|
||||
const mockBanList = new EdoproBanList();
|
||||
(BanListMemoryRepository.findByHash as jest.Mock).mockReturnValue(mockBanList);
|
||||
|
||||
const deck = await deckCreator.build({
|
||||
main: mainCodes,
|
||||
side: sideCodes,
|
||||
banListHash,
|
||||
});
|
||||
|
||||
expect(deck.main).toHaveLength(2);
|
||||
expect(deck.main).toContain(card1);
|
||||
expect(deck.main).toContain(card2);
|
||||
expect(deck.side).toHaveLength(1);
|
||||
expect(deck.side).toContain(card3);
|
||||
expect(deck.extra).toHaveLength(0);
|
||||
expect(BanListMemoryRepository.findByHash).toHaveBeenCalledWith(banListHash);
|
||||
});
|
||||
|
||||
it("should separate extra deck cards from main deck", async () => {
|
||||
const mainCodes = [100, 200];
|
||||
const sideCodes = [];
|
||||
const banListHash = 111;
|
||||
|
||||
const fusionCard = createCard("100", CardTypes.TYPE_MONSTER | CardTypes.TYPE_FUSION);
|
||||
const normalCard = createCard("200", CardTypes.TYPE_MONSTER);
|
||||
|
||||
mockCardRepository.findByCode.mockResolvedValueOnce(fusionCard);
|
||||
mockCardRepository.findByCode.mockResolvedValueOnce(normalCard);
|
||||
|
||||
const deck = await deckCreator.build({
|
||||
main: mainCodes,
|
||||
side: sideCodes,
|
||||
banListHash,
|
||||
});
|
||||
|
||||
expect(deck.main).toHaveLength(1);
|
||||
expect(deck.main).toContain(normalCard);
|
||||
expect(deck.extra).toHaveLength(1);
|
||||
expect(deck.extra).toContain(fusionCard);
|
||||
});
|
||||
|
||||
it("should handle Ritual cards in extra deck when flag is enabled", async () => {
|
||||
const ritualFlag = 0x800000000n;
|
||||
deckCreator = new DeckCreator(mockCardRepository, deckRules, ritualFlag);
|
||||
|
||||
const mainCodes = [300];
|
||||
const sideCodes = [];
|
||||
const banListHash = 111;
|
||||
|
||||
const ritualCard = createCard("300", CardTypes.TYPE_MONSTER | CardTypes.TYPE_RITUAL);
|
||||
|
||||
mockCardRepository.findByCode.mockResolvedValueOnce(ritualCard);
|
||||
|
||||
const deck = await deckCreator.build({
|
||||
main: mainCodes,
|
||||
side: sideCodes,
|
||||
banListHash,
|
||||
});
|
||||
|
||||
expect(deck.main).toHaveLength(0);
|
||||
expect(deck.extra).toHaveLength(1);
|
||||
expect(deck.extra).toContain(ritualCard);
|
||||
});
|
||||
|
||||
it("should keep Ritual cards in main deck when flag is disabled", async () => {
|
||||
const mainCodes = [300];
|
||||
const sideCodes = [];
|
||||
const banListHash = 111;
|
||||
|
||||
const ritualCard = createCard("300", CardTypes.TYPE_MONSTER | CardTypes.TYPE_RITUAL);
|
||||
|
||||
mockCardRepository.findByCode.mockResolvedValueOnce(ritualCard);
|
||||
|
||||
const deck = await deckCreator.build({
|
||||
main: mainCodes,
|
||||
side: sideCodes,
|
||||
banListHash,
|
||||
});
|
||||
|
||||
expect(deck.main).toHaveLength(1);
|
||||
expect(deck.main).toContain(ritualCard);
|
||||
expect(deck.extra).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should ignore missing cards", async () => {
|
||||
const mainCodes = [999];
|
||||
const sideCodes = [888];
|
||||
const banListHash = 111;
|
||||
|
||||
mockCardRepository.findByCode.mockResolvedValue(null);
|
||||
|
||||
const deck = await deckCreator.build({
|
||||
main: mainCodes,
|
||||
side: sideCodes,
|
||||
banListHash,
|
||||
});
|
||||
|
||||
expect(deck.main).toHaveLength(0);
|
||||
expect(deck.side).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("should use default BanList if hash is not found", async () => {
|
||||
const mainCodes: number[] = [];
|
||||
const sideCodes: number[] = [];
|
||||
const banListHash = 999;
|
||||
|
||||
(BanListMemoryRepository.findByHash as jest.Mock).mockReturnValue(null);
|
||||
|
||||
const deck = await deckCreator.build({
|
||||
main: mainCodes,
|
||||
side: sideCodes,
|
||||
banListHash,
|
||||
});
|
||||
|
||||
expect(deck).toBeDefined();
|
||||
expect(BanListMemoryRepository.findByHash).toHaveBeenCalledWith(banListHash);
|
||||
});
|
||||
});
|
||||
|
|
@ -32,7 +32,6 @@ export class DeckCreator {
|
|||
const placeRitualInExtraDeckEnabled = this.placeRitualInExtraDeckEnabled();
|
||||
|
||||
for (const code of main) {
|
||||
|
||||
const card = await this.cardRepository.findByCode(code.toString());
|
||||
if (!card) {
|
||||
continue;
|
||||
|
|
@ -46,7 +45,6 @@ export class DeckCreator {
|
|||
}
|
||||
|
||||
for (const code of side) {
|
||||
|
||||
const card = await this.cardRepository.findByCode(code.toString());
|
||||
if (!card) {
|
||||
continue;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,41 @@
|
|||
import { UpdateDeckMessageParser } from "./UpdateDeckMessageSizeCalculator";
|
||||
|
||||
describe("UpdateDeckMessageParser", () => {
|
||||
it("should parse deck correctly", () => {
|
||||
const mainAndExtraCount = 2;
|
||||
const sideCount = 1;
|
||||
const card1 = 123;
|
||||
const card2 = 456;
|
||||
const card3 = 789;
|
||||
|
||||
const buffer = Buffer.alloc(8 + (mainAndExtraCount + sideCount) * 4);
|
||||
buffer.writeUInt32LE(mainAndExtraCount, 0);
|
||||
buffer.writeUInt32LE(sideCount, 4);
|
||||
buffer.writeUInt32LE(card1, 8);
|
||||
buffer.writeUInt32LE(card2, 12);
|
||||
buffer.writeUInt32LE(card3, 16);
|
||||
|
||||
const parser = new UpdateDeckMessageParser(buffer);
|
||||
const [mainDeck, sideDeck] = parser.getDeck();
|
||||
|
||||
expect(mainDeck).toHaveLength(2);
|
||||
expect(mainDeck).toEqual([card1, card2]);
|
||||
expect(sideDeck).toHaveLength(1);
|
||||
expect(sideDeck).toEqual([card3]);
|
||||
});
|
||||
|
||||
it("should handle empty decks", () => {
|
||||
const mainAndExtraCount = 0;
|
||||
const sideCount = 0;
|
||||
|
||||
const buffer = Buffer.alloc(8);
|
||||
buffer.writeUInt32LE(mainAndExtraCount, 0);
|
||||
buffer.writeUInt32LE(sideCount, 4);
|
||||
|
||||
const parser = new UpdateDeckMessageParser(buffer);
|
||||
const [mainDeck, sideDeck] = parser.getDeck();
|
||||
|
||||
expect(mainDeck).toHaveLength(0);
|
||||
expect(sideDeck).toHaveLength(0);
|
||||
});
|
||||
});
|
||||
147
src/edopro/messages/JSONMessageProcessor.test.ts
Normal file
147
src/edopro/messages/JSONMessageProcessor.test.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
import { JSONMessageProcessor } from "./JSONMessageProcessor";
|
||||
|
||||
describe("JSONMessageProcessor", () => {
|
||||
let processor: JSONMessageProcessor;
|
||||
|
||||
beforeEach(() => {
|
||||
processor = new JSONMessageProcessor();
|
||||
});
|
||||
|
||||
it("should initialize with empty buffer", () => {
|
||||
expect(processor.currentBuffer.length).toBe(0);
|
||||
expect(processor.bufferLength).toBe(0);
|
||||
});
|
||||
|
||||
it("should accumulate data in buffer", () => {
|
||||
processor.read(Buffer.from("abc"));
|
||||
expect(processor.bufferLength).toBe(3);
|
||||
processor.read(Buffer.from("def"));
|
||||
expect(processor.bufferLength).toBe(6);
|
||||
});
|
||||
|
||||
it("should identify when a message is ready", () => {
|
||||
// Size (4 bytes LE) + Data
|
||||
const data = Buffer.from("test");
|
||||
const size = Buffer.alloc(4);
|
||||
size.writeUInt32LE(data.length, 0);
|
||||
|
||||
// Not enough for size header
|
||||
processor.read(size.subarray(0, 2));
|
||||
expect(processor.isMessageReady()).toBe(false);
|
||||
|
||||
// Size header complete, but not enough data
|
||||
processor.read(size.subarray(2));
|
||||
expect(processor.isMessageReady()).toBe(false);
|
||||
|
||||
// Full message
|
||||
processor.read(data);
|
||||
expect(processor.isMessageReady()).toBe(true);
|
||||
});
|
||||
|
||||
it("should process a valid message", () => {
|
||||
const payload = "test message";
|
||||
const data = Buffer.from(payload, "utf-8");
|
||||
const size = Buffer.alloc(4);
|
||||
size.writeUInt32LE(data.length, 0);
|
||||
|
||||
processor.read(size);
|
||||
processor.read(data);
|
||||
|
||||
processor.process();
|
||||
|
||||
expect(processor.size).toBe(data.length);
|
||||
expect(processor.payload.data).toBe(payload);
|
||||
expect(processor.bufferLength).toBe(0);
|
||||
});
|
||||
|
||||
it("should handle partial message processing", () => {
|
||||
const payload = "short";
|
||||
const data = Buffer.from(payload, "utf-8");
|
||||
const size = Buffer.alloc(4);
|
||||
size.writeUInt32LE(data.length, 0);
|
||||
|
||||
processor.read(size);
|
||||
// Don't read full data yet
|
||||
processor.read(data.subarray(0, 2));
|
||||
|
||||
processor.process();
|
||||
|
||||
// Message not ready, nothing should happen
|
||||
expect(processor.size).toBeUndefined();
|
||||
expect(processor.payload.data).toBe("");
|
||||
|
||||
// Read rest
|
||||
processor.read(data.subarray(2));
|
||||
processor.process();
|
||||
|
||||
expect(processor.size).toBe(data.length);
|
||||
expect(processor.payload.data).toBe(payload);
|
||||
});
|
||||
|
||||
it("should handle multiple messages in stream", () => {
|
||||
const msg1 = "message1";
|
||||
const msg2 = "message2";
|
||||
|
||||
const buf1 = Buffer.concat([
|
||||
(() => {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32LE(msg1.length);
|
||||
return b;
|
||||
})(),
|
||||
Buffer.from(msg1),
|
||||
]);
|
||||
|
||||
const buf2 = Buffer.concat([
|
||||
(() => {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32LE(msg2.length);
|
||||
return b;
|
||||
})(),
|
||||
Buffer.from(msg2),
|
||||
]);
|
||||
|
||||
processor.read(buf1);
|
||||
processor.read(buf2);
|
||||
|
||||
expect(processor.isMessageReady()).toBe(true);
|
||||
processor.process();
|
||||
expect(processor.payload.data).toBe(msg1);
|
||||
|
||||
expect(processor.isMessageReady()).toBe(true);
|
||||
processor.process();
|
||||
expect(processor.payload.data).toBe(msg2);
|
||||
|
||||
expect(processor.bufferLength).toBe(0);
|
||||
});
|
||||
|
||||
it("should clear state", () => {
|
||||
processor.read(Buffer.from([1, 2, 3]));
|
||||
processor.clear();
|
||||
expect(processor.bufferLength).toBe(0);
|
||||
expect(processor.size).toBe(0);
|
||||
expect(processor.payload.data).toBe("");
|
||||
});
|
||||
|
||||
it("should handle previous message (though logic is currently commented out or minimal)", () => {
|
||||
// This test is to cover lines 30-32 if they were active, but currently they are mostly empty.
|
||||
// We'll simulate processing twice to ensure stability.
|
||||
const msg = "test";
|
||||
const buf = Buffer.concat([
|
||||
(() => {
|
||||
const b = Buffer.alloc(4);
|
||||
b.writeUInt32LE(msg.length);
|
||||
return b;
|
||||
})(),
|
||||
Buffer.from(msg),
|
||||
]);
|
||||
|
||||
processor.read(buf);
|
||||
processor.process();
|
||||
|
||||
// Mocking a second read/process to hit the line 30 `if (this._data.length)` path potentially
|
||||
processor.read(buf);
|
||||
processor.process();
|
||||
|
||||
expect(processor.payload.data).toBe(msg);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,6 +1,3 @@
|
|||
|
||||
|
||||
|
||||
export interface JSONClientMessage {
|
||||
data: string;
|
||||
// previousMessage: Buffer;
|
||||
|
|
@ -10,9 +7,7 @@ export interface JSONClientMessage {
|
|||
export class JSONMessageProcessor {
|
||||
private buffer: Buffer;
|
||||
private _size: number;
|
||||
private readonly _command: number;
|
||||
private _data: string;
|
||||
private readonly _previousMessage: Buffer;
|
||||
|
||||
constructor() {
|
||||
this.buffer = Buffer.alloc(0);
|
||||
|
|
@ -27,13 +22,8 @@ export class JSONMessageProcessor {
|
|||
if (!this.isMessageReady()) {
|
||||
return;
|
||||
}
|
||||
if (this._data.length) {
|
||||
// this._previousMessage = this._data;
|
||||
}
|
||||
this._size = this.buffer.readUint32LE(0);
|
||||
this._data = this.buffer
|
||||
.subarray(4, this._size + 4)
|
||||
.toString("utf-8");
|
||||
this._data = this.buffer.subarray(4, this._size + 4).toString("utf-8");
|
||||
this.buffer = this.buffer.subarray(this._size + 4);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,2 +1 @@
|
|||
|
||||
export interface Message {}
|
||||
|
|
|
|||
33
src/edopro/messages/client-to-server/JoinGameMessage.test.ts
Normal file
33
src/edopro/messages/client-to-server/JoinGameMessage.test.ts
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
import { JoinGameMessage } from "./JoinGameMessage";
|
||||
|
||||
describe("JoinGameMessage", () => {
|
||||
it("should parse correctly", () => {
|
||||
const buffer = Buffer.alloc(JoinGameMessage.MAX_BYTES_LENGTH);
|
||||
buffer.writeUInt16LE(1, 0); // version2
|
||||
// Skip 2 bytes padding?
|
||||
buffer.writeUInt32LE(12345, 4); // id
|
||||
|
||||
const password = "password";
|
||||
const passwordBuffer = Buffer.from(password, "utf16le");
|
||||
passwordBuffer.copy(buffer, 8);
|
||||
|
||||
buffer.writeUInt32LE(54321, 46); // clientVersion
|
||||
|
||||
const message = new JoinGameMessage(buffer);
|
||||
|
||||
expect(message.version2).toBe(1);
|
||||
expect(message.id).toBe(12345);
|
||||
// Note: The logic in JoinGameMessage is:
|
||||
// this.password = Buffer.from(new TextVO(buffer.subarray(8, 48)).value).toString("utf16le");
|
||||
// TextVO probably strips null bytes. Let's see if we can match the password.
|
||||
// Assuming TextVO handles utf16le correctly or strips zeros and then we interpret as utf16le?
|
||||
// Wait, TextVO usually treats input as string or bytes and cleans it.
|
||||
// If TextVO cleans nulls, then Buffer.from(...).toString("utf16le") might be tricky.
|
||||
// Let's assume simpler check first.
|
||||
|
||||
// If the source code says Buffer.from(new TextVO(...).value).toString("utf16le"), it implies TextVO returns a string (utf8?) or buffer?
|
||||
// Let's check TextVO.
|
||||
|
||||
expect(message.clientVersion).toBe(54321);
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
import { PlayerInfoMessage } from "./PlayerInfoMessage";
|
||||
|
||||
describe("PlayerInfoMessage", () => {
|
||||
it("should parse name only", () => {
|
||||
const name = "Player1";
|
||||
const buffer = Buffer.from(name, "utf16le");
|
||||
const message = new PlayerInfoMessage(buffer, buffer.length);
|
||||
|
||||
expect(message.name).toBe(name);
|
||||
expect(message.password).toBeNull();
|
||||
expect(message.hasMercurySignature).toBe(false);
|
||||
});
|
||||
|
||||
it("should parse name and password", () => {
|
||||
const name = "Player1";
|
||||
const password = "1234";
|
||||
const fullString = `${name}:${password}`;
|
||||
const buffer = Buffer.from(fullString, "utf16le");
|
||||
|
||||
const message = new PlayerInfoMessage(buffer, buffer.length);
|
||||
|
||||
expect(message.name).toBe(name);
|
||||
expect(message.password).toBe(password);
|
||||
expect(message.hasMercurySignature).toBe(false);
|
||||
});
|
||||
|
||||
it("should ignore extra characters after the first 4 password characters", () => {
|
||||
const name = "Player1";
|
||||
const fullString = `${name}:1234$5678`;
|
||||
const buffer = Buffer.from(fullString, "utf16le");
|
||||
|
||||
const message = new PlayerInfoMessage(buffer, buffer.length);
|
||||
|
||||
expect(message.name).toBe(name);
|
||||
expect(message.password).toBe("1234");
|
||||
expect(message.hasMercurySignature).toBe(true);
|
||||
});
|
||||
|
||||
it("should ignore extra characters in the name when no password is provided", () => {
|
||||
const buffer = Buffer.from("Player1$5678", "utf16le");
|
||||
|
||||
const message = new PlayerInfoMessage(buffer, buffer.length);
|
||||
|
||||
expect(message.name).toBe("Player1");
|
||||
expect(message.password).toBeNull();
|
||||
expect(message.hasMercurySignature).toBe(true);
|
||||
});
|
||||
});
|
||||
|
|
@ -1,13 +1,9 @@
|
|||
|
||||
|
||||
|
||||
import * as lzma from "lzma-native";
|
||||
import { promisify } from "util";
|
||||
|
||||
import { UTF8ToUTF16 } from "../../utils/UTF8ToUTF16";
|
||||
import { Client } from "../client/domain/Client";
|
||||
|
||||
|
||||
const lzmaCompress = promisify(lzma.compress);
|
||||
|
||||
enum ReplayTypes {
|
||||
|
|
@ -170,15 +166,13 @@ export class Replay {
|
|||
size += 4 + this._extraCards.length * 4;
|
||||
size += this._responses.reduce(
|
||||
(responseSize, response) => responseSize + response.length + 1,
|
||||
0
|
||||
0,
|
||||
);
|
||||
|
||||
return size;
|
||||
}
|
||||
|
||||
|
||||
async compressData(data: Buffer): Promise<Buffer> {
|
||||
|
||||
return lzmaCompress(data, {
|
||||
preset: 5,
|
||||
dictSize: 1 << 24,
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import { Choose, Result, RockPaperScissor } from "../../../src/edopro/rock-paper-scissor/RockPaperScissor";
|
||||
import { RuleNotFoundError } from "../../../src/edopro/rock-paper-scissor/RuleNotFoundError";
|
||||
import { Choose, Result, RockPaperScissor } from "./RockPaperScissor";
|
||||
import { RuleNotFoundError } from "./RuleNotFoundError";
|
||||
|
||||
describe("<RockPaperScissor>", () => {
|
||||
let SUT: RockPaperScissor;
|
||||
|
|
@ -1,48 +0,0 @@
|
|||
import { EventEmitter } from "stream";
|
||||
import { Logger } from "../../../shared/logger/domain/Logger";
|
||||
import { ISocket } from "../../../shared/socket/domain/ISocket";
|
||||
import { Commands } from "../../../shared/messages/Commands";
|
||||
import { ClientMessage } from "../../../shared/messages/MessageProcessor";
|
||||
import RoomList from "../infrastructure/RoomList";
|
||||
import { Client } from "../../client/domain/Client";
|
||||
import { TokenIndex } from "../../../shared/room/domain/TokenIndex";
|
||||
|
||||
export class ExpressReconnectHandler {
|
||||
constructor(
|
||||
private readonly eventEmitter: EventEmitter,
|
||||
private readonly logger: Logger,
|
||||
private readonly socket: ISocket
|
||||
) {
|
||||
this.eventEmitter.on(
|
||||
Commands.RECONNECT as unknown as string,
|
||||
(message: ClientMessage) => void this.handle(message)
|
||||
);
|
||||
}
|
||||
|
||||
async handle(message: ClientMessage): Promise<void> {
|
||||
this.logger.info("Express reconnect handle started");
|
||||
const token = message.data.toString("utf8");
|
||||
this.logger.info(`Checking token: ${token}`);
|
||||
|
||||
const entry = TokenIndex.getInstance().find(token);
|
||||
if (entry && entry.client instanceof Client) {
|
||||
const player = entry.client as Client;
|
||||
this.logger.info(`MATCH! Found player ${player.name} in room ${entry.roomId}`);
|
||||
|
||||
const room = RoomList.getRooms().find(r => r.id === entry.roomId);
|
||||
if (room) {
|
||||
room.emit("EXPRESS_RECONNECT", message, this.socket);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
this.logger.info(`FAILED: No player found for token: ${token}`);
|
||||
const type = Buffer.from([0xfd]);
|
||||
const status = Buffer.from([0x01]);
|
||||
const data = Buffer.concat([type, status]);
|
||||
const size = Buffer.alloc(2);
|
||||
size.writeUint16LE(data.length);
|
||||
this.socket.send(Buffer.concat([size, data]));
|
||||
this.socket.destroy();
|
||||
}
|
||||
}
|
||||
177
src/edopro/room/application/FinishDuelHandler.test.ts
Normal file
177
src/edopro/room/application/FinishDuelHandler.test.ts
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
import "reflect-metadata";
|
||||
import { container } from "@shared/dependency-injection";
|
||||
import { EventBus } from "@shared/event-bus/EventBus";
|
||||
import WebSocketSingleton from "../../../web-socket-server/WebSocketSingleton";
|
||||
import { Client } from "@edopro/client/domain/Client";
|
||||
import { FinishDuelHandler } from "./FinishDuelHandler";
|
||||
import { DuelFinishReason } from "@edopro/room/domain/DuelFinishReason";
|
||||
import { Room } from "@edopro/room/domain/Room";
|
||||
import { Replay } from "@edopro/replay/Replay";
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("@shared/dependency-injection");
|
||||
jest.mock("@shared/event-bus/EventBus");
|
||||
jest.mock("../../../web-socket-server/WebSocketSingleton");
|
||||
jest.mock("@edopro/client/domain/Client");
|
||||
jest.mock("@edopro/room/domain/Room");
|
||||
|
||||
describe("FinishDuelHandler", () => {
|
||||
let handler: FinishDuelHandler;
|
||||
let mockRoom: jest.Mocked<Room>;
|
||||
let mockEventBus: jest.Mocked<EventBus>;
|
||||
let mockWebSocketSingleton: jest.Mocked<WebSocketSingleton>;
|
||||
let mockClient1: jest.Mocked<Client>;
|
||||
let mockClient2: jest.Mocked<Client>;
|
||||
let mockSpectator: jest.Mocked<Client>;
|
||||
let mockReplay: jest.Mocked<Replay>;
|
||||
|
||||
beforeEach(() => {
|
||||
// Mock WebSocketSingleton
|
||||
mockWebSocketSingleton = {
|
||||
broadcast: jest.fn(),
|
||||
} as unknown as jest.Mocked<WebSocketSingleton>;
|
||||
(WebSocketSingleton.getInstance as jest.Mock).mockReturnValue(mockWebSocketSingleton);
|
||||
|
||||
// Mock EventBus
|
||||
mockEventBus = {
|
||||
publish: jest.fn(),
|
||||
} as unknown as jest.Mocked<EventBus>;
|
||||
(container.get as jest.Mock).mockReturnValue(mockEventBus);
|
||||
|
||||
// Mock Clients
|
||||
mockClient1 = new Client({} as any) as jest.Mocked<Client>;
|
||||
Object.assign(mockClient1, {
|
||||
sendMessage: jest.fn(),
|
||||
notReady: jest.fn(),
|
||||
position: 0,
|
||||
team: 0,
|
||||
});
|
||||
|
||||
mockClient2 = new Client({} as any) as jest.Mocked<Client>;
|
||||
Object.assign(mockClient2, {
|
||||
sendMessage: jest.fn(),
|
||||
notReady: jest.fn(),
|
||||
position: 1,
|
||||
team: 1,
|
||||
});
|
||||
|
||||
mockSpectator = new Client({} as any) as jest.Mocked<Client>;
|
||||
Object.assign(mockSpectator, {
|
||||
sendMessage: jest.fn(),
|
||||
});
|
||||
|
||||
// Mock Replay
|
||||
mockReplay = {
|
||||
addMessage: jest.fn(),
|
||||
addPlayers: jest.fn(),
|
||||
serialize: jest.fn().mockResolvedValue(Buffer.from("replay-data")),
|
||||
} as unknown as jest.Mocked<Replay>;
|
||||
|
||||
// Mock Room
|
||||
mockRoom = {
|
||||
duelWinner: jest.fn(),
|
||||
toRealTimePresentation: jest.fn().mockReturnValue({}),
|
||||
stopRoomTimer: jest.fn(),
|
||||
stopTimer: jest.fn(),
|
||||
clearSpectatorCache: jest.fn(),
|
||||
players: [mockClient1, mockClient2],
|
||||
spectators: [mockSpectator],
|
||||
clients: [mockClient1, mockClient2, mockSpectator],
|
||||
score: "0-0",
|
||||
firstToPlay: 0,
|
||||
replay: mockReplay,
|
||||
resetReplay: jest.fn(),
|
||||
isMatchFinished: jest.fn().mockReturnValue(false),
|
||||
sideDecking: jest.fn(),
|
||||
setClientWhoChoosesTurn: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
team0: 1,
|
||||
team1: 1,
|
||||
} as unknown as jest.Mocked<Room>;
|
||||
|
||||
handler = new FinishDuelHandler({
|
||||
reason: DuelFinishReason.SURRENDERED,
|
||||
winner: 1,
|
||||
room: mockRoom,
|
||||
});
|
||||
});
|
||||
|
||||
it("should handle duel finish with surrender (not match finish)", async () => {
|
||||
await handler.run();
|
||||
|
||||
expect(mockRoom.duelWinner).toHaveBeenCalledWith(1);
|
||||
expect(mockWebSocketSingleton.broadcast).toHaveBeenCalledWith({
|
||||
action: "UPDATE-ROOM",
|
||||
data: {},
|
||||
});
|
||||
expect(mockRoom.stopRoomTimer).toHaveBeenCalled();
|
||||
expect(mockRoom.stopTimer).toHaveBeenCalledTimes(2);
|
||||
expect(mockRoom.clearSpectatorCache).toHaveBeenCalled();
|
||||
|
||||
// Verify score message sent
|
||||
expect(mockClient1.sendMessage).toHaveBeenCalled();
|
||||
expect(mockSpectator.sendMessage).toHaveBeenCalled();
|
||||
|
||||
// Verify win message (due to surrender)
|
||||
expect(mockRoom.replay.addMessage).toHaveBeenCalled();
|
||||
|
||||
// Verify replay handling
|
||||
expect(mockRoom.replay.addPlayers).toHaveBeenCalled();
|
||||
expect(mockRoom.replay.serialize).toHaveBeenCalled();
|
||||
expect(mockRoom.resetReplay).toHaveBeenCalled();
|
||||
|
||||
// Verify side decking
|
||||
expect(mockRoom.sideDecking).toHaveBeenCalled();
|
||||
expect(mockClient1.sendMessage).toHaveBeenCalled(); // SideDeckClientMessage
|
||||
expect(mockClient1.notReady).toHaveBeenCalled();
|
||||
expect(mockSpectator.sendMessage).toHaveBeenCalled(); // SideDeckWaitClientMessage
|
||||
|
||||
// Verify choosing turn logic
|
||||
// Winner is 1 (Team 1). Looser is 0 (Team 0).
|
||||
// Team 0 player is mockClient1 (position 0).
|
||||
expect(mockRoom.setClientWhoChoosesTurn).toHaveBeenCalledWith(mockClient1);
|
||||
});
|
||||
|
||||
it("should handle duel finish with surrender when winner is team 0", async () => {
|
||||
const handlerTeam0 = new FinishDuelHandler({
|
||||
reason: DuelFinishReason.SURRENDERED,
|
||||
winner: 0,
|
||||
room: mockRoom,
|
||||
});
|
||||
|
||||
await handlerTeam0.run();
|
||||
|
||||
expect(mockRoom.duelWinner).toHaveBeenCalledWith(0);
|
||||
// Winner is 0 (Team 0). Looser is 1 (Team 1).
|
||||
// Team 1 player is mockClient2 (position 1).
|
||||
// mockRoom.team1 is 1. position % team1 === 0? 1 % 1 === 0. True.
|
||||
expect(mockRoom.setClientWhoChoosesTurn).toHaveBeenCalledWith(mockClient2);
|
||||
});
|
||||
|
||||
it("should handle match finish", async () => {
|
||||
mockRoom.isMatchFinished.mockReturnValue(true);
|
||||
Object.defineProperty(mockRoom, "matchPlayersHistory", { value: [] });
|
||||
Object.defineProperty(mockRoom, "bestOf", { value: 3 });
|
||||
Object.defineProperty(mockRoom, "banListHash", { value: 123 });
|
||||
Object.defineProperty(mockRoom, "ranked", { value: true });
|
||||
|
||||
await handler.run();
|
||||
|
||||
expect(mockRoom.isMatchFinished).toHaveBeenCalled();
|
||||
// DuelEndMessage sent
|
||||
expect(mockClient1.sendMessage).toHaveBeenCalled();
|
||||
expect(mockSpectator.sendMessage).toHaveBeenCalled();
|
||||
|
||||
// Event published
|
||||
expect(mockEventBus.publish).toHaveBeenCalled();
|
||||
|
||||
// Room removed broadcast
|
||||
expect(mockWebSocketSingleton.broadcast).toHaveBeenCalledWith({
|
||||
action: "REMOVE-ROOM",
|
||||
data: {},
|
||||
});
|
||||
|
||||
// Should NOT side deck
|
||||
expect(mockRoom.sideDecking).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -105,8 +105,9 @@ export class FinishDuelHandler {
|
|||
players: this.room.matchPlayersHistory,
|
||||
date: new Date(),
|
||||
banListHash: this.room.banListHash,
|
||||
banListName: this.room.banListName ?? "N/A",
|
||||
ranked: this.room.ranked,
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
WebSocketSingleton.getInstance().broadcast({
|
||||
|
|
@ -123,14 +124,14 @@ export class FinishDuelHandler {
|
|||
|
||||
if (this.winner === 0) {
|
||||
const looser = this.room.players.find(
|
||||
(_client: Client) => _client.position % this.room.team1 === 0 && _client.team === 1
|
||||
(_client: Client) => _client.position % this.room.team1 === 0 && _client.team === 1,
|
||||
);
|
||||
if (looser && looser instanceof Client) {
|
||||
this.room.setClientWhoChoosesTurn(looser);
|
||||
}
|
||||
} else {
|
||||
const looser = this.room.players.find(
|
||||
(_client: Client) => _client.position % this.room.team0 === 0 && _client.team === 0
|
||||
(_client: Client) => _client.position % this.room.team0 === 0 && _client.team === 0,
|
||||
);
|
||||
if (looser && looser instanceof Client) {
|
||||
this.room.setClientWhoChoosesTurn(looser);
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export class GameCreatorHandler implements GameCreatorMessageHandler {
|
|||
logger: Logger,
|
||||
socket: ISocket,
|
||||
userAuth: UserAuth,
|
||||
roomId: number
|
||||
roomId: number,
|
||||
) {
|
||||
this.eventEmitter = eventEmitter;
|
||||
this.logger = logger.child({ file: "GameCreatorHandler", roomId });
|
||||
|
|
@ -69,14 +69,14 @@ export class GameCreatorHandler implements GameCreatorMessageHandler {
|
|||
private async create(
|
||||
message: CreateGameMessage,
|
||||
playerInfoMessage: PlayerInfoMessage,
|
||||
userId: string | null
|
||||
userId: string | null,
|
||||
): Promise<void> {
|
||||
const room = Room.createFromCreateGameMessage(
|
||||
message,
|
||||
playerInfoMessage,
|
||||
this.roomId,
|
||||
this.eventEmitter,
|
||||
this.logger
|
||||
this.logger,
|
||||
);
|
||||
|
||||
room.waiting();
|
||||
|
|
@ -99,19 +99,23 @@ export class GameCreatorHandler implements GameCreatorMessageHandler {
|
|||
}
|
||||
|
||||
private sendRankedMessage(): void {
|
||||
this.socket.send(ServerMessageClientMessage.create(
|
||||
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.RANKED_ROOM_CREATION_SUCCESS} - ${ServerInfoMessage.GAIN_POINTS_CALL_TO_ACTION}`
|
||||
));
|
||||
this.socket.send(
|
||||
ServerMessageClientMessage.create(
|
||||
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.RANKED_ROOM_CREATION_SUCCESS} - ${ServerInfoMessage.GAIN_POINTS_CALL_TO_ACTION}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
private sendUnrankedMessage(): void {
|
||||
this.socket.send(ServerMessageClientMessage.create(
|
||||
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.UN_RANKED_ROOM_CREATION_SUCCESS}`
|
||||
));
|
||||
this.socket.send(
|
||||
ServerMessageClientMessage.create(
|
||||
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.UN_RANKED_ROOM_CREATION_SUCCESS}`,
|
||||
),
|
||||
);
|
||||
|
||||
if (!config.ranking.enabled) {
|
||||
this.socket.send(
|
||||
ServerMessageClientMessage.create(ServerInfoMessage.UNAVAILABLE_RANKING_SYSTEM)
|
||||
ServerMessageClientMessage.create(ServerInfoMessage.UNAVAILABLE_RANKING_SYSTEM),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,7 +28,7 @@ export class JoinHandler implements JoinMessageHandler {
|
|||
eventEmitter: EventEmitter,
|
||||
logger: Logger,
|
||||
socket: ISocket,
|
||||
checkIfUseCanJoin: CheckIfUseCanJoin
|
||||
checkIfUseCanJoin: CheckIfUseCanJoin,
|
||||
) {
|
||||
this.eventEmitter = eventEmitter;
|
||||
this.logger = logger.child({ file: "JoinHandler" });
|
||||
|
|
@ -36,7 +36,7 @@ export class JoinHandler implements JoinMessageHandler {
|
|||
this.checkIfUseCanJoin = checkIfUseCanJoin;
|
||||
this.eventEmitter.on(
|
||||
Commands.JOIN_GAME as unknown as string,
|
||||
(message: ClientMessage) => void this.handleJoinGame(message)
|
||||
(message: ClientMessage) => void this.handleJoinGame(message),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -45,7 +45,7 @@ export class JoinHandler implements JoinMessageHandler {
|
|||
const joinMessage = new JoinGameMessage(message.data);
|
||||
const playerInfoMessage = new PlayerInfoMessage(message.previousMessage, message.data.length);
|
||||
this.logger.info(
|
||||
`player: ${playerInfoMessage.name} trying to join to room: ${joinMessage.id} with room pass: ${joinMessage.password}`
|
||||
`player: ${playerInfoMessage.name} trying to join to room: ${joinMessage.id} with room pass: ${joinMessage.password}`,
|
||||
);
|
||||
const room = this.findRoom(joinMessage);
|
||||
|
||||
|
|
@ -67,10 +67,10 @@ export class JoinHandler implements JoinMessageHandler {
|
|||
|
||||
if (attempts >= config.rateLimit.limit) {
|
||||
this.logger.info(
|
||||
`player: ${playerInfoMessage.name} with ip: ${ip} tried to join to room: ${room.id} and was already rate limited`
|
||||
`player: ${playerInfoMessage.name} with ip: ${ip} tried to join to room: ${room.id} and was already rate limited`,
|
||||
);
|
||||
this.socket.send(
|
||||
ServerErrorClientMessage.create("Too many attempts. Please try again in a few minutes.")
|
||||
ServerErrorClientMessage.create("Too many attempts. Please try again in a few minutes."),
|
||||
);
|
||||
this.socket.send(ErrorClientMessage.create(ErrorMessages.JOIN_ERROR));
|
||||
this.socket.destroy();
|
||||
|
|
@ -81,6 +81,9 @@ export class JoinHandler implements JoinMessageHandler {
|
|||
|
||||
if (room.ranked) {
|
||||
if (!(await this.checkIfUseCanJoin.check(playerInfoMessage, this.socket))) {
|
||||
// CheckIfUseCanJoin no longer sends the JOINERROR itself (its wire format is
|
||||
// client-specific). edopro/desktop clients use the @edopro ErrorClientMessage.
|
||||
this.socket.send(ErrorClientMessage.create(ErrorMessages.JOIN_ERROR));
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
|
@ -95,7 +98,7 @@ export class JoinHandler implements JoinMessageHandler {
|
|||
}
|
||||
}
|
||||
this.logger.info(
|
||||
`player: ${playerInfoMessage.name} tried to join to room: ${room.id} with wrong password: ${joinMessage.password}`
|
||||
`player: ${playerInfoMessage.name} tried to join to room: ${room.id} with wrong password: ${joinMessage.password}`,
|
||||
);
|
||||
this.socket.send(ServerErrorClientMessage.create("Wrong password"));
|
||||
this.socket.send(ErrorClientMessage.create(ErrorMessages.JOIN_ERROR));
|
||||
|
|
@ -110,7 +113,7 @@ export class JoinHandler implements JoinMessageHandler {
|
|||
}
|
||||
|
||||
this.logger.info(
|
||||
`player: ${playerInfoMessage.name} joined to room: ${room.id} with password: ${joinMessage.password}`
|
||||
`player: ${playerInfoMessage.name} joined to room: ${room.id} with password: ${joinMessage.password}`,
|
||||
);
|
||||
room.emit("JOIN", message, this.socket);
|
||||
}
|
||||
|
|
|
|||
80
src/edopro/room/application/JoinToDuelAsSpectator.test.ts
Normal file
80
src/edopro/room/application/JoinToDuelAsSpectator.test.ts
Normal file
|
|
@ -0,0 +1,80 @@
|
|||
import { ISocket } from "@shared/socket/domain/ISocket";
|
||||
import { Client } from "@edopro/client/domain/Client";
|
||||
import { JoinGameMessage } from "@edopro/messages/client-to-server/JoinGameMessage";
|
||||
import { PlayerInfoMessage } from "@edopro/messages/client-to-server/PlayerInfoMessage";
|
||||
import { JoinToDuelAsSpectator } from "./JoinToDuelAsSpectator";
|
||||
import { Room } from "@edopro/room/domain/Room";
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("@edopro/room/domain/Room");
|
||||
jest.mock("@edopro/client/domain/Client");
|
||||
jest.mock("@edopro/messages/server-to-client/JoinGameClientMessage");
|
||||
jest.mock("@shared/messages/server-to-client/DuelStartClientMessage");
|
||||
jest.mock("@edopro/messages/server-to-client/CatchUpClientMessage");
|
||||
jest.mock("@edopro/messages/server-to-client/ServerMessageClientMessage");
|
||||
|
||||
describe("JoinToDuelAsSpectator", () => {
|
||||
let handler: JoinToDuelAsSpectator;
|
||||
let mockSocket: jest.Mocked<ISocket>;
|
||||
let mockRoom: jest.Mocked<Room>;
|
||||
let mockSpectator: jest.Mocked<Client>;
|
||||
let mockClient1: jest.Mocked<Client>;
|
||||
let mockClient2: jest.Mocked<Client>;
|
||||
|
||||
beforeEach(() => {
|
||||
handler = new JoinToDuelAsSpectator();
|
||||
|
||||
mockSocket = {
|
||||
send: jest.fn(),
|
||||
} as unknown as jest.Mocked<ISocket>;
|
||||
|
||||
mockSpectator = {
|
||||
name: "Spectator\0",
|
||||
sendMessage: jest.fn(),
|
||||
} as unknown as jest.Mocked<Client>;
|
||||
|
||||
mockClient1 = {
|
||||
name: "Player1\0",
|
||||
team: 0,
|
||||
sendMessage: jest.fn(),
|
||||
} as unknown as jest.Mocked<Client>;
|
||||
|
||||
mockClient2 = {
|
||||
name: "Player2",
|
||||
team: 1,
|
||||
sendMessage: jest.fn(),
|
||||
} as unknown as jest.Mocked<Client>;
|
||||
|
||||
mockRoom = {
|
||||
createSpectatorUnsafe: jest.fn().mockReturnValue(mockSpectator),
|
||||
addSpectatorUnsafe: jest.fn(),
|
||||
notifyToAllPlayers: jest.fn(),
|
||||
spectatorCache: [Buffer.from("cache1"), Buffer.from("cache2")],
|
||||
players: [mockClient1, mockClient2],
|
||||
spectators: [mockSpectator],
|
||||
matchScore: jest.fn().mockReturnValue({ team0: 1, team1: 0 }),
|
||||
} as unknown as jest.Mocked<Room>;
|
||||
});
|
||||
|
||||
it("should handle spectator join correctly", async () => {
|
||||
const joinMessage = {} as JoinGameMessage;
|
||||
const playerInfoMessage = { name: "Spectator" } as PlayerInfoMessage;
|
||||
|
||||
await handler.run(joinMessage, playerInfoMessage, mockSocket, mockRoom);
|
||||
|
||||
expect(mockRoom.createSpectatorUnsafe).toHaveBeenCalledWith(mockSocket, "Spectator");
|
||||
expect(mockRoom.addSpectatorUnsafe).toHaveBeenCalledWith(mockSpectator);
|
||||
expect(mockRoom.notifyToAllPlayers).toHaveBeenCalledWith(mockSpectator);
|
||||
|
||||
// Verify messages sent to spectator
|
||||
expect(mockSpectator.sendMessage).toHaveBeenCalledTimes(5); // JoinGame, DuelStart, CatchUp(true), CatchUp(false), ServerMessage(has entered)
|
||||
|
||||
// Verify socket messages (cache + welcome + score)
|
||||
expect(mockSocket.send).toHaveBeenCalledTimes(4); // 2 cache items + Welcome + Score
|
||||
|
||||
// Verify notification to other clients
|
||||
expect(mockClient1.sendMessage).toHaveBeenCalled();
|
||||
expect(mockClient2.sendMessage).toHaveBeenCalled();
|
||||
expect(mockSpectator.sendMessage).toHaveBeenCalled(); // Also notified self? Logic says [...room.clients, ...room.spectators]
|
||||
});
|
||||
});
|
||||
|
|
@ -15,7 +15,7 @@ export class JoinToDuelAsSpectator {
|
|||
joinMessage: JoinGameMessage,
|
||||
playerInfoMessage: PlayerInfoMessage,
|
||||
socket: ISocket,
|
||||
room: Room
|
||||
room: Room,
|
||||
): Promise<void> {
|
||||
const spectator = room.createSpectatorUnsafe(socket, playerInfoMessage.name);
|
||||
spectator.sendMessage(JoinGameClientMessage.createFromRoom(joinMessage, room));
|
||||
|
|
@ -40,20 +40,21 @@ export class JoinToDuelAsSpectator {
|
|||
.map((item) => item.name.replace(/\0/g, "").trim());
|
||||
|
||||
socket.send(
|
||||
ServerMessageClientMessage.create(`Welcome ${spectator.name.replace(/\0/g, "").trim()}`)
|
||||
ServerMessageClientMessage.create(`Welcome ${spectator.name.replace(/\0/g, "").trim()}`),
|
||||
);
|
||||
socket.send(
|
||||
ServerMessageClientMessage.create(
|
||||
`Score: ${team0.join(",")}: ${room.matchScore().team0} vs ${team1.join(",")}: ${room.matchScore().team1
|
||||
}`
|
||||
)
|
||||
`Score: ${team0.join(",")}: ${room.matchScore().team0} vs ${team1.join(",")}: ${
|
||||
room.matchScore().team1
|
||||
}`,
|
||||
),
|
||||
);
|
||||
|
||||
[...room.players, ...room.spectators].forEach((_client: Client) => {
|
||||
_client.sendMessage(
|
||||
ServerMessageClientMessage.create(
|
||||
`${spectator.name} ${ServerInfoMessage.HAS_ENTERED_AS_A_SPECTATOR}`
|
||||
)
|
||||
`${spectator.name} ${ServerInfoMessage.HAS_ENTERED_AS_A_SPECTATOR}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
|
|||
88
src/edopro/room/application/Reconnect.test.ts
Normal file
88
src/edopro/room/application/Reconnect.test.ts
Normal file
|
|
@ -0,0 +1,88 @@
|
|||
import { ISocket } from "@shared/socket/domain/ISocket";
|
||||
import { CheckIfUseCanJoin } from "@shared/user-auth/application/CheckIfUserCanJoin";
|
||||
import { Client } from "@edopro/client/domain/Client";
|
||||
import { JoinGameMessage } from "@edopro/messages/client-to-server/JoinGameMessage";
|
||||
import { PlayerInfoMessage } from "@edopro/messages/client-to-server/PlayerInfoMessage";
|
||||
import { Reconnect } from "./Reconnect";
|
||||
import { Room } from "@edopro/room/domain/Room";
|
||||
|
||||
// Mock dependencies
|
||||
jest.mock("@edopro/room/domain/Room");
|
||||
jest.mock("@edopro/client/domain/Client");
|
||||
jest.mock("@shared/user-auth/application/CheckIfUserCanJoin");
|
||||
jest.mock("@edopro/messages/server-to-client/JoinGameClientMessage");
|
||||
jest.mock("@shared/messages/server-to-client/TypeChangeClientMessage");
|
||||
jest.mock("@shared/messages/server-to-client/PlayerEnterClientMessage");
|
||||
|
||||
describe("Reconnect", () => {
|
||||
let reconnect: Reconnect;
|
||||
let mockCheckIfUseCanJoin: jest.Mocked<CheckIfUseCanJoin>;
|
||||
let mockSocket: jest.Mocked<ISocket>;
|
||||
let mockRoom: jest.Mocked<Room>;
|
||||
let mockPlayer: jest.Mocked<Client>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockCheckIfUseCanJoin = {
|
||||
check: jest.fn(),
|
||||
} as unknown as jest.Mocked<CheckIfUseCanJoin>;
|
||||
|
||||
reconnect = new Reconnect(mockCheckIfUseCanJoin);
|
||||
|
||||
mockSocket = {
|
||||
id: "socket-id",
|
||||
send: jest.fn(),
|
||||
} as unknown as jest.Mocked<ISocket>;
|
||||
|
||||
mockPlayer = {
|
||||
setSocket: jest.fn(),
|
||||
reconnecting: jest.fn(),
|
||||
sendMessage: jest.fn(),
|
||||
host: true,
|
||||
position: 0,
|
||||
} as unknown as jest.Mocked<Client>;
|
||||
|
||||
mockRoom = {
|
||||
ranked: false,
|
||||
players: [mockPlayer],
|
||||
} as unknown as jest.Mocked<Room>;
|
||||
});
|
||||
|
||||
it("should allow reconnect for unranked room", async () => {
|
||||
const joinMessage = {} as JoinGameMessage;
|
||||
const playerInfoMessage = {} as PlayerInfoMessage;
|
||||
|
||||
await reconnect.run(playerInfoMessage, mockPlayer, joinMessage, mockSocket, mockRoom);
|
||||
|
||||
expect(mockPlayer.setSocket).toHaveBeenCalledWith(mockSocket, mockRoom.players, mockRoom);
|
||||
expect(mockPlayer.reconnecting).toHaveBeenCalled();
|
||||
expect(mockPlayer.sendMessage).toHaveBeenCalledTimes(3); // JoinGame, TypeChange, PlayerEnter
|
||||
});
|
||||
|
||||
it("should allow reconnect for ranked room if check passes", async () => {
|
||||
Object.defineProperty(mockRoom, "ranked", { value: true });
|
||||
mockCheckIfUseCanJoin.check.mockResolvedValue(true);
|
||||
|
||||
const joinMessage = {} as JoinGameMessage;
|
||||
|
||||
const playerInfoMessage = {} as PlayerInfoMessage;
|
||||
|
||||
await reconnect.run(playerInfoMessage, mockPlayer, joinMessage, mockSocket, mockRoom);
|
||||
|
||||
expect(mockCheckIfUseCanJoin.check).toHaveBeenCalledWith(playerInfoMessage, mockSocket);
|
||||
expect(mockPlayer.setSocket).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should deny reconnect for ranked room if check fails", async () => {
|
||||
Object.defineProperty(mockRoom, "ranked", { value: true });
|
||||
mockCheckIfUseCanJoin.check.mockResolvedValue(false);
|
||||
|
||||
const joinMessage = {} as JoinGameMessage;
|
||||
|
||||
const playerInfoMessage = {} as PlayerInfoMessage;
|
||||
|
||||
await reconnect.run(playerInfoMessage, mockPlayer, joinMessage, mockSocket, mockRoom);
|
||||
|
||||
expect(mockCheckIfUseCanJoin.check).toHaveBeenCalled();
|
||||
expect(mockPlayer.setSocket).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -7,28 +7,25 @@ import { Client } from "../../client/domain/Client";
|
|||
import { JoinGameMessage } from "../../messages/client-to-server/JoinGameMessage";
|
||||
import { PlayerInfoMessage } from "../../messages/client-to-server/PlayerInfoMessage";
|
||||
import { JoinGameClientMessage } from "../../messages/server-to-client/JoinGameClientMessage";
|
||||
import { ErrorClientMessage } from "../../messages/server-to-client/ErrorClientMessage";
|
||||
import { ErrorMessages } from "../../messages/server-to-client/error-messages/ErrorMessages";
|
||||
import { Room } from "../domain/Room";
|
||||
|
||||
export class Reconnect {
|
||||
constructor(private readonly checkIfUseCanJoin: CheckIfUseCanJoin) { }
|
||||
constructor(private readonly checkIfUseCanJoin: CheckIfUseCanJoin) {}
|
||||
|
||||
async run(
|
||||
playerInfoMessage: PlayerInfoMessage,
|
||||
player: Client,
|
||||
joinMessage: JoinGameMessage,
|
||||
socket: ISocket,
|
||||
room: Room
|
||||
room: Room,
|
||||
): Promise<void> {
|
||||
if (room.ranked && !(await this.checkIfUseCanJoin.check(playerInfoMessage, socket))) {
|
||||
// CheckIfUseCanJoin no longer sends the JOINERROR itself (its wire format is
|
||||
// client-specific). edopro/desktop clients use the @edopro ErrorClientMessage.
|
||||
socket.send(ErrorClientMessage.create(ErrorMessages.JOIN_ERROR));
|
||||
return;
|
||||
|
||||
// if (!player.socket.id || !player.socket.closed) {
|
||||
// socket.send(ServerErrorClientMessage.create("Ya el jugador se encuentra en la partida."));
|
||||
// socket.send(ErrorClientMessage.create(ErrorMessages.JOIN_ERROR));
|
||||
// socket.destroy();
|
||||
|
||||
// return;
|
||||
// }
|
||||
}
|
||||
|
||||
player.setSocket(socket, room.players as Client[], room);
|
||||
|
|
@ -40,7 +37,7 @@ export class Reconnect {
|
|||
room.players.forEach((_client) => {
|
||||
const playerEnterClientMessage = PlayerEnterClientMessage.create(
|
||||
_client.name,
|
||||
_client.position
|
||||
_client.position,
|
||||
);
|
||||
player.sendMessage(playerEnterClientMessage);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -3,79 +3,73 @@ import { EventEmitter } from "stream";
|
|||
|
||||
import { CreateRoomRequest } from "../../../http-server/controllers/CreateRoomController";
|
||||
import { Logger } from "../../../shared/logger/domain/Logger";
|
||||
import { ISocket } from "../../../shared/socket/domain/ISocket";
|
||||
import { UTF8ToUTF16 } from "../../../utils/UTF8ToUTF16";
|
||||
import BanListMemoryRepository from "../../ban-list/infrastructure/BanListMemoryRepository";
|
||||
import { Room } from "../domain/Room";
|
||||
import RoomList from "../infrastructure/RoomList";
|
||||
|
||||
export class RoomCreator {
|
||||
private readonly socket: ISocket;
|
||||
constructor(private readonly logger: Logger) {}
|
||||
constructor(private readonly logger: Logger) {}
|
||||
|
||||
create(payload: CreateRoomRequest): { password: string } {
|
||||
const banlist = BanListMemoryRepository.findByName(payload.banlist);
|
||||
create(payload: CreateRoomRequest): { password: string } {
|
||||
const banlist = BanListMemoryRepository.findByName(payload.banlist);
|
||||
|
||||
if (!banlist) {
|
||||
throw new Error("Banlist not found");
|
||||
}
|
||||
if (!banlist) {
|
||||
throw new Error("Banlist not found");
|
||||
}
|
||||
|
||||
const emitter = new EventEmitter();
|
||||
const utf8Password = this.generateUniqueId().toString();
|
||||
const password = UTF8ToUTF16(
|
||||
utf8Password,
|
||||
utf8Password.length * 2,
|
||||
).toString("utf16le");
|
||||
const emitter = new EventEmitter();
|
||||
const utf8Password = this.generateUniqueId().toString();
|
||||
const password = UTF8ToUTF16(utf8Password, utf8Password.length * 2).toString("utf16le");
|
||||
|
||||
const data = {
|
||||
id: this.generateUniqueId(),
|
||||
name: payload.name,
|
||||
notes:
|
||||
(payload.tournament ? `[${payload.tournament}] ` : "") + payload.name,
|
||||
mode: payload.mode || 0, // 0 = Single, 1 = Match, 2 = Tag
|
||||
needPass: true,
|
||||
team0: payload.teamQuantity || 1,
|
||||
team1: payload.teamQuantity || 1,
|
||||
bestOf: payload.bestOf || 1,
|
||||
duelFlag: BigInt(853505),
|
||||
forbiddenTypes: 83886080,
|
||||
extraRules: 0,
|
||||
startLp: 8000,
|
||||
startHand: 5,
|
||||
drawCount: 1,
|
||||
timeLimit: 700,
|
||||
rule: payload.rule || 4, // 0 = OCG, 1 = TCG, 2 = OCG/TCG, 3 = Prerelease, 4 = Anything Goes
|
||||
noCheck: false,
|
||||
noShuffle: false,
|
||||
banListHash: banlist.hash,
|
||||
isStart: "waiting",
|
||||
mainMin: 40,
|
||||
mainMax: 60,
|
||||
extraMin: 0,
|
||||
extraMax: 15,
|
||||
sideMin: 0,
|
||||
sideMax: 15,
|
||||
duelRule: 0,
|
||||
handshake: 4043399681,
|
||||
password,
|
||||
duelFlagsHight: 1,
|
||||
duelFlagsLow: 853504,
|
||||
ranked: payload.isRanked || false,
|
||||
};
|
||||
const data = {
|
||||
id: this.generateUniqueId(),
|
||||
name: payload.name,
|
||||
notes: (payload.tournament ? `[${payload.tournament}] ` : "") + payload.name,
|
||||
mode: payload.mode || 0, // 0 = Single, 1 = Match, 2 = Tag
|
||||
needPass: true,
|
||||
team0: payload.teamQuantity || 1,
|
||||
team1: payload.teamQuantity || 1,
|
||||
bestOf: payload.bestOf || 1,
|
||||
duelFlag: BigInt(853505),
|
||||
forbiddenTypes: 83886080,
|
||||
extraRules: 0,
|
||||
startLp: 8000,
|
||||
startHand: 5,
|
||||
drawCount: 1,
|
||||
timeLimit: 700,
|
||||
rule: payload.rule || 4, // 0 = OCG, 1 = TCG, 2 = OCG/TCG, 3 = Prerelease, 4 = Anything Goes
|
||||
noCheck: false,
|
||||
noShuffle: false,
|
||||
banListHash: banlist.hash,
|
||||
isStart: "waiting",
|
||||
mainMin: 40,
|
||||
mainMax: 60,
|
||||
extraMin: 0,
|
||||
extraMax: 15,
|
||||
sideMin: 0,
|
||||
sideMax: 15,
|
||||
duelRule: 0,
|
||||
handshake: 4043399681,
|
||||
password,
|
||||
duelFlagsHight: 1,
|
||||
duelFlagsLow: 853504,
|
||||
ranked: payload.isRanked || false,
|
||||
};
|
||||
|
||||
const room = Room.create(data, emitter, this.logger);
|
||||
room.waiting();
|
||||
RoomList.addRoom(room);
|
||||
const room = Room.create(data, emitter, this.logger);
|
||||
room.waiting();
|
||||
RoomList.addRoom(room);
|
||||
|
||||
return {
|
||||
password,
|
||||
};
|
||||
}
|
||||
return {
|
||||
password,
|
||||
};
|
||||
}
|
||||
|
||||
private generateUniqueId(): number {
|
||||
const min = 1000;
|
||||
const max = 9999;
|
||||
private generateUniqueId(): number {
|
||||
const min = 1000;
|
||||
const max = 9999;
|
||||
|
||||
return randomInt(min, max + 1);
|
||||
}
|
||||
return randomInt(min, max + 1);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
|
||||
import { Room } from "../../../../src/edopro/room/domain/Room";
|
||||
import { ClientMother } from "../../shared/mothers/client/ClientMother";
|
||||
import { RoomMother } from "../../shared/mothers/room/RoomMother";
|
||||
import { Room } from "./Room";
|
||||
import { ClientMother } from "@test-support/mothers/client/ClientMother";
|
||||
import { RoomMother } from "@test-support/mothers/room/RoomMother";
|
||||
|
||||
describe("Room", () => {
|
||||
let room: Room;
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
import BanListMemoryRepository from "@edopro/ban-list/infrastructure/BanListMemoryRepository";
|
||||
import { PlayerChangeClientMessage } from "@edopro/messages/server-to-client/PlayerChangeClientMessage";
|
||||
import { ChildProcessWithoutNullStreams } from "child_process";
|
||||
import shuffle from "shuffle-array";
|
||||
|
|
@ -116,7 +117,6 @@ export class Room extends YgoRoom {
|
|||
private _playerExtraDeckSize: number;
|
||||
private _opponentMainDeckSize: number;
|
||||
private _opponentExtraDeckSize: number;
|
||||
private readonly _turn = 0;
|
||||
private readonly timers: Timer[];
|
||||
private readonly roomTimer: Timer;
|
||||
private roomState: RoomState | null = null;
|
||||
|
|
@ -209,11 +209,11 @@ export class Room extends YgoRoom {
|
|||
});
|
||||
this.resetReplay();
|
||||
this.checkIfUserCanReconnect = new CheckIfUseCanJoin(
|
||||
new UserAuth(new UserProfilePostgresRepository())
|
||||
new UserAuth(new UserProfilePostgresRepository()),
|
||||
);
|
||||
this.notifier = new RoomClientNotifier(
|
||||
() => this._players as Client[],
|
||||
() => this._spectators as Client[]
|
||||
() => this._spectators as Client[],
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -231,7 +231,7 @@ export class Room extends YgoRoom {
|
|||
playerInfo: PlayerInfoMessage,
|
||||
id: number,
|
||||
emitter: EventEmitter,
|
||||
logger: Logger
|
||||
logger: Logger,
|
||||
): Room {
|
||||
const ranked = Room.isRanked(playerInfo.password);
|
||||
|
||||
|
|
@ -290,7 +290,6 @@ export class Room extends YgoRoom {
|
|||
}
|
||||
|
||||
resetReplay(): void {
|
||||
|
||||
if (this._replay) {
|
||||
this._replay.reset();
|
||||
}
|
||||
|
|
@ -511,6 +510,10 @@ export class Room extends YgoRoom {
|
|||
return this._replay;
|
||||
}
|
||||
|
||||
get banListName(): string | null {
|
||||
return BanListMemoryRepository.findByHash(this.banListHash)?.name ?? null;
|
||||
}
|
||||
|
||||
setDecksToPlayer(position: number, deck: Deck): void {
|
||||
this.mutex.runExclusive(() => {
|
||||
this.setDecksToPlayerUnsafe(position, deck);
|
||||
|
|
@ -563,7 +566,7 @@ export class Room extends YgoRoom {
|
|||
this.emitter,
|
||||
this.logger,
|
||||
new UserAuth(new UserProfilePostgresRepository()),
|
||||
new DeckCreator(new CardSQLiteTYpeORMRepository(), this.deckRules, this.duelFlag)
|
||||
new DeckCreator(new CardSQLiteTYpeORMRepository(), this.deckRules, this.duelFlag),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -577,7 +580,7 @@ export class Room extends YgoRoom {
|
|||
new Reconnect(this.checkIfUserCanReconnect),
|
||||
new JoinToDuelAsSpectator(),
|
||||
this,
|
||||
new JSONMessageProcessor()
|
||||
new JSONMessageProcessor(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -589,7 +592,7 @@ export class Room extends YgoRoom {
|
|||
this.logger,
|
||||
new Reconnect(this.checkIfUserCanReconnect),
|
||||
new JoinToDuelAsSpectator(),
|
||||
new DeckCreator(new CardSQLiteTYpeORMRepository(), this.deckRules, this.duelFlag)
|
||||
new DeckCreator(new CardSQLiteTYpeORMRepository(), this.deckRules, this.duelFlag),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -606,7 +609,7 @@ export class Room extends YgoRoom {
|
|||
this.emitter,
|
||||
this.logger,
|
||||
new Reconnect(this.checkIfUserCanReconnect),
|
||||
new JoinToDuelAsSpectator()
|
||||
new JoinToDuelAsSpectator(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -617,7 +620,7 @@ export class Room extends YgoRoom {
|
|||
this.emitter,
|
||||
this.logger,
|
||||
new Reconnect(this.checkIfUserCanReconnect),
|
||||
new JoinToDuelAsSpectator()
|
||||
new JoinToDuelAsSpectator(),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -688,16 +691,16 @@ export class Room extends YgoRoom {
|
|||
|
||||
prepareTurnOrder(): void {
|
||||
const team0Players = this.players
|
||||
.filter(p => p.team === 0)
|
||||
.filter((p) => p.team === 0)
|
||||
.sort((a, b) => a.position - b.position) as Client[];
|
||||
|
||||
const team1Players = this.players
|
||||
.filter(p => p.team === 1)
|
||||
.filter((p) => p.team === 1)
|
||||
.sort((a, b) => a.position - b.position) as Client[];
|
||||
|
||||
// 0 = team0 empieza, 1 = team1 empieza
|
||||
const offset0 = this.isRelay ? 0 : (this.firstToPlay === 1 ? 1 : 0);
|
||||
const offset1 = this.isRelay ? 0 : (this.firstToPlay === 0 ? 1 : 0);
|
||||
const offset0 = this.isRelay ? 0 : this.firstToPlay === 1 ? 1 : 0;
|
||||
const offset1 = this.isRelay ? 0 : this.firstToPlay === 0 ? 1 : 0;
|
||||
|
||||
team0Players.forEach((p, idx) => {
|
||||
p.clearTurn();
|
||||
|
|
@ -709,22 +712,18 @@ export class Room extends YgoRoom {
|
|||
p.setDuelPosition((idx + offset1) % this.team1);
|
||||
});
|
||||
|
||||
team0Players.find(p => p.duelPosition === 0)?.turn();
|
||||
team1Players.find(p => p.duelPosition === 0)?.turn();
|
||||
|
||||
|
||||
this.players.forEach((element: Client) => {
|
||||
})
|
||||
team0Players.find((p) => p.duelPosition === 0)?.turn();
|
||||
team1Players.find((p) => p.duelPosition === 0)?.turn();
|
||||
}
|
||||
|
||||
nextTurn(team: number): void {
|
||||
const teamPlayers = (this.players as Client[])
|
||||
.filter(p => p.team === team)
|
||||
.filter((p) => p.team === team)
|
||||
.sort((a: Client, b: Client) => a.duelPosition - b.duelPosition);
|
||||
|
||||
if (teamPlayers.length === 0) return;
|
||||
|
||||
const currentIdx = teamPlayers.findIndex(p => p.inTurn);
|
||||
const currentIdx = teamPlayers.findIndex((p) => p.inTurn);
|
||||
if (currentIdx === -1) return;
|
||||
|
||||
const nextIdx = (currentIdx + 1) % teamPlayers.length;
|
||||
|
|
@ -858,7 +857,7 @@ export class Room extends YgoRoom {
|
|||
JSON.stringify({
|
||||
command: "DESTROY_DUEL",
|
||||
data: {},
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -958,7 +957,6 @@ export class Room extends YgoRoom {
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
private startIpcMetricsReporting(): void {
|
||||
if (process.env.IPC_METRICS_ENABLED !== "true") {
|
||||
return;
|
||||
|
|
@ -1011,8 +1009,6 @@ export class Room extends YgoRoom {
|
|||
return match ? Number(match[1]) : undefined;
|
||||
}
|
||||
|
||||
|
||||
|
||||
private nextSpectatorPositionUnsafe(): number {
|
||||
if (this._spectators.length === 0) {
|
||||
return 8;
|
||||
|
|
|
|||
|
|
@ -10,7 +10,7 @@ import { PlayerRoomState } from "./PlayerRoomState";
|
|||
export class RoomClientNotifier {
|
||||
constructor(
|
||||
private readonly players: () => YgoClient[],
|
||||
private readonly spectators: () => YgoClient[]
|
||||
private readonly spectators: () => YgoClient[],
|
||||
) {}
|
||||
|
||||
sendPlayerChange(player: Client, state: PlayerRoomState): void {
|
||||
|
|
|
|||
126
src/edopro/room/domain/RoomState.test.ts
Normal file
126
src/edopro/room/domain/RoomState.test.ts
Normal file
|
|
@ -0,0 +1,126 @@
|
|||
import { EventEmitter } from "stream";
|
||||
|
||||
import { RoomState } from "./RoomState";
|
||||
import { Commands } from "../../../shared/messages/Commands";
|
||||
import { RoomType } from "src/shared/room/domain/RoomType";
|
||||
|
||||
// RoomState is abstract but declares no abstract members — a bare concrete
|
||||
// subclass is enough to exercise the inherited CHAT and EMOTE handlers
|
||||
// (registered in the constructor). The handlers are private, so we drive them
|
||||
// through the "CHAT" / "EMOTE" events, exactly as the runtime does.
|
||||
class TestRoomState extends RoomState {}
|
||||
|
||||
const makeSocket = () => ({
|
||||
send: jest.fn(),
|
||||
destroy: jest.fn(),
|
||||
close: jest.fn(),
|
||||
});
|
||||
|
||||
const makeChatMessage = (text: string) => ({
|
||||
data: Buffer.from(text, "utf16le"),
|
||||
previousMessage: Buffer.alloc(0),
|
||||
});
|
||||
|
||||
// Emote ids travel as raw utf-8 in the frame body (not utf16le like chat).
|
||||
const makeEmoteMessage = (id: string) => ({
|
||||
data: Buffer.from(id, "utf8"),
|
||||
previousMessage: Buffer.alloc(0),
|
||||
});
|
||||
|
||||
const makeMercuryRoom = (socket: { send: jest.Mock }) =>
|
||||
({
|
||||
roomType: RoomType.MERCURY,
|
||||
isPositionSwapped: false,
|
||||
clients: [{ socket }],
|
||||
}) as unknown as never;
|
||||
|
||||
describe("RoomState — Mercury spectator chat (Option A: server prefixes name)", () => {
|
||||
let eventEmitter: EventEmitter;
|
||||
|
||||
beforeEach(() => {
|
||||
eventEmitter = new EventEmitter();
|
||||
new TestRoomState(eventEmitter);
|
||||
});
|
||||
|
||||
it("prefixes the spectator's name to the chat msg in a Mercury room", () => {
|
||||
const socket = makeSocket();
|
||||
const room = makeMercuryRoom(socket);
|
||||
const spectator = {
|
||||
name: "Duelista 5863",
|
||||
isSpectator: true,
|
||||
position: 7,
|
||||
team: 3,
|
||||
} as unknown as never;
|
||||
|
||||
eventEmitter.emit(Commands.CHAT as unknown as string, makeChatMessage("hola"), room, spectator);
|
||||
|
||||
expect(socket.send).toHaveBeenCalled();
|
||||
const sent = socket.send.mock.calls[0][0] as Buffer;
|
||||
// The outgoing STOC_CHAT msg must carry "Name: text" (UTF-16LE on the wire).
|
||||
expect(sent.includes(Buffer.from("Duelista 5863: hola", "utf16le"))).toBe(true);
|
||||
});
|
||||
|
||||
it("does NOT prefix a name for a duelist (player) chat", () => {
|
||||
const socket = makeSocket();
|
||||
const room = makeMercuryRoom(socket);
|
||||
const player = {
|
||||
name: "Jugador A",
|
||||
isSpectator: false,
|
||||
position: 0,
|
||||
team: 0,
|
||||
} as unknown as never;
|
||||
|
||||
eventEmitter.emit(Commands.CHAT as unknown as string, makeChatMessage("hola"), room, player);
|
||||
|
||||
expect(socket.send).toHaveBeenCalled();
|
||||
const sent = socket.send.mock.calls[0][0] as Buffer;
|
||||
expect(sent.includes(Buffer.from("Jugador A: hola", "utf16le"))).toBe(false);
|
||||
expect(sent.includes(Buffer.from("hola", "utf16le"))).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
describe("RoomState — Mercury emote gate (only seated duelists send)", () => {
|
||||
let eventEmitter: EventEmitter;
|
||||
|
||||
beforeEach(() => {
|
||||
eventEmitter = new EventEmitter();
|
||||
new TestRoomState(eventEmitter);
|
||||
});
|
||||
|
||||
it("broadcasts a duelist's valid emote to the room", () => {
|
||||
const socket = makeSocket();
|
||||
const room = makeMercuryRoom(socket);
|
||||
const duelist = {
|
||||
isSpectator: false,
|
||||
position: 0,
|
||||
tryEmote: () => true,
|
||||
} as unknown as never;
|
||||
|
||||
eventEmitter.emit(Commands.EMOTE as unknown as string, makeEmoteMessage("wave"), room, duelist);
|
||||
|
||||
expect(socket.send).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it("does NOT broadcast a spectator's emote (rejected before rate-limit)", () => {
|
||||
const socket = makeSocket();
|
||||
const room = makeMercuryRoom(socket);
|
||||
const spectator = {
|
||||
isSpectator: true,
|
||||
position: 7,
|
||||
// The gate must short-circuit BEFORE the rate-limiter — if tryEmote is
|
||||
// reached the spectator slipped through, so make that a hard failure.
|
||||
tryEmote: () => {
|
||||
throw new Error("tryEmote must not be reached for a spectator");
|
||||
},
|
||||
} as unknown as never;
|
||||
|
||||
eventEmitter.emit(
|
||||
Commands.EMOTE as unknown as string,
|
||||
makeEmoteMessage("wave"),
|
||||
room,
|
||||
spectator,
|
||||
);
|
||||
|
||||
expect(socket.send).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
|
@ -25,6 +25,12 @@ import { VersionErrorClientMessage } from "../../messages/server-to-client/Versi
|
|||
import { RoomType } from "src/shared/room/domain/RoomType";
|
||||
import { YGOProRoom } from "@ygopro/room/domain/YGOProRoom";
|
||||
import { NetPlayerType, YGOProStocChat, YGOProStocSelectHand } from "ygopro-msg-encode";
|
||||
import {
|
||||
EMOTE_COOLDOWN_MS,
|
||||
MAX_ID_LENGTH,
|
||||
buildStocEmoteFrame,
|
||||
isValidEmoteId,
|
||||
} from "@ygopro/emote/emote-protocol";
|
||||
|
||||
export abstract class RoomState {
|
||||
protected readonly eventEmitter: EventEmitter;
|
||||
|
|
@ -35,7 +41,13 @@ export abstract class RoomState {
|
|||
this.eventEmitter.on(
|
||||
Commands.CHAT as unknown as string,
|
||||
(message: ClientMessage, room: YgoRoom, client: Client) =>
|
||||
this.handleChat(message, room, client)
|
||||
this.handleChat(message, room, client),
|
||||
);
|
||||
|
||||
this.eventEmitter.on(
|
||||
Commands.EMOTE as unknown as string,
|
||||
(message: ClientMessage, room: YgoRoom, client: Client) =>
|
||||
this.handleEmote(message, room, client),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -43,38 +55,6 @@ export abstract class RoomState {
|
|||
this.eventEmitter.removeAllListeners();
|
||||
}
|
||||
|
||||
protected playerAlreadyInRoom(
|
||||
playerInfoMessage: PlayerInfoMessage,
|
||||
room: YgoRoom,
|
||||
socket: ISocket
|
||||
): YgoClient | null {
|
||||
if (!room.ranked) {
|
||||
const player = room.players.find((client) => {
|
||||
return (
|
||||
client.socket.remoteAddress === socket.remoteAddress &&
|
||||
client.socket.closed &&
|
||||
playerInfoMessage.name === client.name
|
||||
);
|
||||
});
|
||||
|
||||
if (!player) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
const player = room.players.find((client) => {
|
||||
return playerInfoMessage.name === client.name;
|
||||
});
|
||||
|
||||
if (!player) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return player;
|
||||
}
|
||||
|
||||
protected validateVersion(message: Buffer, socket: ISocket): void {
|
||||
const joinMessage = new YGOProJoinGameMessage(message);
|
||||
|
||||
|
|
@ -87,12 +67,12 @@ export abstract class RoomState {
|
|||
|
||||
protected sendExistingPlayerErrorMessage(
|
||||
playerInfoMessage: PlayerInfoMessage,
|
||||
socket: ISocket
|
||||
socket: ISocket,
|
||||
): void {
|
||||
socket.send(
|
||||
ServerErrorClientMessage.create(
|
||||
`Already exists a player with the name :${playerInfoMessage.name}`
|
||||
)
|
||||
`Already exists a player with the name :${playerInfoMessage.name}`,
|
||||
),
|
||||
);
|
||||
socket.send(ErrorClientMessage.create(ErrorMessages.JOIN_ERROR));
|
||||
socket.destroy();
|
||||
|
|
@ -102,15 +82,19 @@ export abstract class RoomState {
|
|||
|
||||
protected sendWelcomeMessage(room: YgoRoom, socket: ISocket): void {
|
||||
if (room.ranked) {
|
||||
socket.send(YGOProPlayerChatMessage.create(
|
||||
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.RANKED_ROOM_CREATION_SUCCESS} - ${ServerInfoMessage.GAIN_POINTS_CALL_TO_ACTION}`
|
||||
));
|
||||
socket.send(
|
||||
YGOProPlayerChatMessage.create(
|
||||
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.RANKED_ROOM_CREATION_SUCCESS} - ${ServerInfoMessage.GAIN_POINTS_CALL_TO_ACTION}`,
|
||||
),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
socket.send(YGOProPlayerChatMessage.create(
|
||||
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.UN_RANKED_ROOM_CREATION_SUCCESS}`
|
||||
));
|
||||
socket.send(
|
||||
YGOProPlayerChatMessage.create(
|
||||
`${ServerInfoMessage.WELCOME} - ${ServerInfoMessage.UN_RANKED_ROOM_CREATION_SUCCESS}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
protected processDuelMessage(messageType: CoreMessages, data: Buffer, room: YgoRoom): void {
|
||||
|
|
@ -175,8 +159,15 @@ export abstract class RoomState {
|
|||
: client.position;
|
||||
|
||||
const content = BufferToUTF16(message.data, message.data.length);
|
||||
// STOC_CHAT (opcode 0x19) only carries player_type + msg — there is no name field,
|
||||
// and every spectator shares player_type=7, so the client cannot tell which spectator
|
||||
// spoke (it would fall back to a duelist's identity). Prefix the spectator's name into
|
||||
// the text so the client can attribute the message to the right person.
|
||||
const outgoing = client.isSpectator
|
||||
? `${client.name.replace(/\0/g, "").trim()}: ${content}`
|
||||
: content;
|
||||
const chatMessage = Buffer.from(
|
||||
new YGOProStocChat().fromPartial({ player_type: playerType, msg: content }).toFullPayload()
|
||||
new YGOProStocChat().fromPartial({ player_type: playerType, msg: outgoing }).toFullPayload(),
|
||||
);
|
||||
|
||||
room.clients.forEach((_client: YgoClient) => {
|
||||
|
|
@ -184,6 +175,39 @@ export abstract class RoomState {
|
|||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Relay an emote (custom CTOS 0xfc) to the whole room as STOC 0xfc. Mercury
|
||||
* rooms only — the opcode is understood solely by this project's client, and
|
||||
* a standard ygopro client would neither send nor decode it. Validates the
|
||||
* id against the catalog and rate-limits per client before broadcasting.
|
||||
*/
|
||||
private handleEmote(message: ClientMessage, room: YgoRoom, client: YgoClient): void {
|
||||
if (room.roomType !== RoomType.MERCURY) return;
|
||||
|
||||
// Only seated duelists may emote. Spectators watch and receive emotes but
|
||||
// cannot send them — reject here (the authoritative gate; the client also
|
||||
// hides the picker for spectators).
|
||||
if (client.isSpectator) return;
|
||||
|
||||
// Byte-length pre-check before the utf-8 conversion, so a garbage frame
|
||||
// (megabytes of body) can't force a large string allocation just to fail.
|
||||
if (message.data.length === 0 || message.data.length > MAX_ID_LENGTH) return;
|
||||
|
||||
const emoteId = message.data.toString("utf8");
|
||||
if (!isValidEmoteId(emoteId)) return;
|
||||
if (!client.tryEmote(Date.now(), EMOTE_COOLDOWN_MS)) return;
|
||||
|
||||
// Seat resolution mirrors handleMercuryChat so the client maps the sender
|
||||
// to the correct HUD side (accounting for a swapped board).
|
||||
const ygoproRoom = room as YGOProRoom;
|
||||
const playerType = ygoproRoom.isPositionSwapped ? client.position ^ 1 : client.position;
|
||||
|
||||
const frame = buildStocEmoteFrame(playerType, emoteId);
|
||||
room.clients.forEach((c: YgoClient) => {
|
||||
c.socket.send(frame);
|
||||
});
|
||||
}
|
||||
|
||||
protected sendSystemErrorMessage(message: string, client: YgoClient): void {
|
||||
client.socket.send(YGOProPlayerChatMessage.create(message));
|
||||
}
|
||||
|
|
@ -208,7 +232,7 @@ export abstract class RoomState {
|
|||
if (client.isSpectator) {
|
||||
const chatMessage = SpectatorMessageClientMessage.create(
|
||||
client.name.replace(/\0/g, "").trim(),
|
||||
message.data
|
||||
message.data,
|
||||
);
|
||||
room.players.forEach((player: Client) => {
|
||||
player.socket.send(chatMessage);
|
||||
|
|
@ -224,12 +248,12 @@ export abstract class RoomState {
|
|||
const playerMessage = PlayerMessageClientMessage.create(
|
||||
client.name.replace(/\0/g, "").trim(),
|
||||
message.data,
|
||||
client.team
|
||||
client.team,
|
||||
);
|
||||
const opponentMessage = PlayerMessageClientMessage.create(
|
||||
client.name.replace(/\0/g, "").trim(),
|
||||
message.data,
|
||||
Number(!client.team)
|
||||
Number(!client.team),
|
||||
);
|
||||
|
||||
room.players.forEach((player: YgoClient) => {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import EventEmitter from "events";
|
||||
|
||||
import { Logger } from "../../../../../shared/logger/domain/Logger";
|
||||
|
|
@ -15,13 +13,16 @@ import { JoinToDuelAsSpectator } from "../../../application/JoinToDuelAsSpectato
|
|||
import { Reconnect } from "../../../application/Reconnect";
|
||||
import { Room } from "../../Room";
|
||||
import { RoomState } from "../../RoomState";
|
||||
import { ReconnectionTokenIssuer } from "../../../../../shared/room/application/reconnect/ReconnectionTokenIssuer";
|
||||
import { findReconnectingPlayer } from "../../../../../shared/room/domain/findReconnectingPlayer";
|
||||
import { ReconnectionAckMessage } from "../../../../../shared/messages/server-to-client/ReconnectionAckMessage";
|
||||
|
||||
export class ChossingOrderState extends RoomState {
|
||||
constructor(
|
||||
eventEmitter: EventEmitter,
|
||||
private readonly logger: Logger,
|
||||
private readonly reconnect: Reconnect,
|
||||
private readonly joinToDuelAsSpectator: JoinToDuelAsSpectator
|
||||
private readonly joinToDuelAsSpectator: JoinToDuelAsSpectator,
|
||||
) {
|
||||
super(eventEmitter);
|
||||
|
||||
|
|
@ -30,20 +31,57 @@ export class ChossingOrderState extends RoomState {
|
|||
this.eventEmitter.on(
|
||||
"JOIN" as unknown as string,
|
||||
(message: ClientMessage, room: Room, socket: ISocket) =>
|
||||
this.handleJoin.bind(this)(message, room, socket)
|
||||
this.handleJoin.bind(this)(message, room, socket),
|
||||
);
|
||||
|
||||
this.eventEmitter.on(
|
||||
Commands.READY as unknown as string,
|
||||
(message: ClientMessage, room: Room, client: Client) =>
|
||||
this.handleReady.bind(this)(message, room, client)
|
||||
this.handleReady.bind(this)(message, room, client),
|
||||
);
|
||||
|
||||
this.eventEmitter.on(
|
||||
Commands.TURN_CHOICE as unknown as string,
|
||||
(message: ClientMessage, room: Room, client: Client) =>
|
||||
this.handle.bind(this)(message, room, client)
|
||||
this.handle.bind(this)(message, room, client),
|
||||
);
|
||||
|
||||
this.eventEmitter.on(
|
||||
"EXPRESS_RECONNECT" as unknown as string,
|
||||
(message: ClientMessage, room: Room, socket: ISocket) =>
|
||||
this.handleExpressReconnect.bind(this)(message, room, socket),
|
||||
);
|
||||
}
|
||||
|
||||
private handleExpressReconnect(message: ClientMessage, room: Room, socket: ISocket): void {
|
||||
this.logger.info("CHOSSING_ORDER: EXPRESS_RECONNECT");
|
||||
const token = message.data.toString("utf8");
|
||||
|
||||
const player = ReconnectionTokenIssuer.resolve(
|
||||
token,
|
||||
room.id,
|
||||
(client) => client instanceof Client,
|
||||
) as Client | null;
|
||||
if (!player) {
|
||||
this.logger.info(`CHOSSING_ORDER: no player for token ${token}`);
|
||||
socket.send(ReconnectionAckMessage.failure());
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
player.setSocket(socket, room.players as Client[], room);
|
||||
player.reconnecting();
|
||||
socket.send(ReconnectionAckMessage.success());
|
||||
|
||||
// Re-sync mirrors the name-match reconnect for this phase (handleReady).
|
||||
player.sendMessage(DuelStartClientMessage.create());
|
||||
if (room.clientWhoChoosesTurn.position === player.position) {
|
||||
player.sendMessage(ChooseOrderClientMessage.create());
|
||||
}
|
||||
|
||||
// Rotate the token after a successful reconnection (single-use).
|
||||
player.sendMessage(ReconnectionTokenIssuer.rotate(player, room.id));
|
||||
player.clearReconnecting();
|
||||
}
|
||||
|
||||
private handle(message: ClientMessage, room: Room, player: Client): void {
|
||||
|
|
@ -84,7 +122,12 @@ export class ChossingOrderState extends RoomState {
|
|||
this.logger.info("CHOSSING_ORDER: JOIN");
|
||||
const playerInfoMessage = new PlayerInfoMessage(message.previousMessage, message.data.length);
|
||||
const joinMessage = new JoinGameMessage(message.data);
|
||||
const reconnectingPlayer = this.playerAlreadyInRoom(playerInfoMessage, room, socket);
|
||||
const reconnectingPlayer = findReconnectingPlayer({
|
||||
players: room.players,
|
||||
name: playerInfoMessage.name,
|
||||
remoteAddress: socket.remoteAddress,
|
||||
ranked: room.ranked,
|
||||
});
|
||||
|
||||
if (!(reconnectingPlayer instanceof Client)) {
|
||||
await this.joinToDuelAsSpectator.run(joinMessage, playerInfoMessage, socket, room);
|
||||
|
|
|
|||
387
src/edopro/room/domain/states/dueling/DuelingState.test.ts
Normal file
387
src/edopro/room/domain/states/dueling/DuelingState.test.ts
Normal file
|
|
@ -0,0 +1,387 @@
|
|||
import "reflect-metadata";
|
||||
import { EventEmitter } from "stream";
|
||||
import { Logger } from "@shared/logger/domain/Logger";
|
||||
import { Reconnect } from "@edopro/room/application/Reconnect";
|
||||
import { JoinToDuelAsSpectator } from "@edopro/room/application/JoinToDuelAsSpectator";
|
||||
import { Room } from "@edopro/room/domain/Room";
|
||||
import { JSONMessageProcessor } from "@edopro/messages/JSONMessageProcessor";
|
||||
import { DuelingState } from "./DuelingState";
|
||||
import { Client } from "@edopro/client/domain/Client";
|
||||
import { ClientMessage } from "@shared/messages/MessageProcessor";
|
||||
import { FinishDuelHandler } from "@edopro/room/application/FinishDuelHandler";
|
||||
import { UpdateDeckMessageParser } from "@edopro/deck/application/UpdateDeckMessageSizeCalculator";
|
||||
import { ISocket } from "@shared/socket/domain/ISocket";
|
||||
import { spawn } from "child_process";
|
||||
import WebSocketSingleton from "../../../../../web-socket-server/WebSocketSingleton";
|
||||
import { Commands } from "@shared/messages/Commands";
|
||||
import { TokenIndex } from "@shared/room/domain/TokenIndex";
|
||||
|
||||
// Mocks
|
||||
jest.mock("@shared/logger/domain/Logger");
|
||||
jest.mock("@edopro/room/application/Reconnect");
|
||||
jest.mock("@edopro/room/application/JoinToDuelAsSpectator");
|
||||
jest.mock("@edopro/room/domain/Room");
|
||||
jest.mock("@edopro/messages/JSONMessageProcessor");
|
||||
jest.mock("@edopro/client/domain/Client");
|
||||
jest.mock("@edopro/room/application/FinishDuelHandler");
|
||||
jest.mock("@edopro/deck/application/UpdateDeckMessageSizeCalculator");
|
||||
jest.mock("child_process");
|
||||
jest.mock("../../../../../web-socket-server/WebSocketSingleton");
|
||||
|
||||
describe("DuelingState", () => {
|
||||
let state: DuelingState;
|
||||
let mockEmitter: EventEmitter;
|
||||
let mockLogger: jest.Mocked<Logger>;
|
||||
let mockReconnect: jest.Mocked<Reconnect>;
|
||||
let mockJoinToDuelAsSpectator: jest.Mocked<JoinToDuelAsSpectator>;
|
||||
let mockRoom: jest.Mocked<Room>;
|
||||
let mockJsonMessageProcessor: jest.Mocked<JSONMessageProcessor>;
|
||||
let mockClient: jest.Mocked<Client>;
|
||||
let mockSocket: jest.Mocked<ISocket>;
|
||||
let mockCore: any;
|
||||
let mockWebSocketSingleton: jest.Mocked<WebSocketSingleton>;
|
||||
|
||||
beforeEach(() => {
|
||||
mockEmitter = new EventEmitter();
|
||||
|
||||
mockWebSocketSingleton = {
|
||||
broadcast: jest.fn(),
|
||||
} as unknown as jest.Mocked<WebSocketSingleton>;
|
||||
(WebSocketSingleton.getInstance as jest.Mock).mockReturnValue(mockWebSocketSingleton);
|
||||
|
||||
mockLogger = {
|
||||
child: jest.fn().mockReturnThis(),
|
||||
info: jest.fn(),
|
||||
error: jest.fn(),
|
||||
} as unknown as jest.Mocked<Logger>;
|
||||
|
||||
mockReconnect = {
|
||||
run: jest.fn(),
|
||||
} as unknown as jest.Mocked<Reconnect>;
|
||||
|
||||
mockJoinToDuelAsSpectator = {
|
||||
run: jest.fn(),
|
||||
} as unknown as jest.Mocked<JoinToDuelAsSpectator>;
|
||||
|
||||
mockJsonMessageProcessor = {
|
||||
read: jest.fn(),
|
||||
process: jest.fn(),
|
||||
isMessageReady: jest.fn(),
|
||||
payload: { data: "", size: 0 },
|
||||
currentBuffer: Buffer.alloc(0),
|
||||
clear: jest.fn(),
|
||||
} as unknown as jest.Mocked<JSONMessageProcessor>;
|
||||
|
||||
mockSocket = {
|
||||
send: jest.fn(),
|
||||
remoteAddress: "127.0.0.1",
|
||||
destroy: jest.fn(),
|
||||
} as unknown as jest.Mocked<ISocket>;
|
||||
|
||||
mockClient = {
|
||||
logger: mockLogger,
|
||||
sendMessage: jest.fn(),
|
||||
socket: mockSocket,
|
||||
setCanReconnect: jest.fn(),
|
||||
setReconnectionToken: jest.fn(),
|
||||
reconnectionToken: null,
|
||||
deck: { main: [], side: [], extra: [] },
|
||||
position: 0,
|
||||
team: 0,
|
||||
duelPosition: 0,
|
||||
host: true,
|
||||
isSpectator: false,
|
||||
isReconnecting: false,
|
||||
canReconnect: false,
|
||||
clearReconnecting: jest.fn(),
|
||||
} as unknown as jest.Mocked<Client>;
|
||||
|
||||
mockRoom = {
|
||||
players: [mockClient],
|
||||
spectators: [],
|
||||
startLp: 8000,
|
||||
startHand: 5,
|
||||
drawCount: 1,
|
||||
timeLimit: 300,
|
||||
duelFlag: 0n,
|
||||
firstToPlay: 0,
|
||||
banListHash: 123,
|
||||
id: 1,
|
||||
score: "0-0",
|
||||
replay: {
|
||||
setSeed: jest.fn(),
|
||||
addMessage: jest.fn(),
|
||||
addResponse: jest.fn(),
|
||||
},
|
||||
prepareTurnOrder: jest.fn(),
|
||||
setDuel: jest.fn(),
|
||||
createDuel: jest.fn(),
|
||||
setPlayerDecksSize: jest.fn(),
|
||||
setOpponentDecksSize: jest.fn(),
|
||||
sendMessageToCpp: jest.fn(),
|
||||
recordCppStdoutChunk: jest.fn(),
|
||||
recordCppFrameProcessed: jest.fn(),
|
||||
recordCppParseError: jest.fn(),
|
||||
recordCppDeferredProcessTick: jest.fn(),
|
||||
cacheTeamMessage: jest.fn(),
|
||||
resetTimer: jest.fn(),
|
||||
calculateTimeReceiver: jest.fn().mockReturnValue(0),
|
||||
getTime: jest.fn().mockReturnValue(300),
|
||||
nextTurn: jest.fn(),
|
||||
isFinished: jest.fn().mockReturnValue(false),
|
||||
finished: jest.fn(),
|
||||
stopTimer: jest.fn(),
|
||||
setLastPhaseMessage: jest.fn(),
|
||||
lastPhaseMessage: null,
|
||||
playerMainDeckSize: 40,
|
||||
playerExtraDeckSize: 15,
|
||||
opponentMainDeckSize: 40,
|
||||
opponentExtraDeckSize: 15,
|
||||
isFirstDuel: jest.fn().mockReturnValue(true),
|
||||
toRealTimePresentation: jest.fn().mockReturnValue({}),
|
||||
} as unknown as jest.Mocked<Room>;
|
||||
|
||||
mockCore = {
|
||||
stderr: { on: jest.fn() },
|
||||
stdout: { on: jest.fn() },
|
||||
on: jest.fn(),
|
||||
stdin: { write: jest.fn() },
|
||||
};
|
||||
(spawn as jest.Mock).mockReturnValue(mockCore);
|
||||
|
||||
state = new DuelingState(
|
||||
mockEmitter,
|
||||
mockLogger,
|
||||
mockReconnect,
|
||||
mockJoinToDuelAsSpectator,
|
||||
mockRoom,
|
||||
mockJsonMessageProcessor,
|
||||
);
|
||||
});
|
||||
|
||||
it("should initialize and start duel", () => {
|
||||
expect(mockRoom.setDuel).toHaveBeenCalledWith(mockCore);
|
||||
expect(mockRoom.createDuel).toHaveBeenCalled();
|
||||
expect(mockLogger.info).toHaveBeenCalledWith("Starting Duel");
|
||||
});
|
||||
|
||||
it("should handle UPDATE_DECK command (valid deck)", () => {
|
||||
const message = { data: Buffer.alloc(10) } as ClientMessage;
|
||||
const mockParser = {
|
||||
getDeck: jest.fn().mockReturnValue([[1, 2], [3]]),
|
||||
};
|
||||
(UpdateDeckMessageParser as jest.Mock).mockReturnValue(mockParser);
|
||||
|
||||
Object.defineProperty(mockClient.deck, "main", {
|
||||
value: [{ code: "1" }, { code: "2" }],
|
||||
});
|
||||
Object.defineProperty(mockClient.deck, "side", { value: [{ code: "3" }] });
|
||||
Object.defineProperty(mockClient.deck, "extra", { value: [] });
|
||||
|
||||
mockEmitter.emit(Commands.UPDATE_DECK as unknown as string, message, mockRoom, mockClient);
|
||||
|
||||
expect(mockClient.setCanReconnect).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it("should handle UPDATE_DECK command (invalid deck length)", () => {
|
||||
const message = { data: Buffer.alloc(10) } as ClientMessage;
|
||||
const mockParser = {
|
||||
getDeck: jest.fn().mockReturnValue([[1], []]),
|
||||
};
|
||||
(UpdateDeckMessageParser as jest.Mock).mockReturnValue(mockParser);
|
||||
|
||||
Object.defineProperty(mockClient.deck, "main", {
|
||||
value: [{ code: "1" }, { code: "2" }],
|
||||
});
|
||||
|
||||
mockEmitter.emit(Commands.UPDATE_DECK as unknown as string, message, mockRoom, mockClient);
|
||||
|
||||
expect(mockClient.setCanReconnect).toHaveBeenCalledWith(false);
|
||||
expect(mockClient.socket.send).toHaveBeenCalled(); // Error message
|
||||
});
|
||||
|
||||
it("should handle SURRENDER command", () => {
|
||||
const message = {} as ClientMessage;
|
||||
|
||||
mockEmitter.emit(Commands.SURRENDER as unknown as string, message, mockRoom, mockClient);
|
||||
|
||||
expect(mockRoom.sendMessageToCpp).toHaveBeenCalledWith(expect.stringContaining("DESTROY_DUEL"));
|
||||
expect(FinishDuelHandler).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should handle RESPONSE command", () => {
|
||||
const message = {
|
||||
data: Buffer.from([0x01, 0x02]),
|
||||
} as ClientMessage;
|
||||
|
||||
mockEmitter.emit(Commands.RESPONSE as unknown as string, message, mockRoom, mockClient);
|
||||
|
||||
expect(mockRoom.replay.addResponse).toHaveBeenCalled();
|
||||
expect(mockRoom.stopTimer).toHaveBeenCalled();
|
||||
expect(mockRoom.sendMessageToCpp).toHaveBeenCalledWith(expect.stringContaining("RESPONSE"));
|
||||
});
|
||||
|
||||
it("should handle READY command (reconnecting)", () => {
|
||||
const message = {} as ClientMessage;
|
||||
Object.defineProperty(mockClient, "isReconnecting", { value: true });
|
||||
Object.defineProperty(mockClient, "canReconnect", { value: true });
|
||||
|
||||
mockEmitter.emit(Commands.READY as unknown as string, message, mockRoom, mockClient);
|
||||
|
||||
expect(mockClient.sendMessage).toHaveBeenCalled(); // StartDuel messages
|
||||
expect(mockRoom.sendMessageToCpp).toHaveBeenCalledWith(expect.stringContaining("GET_FIELD"));
|
||||
});
|
||||
|
||||
it("should handle JOIN command (password fail)", () => {
|
||||
const message = {
|
||||
data: Buffer.alloc(50),
|
||||
previousMessage: Buffer.alloc(40),
|
||||
} as ClientMessage;
|
||||
Object.defineProperty(mockRoom, "password", { value: "secret" });
|
||||
|
||||
// Mock JoinGameMessage constructor behavior?
|
||||
|
||||
// It reads from buffer. We passed empty buffer, so password will be empty string.
|
||||
// "secret" !== "" -> fail
|
||||
|
||||
mockEmitter.emit("JOIN", message, mockRoom, mockSocket);
|
||||
|
||||
expect(mockSocket.send).toHaveBeenCalled();
|
||||
expect(mockSocket.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("should process all ready core messages in the same tick when under limit", () => {
|
||||
Object.defineProperty(mockJsonMessageProcessor, "payload", {
|
||||
value: {
|
||||
data: JSON.stringify({ type: "UNKNOWN" }),
|
||||
size: 0,
|
||||
},
|
||||
});
|
||||
mockJsonMessageProcessor.isMessageReady
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce(true)
|
||||
.mockReturnValueOnce(false)
|
||||
.mockReturnValueOnce(false);
|
||||
|
||||
(state as any).processMessage();
|
||||
|
||||
expect(mockJsonMessageProcessor.process).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it("should defer remaining core messages to next tick when limit is reached", () => {
|
||||
Object.defineProperty(mockJsonMessageProcessor, "payload", {
|
||||
value: {
|
||||
data: JSON.stringify({ type: "UNKNOWN" }),
|
||||
size: 0,
|
||||
},
|
||||
});
|
||||
|
||||
let readyCalls = 0;
|
||||
mockJsonMessageProcessor.isMessageReady.mockImplementation(() => {
|
||||
readyCalls += 1;
|
||||
return readyCalls <= 1001;
|
||||
});
|
||||
|
||||
const setImmediateSpy = jest.spyOn(global, "setImmediate").mockImplementation(() => {
|
||||
return 0 as unknown as NodeJS.Immediate;
|
||||
});
|
||||
|
||||
(state as any).processMessage();
|
||||
|
||||
expect(mockJsonMessageProcessor.process).toHaveBeenCalledTimes(1000);
|
||||
expect(setImmediateSpy).toHaveBeenCalledTimes(1);
|
||||
|
||||
setImmediateSpy.mockRestore();
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Characterization of the token-based express reconnect flow. These pin the
|
||||
// CURRENT observable behavior before the shared reconnect layer is extracted,
|
||||
// so the refactor must keep them green (identical behavior).
|
||||
// ---------------------------------------------------------------------------
|
||||
describe("EXPRESS_RECONNECT (characterization)", () => {
|
||||
const SUCCESS_ACK = Buffer.from([0x02, 0x00, 0xfd, 0x00]);
|
||||
const FAILURE_ACK = Buffer.from([0x02, 0x00, 0xfd, 0x01]);
|
||||
|
||||
const makeRealClient = (overrides: Record<string, unknown> = {}): Client => {
|
||||
const client = new Client({} as any) as any; // instanceof Client === true
|
||||
Object.assign(client, {
|
||||
name: "P1",
|
||||
position: 0,
|
||||
team: 0,
|
||||
cache: null,
|
||||
reconnectionToken: null,
|
||||
sendMessage: jest.fn(),
|
||||
setSocket: jest.fn(),
|
||||
reconnecting: jest.fn(),
|
||||
clearReconnecting: jest.fn(),
|
||||
setCanReconnect: jest.fn(),
|
||||
setReconnectionToken: jest.fn(),
|
||||
clearReconnectionToken: jest.fn(),
|
||||
...overrides,
|
||||
});
|
||||
return client as Client;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
TokenIndex.getInstance().clear();
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
TokenIndex.getInstance().clear();
|
||||
});
|
||||
|
||||
it("valid token: success ack + setSocket + REFRESH_FIELD to core", () => {
|
||||
const player = makeRealClient();
|
||||
TokenIndex.getInstance().register("tok", player, mockRoom.id);
|
||||
const message = { data: Buffer.from("tok", "utf8") } as ClientMessage;
|
||||
|
||||
mockEmitter.emit("EXPRESS_RECONNECT", message, mockRoom, mockSocket);
|
||||
|
||||
expect(mockSocket.send).toHaveBeenCalledWith(SUCCESS_ACK);
|
||||
expect(player.setSocket).toHaveBeenCalledWith(mockSocket, mockRoom.players, mockRoom);
|
||||
expect(player.reconnecting).toHaveBeenCalled();
|
||||
expect(mockRoom.sendMessageToCpp).toHaveBeenCalledWith(
|
||||
expect.stringContaining("REFRESH_FIELD"),
|
||||
);
|
||||
});
|
||||
|
||||
it("unknown token: failure ack + socket destroyed", () => {
|
||||
const message = { data: Buffer.from("ghost", "utf8") } as ClientMessage;
|
||||
|
||||
mockEmitter.emit("EXPRESS_RECONNECT", message, mockRoom, mockSocket);
|
||||
|
||||
expect(mockSocket.send).toHaveBeenCalledWith(FAILURE_ACK);
|
||||
expect(mockSocket.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("token registered to another room: failure ack + socket destroyed", () => {
|
||||
const player = makeRealClient();
|
||||
TokenIndex.getInstance().register("tok", player, mockRoom.id + 999);
|
||||
const message = { data: Buffer.from("tok", "utf8") } as ClientMessage;
|
||||
|
||||
mockEmitter.emit("EXPRESS_RECONNECT", message, mockRoom, mockSocket);
|
||||
|
||||
expect(mockSocket.send).toHaveBeenCalledWith(FAILURE_ACK);
|
||||
expect(mockSocket.destroy).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("core RECONNECT: rotates the token (old gone, new 32-hex issued)", () => {
|
||||
const player = makeRealClient({ reconnectionToken: "old", position: 0 });
|
||||
(mockRoom as any).players = [player];
|
||||
TokenIndex.getInstance().register("old", player, mockRoom.id);
|
||||
|
||||
(state as any).handleCoreReconnect({
|
||||
position: 0,
|
||||
team: 0,
|
||||
cacheable: false,
|
||||
});
|
||||
|
||||
expect(TokenIndex.getInstance().find("old")).toBeUndefined();
|
||||
expect(player.setReconnectionToken).toHaveBeenCalledWith(
|
||||
expect.stringMatching(/^[0-9a-f]{32}$/),
|
||||
);
|
||||
expect(player.sendMessage).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -1,5 +1,3 @@
|
|||
|
||||
|
||||
import BanListMemoryRepository from "@edopro/ban-list/infrastructure/BanListMemoryRepository";
|
||||
import { ServerInfoMessage } from "@edopro/messages/domain/ServerInfoMessage";
|
||||
import { spawn } from "child_process";
|
||||
|
|
@ -20,18 +18,20 @@ import { Commands } from "../../../../../shared/messages/Commands";
|
|||
import { JSONMessageProcessor } from "../../../../messages/JSONMessageProcessor";
|
||||
import { ClientMessage } from "../../../../../shared/messages/MessageProcessor";
|
||||
import { ErrorMessages } from "../../../../messages/server-to-client/error-messages/ErrorMessages";
|
||||
import { serializeCoreLaunchPayload } from "./serialize-core-launch-payload";
|
||||
import { ErrorClientMessage } from "../../../../messages/server-to-client/ErrorClientMessage";
|
||||
import { StartDuelClientMessage } from "../../../../messages/server-to-client/game-messages/StartDuelClientMessage";
|
||||
import { TimeLimitClientMessage } from "../../../../messages/server-to-client/game-messages/TimeLimitClientMessage";
|
||||
import { CatchUpClientMessage } from "../../../../messages/server-to-client/CatchUpClientMessage";
|
||||
import { PlayerChangeClientMessage } from "../../../../messages/server-to-client/PlayerChangeClientMessage";
|
||||
import { ReconnectionTokenClientMessage } from "../../../../messages/server-to-client/ReconnectionTokenClientMessage";
|
||||
import { ReconnectionTokenIssuer } from "../../../../../shared/room/application/reconnect/ReconnectionTokenIssuer";
|
||||
import { findReconnectingPlayer } from "../../../../../shared/room/domain/findReconnectingPlayer";
|
||||
import { ReconnectionAckMessage } from "../../../../../shared/messages/server-to-client/ReconnectionAckMessage";
|
||||
import { ServerErrorClientMessage } from "../../../../messages/server-to-client/ServerErrorMessageClientMessage";
|
||||
import { ServerMessageClientMessage } from "../../../../messages/server-to-client/ServerMessageClientMessage";
|
||||
import { FinishDuelHandler } from "../../../application/FinishDuelHandler";
|
||||
import { JoinToDuelAsSpectator } from "../../../application/JoinToDuelAsSpectator";
|
||||
import { Reconnect } from "../../../application/Reconnect";
|
||||
import { TokenIndex } from "../../../../../shared/room/domain/TokenIndex";
|
||||
import { DuelFinishReason } from "../../DuelFinishReason";
|
||||
import { Room } from "../../Room";
|
||||
import { RoomState } from "../../RoomState";
|
||||
|
|
@ -105,7 +105,7 @@ export class DuelingState extends RoomState {
|
|||
private readonly reconnect: Reconnect,
|
||||
private readonly joinToDuelAsSpectator: JoinToDuelAsSpectator,
|
||||
private readonly room: Room,
|
||||
private readonly jsonMessageProcessor: JSONMessageProcessor
|
||||
private readonly jsonMessageProcessor: JSONMessageProcessor,
|
||||
) {
|
||||
super(eventEmitter);
|
||||
|
||||
|
|
@ -116,37 +116,37 @@ export class DuelingState extends RoomState {
|
|||
this.eventEmitter.on(
|
||||
"JOIN" as unknown as string,
|
||||
(message: ClientMessage, room: Room, socket: ISocket) =>
|
||||
this.handleJoin.bind(this)(message, room, socket)
|
||||
this.handleJoin.bind(this)(message, room, socket),
|
||||
);
|
||||
|
||||
this.eventEmitter.on(
|
||||
"EXPRESS_RECONNECT" as unknown as string,
|
||||
(message: ClientMessage, room: Room, socket: ISocket) =>
|
||||
this.handleExpressReconnect.bind(this)(message, room, socket)
|
||||
this.handleExpressReconnect.bind(this)(message, room, socket),
|
||||
);
|
||||
|
||||
this.eventEmitter.on(
|
||||
Commands.SURRENDER as unknown as string,
|
||||
(message: ClientMessage, _room: Room, client: Client) =>
|
||||
this.handleSurrender.bind(this)(message, client)
|
||||
this.handleSurrender.bind(this)(message, client),
|
||||
);
|
||||
|
||||
this.eventEmitter.on(
|
||||
Commands.RESPONSE as unknown as string,
|
||||
(message: ClientMessage, _room: Room, client: Client) =>
|
||||
this.handleResponse.bind(this)(message, client)
|
||||
this.handleResponse.bind(this)(message, client),
|
||||
);
|
||||
|
||||
this.eventEmitter.on(
|
||||
Commands.READY as unknown as string,
|
||||
(message: ClientMessage, room: Room, client: Client) =>
|
||||
this.handleReady.bind(this)(message, room, client)
|
||||
this.handleReady.bind(this)(message, room, client),
|
||||
);
|
||||
|
||||
this.eventEmitter.on(
|
||||
Commands.UPDATE_DECK as unknown as string,
|
||||
(message: ClientMessage, room: Room, client: Client) =>
|
||||
this.handleUpdateDeck.bind(this)(message, room, client)
|
||||
this.handleUpdateDeck.bind(this)(message, room, client),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -165,8 +165,8 @@ export class DuelingState extends RoomState {
|
|||
if (completeCurrentDeck.length !== completeIncomingDeck.length) {
|
||||
client.socket.send(
|
||||
ServerErrorClientMessage.create(
|
||||
"Por favor selecciona el mismo deck de la partida en curso para poder reconectar"
|
||||
)
|
||||
"Por favor selecciona el mismo deck de la partida en curso para poder reconectar",
|
||||
),
|
||||
);
|
||||
const message = ErrorClientMessage.create(ErrorMessages.DECK_ERROR);
|
||||
client.socket.send(message);
|
||||
|
|
@ -183,8 +183,8 @@ export class DuelingState extends RoomState {
|
|||
if (!completeIncomingDeck.every((item) => completeCurrentDeck.includes(item))) {
|
||||
client.socket.send(
|
||||
ServerErrorClientMessage.create(
|
||||
"Por favor selecciona el mismo deck de la partida en curso para poder reconectar"
|
||||
)
|
||||
"Por favor selecciona el mismo deck de la partida en curso para poder reconectar",
|
||||
),
|
||||
);
|
||||
|
||||
const message = ErrorClientMessage.create(ErrorMessages.DECK_ERROR);
|
||||
|
|
@ -220,21 +220,20 @@ export class DuelingState extends RoomState {
|
|||
|
||||
this.logger.info("Starting Duel");
|
||||
|
||||
// The reconnection token is issued once at match start (WaitingState), not
|
||||
// per-game, so it survives across RPS, side-decking and every duel in a
|
||||
// match. It is only rotated after each successful reconnection.
|
||||
this.room.players.forEach((item) => {
|
||||
item.socket.send(ServerMessageClientMessage.create(ServerInfoMessage.STARTING_DUEL));
|
||||
const reconnectionToken = crypto.randomBytes(16).toString("hex");
|
||||
item.setReconnectionToken(reconnectionToken);
|
||||
TokenIndex.getInstance().register(reconnectionToken, item, this.room.id);
|
||||
item.socket.send(ReconnectionTokenClientMessage.create(reconnectionToken));
|
||||
});
|
||||
|
||||
const core = spawn(
|
||||
`./core/CoreIntegrator`,
|
||||
[
|
||||
JSON.stringify({
|
||||
serializeCoreLaunchPayload({
|
||||
config: {
|
||||
startLp: this.room.startLp.toString(),
|
||||
seeds: seeds.map((seed) => Number(seed)),
|
||||
seeds,
|
||||
flags: Number(this.room.duelFlag),
|
||||
lp: this.room.startLp,
|
||||
startingDrawCount: this.room.startHand,
|
||||
|
|
@ -247,7 +246,7 @@ export class DuelingState extends RoomState {
|
|||
],
|
||||
{
|
||||
cwd: process.cwd(),
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
this.room.setDuel(core);
|
||||
|
|
@ -286,7 +285,7 @@ export class DuelingState extends RoomState {
|
|||
this.logger.info(`payload:data ${payload.data}`);
|
||||
this.logger.info(`payload:size ${payload.size}`);
|
||||
this.logger.info(
|
||||
`payload:buffer ${this.jsonMessageProcessor.currentBuffer.toString("hex")}`
|
||||
`payload:buffer ${this.jsonMessageProcessor.currentBuffer.toString("hex")}`,
|
||||
);
|
||||
this.logger.info(`payload:buffer ${this.jsonMessageProcessor.currentBuffer.toString()}`);
|
||||
this.logger.info(`score: ${this.room.score}`);
|
||||
|
|
@ -349,7 +348,7 @@ export class DuelingState extends RoomState {
|
|||
this.processDuelMessage(
|
||||
_coreMessage.message,
|
||||
Buffer.from(_coreMessage.data, "hex"),
|
||||
this.room
|
||||
this.room,
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -419,18 +418,9 @@ export class DuelingState extends RoomState {
|
|||
|
||||
player.sendMessage(CatchUpClientMessage.create({ catchingUp: false }));
|
||||
|
||||
const oldToken = player.reconnectionToken;
|
||||
// Rotate the token after a successful reconnection (single-use).
|
||||
player.clearReconnecting();
|
||||
player.clearReconnectionToken();
|
||||
if (oldToken) {
|
||||
TokenIndex.getInstance().unregister(oldToken);
|
||||
}
|
||||
|
||||
// Generate new token for future reconnections
|
||||
const newToken = crypto.randomBytes(16).toString("hex");
|
||||
player.setReconnectionToken(newToken);
|
||||
TokenIndex.getInstance().register(newToken, player, this.room.id);
|
||||
player.sendMessage(ReconnectionTokenClientMessage.create(newToken));
|
||||
player.sendMessage(ReconnectionTokenIssuer.rotate(player, this.room.id));
|
||||
|
||||
if (player.cache) {
|
||||
this.logger.info(`Sending last cached message to ${player.name} after reconnection`);
|
||||
|
|
@ -440,16 +430,16 @@ export class DuelingState extends RoomState {
|
|||
this.room.players.forEach((client: Client) => {
|
||||
client.sendMessage(
|
||||
ServerMessageClientMessage.create(
|
||||
`${player.name} ${ServerInfoMessage.HAS_ENTERED_TO_THE_DUEL}`
|
||||
)
|
||||
`${player.name} ${ServerInfoMessage.HAS_ENTERED_TO_THE_DUEL}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
this.room.spectators.forEach((spectator: Client) => {
|
||||
spectator.sendMessage(
|
||||
ServerMessageClientMessage.create(
|
||||
`${player.name} ${ServerInfoMessage.HAS_ENTERED_TO_THE_DUEL}`
|
||||
)
|
||||
`${player.name} ${ServerInfoMessage.HAS_ENTERED_TO_THE_DUEL}`,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
|
|
@ -472,7 +462,7 @@ export class DuelingState extends RoomState {
|
|||
position: player.position,
|
||||
team: player.team,
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -489,7 +479,7 @@ export class DuelingState extends RoomState {
|
|||
JSON.stringify({
|
||||
command: "DESTROY_DUEL",
|
||||
data: {},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
this.room.finished();
|
||||
|
|
@ -553,7 +543,7 @@ export class DuelingState extends RoomState {
|
|||
|
||||
if (message.position) {
|
||||
const player = [...this.room.players, ...this.room.spectators].find(
|
||||
(player: Client) => player.position === message.position
|
||||
(player: Client) => player.position === message.position,
|
||||
);
|
||||
|
||||
(<Client | undefined>player)?.sendMessage(payload);
|
||||
|
|
@ -585,7 +575,7 @@ export class DuelingState extends RoomState {
|
|||
}
|
||||
|
||||
const player = [...this.room.players, ...this.room.spectators].find(
|
||||
(player: Client) => player.inTurn && player.team === message.receiver
|
||||
(player: Client) => player.inTurn && player.team === message.receiver,
|
||||
);
|
||||
|
||||
(<Client | undefined>player)?.sendMessage(payload);
|
||||
|
|
@ -645,43 +635,35 @@ export class DuelingState extends RoomState {
|
|||
JSON.stringify({
|
||||
command: "SET_DECKS",
|
||||
data: {},
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
private async handleExpressReconnect(
|
||||
message: ClientMessage,
|
||||
room: Room,
|
||||
socket: ISocket
|
||||
socket: ISocket,
|
||||
): Promise<void> {
|
||||
this.logger.info("DUELING_STATE: EXPRESS_RECONNECT - START");
|
||||
const token = message.data.toString("utf8");
|
||||
this.logger.info(`DUELING_STATE: Token received: ${token}`);
|
||||
|
||||
const entry = TokenIndex.getInstance().find(token);
|
||||
if (!entry || !(entry.client instanceof Client) || entry.roomId !== room.id) {
|
||||
const player = ReconnectionTokenIssuer.resolve(
|
||||
token,
|
||||
room.id,
|
||||
(client) => client instanceof Client,
|
||||
) as Client | null;
|
||||
if (!player) {
|
||||
this.logger.info(`DUELING_STATE: Player not found for token ${token} or room mismatch`);
|
||||
const type = Buffer.from([0xfd]);
|
||||
const status = Buffer.from([0x01]);
|
||||
const dataStatus = Buffer.concat([type, status]);
|
||||
const size = Buffer.alloc(2);
|
||||
size.writeUint16LE(dataStatus.length);
|
||||
socket.send(Buffer.concat([size, dataStatus]));
|
||||
socket.send(ReconnectionAckMessage.failure());
|
||||
socket.destroy();
|
||||
return;
|
||||
}
|
||||
|
||||
const player = entry.client as Client;
|
||||
|
||||
this.logger.info(`DUELING_STATE: Match found for player ${player.name}. Restoring session.`);
|
||||
|
||||
// 1. Send success status
|
||||
const type = Buffer.from([0xfd]);
|
||||
const status = Buffer.from([0x00]);
|
||||
const dataStatus = Buffer.concat([type, status]);
|
||||
const size = Buffer.alloc(2);
|
||||
size.writeUint16LE(dataStatus.length);
|
||||
socket.send(Buffer.concat([size, dataStatus]));
|
||||
socket.send(ReconnectionAckMessage.success());
|
||||
|
||||
// 2. Perform the reconnection logic (similar to handleReady but without waiting for READY)
|
||||
player.setSocket(socket, room.players as Client[], room);
|
||||
|
|
@ -699,7 +681,7 @@ export class DuelingState extends RoomState {
|
|||
playerExtraDeckSize: room.playerExtraDeckSize,
|
||||
opponentMainDeckSize: room.opponentMainDeckSize,
|
||||
opponentExtraDeckSize: room.opponentExtraDeckSize,
|
||||
})
|
||||
}),
|
||||
);
|
||||
player.sendMessage(Buffer.from("0300012800", "hex")); // MSG_NEW_TURN (0)
|
||||
if (room.lastPhaseMessage) {
|
||||
|
|
@ -713,7 +695,7 @@ export class DuelingState extends RoomState {
|
|||
position: player.position,
|
||||
team: player.team,
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
this.logger.info("DUELING_STATE: EXPRESS_RECONNECT - COMPLETED");
|
||||
|
|
@ -734,7 +716,7 @@ export class DuelingState extends RoomState {
|
|||
playerExtraDeckSize: room.playerExtraDeckSize,
|
||||
opponentMainDeckSize: room.opponentMainDeckSize,
|
||||
opponentExtraDeckSize: room.opponentExtraDeckSize,
|
||||
})
|
||||
}),
|
||||
);
|
||||
player.sendMessage(Buffer.from("0300012800", "hex"));
|
||||
if (room.lastPhaseMessage) {
|
||||
|
|
@ -746,7 +728,7 @@ export class DuelingState extends RoomState {
|
|||
data: {
|
||||
position: player.position,
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -761,7 +743,12 @@ export class DuelingState extends RoomState {
|
|||
|
||||
return;
|
||||
}
|
||||
const reconnectingPlayer = this.playerAlreadyInRoom(playerInfoMessage, room, socket);
|
||||
const reconnectingPlayer = findReconnectingPlayer({
|
||||
players: room.players,
|
||||
name: playerInfoMessage.name,
|
||||
remoteAddress: socket.remoteAddress,
|
||||
ranked: room.ranked,
|
||||
});
|
||||
|
||||
if (!(reconnectingPlayer instanceof Client)) {
|
||||
await this.joinToDuelAsSpectator.run(joinMessage, playerInfoMessage, socket, room);
|
||||
|
|
@ -785,7 +772,7 @@ export class DuelingState extends RoomState {
|
|||
JSON.stringify({
|
||||
command: "DESTROY_DUEL",
|
||||
data: {},
|
||||
})
|
||||
}),
|
||||
);
|
||||
|
||||
const finishDuelHandler = new FinishDuelHandler({
|
||||
|
|
@ -819,7 +806,7 @@ export class DuelingState extends RoomState {
|
|||
replier: player.team,
|
||||
message: data,
|
||||
},
|
||||
})
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,61 @@
|
|||
import { serializeCoreLaunchPayload } from "./serialize-core-launch-payload";
|
||||
|
||||
const SEEDS: bigint[] = [12345678901234567890n, 18446744073709551615n, 9007199254740993n, 7n];
|
||||
|
||||
const createPayload = (players: unknown[] = []) => ({
|
||||
config: {
|
||||
startLp: "8000",
|
||||
seeds: SEEDS,
|
||||
flags: 190464,
|
||||
lp: 8000,
|
||||
startingDrawCount: 5,
|
||||
drawCountPerTurn: 1,
|
||||
firstToPlay: 0,
|
||||
timeLimit: 240,
|
||||
},
|
||||
players,
|
||||
});
|
||||
|
||||
describe("serializeCoreLaunchPayload", () => {
|
||||
it("serializes 64-bit seeds as exact JSON integers", () => {
|
||||
const json = serializeCoreLaunchPayload(createPayload());
|
||||
|
||||
expect(json).toContain(
|
||||
'"seeds":[12345678901234567890,18446744073709551615,9007199254740993,7]',
|
||||
);
|
||||
});
|
||||
|
||||
it("produces valid JSON with the rest of the config intact", () => {
|
||||
const json = serializeCoreLaunchPayload(createPayload());
|
||||
|
||||
const parsed = JSON.parse(json) as { config: Record<string, unknown> };
|
||||
|
||||
expect(parsed.config.startLp).toBe("8000");
|
||||
expect(parsed.config.flags).toBe(190464);
|
||||
expect(parsed.config.timeLimit).toBe(240);
|
||||
});
|
||||
|
||||
it("serializes players untouched", () => {
|
||||
const players = [{ team: 0, mainDeck: [10000, 10001], sideDeck: [], extraDeck: [], turn: 0 }];
|
||||
|
||||
const json = serializeCoreLaunchPayload(createPayload(players));
|
||||
|
||||
const parsed = JSON.parse(json) as { players: unknown[] };
|
||||
|
||||
expect(parsed.players).toEqual(players);
|
||||
});
|
||||
|
||||
it("is not corrupted by player fields containing the exact seeds placeholder", () => {
|
||||
const maliciousName = '__SEEDS_PLACEHOLDER__"]},"x":[';
|
||||
const players = [
|
||||
{ team: 0, name: maliciousName, mainDeck: [], sideDeck: [], extraDeck: [], turn: 0 },
|
||||
];
|
||||
|
||||
const json = serializeCoreLaunchPayload(createPayload(players));
|
||||
|
||||
const parsed = JSON.parse(json) as { players: Array<{ name: string }> };
|
||||
|
||||
expect(parsed.players[0]!.name).toBe(maliciousName);
|
||||
expect(json).toContain('"seeds":[12345678901234567890');
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
const SEEDS_PLACEHOLDER = "__SEEDS_PLACEHOLDER__";
|
||||
|
||||
export interface CoreLaunchPayload {
|
||||
config: {
|
||||
startLp: string;
|
||||
seeds: bigint[];
|
||||
flags: number;
|
||||
lp: number;
|
||||
startingDrawCount: number;
|
||||
drawCountPerTurn: number;
|
||||
firstToPlay: number;
|
||||
timeLimit: number;
|
||||
};
|
||||
players: unknown[];
|
||||
}
|
||||
|
||||
// The core parses `seeds` as uint64_t. Serializing them through Number
|
||||
// rounds everything above 2^53, so they are spliced into the JSON as raw
|
||||
// integer literals. The splice runs only over the config serialization,
|
||||
// which contains no player-provided data, so the placeholder cannot be
|
||||
// forged from the outside.
|
||||
export const serializeCoreLaunchPayload = (payload: CoreLaunchPayload): string => {
|
||||
const { seeds, ...config } = payload.config;
|
||||
const configJson = JSON.stringify({ ...config, seeds: SEEDS_PLACEHOLDER }).replace(
|
||||
`"${SEEDS_PLACEHOLDER}"`,
|
||||
`[${seeds.join(",")}]`,
|
||||
);
|
||||
return `{"config":${configJson},"players":${JSON.stringify(payload.players)}}`;
|
||||
};
|
||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue