Compare commits
1
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
91b2980e89 |
@@ -1,29 +1,40 @@
|
|||||||
# core2026 작업 지침
|
# core2026 작업 지침
|
||||||
|
|
||||||
## 적용 범위와 문서 역할
|
## 적용 범위와 목표
|
||||||
|
|
||||||
이 파일은 `core2026/`의 제품별 추가 규칙을 담습니다. 사용자 지시, 더 가까운
|
이 파일은 `core2026/` 전체에 적용됩니다. 하위 디렉터리에 더 가까운
|
||||||
AGENTS, 이 파일, [상위 AGENTS](../AGENTS.md) 순으로 적용합니다. 공통 Git·보고·
|
`AGENTS.md`가 생기면 사용자 지시, 가까운 파일, 이 파일, 상위 작업공간
|
||||||
보안·Ref 계약은 상위 지침을 따르며, 아래에 같은 정책을 별도로 재정의하지 않습니다.
|
지침 순으로 적용합니다.
|
||||||
|
|
||||||
상위 지침의 상대 경로는 `sam_rebuild/`, 이 파일의 경로는 `core2026/` 기준입니다.
|
`../ref/sam`은 누락·회귀를 찾고 기존 개념과 사용자 기대를 이해하기 위한 PHP
|
||||||
독립 checkout에서 상위 작업공간 문서를 참조할 수 없으면 실제 작업공간 위치를
|
기준 구현입니다. `core2026`은 0.1.0 출시 이후 독립적으로 발전하는 제품이므로,
|
||||||
확인하고, 확인하지 못한 규칙이나 Ref 근거를 읽었다고 보고하지 않습니다.
|
이관 중인 계승 영역은 기존 결과·상태 전이·권한·화면·운영 경계를 우선 보존하되
|
||||||
|
명시적인 신규 기능·밸런스·UX 결정까지 Ref와 기계적으로 일치시키지 않습니다.
|
||||||
|
|
||||||
| 작업 | 상세 기준 |
|
## 현재 기준과 저장소 경계
|
||||||
| -------------------------- | ------------------------------------------------------------------------------------------ |
|
|
||||||
| 구조·파일 책임 | [README](README.md#저장소-구성), [개발자 핸드북](docs/developer/index.md) |
|
|
||||||
| Ref 비교·계측·분류 | [상위 호환성 계약](../AGENTS.md#호환성-계약), [매핑](../docs/ref-core2026-mapping.md) |
|
|
||||||
| 테스트 준비·실행·오류 복구 | [테스트 정책](docs/testing-policy.md) |
|
|
||||||
| CSS·실제 Chromium 비교 | [CSS 구조](docs/frontend-css-architecture.md), [호환 검증](docs/frontend-legacy-parity.md) |
|
|
||||||
| DB 이관 | [이관 절차](tools/legacy-db-migration/README.md) |
|
|
||||||
| 배포 대상 선택 | [환경 라우팅](../docs/docker-environment-routing.md) |
|
|
||||||
| prefix·빌드 변수 | [Caddy 계약](docs/e2e-caddy-routing.md) |
|
|
||||||
| Gateway·profile 릴리스 | [릴리스 운영](docs/release-operations.md) |
|
|
||||||
|
|
||||||
PHP 기준 구현은 `../ref/sam`이며 이 저장소 내부 `legacy/`가 아닙니다.
|
- `core2026/`은 제품 구현 저장소입니다. gateway/game frontend·API, game engine,
|
||||||
매핑과 `../report/`는 별도 상위 Git 저장소 소유입니다. 변경 시작·종료에
|
공통 package, Prisma와 검증 도구가 실제로 존재합니다.
|
||||||
각 저장소의 status·branch·remote·HEAD를 확인하고 관련 없는 dirty 변경을 보존합니다.
|
- PHP 기준 구현은 이 저장소 내부 `legacy/`가 아니라 `../ref/sam`입니다.
|
||||||
|
`devel`은 변경하지 않는 기준선이고 비교 fixture·계측은 `ng_compare`에만
|
||||||
|
두어 주세요.
|
||||||
|
- 아키텍처 매핑과 보고서는 별도 상위 저장소의
|
||||||
|
`../docs/ref-core2026-mapping.md`, `../report/`에 있습니다. 두 Git 경계의
|
||||||
|
commit과 상태를 혼동하지 말아 주세요.
|
||||||
|
- 이미지의 운영 소유자는 외부 Caddy의 `/image/*`입니다. 제품 저장소에 무단
|
||||||
|
복제하거나 앱 경로로 rewrite하지 말아 주세요.
|
||||||
|
|
||||||
|
작업 시작과 종료에 최소한 다음을 확인해 주세요.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git status --short --branch
|
||||||
|
git remote -v
|
||||||
|
git rev-parse HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
상위 매핑/보고서를 바꾸면 상위 저장소에서도 같은 검사를 별도로 수행해 주세요.
|
||||||
|
dirty 파일은 사용자의 변경일 수 있으므로 관련 없는 수정, 정리, stage,
|
||||||
|
commit 또는 삭제를 하지 말아 주세요.
|
||||||
|
|
||||||
## 완료 상태를 해석하는 법
|
## 완료 상태를 해석하는 법
|
||||||
|
|
||||||
@@ -44,19 +55,87 @@ PHP 기준 구현은 `../ref/sam`이며 이 저장소 내부 `legacy/`가 아닙
|
|||||||
기능 작업 전에 관련 매핑 항목과 최신 report를 읽고, 오래된 결론은 현재
|
기능 작업 전에 관련 매핑 항목과 최신 report를 읽고, 오래된 결론은 현재
|
||||||
코드와 commit으로 다시 확인해 주세요.
|
코드와 commit으로 다시 확인해 주세요.
|
||||||
|
|
||||||
## 도메인 조립과 비교
|
## 실제 구조와 책임
|
||||||
|
|
||||||
|
- `app/gateway-frontend`: 가입·로그인, 로비, 계정, 관리자 운영 UI
|
||||||
|
- `app/gateway-api`: 계정·세션·profile 정책, operation queue와 PM2
|
||||||
|
orchestration
|
||||||
|
- `app/game-frontend`: profile별 게임 SPA와 ref 호환 UI
|
||||||
|
- `app/game-api`: tRPC/SSE, 조회·입력 API와 battle/auction/tournament worker
|
||||||
|
- `app/game-engine`: turn daemon, scheduler, 월간 lifecycle와 DB flush
|
||||||
|
- `packages/common`: 타입, 직렬화, RNG와 공통 유틸리티
|
||||||
|
- `packages/logic`: 전투·명령·월간 action과 typed action module 도메인 로직
|
||||||
|
- `packages/infra`: gateway/game Prisma schema, migration과 client
|
||||||
|
- `tools/integration-tests`: PostgreSQL/Redis 및 ref↔core 차등
|
||||||
|
- `tools/frontend-legacy-parity`: 실제 Chromium 비교
|
||||||
|
- `tools/legacy-db-migration`: 장기보존 데이터 CLI 이관
|
||||||
|
|
||||||
|
`tools/build-scripts/build-server.mjs`는 현재 profile resource 복사
|
||||||
|
placeholder입니다. 이를 완성된 배포 bundle이나 검증된 profile build로 설명하지
|
||||||
|
않습니다. 운영 build/reset/open은 gateway operation, commit별 worktree와
|
||||||
|
orchestrator 실행 경로를 조사해 주세요.
|
||||||
|
|
||||||
장수 action은 `packages/logic/src/actionModules/`에 둡니다. 계산 fold,
|
장수 action은 `packages/logic/src/actionModules/`에 둡니다. 계산 fold,
|
||||||
priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로 합치지
|
priority/unique-ID trigger와 닫힌 의미 이벤트를 한 범용 hook으로 합치지
|
||||||
말아 주세요. 제품용 module 순서는 `loadActionModuleBundle()`의
|
말아 주세요. 제품용 module 순서는 `loadActionModuleBundle()`의
|
||||||
`RefOrderedActionStack`에서만 조립하며, 새 의미 이벤트는
|
`RefOrderedActionStack`에서만 조립하며, 새 의미 이벤트는
|
||||||
`GeneralActionEventPayloadMap`에 payload와 필수 context를 먼저 선언합니다.
|
`GeneralActionEventPayloadMap`에 payload와 필수 context를 먼저 선언해
|
||||||
상세 계약은 [행동 모듈 프로토콜](docs/architecture/action-module-protocol.md)을 따릅니다.
|
주세요. 자세한 계약은 `docs/architecture/action-module-protocol.md`에
|
||||||
|
있습니다.
|
||||||
|
|
||||||
차등 테스트는 계승 계약에 exact equality를 요구합니다. 의도적 제품 차이는
|
## 레거시 매핑과 비교
|
||||||
별도 기대값·허용 목록·버전된 fixture로 명시하며 테스트를 끄거나 Ref 기대값에
|
|
||||||
되맞추지 않습니다. 분류·호출 순서·소유권·계측의 공통 요구는 상위 호환성 계약을
|
기능을 변경하기 전에 다음을 end-to-end로 연결해 주세요.
|
||||||
따릅니다.
|
|
||||||
|
1. ref entry point와 호출 순서
|
||||||
|
2. PHP domain class, SQL read/write, template/CSS/JS와 사용자 출력
|
||||||
|
3. core service/router/action/loader/flush와 frontend 호출부
|
||||||
|
4. session/auth, validation, transaction, 비동기 경계와 오류 복구
|
||||||
|
5. RNG 생성·소비와 persistence 순서
|
||||||
|
|
||||||
|
`../docs/ref-core2026-mapping.md`에는 1:1 대응을 억지로 만들지 말고 1:N/N:1
|
||||||
|
소유권과 변환 지점을 기록해 주세요. `확인`, `부분 확인`, `가설`, `미구현`,
|
||||||
|
`의도적 차이`를 근거와 함께 사용해 주세요. 문서와 코드가 다르면 먼저 양쪽 기준
|
||||||
|
commit과 실제 실행을 확인하고 사실관계를 고쳐 주세요.
|
||||||
|
|
||||||
|
Ref 비교 전에 대상 계약도 구분해 주세요.
|
||||||
|
|
||||||
|
- 별도 제품 결정이 없는 기존 기능은 **계승 계약**으로 보고 사용자 목적, 도메인
|
||||||
|
의미, 권한·소유권, 상태 수명주기와 관찰 가능한 결과를 비교합니다.
|
||||||
|
- 신규 기능·밸런스·안전·운영·UX 정책은 **의도적 제품 차이**로 기록하고 목적,
|
||||||
|
적용 버전과 범위, 상태·후속 턴 영향, migration/호환 경계와 Core 회귀 테스트를
|
||||||
|
둡니다. Ref와 다른 값이 나오는 것 자체는 실패가 아닙니다.
|
||||||
|
- 제품 결정 없이 기존 개념이나 정보가 사라진 경우는 **누락·회귀**, Ref에 대응
|
||||||
|
개념이 없는 기능은 **비교 불가**입니다. 후자는 자체 불변조건으로 검증합니다.
|
||||||
|
|
||||||
|
차등 테스트는 차이를 감지하는 도구이지 제품 정책의 판정자가 아닙니다. 계승
|
||||||
|
계약에만 exact equality를 요구하고, 의도적 차이는 별도 기대값·허용 목록·버전된
|
||||||
|
fixture로 명시하여 무작정 테스트를 끄거나 Ref 기대값에 되맞추지 마세요.
|
||||||
|
|
||||||
|
ref 계측이 필요하면 다음을 지켜 주세요.
|
||||||
|
|
||||||
|
- `devel`에 test, endpoint, fixture나 debug 코드를 commit하지 말아 주세요.
|
||||||
|
- 기존 `ng_compare` 이력과 분기 기준을 조사하고 reset/overwrite하지 말아 주세요.
|
||||||
|
- 계측은 최소·deterministic·가능하면 read-only로 두고 test 환경 guard를
|
||||||
|
사용해 주세요.
|
||||||
|
- 계측 유무가 결과, RNG 소비, DB mutation과 출력 순서를 바꾸지 않는지
|
||||||
|
확인해 주세요.
|
||||||
|
- ref와 core 변경은 서로 다른 저장소와 commit으로 관리해 주세요.
|
||||||
|
|
||||||
|
## 계승 계약의 호환성 우선순위
|
||||||
|
|
||||||
|
1. 전투 결과, 계산식, 판정·반올림·정렬과 RNG 소비 순서
|
||||||
|
2. 턴/명령 조건, 자원 변화, DB 상태 전이와 권한
|
||||||
|
3. API 요청·응답·오류, session과 인증
|
||||||
|
4. 문구, 로그, 화면 흐름과 룩앤필
|
||||||
|
5. 내부 구현 세부사항
|
||||||
|
|
||||||
|
레거시의 이상해 보이는 동작도 계승 계약일 수 있습니다. 보안 또는 데이터 손상
|
||||||
|
위험이 아니면 먼저 재현하고 제품 변경은 분리해 주세요. 반대로 승인·출시된 Core
|
||||||
|
차이는 Ref 결과만을 이유로 되돌리지 않습니다. 의도적 차이는 사용자 경험·저장
|
||||||
|
상태·후속 턴에 영향을 줄 수 있으므로 그 영향과 전환 경계를 mapping/report에
|
||||||
|
명시합니다. 보안, 데이터 무결성, 인증 actor 권한과 운영 안전은 신규 기능이라는
|
||||||
|
이름만으로 완화하지 않습니다.
|
||||||
|
|
||||||
## 인증과 권한
|
## 인증과 권한
|
||||||
|
|
||||||
@@ -103,18 +182,18 @@ API request
|
|||||||
|
|
||||||
게임 난수는 `packages/common/src/util/LiteHashDRBG.ts`, `RNG.ts`,
|
게임 난수는 `packages/common/src/util/LiteHashDRBG.ts`, `RNG.ts`,
|
||||||
`RandUtil.ts` 흐름을 사용해 주세요. `Math.random()` 같은 임의 경로를 gameplay에
|
`RandUtil.ts` 흐름을 사용해 주세요. `Math.random()` 같은 임의 경로를 gameplay에
|
||||||
추가하지 말아 주세요. 계승 계약에서는 후보가 하나뿐인 선택, 실패 분기, fallback에서도 Ref가
|
추가하지 말아 주세요. 후보가 하나뿐인 선택, 실패 분기, fallback에서도 ref가
|
||||||
소비하는 RNG call을 생략하지 말아 주세요.
|
소비하는 RNG call을 생략하지 말아 주세요.
|
||||||
|
|
||||||
전투/RNG 변경에는 fixed seed 결과뿐 아니라 RNG trace, 로그, 전체 state
|
전투/RNG 변경에는 fixed seed 결과뿐 아니라 RNG trace, 로그, 전체 state
|
||||||
side effect와 실패 경로를 검증합니다. 계승 계약은 Ref와 비교하고 의도적 제품
|
side effect와 실패 경로의 ref 비교가 필요합니다. comparator ignore를 늘리기
|
||||||
차이·Core 전용 기능은 문서화한 Core 기대값과 불변조건을 사용합니다. comparator ignore를 늘리기
|
|
||||||
전에 canonical snapshot에서 누락된 field가 없는지 확인해 주세요.
|
전에 canonical snapshot에서 누락된 field가 없는지 확인해 주세요.
|
||||||
|
|
||||||
## 프론트엔드와 룩앤필
|
## 프론트엔드와 룩앤필
|
||||||
|
|
||||||
UI의 공통 보존·Chromium·artifact 요구는 [상위 UI 지침](../AGENTS.md#ui와-룩앤필)을
|
새 디자인 시스템이나 현대적 재해석을 임의로 도입하지 말아 주세요. 보존 범위는
|
||||||
따릅니다. 의도적 UX 차이는 Core geometry·interaction 기대값을 명시합니다.
|
layout, font, line-height, 줄바꿈, 색상, texture, border, shadow, opacity,
|
||||||
|
이미지 natural size/aspect ratio/object-fit과 표시 순서입니다.
|
||||||
|
|
||||||
- gateway `/gateway/`, game `/che/`와 `/hwe/` prefix에서 direct navigation,
|
- gateway `/gateway/`, game `/che/`와 `/hwe/` prefix에서 direct navigation,
|
||||||
refresh, tRPC, SSE와 asset URL을 확인해 주세요.
|
refresh, tRPC, SSE와 asset URL을 확인해 주세요.
|
||||||
@@ -131,10 +210,20 @@ UI의 공통 보존·Chromium·artifact 요구는 [상위 UI 지침](../AGENTS.m
|
|||||||
- `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을
|
- `v-html`은 입력 source와 sanitization/allowlist를 확인해 주세요. 기존 warning을
|
||||||
일괄 disable하지 말아 주세요.
|
일괄 disable하지 말아 주세요.
|
||||||
|
|
||||||
Chromium 비교는 같은 locale과 font도 맞추고 dropdown·modal을 실제로 열어
|
UI를 변경하면 실제 Chromium을 사용해 주세요.
|
||||||
확인합니다. auth redirect만 확인하고 visual parity로 보고하지 않습니다.
|
|
||||||
|
- ref와 core에 같은 Chromium, viewport, device scale, zoom, locale, font,
|
||||||
|
image와 로그인/test data를 사용해 주세요.
|
||||||
|
- 전체·영역 screenshot과 `getBoundingClientRect()`,
|
||||||
|
`getComputedStyle()`을 수집해 주세요.
|
||||||
|
- `hover()`, focus, pointer down/up, checked/selected/disabled, dropdown,
|
||||||
|
modal과 transition 상태를 실제 interaction으로 만들어 주세요.
|
||||||
|
- pixel diff mask는 시간·난수 등 불가피한 영역만 최소화하고 이유를 기록해 주세요.
|
||||||
|
- auth redirect만 확인하고 visual parity라고 주장하지 말아 주세요.
|
||||||
|
- 민감정보가 보이는 artifact는 저장하거나 report에 넣지 말아 주세요.
|
||||||
|
|
||||||
CSS layer와 selector 경계는 `docs/frontend-css-architecture.md`, 비교 실행은
|
CSS layer와 selector 경계는 `docs/frontend-css-architecture.md`, 비교 실행은
|
||||||
`docs/frontend-legacy-parity.md`를 따릅니다.
|
`docs/frontend-legacy-parity.md`를 따라 주세요.
|
||||||
|
|
||||||
## DB와 레거시 데이터 이관
|
## DB와 레거시 데이터 이관
|
||||||
|
|
||||||
@@ -174,29 +263,94 @@ schema 변경 후 최소한 다음을 위험도에 맞게 확인해 주세요.
|
|||||||
worktree가 같은 instance를 공유하지 않도록 해 주세요. volume 삭제 명령은 사용자가
|
worktree가 같은 instance를 공유하지 않도록 해 주세요. volume 삭제 명령은 사용자가
|
||||||
명시적으로 데이터 폐기를 요청하지 않으면 실행하지 말아 주세요.
|
명시적으로 데이터 폐기를 요청하지 않으면 실행하지 말아 주세요.
|
||||||
|
|
||||||
컨테이너 서비스는 필요한 listener를 `0.0.0.0`에 bind합니다. 로컬 E2E의
|
현재 외부 호스트 계약은 `0.0.0.0` bind와 `/gateway/`, `/che/`, `/hwe/`
|
||||||
`/gateway/`, `/che/`, `/hwe/`와 운영 stack의 활성 prefix를 혼동하지 않습니다.
|
prefix입니다. Caddy는 외부 인프라로 취급하고 요청 없이 설정 변경을 전제하지
|
||||||
대상 선택은 상위 환경 라우팅, frontend/API 변수와 preview 명령은
|
않습니다. local preview/mock 성공을 외부 HTTPS 성공으로 보고하지 말아 주세요.
|
||||||
`docs/e2e-caddy-routing.md`를 확인합니다. local preview/mock 성공은 외부
|
|
||||||
HTTPS 검증과 구분합니다.
|
Gateway preview를 검증할 때 최소 계약은 다음과 같습니다.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
VITE_APP_BASE_PATH=/gateway \
|
||||||
|
VITE_GATEWAY_API_URL=/gateway/api/trpc \
|
||||||
|
VITE_GAME_API_URL_TEMPLATE='/{profile}/api/trpc' \
|
||||||
|
VITE_GAME_WEB_URL_TEMPLATE='/{profile}/' \
|
||||||
|
pnpm --filter @sammo-ts/gateway-frontend build
|
||||||
|
```
|
||||||
|
|
||||||
|
profile별 정확한 포트와 game frontend/API 변수는
|
||||||
|
`docs/e2e-caddy-routing.md`에서 확인하고 현재 인프라와 대조해 주세요.
|
||||||
|
|
||||||
## 개발과 검증 절차
|
## 개발과 검증 절차
|
||||||
|
|
||||||
상위 [작업 흐름과 검증](../AGENTS.md#작업-흐름과-검증)에 따라 변경 계약에 가까운
|
1. 현재 branch/status/remote, 사용자 변경과 가까운 지침을 확인해 주세요.
|
||||||
검증부터 실행하고, 명령·결과·skip·baseline 실패와 미검증 범위를 보고합니다.
|
2. 관련 mapping/report와 ref/core 호출 경로를 조사해 주세요.
|
||||||
명령 목록·간단 출력 정책·새 worktree 준비·Playwright port 격리는
|
3. 보존할 계약, 허용할 차이와 검증 범위를 먼저 정합니다.
|
||||||
[테스트 정책](docs/testing-policy.md)을 기준으로 합니다.
|
4. 코드와 함께 type/schema/migration/fixture/mapping을 갱신해 주세요.
|
||||||
|
5. 좁은 단위 test → typecheck/lint/build → DB integration → ref 차등 →
|
||||||
|
Chromium E2E 순으로 위험에 맞게 넓혀 주세요.
|
||||||
|
6. 명령, 결과, skip/미검증과 baseline failure를 분리해 report에 기록해 주세요.
|
||||||
|
7. 양쪽 Git diff/status와 필요한 ancestry를 다시 확인해 주세요.
|
||||||
|
|
||||||
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행합니다.
|
기본 정적 검사는 실제 루트 script를 사용해 주세요.
|
||||||
- package import나 파일 위치 변경 후 `pnpm check:architecture`를 실행합니다.
|
|
||||||
`packages/logic`의 runtime I/O는 `ports/`와 app/infra adapter로 분리합니다.
|
```sh
|
||||||
- Vitest filter에 불필요한 `--`를 넣지 않습니다.
|
CI=1 pnpm typecheck
|
||||||
|
pnpm check:architecture
|
||||||
|
pnpm lint
|
||||||
|
pnpm test
|
||||||
|
pnpm build
|
||||||
|
```
|
||||||
|
|
||||||
|
루트 `pnpm test`와 `pnpm build`는 간단 출력이 기본입니다. Codex도 일반 검증에는
|
||||||
|
이 명령을 사용하고, 실패 진단이나 전체 task 진행이 필요한 경우에만
|
||||||
|
`pnpm test-verbose`, `pnpm build-verbose`를 사용해 주세요. 서버 빌드 워커의
|
||||||
|
streaming 상세 로그는 관리자 관찰 계약이므로 간단 출력으로 바꾸지 말아 주세요.
|
||||||
|
|
||||||
|
- 모든 코드 변경 후 `CI=1 pnpm typecheck`를 실행해 주세요.
|
||||||
|
- package import나 파일 위치를 변경한 뒤 `pnpm check:architecture`를 실행해
|
||||||
|
주세요. `packages/logic`의 runtime I/O는 `ports/` interface와 app/infra
|
||||||
|
adapter로 분리합니다.
|
||||||
|
- `pnpm test`의 skip 수를 pass처럼 보고하지 말아 주세요.
|
||||||
|
- Vitest file/name filter는 package script 뒤에 불필요한 `--`를 넣지 말아 주세요.
|
||||||
예: `pnpm --filter @sammo-ts/game-engine test monthlyCoreEventHandler.test.ts`
|
예: `pnpm --filter @sammo-ts/game-engine test monthlyCoreEventHandler.test.ts`
|
||||||
- frontend package의 `test` placeholder는 UI 검증이 아닙니다. 해당 Playwright
|
- frontend package의 `test` placeholder를 실제 UI 검증으로 오해하지 말아 주세요.
|
||||||
script 또는 legacy parity suite를 실행합니다.
|
해당 Playwright script 또는 legacy parity suite를 사용해 주세요.
|
||||||
- ref checkout·Docker·DB·secret 요구는 해당 실행 문서에서 확인합니다.
|
- 새 worktree에서 direct Playwright를 실행하기 전에 현재 호스트의 `fnm use`
|
||||||
오래된 report만 보고 명령을 가정하지 않습니다.
|
(nvm 환경은 `nvm use`) 후
|
||||||
- 문서만 변경할 때의 검증도 테스트 정책을 따릅니다.
|
`pnpm test:bootstrap`을 실행해 install, Prisma client와
|
||||||
|
common → logic → infra → game-engine → API 산출물을 준비해 주세요. 이미
|
||||||
|
install된 checkout은 `pnpm test:prepare`를 사용합니다. Playwright
|
||||||
|
`webServer` 실패를 이 준비가 끝나기 전 제품 회귀로 분류하지 말아 주세요.
|
||||||
|
- E2E port가 다른 worktree와 겹치면 설정 파일을 임시 수정하지 말고
|
||||||
|
`PLAYWRIGHT_FRONTEND_PORT`, `PLAYWRIGHT_GATEWAY_FRONTEND_PORT` 또는
|
||||||
|
`FRONTEND_PARITY_*_PORT`를 지정합니다. 같은 game frontend `dist`를 쓰는
|
||||||
|
profile production run은 직렬로 실행합니다.
|
||||||
|
- 전체 lint/test의 기존 실패가 있으면 targeted 결과와 baseline 재현 결과를
|
||||||
|
구분하고, 관련 없는 기대값을 완화해 숨기지 말아 주세요.
|
||||||
|
|
||||||
|
외부 서비스 없는 기본 integration:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm test:integration
|
||||||
|
```
|
||||||
|
|
||||||
|
전용 PostgreSQL/Redis를 준비한 조건부 전체 경계:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm test:integration:conditional
|
||||||
|
```
|
||||||
|
|
||||||
|
ref 명령과 UI entry point:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
pnpm check:legacy:general
|
||||||
|
pnpm check:legacy:nation
|
||||||
|
pnpm test:e2e:frontend-legacy
|
||||||
|
```
|
||||||
|
|
||||||
|
각 command가 요구하는 ref checkout, Docker, DB URL, secret과 fixture는 관련
|
||||||
|
docs에서 확인해 주세요. 존재하지 않는 명령을 오래된 report나 제안 문서만 보고
|
||||||
|
실행하지 말아 주세요.
|
||||||
|
|
||||||
## 코드 스타일
|
## 코드 스타일
|
||||||
|
|
||||||
@@ -220,22 +374,42 @@ HTTPS 검증과 구분합니다.
|
|||||||
|
|
||||||
## 문서와 보고서
|
## 문서와 보고서
|
||||||
|
|
||||||
변경이 영향을 주는 문서만 같은 작업에서 갱신합니다. 구조·시작점은 README,
|
기능·운영·호환성에 의미 있는 변경은 코드와 같은 작업에서 다음을 갱신해 주세요.
|
||||||
반복 규칙은 AGENTS, 구현·운영 상세는 `docs/`, Ref 대응은 상위 매핑이 소유합니다.
|
|
||||||
모든 기능 변경에서 README와 AGENTS까지 기계적으로 수정하지 않습니다.
|
|
||||||
|
|
||||||
보고서의 필수 내용과 저장소별 commit 기록은
|
- `README.md`: 사용자가 알아야 할 현재 구조, 시작점과 운영 경계
|
||||||
[상위 보고서 규칙](../AGENTS.md#보고서와-commit)을 따릅니다.
|
- 이 `AGENTS.md`: 반복 작업 규칙과 검증 계약
|
||||||
|
- `docs/*`: core2026 내부 구현·운영 상세
|
||||||
|
- `../docs/ref-core2026-mapping.md`: ref↔core 근거와 상태
|
||||||
|
- `../report/YYYY-MM-DD-간결한-작업명.md`: 재현 가능한 인수인계
|
||||||
|
|
||||||
|
보고서에는 목적/범위, 조사한 ref, 변경 파일, 보존 계약, 명령과 결과,
|
||||||
|
skip/미검증, 알려진 차이, 후속 작업과 저장소별 commit을 포함합니다. commit
|
||||||
|
전이면 `커밋 전`으로 기록하고 서로 다른 저장소의 hash를 명확히 구분해 주세요.
|
||||||
|
|
||||||
## Git, worktree와 commit
|
## Git, worktree와 commit
|
||||||
|
|
||||||
[상위 Git 규칙](../AGENTS.md#저장소와-git-경계)과
|
- 중·장기 작업은 전용 branch/worktree와 고유 DB/Redis instance에서 합니다.
|
||||||
[기본 commit 정책](../AGENTS.md#보고서와-commit)을 따릅니다. 일반 작업은
|
- 최신 `main`을 통합하고 회귀를 확인한 뒤 요청된 범위에 따라 local `main`에
|
||||||
충돌 없는 현재 checkout에서 수행하고, 완료한 관련 변경은 사용자가 금지하지
|
병합해 주세요. 원격 push는 별도 요청 없이는 하지 말아 주세요.
|
||||||
않았다면 한국어 설명으로 commit합니다. push·merge·배포는 요청된 범위에 한합니다.
|
- 관련 없는 사용자 변경을 stage/commit/revert하지 말아 주세요.
|
||||||
|
- ref 비교 변경과 core 제품 변경, 상위 mapping/report 변경은 각 Git
|
||||||
|
저장소에서 별도 commit해 주세요.
|
||||||
|
- 생성물, `.env`, DB volume, log, coverage, screenshot, test-results와
|
||||||
|
secret을 commit하지 말아 주세요.
|
||||||
|
- worktree 정리는 clean status와
|
||||||
|
`git merge-base --is-ancestor HEAD <baseline>`을 모두 확인한 뒤 non-force
|
||||||
|
제거해 주세요. 이름, 나이 또는 uncommitted 여부만으로 삭제하지 말아 주세요.
|
||||||
|
- 사용자가 commit을 요청하지 않은 일반 작업은 diff와 권장 commit 경계를
|
||||||
|
준비하되 임의로 commit하지 말아 주세요.
|
||||||
|
|
||||||
Core 제품, Ref 계측, 상위 문서는 각 저장소에서 별도 commit합니다. 생성물,
|
## Qwen 보조 분석
|
||||||
환경·secret, DB volume, log, coverage, screenshot과 test-results는 제외합니다.
|
|
||||||
worktree 정리가 요청 범위라면 clean status와
|
Qwen은 대규모 파일 목록, diff·로그 분류, 반복 추출과 누락 후보 재검색에만
|
||||||
`git merge-base --is-ancestor HEAD <baseline>`을 확인한 뒤 non-force로 제거합니다.
|
사용해 주세요. 필요한 최소 발췌만 전달하고 secret, 개인정보와 환경 파일 값을
|
||||||
이름·나이·uncommitted 여부만으로 삭제하지 않습니다.
|
보내지 말아 주세요.
|
||||||
|
|
||||||
|
- 아키텍처·보안·DB mutation·전투/RNG 판단과 최종 검증을 위임하지 말아 주세요.
|
||||||
|
- 결과는 파일, Git, test 또는 실제 trace로 독립 검증해 주세요.
|
||||||
|
- 응답 budget은 128–4096 token 범위로 제한해 주세요.
|
||||||
|
- 실패하면 health 확인 후 일시 오류일 때 한 번만 재시도하고, 선택적 작업이면
|
||||||
|
로컬 분석을 계속해 주세요.
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
# 삼국지 모의전투 HiDCHe core2026
|
# 삼국지 모의전투 HiDCHe core2026
|
||||||
|
|
||||||
`core2026`은 PHP 기준 구현 `../ref/sam`에서 출발해 0.1.0 이후 독립적으로
|
`core2026`은 `../ref/sam`의 PHP 서비스를 TypeScript로 호환 이관하는 pnpm
|
||||||
발전하는 TypeScript pnpm 모노레포입니다. 계승 영역은 Ref의 동작·상태·권한과
|
모노레포입니다. 전투·턴·권한·저장 상태·API·화면 동작은 ref 구현과 실제 실행
|
||||||
비교하고, 명시적인 신규 기능·밸런스·UX는 Core 계약으로 검증합니다.
|
결과를 기준으로 검증합니다.
|
||||||
작업 규칙과 분야별 지침의 시작점은 [AGENTS.md](AGENTS.md)입니다.
|
|
||||||
|
|
||||||
## 저장소 구성
|
## 저장소 구성
|
||||||
|
|
||||||
@@ -253,11 +252,9 @@ Game과 gateway가 같은 database/schema를 쓰므로 deploy 순서는
|
|||||||
`game → gateway`입니다. 양쪽 migration directory 이름은 공유
|
`game → gateway`입니다. 양쪽 migration directory 이름은 공유
|
||||||
`_prisma_migrations`에서 충돌하지 않도록 전역적으로 고유해야 합니다.
|
`_prisma_migrations`에서 충돌하지 않도록 전역적으로 고유해야 합니다.
|
||||||
|
|
||||||
활성 prefix는 배포 환경별로 다릅니다. 로컬 E2E의 `/gateway/`, `/che/`,
|
활성 외부 prefix는 `/gateway/`, `/che/`, `/hwe/`입니다. 앱은 필요한
|
||||||
`/hwe/`를 운영 전체의 목록으로 해석하지 않습니다. 대상 host·stack은
|
listener를 `0.0.0.0`에 bind하고 prefix를 보존한 frontend, tRPC, SSE,
|
||||||
[환경 라우팅](../docs/docker-environment-routing.md), 앱의 listener·frontend·
|
direct-navigation URL을 사용합니다. `/image/*`는 외부 Caddy가 소유합니다.
|
||||||
tRPC·SSE·direct-navigation 설정은 [Caddy 계약](docs/e2e-caddy-routing.md)을
|
|
||||||
따릅니다. 이미지 제공은 외부 서비스의 소유 경계입니다.
|
|
||||||
|
|
||||||
`build:server`는 profile resource를 `dist/<profile>`에 복사하는 도구입니다.
|
`build:server`는 profile resource를 `dist/<profile>`에 복사하는 도구입니다.
|
||||||
완전한 API·daemon·frontend 배포 bundle은 gateway orchestrator의
|
완전한 API·daemon·frontend 배포 bundle은 gateway orchestrator의
|
||||||
|
|||||||
@@ -26,7 +26,7 @@ export const openAuctionWithDaemon = async (
|
|||||||
generalId: number,
|
generalId: number,
|
||||||
input: OpenAuctionInput,
|
input: OpenAuctionInput,
|
||||||
requestId?: string
|
requestId?: string
|
||||||
): Promise<{ auctionId: number; closeAt: string; closeTick: number }> => {
|
): Promise<{ auctionId: number; closeAt: string }> => {
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'auctionOpen',
|
type: 'auctionOpen',
|
||||||
...(requestId ? { requestId } : {}),
|
...(requestId ? { requestId } : {}),
|
||||||
@@ -46,11 +46,10 @@ export const openAuctionWithDaemon = async (
|
|||||||
const closeAt = new Date(result.closeAt);
|
const closeAt = new Date(result.closeAt);
|
||||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||||
{ score: resolveAuctionTimerScore(gameTime, closeAt, BigInt(result.closeTick)), value: String(result.auctionId) },
|
{ score: resolveAuctionTimerScore(gameTime, closeAt), value: String(result.auctionId) },
|
||||||
]);
|
]);
|
||||||
return {
|
return {
|
||||||
auctionId: result.auctionId,
|
auctionId: result.auctionId,
|
||||||
closeAt: result.closeAt,
|
closeAt: result.closeAt,
|
||||||
closeTick: result.closeTick,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -10,19 +10,18 @@ interface RedisSortedSetClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => {
|
export const resolveAuctionTimerScore = (time: CurrentGameTime, closeAt: Date, closeTick?: bigint | null): number => {
|
||||||
void time;
|
if (closeTick !== null && closeTick !== undefined) {
|
||||||
void closeAt;
|
const value = Number(closeTick);
|
||||||
if (closeTick === null || closeTick === undefined) throw new Error('Auction close tick is required.');
|
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
||||||
const value = Number(closeTick);
|
return value;
|
||||||
if (!Number.isSafeInteger(value)) throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
}
|
||||||
return value;
|
return time.dateToTick(closeAt) ?? closeAt.getTime();
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => {
|
export const resolveAuctionSeedScore = (time: CurrentGameTime, row: AuctionTimerRow): number => {
|
||||||
if (row.status === 'FINALIZING') {
|
if (row.status === 'FINALIZING') {
|
||||||
// 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다.
|
// 마감 판정은 이미 끝났으므로 원래 deadline을 기다리지 않고 durable event 복구를 즉시 재시도한다.
|
||||||
if (time.tick === null) throw new Error('Current game tick is required for auction recovery.');
|
return time.tick ?? time.now.getTime();
|
||||||
return time.tick;
|
|
||||||
}
|
}
|
||||||
return resolveAuctionTimerScore(time, row.closeAt, row.closeTick);
|
return resolveAuctionTimerScore(time, row.closeAt, row.closeTick);
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,7 +1,4 @@
|
|||||||
import {
|
import {
|
||||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
|
||||||
GamePrisma,
|
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
|
||||||
createGamePostgresConnector,
|
createGamePostgresConnector,
|
||||||
createRedisConnector,
|
createRedisConnector,
|
||||||
type GamePrismaClient,
|
type GamePrismaClient,
|
||||||
@@ -13,7 +10,6 @@ import { resolveGameApiConfigFromEnv } from '../config.js';
|
|||||||
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
|
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
|
||||||
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
|
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
|
||||||
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
|
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
|
||||||
import { ensureActiveRedisClockFence } from '../services/redisClockFence.js';
|
|
||||||
import { buildAuctionTimerKeys } from './keys.js';
|
import { buildAuctionTimerKeys } from './keys.js';
|
||||||
import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js';
|
import { resolveAuctionTimerScore, seedAuctionTimers } from './scheduler.js';
|
||||||
|
|
||||||
@@ -28,27 +24,13 @@ interface RedisTimerClient {
|
|||||||
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
|
zAdd(key: string, values: Array<{ score: number; value: string }>): Promise<number>;
|
||||||
zRem(key: string, values: string | string[]): Promise<number>;
|
zRem(key: string, values: string | string[]): Promise<number>;
|
||||||
zRemRangeByScore(key: string, min: number, max: number): Promise<number>;
|
zRemRangeByScore(key: string, min: number, max: number): Promise<number>;
|
||||||
eval?(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const POP_DUE_AUCTIONS_SCRIPT = `
|
|
||||||
if redis.call('GET', KEYS[2]) ~= ARGV[1]
|
|
||||||
or redis.call('GET', KEYS[3]) ~= ARGV[2]
|
|
||||||
or redis.call('GET', KEYS[4]) ~= 'RUNNING' then
|
|
||||||
return { '__CLOCK_FENCE__' }
|
|
||||||
end
|
|
||||||
local ids = redis.call('ZRANGEBYSCORE', KEYS[1], '-inf', ARGV[3], 'LIMIT', 0, ARGV[4])
|
|
||||||
if #ids > 0 then
|
|
||||||
redis.call('ZREM', KEYS[1], unpack(ids))
|
|
||||||
end
|
|
||||||
return ids
|
|
||||||
`;
|
|
||||||
|
|
||||||
const AUCTION_FINALIZE_RECOVERY_LIMIT = 1;
|
const AUCTION_FINALIZE_RECOVERY_LIMIT = 1;
|
||||||
|
|
||||||
interface AuctionFinalizeDeadline {
|
interface AuctionFinalizeDeadline {
|
||||||
closeAt: Date;
|
closeAt: Date;
|
||||||
closeTick: bigint;
|
closeTick: bigint | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AuctionFinalizeCommand {
|
interface AuctionFinalizeCommand {
|
||||||
@@ -56,10 +38,19 @@ interface AuctionFinalizeCommand {
|
|||||||
requestId: string;
|
requestId: string;
|
||||||
auctionId: number;
|
auctionId: number;
|
||||||
expectedCloseAt: string;
|
expectedCloseAt: string;
|
||||||
expectedCloseTick: number;
|
expectedCloseTick?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
const readSafeCloseTick = (closeTick: bigint): number => {
|
interface AuctionFinalizeEventRecord {
|
||||||
|
target: string;
|
||||||
|
eventType: string;
|
||||||
|
payload: unknown;
|
||||||
|
status: string;
|
||||||
|
result: unknown;
|
||||||
|
}
|
||||||
|
|
||||||
|
const readSafeCloseTick = (closeTick: bigint | null): number | undefined => {
|
||||||
|
if (closeTick === null) return undefined;
|
||||||
const value = Number(closeTick);
|
const value = Number(closeTick);
|
||||||
if (!Number.isSafeInteger(value)) {
|
if (!Number.isSafeInteger(value)) {
|
||||||
throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
throw new Error(`Auction close tick is unsafe: ${closeTick}`);
|
||||||
@@ -72,7 +63,14 @@ export const buildAuctionFinalizeRequestId = (
|
|||||||
deadline: AuctionFinalizeDeadline,
|
deadline: AuctionFinalizeDeadline,
|
||||||
retry = 0
|
retry = 0
|
||||||
): string => {
|
): string => {
|
||||||
const base = `auction:finalize:${auctionId}:tick:${deadline.closeTick.toString()}`;
|
const generation =
|
||||||
|
deadline.closeTick === null ? deadline.closeAt.getTime().toString() : `tick:${deadline.closeTick.toString()}`;
|
||||||
|
const base = `auction:finalize:${auctionId}:${generation}`;
|
||||||
|
return retry > 0 ? `${base}:retry:${retry}` : base;
|
||||||
|
};
|
||||||
|
|
||||||
|
const buildLegacyAuctionFinalizeRequestId = (auctionId: number, closeAt: Date, retry = 0): string => {
|
||||||
|
const base = `auction:finalize:${auctionId}:${closeAt.getTime()}`;
|
||||||
return retry > 0 ? `${base}:retry:${retry}` : base;
|
return retry > 0 ? `${base}:retry:${retry}` : base;
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -85,7 +83,7 @@ const buildAuctionFinalizeCommand = (
|
|||||||
requestId,
|
requestId,
|
||||||
auctionId,
|
auctionId,
|
||||||
expectedCloseAt: deadline.closeAt.toISOString(),
|
expectedCloseAt: deadline.closeAt.toISOString(),
|
||||||
expectedCloseTick: readSafeCloseTick(deadline.closeTick),
|
...(deadline.closeTick === null ? {} : { expectedCloseTick: readSafeCloseTick(deadline.closeTick) }),
|
||||||
});
|
});
|
||||||
|
|
||||||
const isMatchingAuctionFinalizeEvent = (
|
const isMatchingAuctionFinalizeEvent = (
|
||||||
@@ -97,7 +95,10 @@ const isMatchingAuctionFinalizeEvent = (
|
|||||||
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
|
payload !== null && typeof payload === 'object' && !Array.isArray(payload)
|
||||||
? (payload as Record<string, unknown>)
|
? (payload as Record<string, unknown>)
|
||||||
: null;
|
: null;
|
||||||
const expectedGenerationMatches = payloadRecord?.expectedCloseTick === command.expectedCloseTick;
|
const expectedGenerationMatches =
|
||||||
|
payloadRecord?.expectedCloseTick !== undefined
|
||||||
|
? payloadRecord.expectedCloseTick === command.expectedCloseTick
|
||||||
|
: payloadRecord?.expectedCloseAt === undefined || payloadRecord.expectedCloseAt === command.expectedCloseAt;
|
||||||
return (
|
return (
|
||||||
event.target === 'ENGINE' &&
|
event.target === 'ENGINE' &&
|
||||||
event.eventType === command.type &&
|
event.eventType === command.type &&
|
||||||
@@ -114,29 +115,12 @@ const isSuccessfulAuctionFinalizeResult = (result: unknown, auctionId: number):
|
|||||||
return resultRecord.type === 'auctionFinalize' && resultRecord.ok === true && resultRecord.auctionId === auctionId;
|
return resultRecord.type === 'auctionFinalize' && resultRecord.ok === true && resultRecord.auctionId === auctionId;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const popDueAuctionIds = async (
|
const popDueAuctionIds = async (
|
||||||
redis: RedisTimerClient,
|
redis: RedisTimerClient,
|
||||||
timerKey: string,
|
timerKey: string,
|
||||||
nowMs: number,
|
nowMs: number,
|
||||||
batchSize: number,
|
batchSize: number
|
||||||
clockFence?: {
|
|
||||||
activeRevisionKey: string;
|
|
||||||
deadlineGenerationKey: string;
|
|
||||||
phaseKey: string;
|
|
||||||
revision: number;
|
|
||||||
generation: number;
|
|
||||||
}
|
|
||||||
): Promise<string[]> => {
|
): Promise<string[]> => {
|
||||||
if (clockFence) {
|
|
||||||
if (!redis.eval) throw new Error('Redis EVAL is required for revision-fenced auction due-pop.');
|
|
||||||
const result = await redis.eval(POP_DUE_AUCTIONS_SCRIPT, {
|
|
||||||
keys: [timerKey, clockFence.activeRevisionKey, clockFence.deadlineGenerationKey, clockFence.phaseKey],
|
|
||||||
arguments: [String(clockFence.revision), String(clockFence.generation), String(nowMs), String(batchSize)],
|
|
||||||
});
|
|
||||||
if (!Array.isArray(result)) throw new Error('Auction due-pop returned an invalid Redis result.');
|
|
||||||
if (result[0] === '__CLOCK_FENCE__') return [];
|
|
||||||
return result.map(String);
|
|
||||||
}
|
|
||||||
const ids = await redis.zRangeByScore(timerKey, 0, nowMs, { LIMIT: { offset: 0, count: batchSize } });
|
const ids = await redis.zRangeByScore(timerKey, 0, nowMs, { LIMIT: { offset: 0, count: batchSize } });
|
||||||
if (ids.length > 0) {
|
if (ids.length > 0) {
|
||||||
await redis.zRem(timerKey, ids);
|
await redis.zRem(timerKey, ids);
|
||||||
@@ -173,12 +157,13 @@ export const reconcilePendingAuctionTimers = async (options: {
|
|||||||
if (row.status !== 'OPEN' && row.status !== 'FINALIZING') {
|
if (row.status !== 'OPEN' && row.status !== 'FINALIZING') {
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (row.closeTick === null) throw new Error(`Auction ${row.id} has no GAME_TIME close authority.`);
|
|
||||||
const deadline = { closeAt: row.closeAt, closeTick: row.closeTick };
|
const deadline = { closeAt: row.closeAt, closeTick: row.closeTick };
|
||||||
const canonicalBase = buildAuctionFinalizeRequestId(row.id, deadline);
|
const canonicalBase = buildAuctionFinalizeRequestId(row.id, deadline);
|
||||||
|
const legacyBase = buildLegacyAuctionFinalizeRequestId(row.id, row.closeAt);
|
||||||
|
const bases = [...new Set([canonicalBase, legacyBase])];
|
||||||
const events = await options.db.inputEvent.findMany({
|
const events = await options.db.inputEvent.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [{ requestId: canonicalBase }, { requestId: { startsWith: `${canonicalBase}:retry:` } }],
|
OR: bases.flatMap((base) => [{ requestId: base }, { requestId: { startsWith: `${base}:retry:` } }]),
|
||||||
},
|
},
|
||||||
select: { requestId: true, target: true, eventType: true, payload: true, status: true },
|
select: { requestId: true, target: true, eventType: true, payload: true, status: true },
|
||||||
orderBy: { sequence: 'desc' },
|
orderBy: { sequence: 'desc' },
|
||||||
@@ -197,10 +182,7 @@ export const reconcilePendingAuctionTimers = async (options: {
|
|||||||
timers.push({
|
timers.push({
|
||||||
score:
|
score:
|
||||||
row.status === 'FINALIZING'
|
row.status === 'FINALIZING'
|
||||||
? (() => {
|
? (options.gameTime.tick ?? options.gameTime.now.getTime())
|
||||||
if (options.gameTime.tick === null) throw new Error('Current game tick is required.');
|
|
||||||
return options.gameTime.tick;
|
|
||||||
})()
|
|
||||||
: resolveAuctionTimerScore(options.gameTime, row.closeAt, row.closeTick),
|
: resolveAuctionTimerScore(options.gameTime, row.closeAt, row.closeTick),
|
||||||
value: String(row.id),
|
value: String(row.id),
|
||||||
});
|
});
|
||||||
@@ -223,8 +205,6 @@ export const processDueAuctionId = async (options: {
|
|||||||
nowMs: number;
|
nowMs: number;
|
||||||
nowTick?: number | null;
|
nowTick?: number | null;
|
||||||
historyNowMs?: number;
|
historyNowMs?: number;
|
||||||
expectedClockRevision?: number;
|
|
||||||
expectedDeadlineGeneration?: number;
|
|
||||||
}): Promise<'PENDING' | 'RESCHEDULED' | 'IGNORED'> => {
|
}): Promise<'PENDING' | 'RESCHEDULED' | 'IGNORED'> => {
|
||||||
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options;
|
const { db, redis, timerKey, historyKey, id, nowMs, nowTick = null, historyNowMs = nowMs } = options;
|
||||||
const auctionId = Number(id);
|
const auctionId = Number(id);
|
||||||
@@ -233,29 +213,6 @@ export const processDueAuctionId = async (options: {
|
|||||||
}
|
}
|
||||||
const now = new Date(nowMs);
|
const now = new Date(nowMs);
|
||||||
const outcome = await db.$transaction(async (transaction) => {
|
const outcome = await db.$transaction(async (transaction) => {
|
||||||
if (options.expectedClockRevision !== undefined || options.expectedDeadlineGeneration !== undefined) {
|
|
||||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
|
||||||
const [world] = await transaction.$queryRaw<
|
|
||||||
Array<{ clockPhase: string | null; clockRevision: bigint; deadlineGeneration: bigint }>
|
|
||||||
>(GamePrisma.sql`
|
|
||||||
SELECT
|
|
||||||
clock_phase AS "clockPhase",
|
|
||||||
clock_revision AS "clockRevision",
|
|
||||||
deadline_generation AS "deadlineGeneration"
|
|
||||||
FROM world_state
|
|
||||||
ORDER BY id ASC
|
|
||||||
LIMIT 1
|
|
||||||
FOR UPDATE
|
|
||||||
`);
|
|
||||||
if (
|
|
||||||
!world ||
|
|
||||||
world.clockPhase !== 'RUNNING' ||
|
|
||||||
world.clockRevision !== BigInt(options.expectedClockRevision ?? -1) ||
|
|
||||||
world.deadlineGeneration !== BigInt(options.expectedDeadlineGeneration ?? -1)
|
|
||||||
) {
|
|
||||||
return { status: 'RESCHEDULED' as const, clockFenceFailed: true };
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const current = await transaction.auction.findUnique({
|
const current = await transaction.auction.findUnique({
|
||||||
where: { id: auctionId },
|
where: { id: auctionId },
|
||||||
select: { status: true, closeAt: true, closeTick: true },
|
select: { status: true, closeAt: true, closeTick: true },
|
||||||
@@ -264,10 +221,10 @@ export const processDueAuctionId = async (options: {
|
|||||||
return { status: 'IGNORED' as const };
|
return { status: 'IGNORED' as const };
|
||||||
}
|
}
|
||||||
if (current.status === 'OPEN') {
|
if (current.status === 'OPEN') {
|
||||||
if (current.closeTick === null || nowTick === null) {
|
const isDue =
|
||||||
throw new Error(`Auction ${auctionId} cannot be evaluated without GAME_TIME authority.`);
|
current.closeTick !== null && nowTick !== null
|
||||||
}
|
? current.closeTick <= BigInt(nowTick)
|
||||||
const isDue = current.closeTick <= BigInt(nowTick);
|
: current.closeTick === null && current.closeAt.getTime() <= now.getTime();
|
||||||
if (!isDue) {
|
if (!isDue) {
|
||||||
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick };
|
return { status: 'RESCHEDULED' as const, closeAt: current.closeAt, closeTick: current.closeTick };
|
||||||
}
|
}
|
||||||
@@ -276,15 +233,24 @@ export const processDueAuctionId = async (options: {
|
|||||||
return { status: 'IGNORED' as const };
|
return { status: 'IGNORED' as const };
|
||||||
}
|
}
|
||||||
|
|
||||||
if (current.closeTick === null) throw new Error(`Auction ${auctionId} has no GAME_TIME close authority.`);
|
|
||||||
const deadline = { closeAt: current.closeAt, closeTick: current.closeTick };
|
const deadline = { closeAt: current.closeAt, closeTick: current.closeTick };
|
||||||
for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) {
|
for (let retry = 0; retry <= AUCTION_FINALIZE_RECOVERY_LIMIT; retry += 1) {
|
||||||
const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry);
|
const requestId = buildAuctionFinalizeRequestId(auctionId, deadline, retry);
|
||||||
const existing = await transaction.inputEvent.findUnique({
|
const legacyRequestId = buildLegacyAuctionFinalizeRequestId(auctionId, current.closeAt, retry);
|
||||||
where: { requestId },
|
const candidateRequestIds = [...new Set([requestId, legacyRequestId])];
|
||||||
select: { target: true, eventType: true, payload: true, status: true, result: true },
|
let existing: AuctionFinalizeEventRecord | null = null;
|
||||||
});
|
let existingRequestId = requestId;
|
||||||
const command = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
|
for (const candidateRequestId of candidateRequestIds) {
|
||||||
|
existing = await transaction.inputEvent.findUnique({
|
||||||
|
where: { requestId: candidateRequestId },
|
||||||
|
select: { target: true, eventType: true, payload: true, status: true, result: true },
|
||||||
|
});
|
||||||
|
if (existing) {
|
||||||
|
existingRequestId = candidateRequestId;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const command = buildAuctionFinalizeCommand(auctionId, deadline, existingRequestId);
|
||||||
if (!existing) {
|
if (!existing) {
|
||||||
const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
|
const nextCommand = buildAuctionFinalizeCommand(auctionId, deadline, requestId);
|
||||||
await transaction.inputEvent.create({
|
await transaction.inputEvent.create({
|
||||||
@@ -298,14 +264,14 @@ export const processDueAuctionId = async (options: {
|
|||||||
return { status: 'PENDING' as const };
|
return { status: 'PENDING' as const };
|
||||||
}
|
}
|
||||||
if (!isMatchingAuctionFinalizeEvent(existing, command)) {
|
if (!isMatchingAuctionFinalizeEvent(existing, command)) {
|
||||||
throw new Error(`Conflicting durable auction finalization event: ${requestId}`);
|
throw new Error(`Conflicting durable auction finalization event: ${existingRequestId}`);
|
||||||
}
|
}
|
||||||
if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
|
if (existing.status === 'PENDING' || existing.status === 'PROCESSING') {
|
||||||
return { status: 'PENDING' as const };
|
return { status: 'PENDING' as const };
|
||||||
}
|
}
|
||||||
if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) {
|
if (existing.status === 'SUCCEEDED' && isSuccessfulAuctionFinalizeResult(existing.result, auctionId)) {
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Auction remained ${current.status} after successful durable event: ${requestId}`
|
`Auction remained ${current.status} after successful durable event: ${existingRequestId}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -318,10 +284,6 @@ export const processDueAuctionId = async (options: {
|
|||||||
return 'PENDING';
|
return 'PENDING';
|
||||||
}
|
}
|
||||||
if (outcome.status === 'RESCHEDULED') {
|
if (outcome.status === 'RESCHEDULED') {
|
||||||
if ('clockFenceFailed' in outcome) {
|
|
||||||
await redis.zAdd(timerKey, [{ score: nowTick ?? nowMs, value: id }]);
|
|
||||||
return 'RESCHEDULED';
|
|
||||||
}
|
|
||||||
const gameTime = await loadCurrentGameTime(db, now);
|
const gameTime = await loadCurrentGameTime(db, now);
|
||||||
await redis.zAdd(timerKey, [
|
await redis.zAdd(timerKey, [
|
||||||
{
|
{
|
||||||
@@ -353,30 +315,18 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
|||||||
{ name: 'auction-worker-postgres', run: () => postgres.disconnect() },
|
{ name: 'auction-worker-postgres', run: () => postgres.disconnect() },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
let nextResyncAt = performance.now();
|
let nextResyncAt = Date.now();
|
||||||
const pendingFinalizationIds = new Set<number>();
|
const pendingFinalizationIds = new Set<number>();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
while (!control.signal.aborted) {
|
while (!control.signal.aborted) {
|
||||||
const operationalNowMs = Date.now();
|
const operationalNowMs = Date.now();
|
||||||
const operationalElapsedMs = performance.now();
|
|
||||||
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
|
const gameTime = await loadCurrentGameTime(postgres.prisma, new Date(operationalNowMs));
|
||||||
const gameNowMs = gameTime.now.getTime();
|
const gameNowMs = gameTime.now.getTime();
|
||||||
const dueScore = gameTime.tick ?? gameNowMs;
|
const dueScore = gameTime.tick ?? gameNowMs;
|
||||||
if (gameTime.phase && gameTime.phase !== 'RUNNING') {
|
if (operationalNowMs >= nextResyncAt) {
|
||||||
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const clockFence = gameTime.phase
|
|
||||||
? await ensureActiveRedisClockFence(redis.client, config.profileName, gameTime)
|
|
||||||
: null;
|
|
||||||
if (gameTime.phase && !clockFence) {
|
|
||||||
await waitForWorkerPoll(control.signal, config.auctionTimerPollMs);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (operationalElapsedMs >= nextResyncAt) {
|
|
||||||
await seedAuctionTimers(postgres.prisma, redis.client, keys);
|
await seedAuctionTimers(postgres.prisma, redis.client, keys);
|
||||||
nextResyncAt = operationalElapsedMs + config.auctionTimerResyncMs;
|
nextResyncAt = operationalNowMs + config.auctionTimerResyncMs;
|
||||||
}
|
}
|
||||||
if (pendingFinalizationIds.size > 0) {
|
if (pendingFinalizationIds.size > 0) {
|
||||||
const reconciliation = await reconcilePendingAuctionTimers({
|
const reconciliation = await reconcilePendingAuctionTimers({
|
||||||
@@ -395,7 +345,7 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
|||||||
if (historyTrimBefore > 0) {
|
if (historyTrimBefore > 0) {
|
||||||
await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore);
|
await redis.client.zRemRangeByScore(keys.historyKey, 0, historyTrimBefore);
|
||||||
}
|
}
|
||||||
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100, clockFence ?? undefined);
|
const dueIds = await popDueAuctionIds(redis.client, keys.timerKey, dueScore, 100);
|
||||||
if (dueIds.length > 0) {
|
if (dueIds.length > 0) {
|
||||||
for (const id of dueIds) {
|
for (const id of dueIds) {
|
||||||
try {
|
try {
|
||||||
@@ -408,12 +358,6 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
|||||||
nowMs: gameNowMs,
|
nowMs: gameNowMs,
|
||||||
nowTick: gameTime.tick,
|
nowTick: gameTime.tick,
|
||||||
historyNowMs: operationalNowMs,
|
historyNowMs: operationalNowMs,
|
||||||
...(clockFence
|
|
||||||
? {
|
|
||||||
expectedClockRevision: clockFence.revision,
|
|
||||||
expectedDeadlineGeneration: clockFence.generation,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
if (outcome === 'PENDING') {
|
if (outcome === 'PENDING') {
|
||||||
pendingFinalizationIds.add(Number(id));
|
pendingFinalizationIds.add(Number(id));
|
||||||
@@ -451,4 +395,3 @@ export const runAuctionWorker = async (options: AuctionWorkerOptions = {}): Prom
|
|||||||
await closeResources();
|
await closeResources();
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
import { performance } from 'node:perf_hooks';
|
|
||||||
|
|||||||
@@ -65,18 +65,7 @@ export type WorldStateMeta = z.infer<typeof zWorldStateMeta>;
|
|||||||
|
|
||||||
type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>;
|
type PrismaWorldStateRow = GamePrisma.WorldStateGetPayload<Record<string, never>>;
|
||||||
type PrismaGeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>;
|
type PrismaGeneralRow = GamePrisma.GeneralGetPayload<Record<string, never>>;
|
||||||
type WorldClockFields =
|
type WorldClockFields = 'clockBaseTime' | 'clockTick' | 'clockMode' | 'clockWallAnchor' | 'lastTurnTick';
|
||||||
| 'clockBaseTime'
|
|
||||||
| 'clockTick'
|
|
||||||
| 'clockMode'
|
|
||||||
| 'clockWallAnchor'
|
|
||||||
| 'clockRecoveryStartTick'
|
|
||||||
| 'clockRecoveryEndTick'
|
|
||||||
| 'clockRecoveryStartWallAt'
|
|
||||||
| 'lastTurnTick'
|
|
||||||
| 'clockPhase'
|
|
||||||
| 'clockRevision'
|
|
||||||
| 'deadlineGeneration';
|
|
||||||
type GeneralClockFields = 'turnTick' | 'recentWarTick';
|
type GeneralClockFields = 'turnTick' | 'recentWarTick';
|
||||||
|
|
||||||
// Transitional API fixtures may still model the pre-clock row. Runtime Prisma
|
// Transitional API fixtures may still model the pre-clock row. Runtime Prisma
|
||||||
|
|||||||
@@ -1,15 +1,10 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { performance } from 'node:perf_hooks';
|
|
||||||
|
|
||||||
import {
|
import { acquireGameSchemaAdvisoryXactLock, type DatabaseClient, type GamePrisma } from '@sammo-ts/infra';
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
|
||||||
readInputEventClockCoordinate,
|
|
||||||
type DatabaseClient,
|
|
||||||
type GamePrisma,
|
|
||||||
} from '@sammo-ts/infra';
|
|
||||||
|
|
||||||
import type { TurnDaemonTransport } from './transport.js';
|
import type { TurnDaemonTransport } from './transport.js';
|
||||||
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
|
import type { TurnDaemonCommand, TurnDaemonCommandResult, TurnDaemonStatus } from './types.js';
|
||||||
|
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||||
|
|
||||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||||
|
|
||||||
@@ -87,12 +82,9 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
async sendCommand(command: TurnDaemonCommand): Promise<string> {
|
async sendCommand(command: TurnDaemonCommand): Promise<string> {
|
||||||
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
|
const requestId = ('requestId' in command ? command.requestId : undefined) ?? randomUUID();
|
||||||
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
|
const durableCommand = JSON.parse(JSON.stringify({ ...command, requestId })) as TurnDaemonCommand;
|
||||||
// Rolling-upgrade compatibility: older API versions supplied game
|
if (durableCommand.type === 'npcPossessGeneral') {
|
||||||
// coordinates. They are deliberately not persisted as command facts;
|
delete durableCommand.acceptedGameAt;
|
||||||
// the daemon assigns the authoritative processing coordinate while
|
}
|
||||||
// claiming the input event under the clock fence.
|
|
||||||
delete (durableCommand as unknown as Record<string, unknown>).acceptedGameAt;
|
|
||||||
delete (durableCommand as unknown as Record<string, unknown>).acceptedGameTick;
|
|
||||||
if (command.type === 'npcPossessGeneral') {
|
if (command.type === 'npcPossessGeneral') {
|
||||||
const existing = await this.db.inputEvent.findUnique({
|
const existing = await this.db.inputEvent.findUnique({
|
||||||
where: { requestId },
|
where: { requestId },
|
||||||
@@ -111,14 +103,15 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
try {
|
try {
|
||||||
if (command.type === 'npcPossessGeneral' && this.db.$transaction) {
|
if (command.type === 'npcPossessGeneral' && this.db.$transaction) {
|
||||||
const rejectionReason = await this.db.$transaction(async (transaction) => {
|
const rejectionReason = await this.db.$transaction(async (transaction) => {
|
||||||
const coordinate = await readInputEventClockCoordinate(transaction);
|
|
||||||
await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global');
|
await acquireGameSchemaAdvisoryXactLock(transaction, 'npc-possession:global');
|
||||||
await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`);
|
await acquireGameSchemaAdvisoryXactLock(transaction, `npc-possession:user:${command.userId}`);
|
||||||
|
const acceptedAt = new Date(Math.floor(Date.now() / 1000) * 1000);
|
||||||
|
const acceptedGameAt = (await loadCurrentGameTime(transaction, acceptedAt)).now;
|
||||||
const token = await transaction.npcSelectionToken.findFirst({
|
const token = await transaction.npcSelectionToken.findFirst({
|
||||||
where: {
|
where: {
|
||||||
ownerUserId: command.userId,
|
ownerUserId: command.userId,
|
||||||
nonce: command.tokenNonce,
|
nonce: command.tokenNonce,
|
||||||
validUntilTick: { gte: coordinate.gameTick },
|
validUntil: { gte: acceptedGameAt },
|
||||||
},
|
},
|
||||||
select: { pickResult: true },
|
select: { pickResult: true },
|
||||||
});
|
});
|
||||||
@@ -133,20 +126,18 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
) {
|
) {
|
||||||
return '선택한 장수가 목록에 없습니다.';
|
return '선택한 장수가 목록에 없습니다.';
|
||||||
}
|
}
|
||||||
await this.createInputEvent(transaction, durableCommand, requestId);
|
const acceptedCommand: Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }> = {
|
||||||
|
...(durableCommand as Extract<TurnDaemonCommand, { type: 'npcPossessGeneral' }>),
|
||||||
|
acceptedGameAt: acceptedGameAt.toISOString(),
|
||||||
|
};
|
||||||
|
await this.createInputEvent(transaction, acceptedCommand, requestId, acceptedAt);
|
||||||
return null;
|
return null;
|
||||||
});
|
});
|
||||||
if (rejectionReason) {
|
if (rejectionReason) {
|
||||||
throw new RejectedNpcPossessionCommandError('PRECONDITION_FAILED', rejectionReason);
|
throw new RejectedNpcPossessionCommandError('PRECONDITION_FAILED', rejectionReason);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
if (this.db.$transaction) {
|
await this.createInputEvent(this.db, durableCommand, requestId);
|
||||||
await this.db.$transaction(async (transaction) => {
|
|
||||||
await this.createInputEvent(transaction, durableCommand, requestId);
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
await this.createInputEvent(this.db, durableCommand, requestId);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof RejectedNpcPossessionCommandError) {
|
if (error instanceof RejectedNpcPossessionCommandError) {
|
||||||
@@ -174,7 +165,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
private async createInputEvent(
|
private async createInputEvent(
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
command: TurnDaemonCommand,
|
command: TurnDaemonCommand,
|
||||||
requestId: string
|
requestId: string,
|
||||||
|
createdAt?: Date
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
await db.inputEvent.create({
|
await db.inputEvent.create({
|
||||||
data: {
|
data: {
|
||||||
@@ -183,8 +175,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
eventType: command.type,
|
eventType: command.type,
|
||||||
payload: asJson(command),
|
payload: asJson(command),
|
||||||
actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null,
|
actorUserId: 'userId' in command && typeof command.userId === 'string' ? command.userId : null,
|
||||||
// PostgreSQL owns created_at WALL_TIME. ENGINE assigns the
|
...(createdAt ? { createdAt } : {}),
|
||||||
// authoritative game coordinate when the daemon claims it.
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -201,8 +192,8 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async waitForResult<T>(requestId: string, timeoutMs?: number): Promise<T | null> {
|
private async waitForResult<T>(requestId: string, timeoutMs?: number): Promise<T | null> {
|
||||||
const deadline = performance.now() + (timeoutMs ?? this.requestTimeoutMs);
|
const deadline = Date.now() + (timeoutMs ?? this.requestTimeoutMs);
|
||||||
while (performance.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const event = await this.db.inputEvent.findUnique({
|
const event = await this.db.inputEvent.findUnique({
|
||||||
where: { requestId },
|
where: { requestId },
|
||||||
select: { status: true, result: true, error: true },
|
select: { status: true, result: true, error: true },
|
||||||
@@ -213,7 +204,7 @@ export class DatabaseTurnDaemonTransport implements TurnDaemonTransport {
|
|||||||
if (event?.status === 'FAILED') {
|
if (event?.status === 'FAILED') {
|
||||||
throw new FailedTurnDaemonCommandError(requestId, event.error);
|
throw new FailedTurnDaemonCommandError(requestId, event.error);
|
||||||
}
|
}
|
||||||
await delay(Math.min(50, Math.max(1, deadline - performance.now())));
|
await delay(Math.min(50, Math.max(1, deadline - Date.now())));
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,11 +1,6 @@
|
|||||||
import { createHash } from 'node:crypto';
|
import { createHash } from 'node:crypto';
|
||||||
|
|
||||||
import {
|
import { GamePrisma, type DatabaseClient as InfraDatabaseClient } from '@sammo-ts/infra';
|
||||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
|
||||||
GamePrisma,
|
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
|
||||||
type DatabaseClient as InfraDatabaseClient,
|
|
||||||
} from '@sammo-ts/infra';
|
|
||||||
|
|
||||||
import type { DatabaseClient } from './context.js';
|
import type { DatabaseClient } from './context.js';
|
||||||
|
|
||||||
@@ -34,6 +29,8 @@ type SavepointDatabaseClient = InfraDatabaseClient & {
|
|||||||
$executeRawUnsafe(query: string): Promise<number>;
|
$executeRawUnsafe(query: string): Promise<number>;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||||
|
|
||||||
const canonicalJson = (value: unknown): string =>
|
const canonicalJson = (value: unknown): string =>
|
||||||
JSON.stringify(value, (_key, entry: unknown) => {
|
JSON.stringify(value, (_key, entry: unknown) => {
|
||||||
if (typeof entry === 'bigint') {
|
if (typeof entry === 'bigint') {
|
||||||
@@ -88,9 +85,6 @@ const insertPendingIfAbsent = async (
|
|||||||
actor_user_id,
|
actor_user_id,
|
||||||
status,
|
status,
|
||||||
attempts,
|
attempts,
|
||||||
accepted_game_tick,
|
|
||||||
accepted_clock_revision,
|
|
||||||
accepted_deadline_generation,
|
|
||||||
created_at
|
created_at
|
||||||
)
|
)
|
||||||
VALUES (
|
VALUES (
|
||||||
@@ -101,9 +95,6 @@ const insertPendingIfAbsent = async (
|
|||||||
${options.actorUserId},
|
${options.actorUserId},
|
||||||
'PENDING'::"InputEventStatus",
|
'PENDING'::"InputEventStatus",
|
||||||
0,
|
0,
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
NULL,
|
|
||||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
||||||
)
|
)
|
||||||
ON CONFLICT (request_id) DO NOTHING
|
ON CONFLICT (request_id) DO NOTHING
|
||||||
@@ -162,22 +153,20 @@ const claimInputEvent = async (
|
|||||||
requestId: string,
|
requestId: string,
|
||||||
payloadIdentity: ApiInputPayloadIdentity
|
payloadIdentity: ApiInputPayloadIdentity
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
await db.$executeRaw(GamePrisma.sql`
|
await db.inputEvent.update({
|
||||||
UPDATE input_event
|
where: { requestId },
|
||||||
SET payload = CAST(${JSON.stringify(payloadIdentity)} AS jsonb),
|
data: {
|
||||||
status = 'PROCESSING'::"InputEventStatus",
|
payload: asJson(payloadIdentity),
|
||||||
result = NULL,
|
status: 'PROCESSING',
|
||||||
error = NULL,
|
result: GamePrisma.DbNull,
|
||||||
attempts = attempts + 1,
|
error: null,
|
||||||
locked_by = NULL,
|
attempts: { increment: 1 },
|
||||||
lease_until = NULL,
|
lockedBy: null,
|
||||||
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
leaseUntil: null,
|
||||||
processing_game_tick = NULL,
|
processingAt: new Date(),
|
||||||
processing_clock_revision = NULL,
|
completedAt: null,
|
||||||
processing_deadline_generation = NULL,
|
},
|
||||||
completed_at = NULL
|
});
|
||||||
WHERE request_id = ${requestId}
|
|
||||||
`);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const markUnexpectedFailure = async (
|
const markUnexpectedFailure = async (
|
||||||
@@ -188,7 +177,6 @@ const markUnexpectedFailure = async (
|
|||||||
actorUserId: string | null;
|
actorUserId: string | null;
|
||||||
payloadIdentity: ApiInputPayloadIdentity;
|
payloadIdentity: ApiInputPayloadIdentity;
|
||||||
error: unknown;
|
error: unknown;
|
||||||
acquireClockFence: boolean;
|
|
||||||
}
|
}
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (!db.$transaction) return;
|
if (!db.$transaction) return;
|
||||||
@@ -196,9 +184,6 @@ const markUnexpectedFailure = async (
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
await db.$transaction(async (transaction) => {
|
await db.$transaction(async (transaction) => {
|
||||||
if (options.acquireClockFence) {
|
|
||||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
|
||||||
}
|
|
||||||
await insertPendingIfAbsent(transaction, options);
|
await insertPendingIfAbsent(transaction, options);
|
||||||
const row = await lockInputEvent(transaction, options.requestId);
|
const row = await lockInputEvent(transaction, options.requestId);
|
||||||
const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options);
|
const identityMatches = isMatchingIdentity(row, options) || canAdoptLegacyFailedPayload(row, options);
|
||||||
@@ -207,19 +192,20 @@ const markUnexpectedFailure = async (
|
|||||||
// late failure recorder must never replace its durable success.
|
// late failure recorder must never replace its durable success.
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await transaction.$executeRaw(GamePrisma.sql`
|
await transaction.inputEvent.update({
|
||||||
UPDATE input_event
|
where: { requestId: options.requestId },
|
||||||
SET payload = CAST(${JSON.stringify(options.payloadIdentity)} AS jsonb),
|
data: {
|
||||||
status = 'FAILED'::"InputEventStatus",
|
payload: asJson(options.payloadIdentity),
|
||||||
result = NULL,
|
status: 'FAILED',
|
||||||
error = ${message},
|
result: GamePrisma.DbNull,
|
||||||
attempts = attempts + 1,
|
error: message,
|
||||||
locked_by = NULL,
|
attempts: { increment: 1 },
|
||||||
lease_until = NULL,
|
lockedBy: null,
|
||||||
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
leaseUntil: null,
|
||||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
processingAt: new Date(),
|
||||||
WHERE request_id = ${options.requestId}
|
completedAt: new Date(),
|
||||||
`);
|
},
|
||||||
|
});
|
||||||
});
|
});
|
||||||
} catch {
|
} catch {
|
||||||
// Preserve the transaction failure that the caller actually observed. If
|
// Preserve the transaction failure that the caller actually observed. If
|
||||||
@@ -234,12 +220,10 @@ export const executeInputEvent = async <T>(options: {
|
|||||||
eventType: string;
|
eventType: string;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
actorUserId?: string | null;
|
actorUserId?: string | null;
|
||||||
acquireClockFence?: boolean;
|
|
||||||
execute(db: DatabaseClient): Promise<T>;
|
execute(db: DatabaseClient): Promise<T>;
|
||||||
}): Promise<T> => {
|
}): Promise<T> => {
|
||||||
const { db, requestId, eventType, payload, execute } = options;
|
const { db, requestId, eventType, payload, execute } = options;
|
||||||
const actorUserId = options.actorUserId ?? null;
|
const actorUserId = options.actorUserId ?? null;
|
||||||
const acquireClockFence = options.acquireClockFence !== false;
|
|
||||||
const payloadIdentity = createApiInputPayloadIdentity(payload);
|
const payloadIdentity = createApiInputPayloadIdentity(payload);
|
||||||
if (!db.$transaction) {
|
if (!db.$transaction) {
|
||||||
return execute(db);
|
return execute(db);
|
||||||
@@ -249,9 +233,6 @@ export const executeInputEvent = async <T>(options: {
|
|||||||
let outcome: InputEventOutcome<T>;
|
let outcome: InputEventOutcome<T>;
|
||||||
try {
|
try {
|
||||||
outcome = await db.$transaction(async (transaction) => {
|
outcome = await db.$transaction(async (transaction) => {
|
||||||
if (acquireClockFence) {
|
|
||||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
|
||||||
}
|
|
||||||
await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity });
|
await insertPendingIfAbsent(transaction, { requestId, eventType, actorUserId, payloadIdentity });
|
||||||
const row = await lockInputEvent(transaction, requestId);
|
const row = await lockInputEvent(transaction, requestId);
|
||||||
const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity });
|
const identityMatches = isMatchingIdentity(row, { eventType, actorUserId, payloadIdentity });
|
||||||
@@ -277,41 +258,36 @@ export const executeInputEvent = async <T>(options: {
|
|||||||
try {
|
try {
|
||||||
const value = await execute(transaction);
|
const value = await execute(transaction);
|
||||||
const durableResult = canonicalJsonValue(value);
|
const durableResult = canonicalJsonValue(value);
|
||||||
await transaction.$executeRaw(GamePrisma.sql`
|
await transaction.inputEvent.update({
|
||||||
UPDATE input_event
|
where: { requestId },
|
||||||
SET status = 'SUCCEEDED'::"InputEventStatus",
|
data: {
|
||||||
result = CAST(${JSON.stringify(durableResult)} AS jsonb),
|
status: 'SUCCEEDED',
|
||||||
error = NULL,
|
result: asJson(durableResult),
|
||||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
error: null,
|
||||||
WHERE request_id = ${requestId}
|
completedAt: new Date(),
|
||||||
`);
|
},
|
||||||
|
});
|
||||||
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||||
return { kind: 'executed', value };
|
return { kind: 'executed', value };
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
await savepointDb.$executeRawUnsafe(`ROLLBACK TO SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||||
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
await savepointDb.$executeRawUnsafe(`RELEASE SAVEPOINT ${BUSINESS_SAVEPOINT}`);
|
||||||
const message = error instanceof Error ? error.message : 'Unknown API input event error.';
|
const message = error instanceof Error ? error.message : 'Unknown API input event error.';
|
||||||
await transaction.$executeRaw(GamePrisma.sql`
|
await transaction.inputEvent.update({
|
||||||
UPDATE input_event
|
where: { requestId },
|
||||||
SET status = 'FAILED'::"InputEventStatus",
|
data: {
|
||||||
result = NULL,
|
status: 'FAILED',
|
||||||
error = ${message},
|
result: GamePrisma.DbNull,
|
||||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
error: message,
|
||||||
WHERE request_id = ${requestId}
|
completedAt: new Date(),
|
||||||
`);
|
},
|
||||||
|
});
|
||||||
return { kind: 'failed', error };
|
return { kind: 'failed', error };
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (businessStarted && !(error instanceof DuplicateInputEventError)) {
|
if (businessStarted && !(error instanceof DuplicateInputEventError)) {
|
||||||
await markUnexpectedFailure(db, {
|
await markUnexpectedFailure(db, { requestId, eventType, actorUserId, payloadIdentity, error });
|
||||||
requestId,
|
|
||||||
eventType,
|
|
||||||
actorUserId,
|
|
||||||
payloadIdentity,
|
|
||||||
error,
|
|
||||||
acquireClockFence,
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import type { GameApiContext, WorldStateRow } from '../context.js';
|
import type { GameApiContext, WorldStateRow } from '../context.js';
|
||||||
import { asRecord, isRecord } from '@sammo-ts/common';
|
import { asRecord, isRecord } from '@sammo-ts/common';
|
||||||
import { resolveUniqueConfig } from '@sammo-ts/logic/rewards/uniqueLottery.js';
|
|
||||||
import { readMapWorldSourceRevision } from './worldMapSourceRevision.js';
|
import { readMapWorldSourceRevision } from './worldMapSourceRevision.js';
|
||||||
|
|
||||||
export type MapCityCompact = [number, number, number, number, number, number];
|
export type MapCityCompact = [number, number, number, number, number, number];
|
||||||
@@ -17,7 +16,6 @@ export type BaseMapResult = {
|
|||||||
initialLevel: number;
|
initialLevel: number;
|
||||||
increaseYears: number;
|
increaseYears: number;
|
||||||
};
|
};
|
||||||
uniqueItemLimit: { count: number; until: { year: number; month: number } | null };
|
|
||||||
cityList: MapCityCompact[];
|
cityList: MapCityCompact[];
|
||||||
nationList: MapNationCompact[];
|
nationList: MapNationCompact[];
|
||||||
};
|
};
|
||||||
@@ -54,7 +52,7 @@ const MAP_VERSION = 0 as const;
|
|||||||
const BASE_MAP_TTL_SECONDS = 30;
|
const BASE_MAP_TTL_SECONDS = 30;
|
||||||
const PUBLIC_MAP_TTL_SECONDS = 600;
|
const PUBLIC_MAP_TTL_SECONDS = 600;
|
||||||
|
|
||||||
const resolveStartYear = (worldState: Pick<WorldStateRow, 'meta'>): number => {
|
const resolveStartYear = (worldState: WorldStateRow): number => {
|
||||||
const meta = asRecord(worldState.meta);
|
const meta = asRecord(worldState.meta);
|
||||||
const scenarioMeta = asRecord(meta.scenarioMeta);
|
const scenarioMeta = asRecord(meta.scenarioMeta);
|
||||||
const startYear = scenarioMeta.startYear;
|
const startYear = scenarioMeta.startYear;
|
||||||
@@ -89,28 +87,6 @@ const resolveTechLevelLimit = (worldState: WorldStateRow): BaseMapResult['techLe
|
|||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
// 획득/경매와 같은 시나리오 설정을 사용하며, 장수 개인의 보유 수는 공개하지 않는다.
|
|
||||||
export const resolveMapUniqueItemLimit = (
|
|
||||||
worldState: Pick<WorldStateRow, 'config' | 'meta' | 'currentYear'>
|
|
||||||
): BaseMapResult['uniqueItemLimit'] => {
|
|
||||||
const config = resolveUniqueConfig(asRecord(asRecord(worldState.config).const));
|
|
||||||
const startYear = resolveStartYear(worldState);
|
|
||||||
const relativeYear = worldState.currentYear - startYear;
|
|
||||||
const slotCount = Object.keys(config.allItems).length;
|
|
||||||
let count = Math.min(1, slotCount);
|
|
||||||
for (const [targetYear, targetCount] of config.maxUniqueItemLimit) {
|
|
||||||
const nextCount = Math.min(targetCount, slotCount);
|
|
||||||
if (relativeYear < targetYear) {
|
|
||||||
if (nextCount !== count) {
|
|
||||||
return { count, until: { year: startYear + targetYear - 1, month: 12 } };
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
count = nextCount;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { count, until: null };
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeNumberRecord = (value: unknown): Record<number, number> => {
|
const normalizeNumberRecord = (value: unknown): Record<number, number> => {
|
||||||
if (!isRecord(value)) {
|
if (!isRecord(value)) {
|
||||||
return {};
|
return {};
|
||||||
@@ -178,10 +154,7 @@ const loadBaseMap = async (
|
|||||||
}
|
}
|
||||||
if (cached) {
|
if (cached) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(cached) as BaseMapResult;
|
return JSON.parse(cached) as BaseMapResult;
|
||||||
if (parsed.uniqueItemLimit) {
|
|
||||||
return parsed;
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
// Ignore cache parse errors.
|
// Ignore cache parse errors.
|
||||||
}
|
}
|
||||||
@@ -234,7 +207,6 @@ const loadBaseMap = async (
|
|||||||
year: worldState.currentYear,
|
year: worldState.currentYear,
|
||||||
month: worldState.currentMonth,
|
month: worldState.currentMonth,
|
||||||
techLevelLimit: resolveTechLevelLimit(worldState),
|
techLevelLimit: resolveTechLevelLimit(worldState),
|
||||||
uniqueItemLimit: resolveMapUniqueItemLimit(worldState),
|
|
||||||
cityList,
|
cityList,
|
||||||
nationList,
|
nationList,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,13 +1,9 @@
|
|||||||
import {
|
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
||||||
enqueuePrivateMessageWebPush,
|
import { enqueuePrivateMessageWebPush, GamePrisma } from '@sammo-ts/infra';
|
||||||
GamePrisma,
|
|
||||||
persistMessageEnvelope,
|
|
||||||
type MessageGameContext,
|
|
||||||
} from '@sammo-ts/infra';
|
|
||||||
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
|
import type { MessagePayload, MessageRecordDraft, MessageType } from '@sammo-ts/logic';
|
||||||
|
|
||||||
import type { DatabaseClient } from '../context.js';
|
import type { DatabaseClient } from '../context.js';
|
||||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
import { loadCurrentGameTime, type CurrentGameTime } from '../services/gameClock.js';
|
||||||
|
|
||||||
export interface MessageView {
|
export interface MessageView {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -26,9 +22,7 @@ interface MessageRow {
|
|||||||
src: number;
|
src: number;
|
||||||
dest: number;
|
dest: number;
|
||||||
time: Date;
|
time: Date;
|
||||||
created_at_wall: Date;
|
valid_until: Date;
|
||||||
action_status: string | null;
|
|
||||||
expires_game_tick: bigint | null;
|
|
||||||
message: unknown;
|
message: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -54,73 +48,70 @@ const formatMessageTime = (value: Date): string => {
|
|||||||
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
|
)} ${pad(value.getHours())}:${pad(value.getMinutes())}:${pad(value.getSeconds())}`;
|
||||||
};
|
};
|
||||||
|
|
||||||
const toMessageView = (row: MessageRow, currentGameTick: bigint | null): MessageView => {
|
const messageValidityPredicate = (gameTime: CurrentGameTime) => {
|
||||||
|
if (gameTime.tick === null) {
|
||||||
|
// A legacy or partially migrated profile has no authoritative logical
|
||||||
|
// tick. Rows that already carry a tick still need the wall-time
|
||||||
|
// fallback used by the clock migration.
|
||||||
|
return GamePrisma.sql`valid_until > ${gameTime.now}`;
|
||||||
|
}
|
||||||
|
return GamePrisma.sql`(
|
||||||
|
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(gameTime.tick)})
|
||||||
|
OR (valid_until_tick IS NULL AND valid_until > ${gameTime.now})
|
||||||
|
)`;
|
||||||
|
};
|
||||||
|
|
||||||
|
const toMessageView = (row: MessageRow): MessageView => {
|
||||||
const payload = parsePayload(row.message);
|
const payload = parsePayload(row.message);
|
||||||
const actionStatus = typeof row.action_status === 'string' ? row.action_status : null;
|
|
||||||
// 제의 종료는 본문 삭제가 아니다. 저장된 종료 상태를 기한 경과보다 우선한다.
|
|
||||||
const actionState =
|
|
||||||
actionStatus === null
|
|
||||||
? null
|
|
||||||
: actionStatus === 'PENDING'
|
|
||||||
? row.expires_game_tick !== null && currentGameTick !== null && row.expires_game_tick <= currentGameTick
|
|
||||||
? 'expired'
|
|
||||||
: 'pending'
|
|
||||||
: actionStatus === 'RESOLVED'
|
|
||||||
? 'resolved'
|
|
||||||
: 'unavailable';
|
|
||||||
return {
|
return {
|
||||||
id: row.id,
|
id: row.id,
|
||||||
msgType: row.type,
|
msgType: row.type,
|
||||||
src: payload.src,
|
src: payload.src,
|
||||||
dest: row.type === 'public' ? null : payload.dest,
|
dest: row.type === 'public' ? null : payload.dest,
|
||||||
text: payload.text,
|
text: payload.text,
|
||||||
option:
|
option: payload.option ?? null,
|
||||||
actionState !== null && payload.option && typeof payload.option === 'object'
|
time: formatMessageTime(new Date(row.time)),
|
||||||
? { ...payload.option, actionState, used: actionState !== 'pending' }
|
|
||||||
: (payload.option ?? null),
|
|
||||||
time: formatMessageTime(new Date(row.created_at_wall ?? row.time)),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => {
|
export const insertMessage = async (db: DatabaseClient, draft: MessageRecordDraft): Promise<number> => {
|
||||||
const action = draft.payload.option && Reflect.get(draft.payload.option, 'action');
|
const gameTime = await loadCurrentGameTime(db);
|
||||||
let gameContext: MessageGameContext | null = null;
|
const toTickOrNull = (date: Date): bigint | null => {
|
||||||
if (typeof action === 'string' && action !== '') {
|
// Ref represents its unlimited 9999-12-31 message lifetime with the
|
||||||
const gameTime = await loadCurrentGameTime(db);
|
// largest safe game tick instead of falling back to a wall-clock-only row.
|
||||||
if (
|
if (date.getUTCFullYear() >= 9000) {
|
||||||
gameTime.tick === null ||
|
return BigInt(MAX_SAFE_GAME_TICK);
|
||||||
gameTime.revision === null ||
|
|
||||||
gameTime.revision === undefined ||
|
|
||||||
gameTime.deadlineGeneration === null ||
|
|
||||||
gameTime.deadlineGeneration === undefined
|
|
||||||
) {
|
|
||||||
throw new Error(`Actionable message ${action} requires an initialized game clock.`);
|
|
||||||
}
|
}
|
||||||
let expiresGameTick: bigint | null = null;
|
try {
|
||||||
if (draft.validUntil.getUTCFullYear() < 9000) {
|
const tick = gameTime.dateToTick(date);
|
||||||
const expires = gameTime.dateToTick(draft.validUntil);
|
return tick === null ? null : BigInt(tick);
|
||||||
if (expires === null) throw new Error(`Actionable message ${action} requires a GAME_TIME deadline.`);
|
} catch {
|
||||||
expiresGameTick = BigInt(expires);
|
return null;
|
||||||
}
|
}
|
||||||
gameContext = {
|
};
|
||||||
occurredGameTick: BigInt(gameTime.tick),
|
const rows = await db.$queryRaw<Array<{ id: number }>>`
|
||||||
clockRevision: BigInt(gameTime.revision),
|
INSERT INTO message (mailbox, type, src, dest, time, time_tick, valid_until, valid_until_tick, message)
|
||||||
deadlineGeneration: BigInt(gameTime.deadlineGeneration),
|
VALUES (
|
||||||
expiresGameTick,
|
${draft.mailbox},
|
||||||
};
|
${draft.msgType},
|
||||||
|
${draft.srcId},
|
||||||
|
${draft.destId},
|
||||||
|
${draft.time},
|
||||||
|
${toTickOrNull(draft.time)},
|
||||||
|
${draft.validUntil},
|
||||||
|
${toTickOrNull(draft.validUntil)},
|
||||||
|
CAST(${JSON.stringify(draft.payload)} AS jsonb)
|
||||||
|
)
|
||||||
|
RETURNING id
|
||||||
|
`;
|
||||||
|
const id = rows[0]?.id;
|
||||||
|
if (!id) {
|
||||||
|
throw new Error('Failed to insert message row.');
|
||||||
}
|
}
|
||||||
const id = await persistMessageEnvelope(db, draft, gameContext);
|
|
||||||
await enqueuePrivateMessageWebPush(db, draft, id);
|
await enqueuePrivateMessageWebPush(db, draft, id);
|
||||||
return id;
|
return id;
|
||||||
};
|
};
|
||||||
|
|
||||||
const loadMessageViews = async (db: DatabaseClient, rows: MessageRow[]): Promise<MessageView[]> => {
|
|
||||||
if (!rows.some((row) => typeof row.action_status === 'string')) return rows.map((row) => toMessageView(row, null));
|
|
||||||
const gameTime = await loadCurrentGameTime(db);
|
|
||||||
const currentGameTick = gameTime.tick === null ? null : BigInt(gameTime.tick);
|
|
||||||
return rows.map((row) => toMessageView(row, currentGameTick));
|
|
||||||
};
|
|
||||||
|
|
||||||
export const fetchMessagesFromMailbox = async (params: {
|
export const fetchMessagesFromMailbox = async (params: {
|
||||||
db: DatabaseClient;
|
db: DatabaseClient;
|
||||||
mailbox: number;
|
mailbox: number;
|
||||||
@@ -129,20 +120,19 @@ export const fetchMessagesFromMailbox = async (params: {
|
|||||||
fromSeq: number;
|
fromSeq: number;
|
||||||
}): Promise<MessageView[]> => {
|
}): Promise<MessageView[]> => {
|
||||||
const fromSeq = Math.max(params.fromSeq, 0);
|
const fromSeq = Math.max(params.fromSeq, 0);
|
||||||
|
const gameTime = await loadCurrentGameTime(params.db);
|
||||||
const rows = await params.db.$queryRaw<MessageRow[]>`
|
const rows = await params.db.$queryRaw<MessageRow[]>`
|
||||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||||
m.created_at_wall, m.message,
|
FROM message
|
||||||
ma.status AS action_status, ma.expires_game_tick
|
WHERE mailbox = ${params.mailbox}
|
||||||
FROM message m
|
AND type = ${params.msgType}
|
||||||
LEFT JOIN message_action ma ON ma.message_id = m.id
|
AND ${messageValidityPredicate(gameTime)}
|
||||||
WHERE m.mailbox = ${params.mailbox}
|
AND id >= ${fromSeq}
|
||||||
AND m.type = ${params.msgType}
|
ORDER BY id DESC
|
||||||
AND m.id >= ${fromSeq}
|
|
||||||
ORDER BY m.id DESC
|
|
||||||
LIMIT ${params.limit}
|
LIMIT ${params.limit}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return loadMessageViews(params.db, rows);
|
return rows.map(toMessageView);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchOldMessagesFromMailbox = async (params: {
|
export const fetchOldMessagesFromMailbox = async (params: {
|
||||||
@@ -152,30 +142,28 @@ export const fetchOldMessagesFromMailbox = async (params: {
|
|||||||
toSeq: number;
|
toSeq: number;
|
||||||
limit: number;
|
limit: number;
|
||||||
}): Promise<MessageView[]> => {
|
}): Promise<MessageView[]> => {
|
||||||
|
const gameTime = await loadCurrentGameTime(params.db);
|
||||||
const rows = await params.db.$queryRaw<MessageRow[]>`
|
const rows = await params.db.$queryRaw<MessageRow[]>`
|
||||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||||
m.created_at_wall, m.message,
|
FROM message
|
||||||
ma.status AS action_status, ma.expires_game_tick
|
WHERE mailbox = ${params.mailbox}
|
||||||
FROM message m
|
AND type = ${params.msgType}
|
||||||
LEFT JOIN message_action ma ON ma.message_id = m.id
|
AND ${messageValidityPredicate(gameTime)}
|
||||||
WHERE m.mailbox = ${params.mailbox}
|
AND id < ${params.toSeq}
|
||||||
AND m.type = ${params.msgType}
|
ORDER BY id DESC
|
||||||
AND m.id < ${params.toSeq}
|
|
||||||
ORDER BY m.id DESC
|
|
||||||
LIMIT ${params.limit}
|
LIMIT ${params.limit}
|
||||||
`;
|
`;
|
||||||
|
|
||||||
return loadMessageViews(params.db, rows);
|
return rows.map(toMessageView);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
|
export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
|
||||||
|
const gameTime = await loadCurrentGameTime(db);
|
||||||
const rows = await db.$queryRaw<MessageRow[]>`
|
const rows = await db.$queryRaw<MessageRow[]>`
|
||||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||||
m.created_at_wall, m.message,
|
FROM message
|
||||||
ma.status AS action_status, ma.expires_game_tick
|
WHERE id = ${id}
|
||||||
FROM message m
|
AND ${messageValidityPredicate(gameTime)}
|
||||||
LEFT JOIN message_action ma ON ma.message_id = m.id
|
|
||||||
WHERE m.id = ${id}
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
`;
|
`;
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
@@ -184,29 +172,20 @@ export const fetchMessageById = async (db: DatabaseClient, id: number): Promise<
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
mailbox: row.mailbox,
|
mailbox: row.mailbox,
|
||||||
msgType: row.type,
|
msgType: row.type,
|
||||||
time: new Date(row.created_at_wall ?? row.time),
|
time: new Date(row.time),
|
||||||
payload: parsePayload(row.message),
|
payload: parsePayload(row.message),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
|
export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number): Promise<StoredMessage | null> => {
|
||||||
const gameTime = await loadCurrentGameTime(db);
|
const gameTime = await loadCurrentGameTime(db);
|
||||||
if (gameTime.tick === null) throw new Error('Actionable message response requires an initialized game clock.');
|
|
||||||
const rows = await db.$queryRaw<MessageRow[]>`
|
const rows = await db.$queryRaw<MessageRow[]>`
|
||||||
SELECT m.id, m.mailbox, m.type, m.src, m.dest, m.time,
|
SELECT id, mailbox, type, src, dest, time, valid_until, message
|
||||||
m.created_at_wall, m.message,
|
FROM message
|
||||||
ma.status AS action_status, ma.expires_game_tick
|
WHERE id = ${id}
|
||||||
FROM message m
|
AND ${messageValidityPredicate(gameTime)}
|
||||||
JOIN message_action ma ON ma.message_id = m.id
|
|
||||||
JOIN world_state world ON TRUE
|
|
||||||
WHERE m.id = ${id}
|
|
||||||
AND ma.status = 'PENDING'
|
|
||||||
AND (ma.expires_game_tick IS NULL OR ma.expires_game_tick > ${BigInt(gameTime.tick)})
|
|
||||||
AND world.clock_phase IN ('RUNNING', 'MANUAL')
|
|
||||||
AND ma.clock_revision = world.clock_revision
|
|
||||||
AND ma.deadline_generation = world.deadline_generation
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
FOR UPDATE OF m, ma, world
|
FOR UPDATE
|
||||||
`;
|
`;
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
if (!row) return null;
|
if (!row) return null;
|
||||||
@@ -214,7 +193,7 @@ export const fetchMessageByIdForUpdate = async (db: DatabaseClient, id: number):
|
|||||||
id: row.id,
|
id: row.id,
|
||||||
mailbox: row.mailbox,
|
mailbox: row.mailbox,
|
||||||
msgType: row.type,
|
msgType: row.type,
|
||||||
time: new Date(row.created_at_wall ?? row.time),
|
time: new Date(row.time),
|
||||||
payload: parsePayload(row.message),
|
payload: parsePayload(row.message),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
@@ -223,16 +202,16 @@ export const invalidateMessages = async (db: DatabaseClient, ids: number[]): Pro
|
|||||||
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
|
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
|
||||||
if (uniqueIds.length === 0) return;
|
if (uniqueIds.length === 0) return;
|
||||||
const gameTime = await loadCurrentGameTime(db);
|
const gameTime = await loadCurrentGameTime(db);
|
||||||
if (gameTime.tick === null) throw new Error('Actionable message invalidation requires an initialized game clock.');
|
|
||||||
await db.messageAction.updateMany({
|
|
||||||
where: { messageId: { in: uniqueIds }, status: 'PENDING' },
|
|
||||||
data: { status: 'RESOLVED', resolvedGameTick: BigInt(gameTime.tick) },
|
|
||||||
});
|
|
||||||
await db.message.updateMany({
|
await db.message.updateMany({
|
||||||
where: { id: { in: uniqueIds } },
|
where: { id: { in: uniqueIds } },
|
||||||
data: {
|
data: {
|
||||||
validUntil: gameTime.now,
|
validUntil: gameTime.now,
|
||||||
validUntilTick: BigInt(gameTime.tick),
|
// A partially migrated profile can still carry a legacy logical
|
||||||
|
// sentinel even while no authoritative clock exists. Replace it
|
||||||
|
// with an already-expired logical tick when expiring by wall time;
|
||||||
|
// NULL would fall back to the wall timestamp after clock recovery
|
||||||
|
// and could make the handled message visible again.
|
||||||
|
validUntilTick: gameTime.tick === null ? 0n : BigInt(gameTime.tick),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -254,48 +233,8 @@ export const tombstoneMessages = async (db: DatabaseClient, ids: number[]): Prom
|
|||||||
END
|
END
|
||||||
) || jsonb_build_object('invalid', true),
|
) || jsonb_build_object('invalid', true),
|
||||||
true
|
true
|
||||||
),
|
)
|
||||||
tombstoned_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
|
||||||
WHERE id IN (${GamePrisma.join(uniqueIds)})
|
WHERE id IN (${GamePrisma.join(uniqueIds)})
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const tombstoneMessagesWithinDeleteWindow = async (
|
|
||||||
db: DatabaseClient,
|
|
||||||
authorityMessageId: number,
|
|
||||||
ids: number[]
|
|
||||||
): Promise<number[]> => {
|
|
||||||
const uniqueIds = Array.from(new Set(ids.filter((id) => Number.isInteger(id) && id > 0)));
|
|
||||||
if (uniqueIds.length === 0) return [];
|
|
||||||
const rows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
|
||||||
WITH wall AS (
|
|
||||||
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
|
|
||||||
), authority AS (
|
|
||||||
SELECT m.id
|
|
||||||
FROM message m, wall
|
|
||||||
WHERE m.id = ${authorityMessageId}
|
|
||||||
AND m.tombstoned_at_wall IS NULL
|
|
||||||
AND m.delete_until_wall >= wall.now_wall
|
|
||||||
FOR UPDATE
|
|
||||||
)
|
|
||||||
UPDATE message m
|
|
||||||
SET message = jsonb_set(
|
|
||||||
jsonb_set(m.message, '{text}', to_jsonb(${'삭제된 메시지입니다.'}::text), true),
|
|
||||||
'{option}',
|
|
||||||
(
|
|
||||||
CASE
|
|
||||||
WHEN jsonb_typeof(m.message->'option') = 'object' THEN m.message->'option'
|
|
||||||
ELSE '{}'::jsonb
|
|
||||||
END
|
|
||||||
) || jsonb_build_object('invalid', true),
|
|
||||||
true
|
|
||||||
),
|
|
||||||
tombstoned_at_wall = wall.now_wall
|
|
||||||
FROM wall
|
|
||||||
WHERE m.id IN (${GamePrisma.join(uniqueIds)})
|
|
||||||
AND EXISTS (SELECT 1 FROM authority)
|
|
||||||
RETURNING m.id
|
|
||||||
`);
|
|
||||||
return rows.map(({ id }) => id).sort((left, right) => left - right);
|
|
||||||
};
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ const DEFAULT_NATION = {
|
|||||||
color: '#000000',
|
color: '#000000',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const DEFAULT_SHARED_ICON_PUBLIC_URL = 'https://sam-image.hided.net/icons';
|
||||||
|
|
||||||
export const resolveNationInfo = async (
|
export const resolveNationInfo = async (
|
||||||
db: DatabaseClient,
|
db: DatabaseClient,
|
||||||
nationId: number
|
nationId: number
|
||||||
@@ -23,13 +25,14 @@ export const resolveNationInfo = async (
|
|||||||
|
|
||||||
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
|
export const buildTargetFromGeneral = async (db: DatabaseClient, general: GeneralRow): Promise<MessageTarget> => {
|
||||||
const nation = await resolveNationInfo(db, general.nationId);
|
const nation = await resolveNationInfo(db, general.nationId);
|
||||||
|
const picture = general.picture?.trim() || 'default.jpg';
|
||||||
return {
|
return {
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
generalName: general.name,
|
generalName: general.name,
|
||||||
nationId: general.nationId,
|
nationId: general.nationId,
|
||||||
nationName: nation.name,
|
nationName: nation.name,
|
||||||
color: nation.color,
|
color: nation.color,
|
||||||
icon: resolveMessageTargetIcon({ picture: general.picture, imageServer: general.imageServer }),
|
icon: general.imageServer ? `d_pic/${picture}` : `${DEFAULT_SHARED_ICON_PUBLIC_URL}/${picture}`,
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import {
|
import {
|
||||||
readModelOutboxPayloadToDiplomacyMailboxes,
|
|
||||||
readModelOutboxPayloadToChanges,
|
readModelOutboxPayloadToChanges,
|
||||||
readModelOutboxPayloadToMessageMailboxes,
|
readModelOutboxPayloadToMessageMailboxes,
|
||||||
type ReadModelDomain,
|
type ReadModelDomain,
|
||||||
@@ -21,7 +20,6 @@ const NON_DASHBOARD_DOMAINS: ReadonlySet<ReadModelDomain> = new Set([
|
|||||||
'access.general',
|
'access.general',
|
||||||
'dashboard.global',
|
'dashboard.global',
|
||||||
'messages.mailbox',
|
'messages.mailbox',
|
||||||
'messages.diplomacyMailbox',
|
|
||||||
'tournament',
|
'tournament',
|
||||||
'betting',
|
'betting',
|
||||||
]);
|
]);
|
||||||
@@ -88,9 +86,8 @@ export class ReadModelOutboxWorker implements ReadModelOutboxWakeup {
|
|||||||
this.db,
|
this.db,
|
||||||
async (payload) => {
|
async (payload) => {
|
||||||
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
|
const mailboxes = readModelOutboxPayloadToMessageMailboxes(payload);
|
||||||
const diplomacyMailboxes = readModelOutboxPayloadToDiplomacyMailboxes(payload);
|
if (mailboxes.length > 0) {
|
||||||
if (mailboxes.length > 0 || diplomacyMailboxes.length > 0) {
|
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes);
|
||||||
await publishRealtimeMessageChanges(this.redis, this.profileName, mailboxes, diplomacyMailboxes);
|
|
||||||
}
|
}
|
||||||
if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) {
|
if (payload.changes.some(([domain]) => !NON_DASHBOARD_DOMAINS.has(domain))) {
|
||||||
const changes = readModelOutboxPayloadToChanges(payload);
|
const changes = readModelOutboxPayloadToChanges(payload);
|
||||||
|
|||||||
@@ -13,7 +13,7 @@ import { MESSAGE_MAILBOX_NATIONAL_BASE, MESSAGE_MAILBOX_PUBLIC } from '@sammo-ts
|
|||||||
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
|
const uniqueIdentities = (identities: readonly RealtimeViewerIdentity[]): RealtimeViewerIdentity[] => {
|
||||||
const seen = new Set<string>();
|
const seen = new Set<string>();
|
||||||
return identities.filter((identity) => {
|
return identities.filter((identity) => {
|
||||||
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}:${identity.canReadDiplomacy}`;
|
const key = `${identity.generalId ?? ''}:${identity.cityId ?? ''}:${identity.nationId ?? ''}`;
|
||||||
if (seen.has(key)) return false;
|
if (seen.has(key)) return false;
|
||||||
seen.add(key);
|
seen.add(key);
|
||||||
return true;
|
return true;
|
||||||
@@ -44,7 +44,6 @@ export const shouldReloadRealtimeViewerIdentity = (event: RealtimeEvent, identit
|
|||||||
const changes = eventChanges(event);
|
const changes = eventChanges(event);
|
||||||
if (!changes) return false;
|
if (!changes) return false;
|
||||||
const generalId = identity.generalId;
|
const generalId = identity.generalId;
|
||||||
if (identity.nationId !== null && changes.nationIds.includes(identity.nationId)) return true;
|
|
||||||
return [
|
return [
|
||||||
changes.generalIds,
|
changes.generalIds,
|
||||||
changes.mapGeneralIds ?? changes.generalIds,
|
changes.mapGeneralIds ?? changes.generalIds,
|
||||||
@@ -70,20 +69,8 @@ export const toPublicRealtimeEvent = (
|
|||||||
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
|
identities.length > 0 ? identities : [{ generalId: null, cityId: null, nationId: null }]
|
||||||
);
|
);
|
||||||
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
|
if (event.type === 'messageCreated' || event.type === 'messagesChanged') {
|
||||||
const mailboxes =
|
const mailboxes = event.type === 'messageCreated' ? [event.mailbox] : event.mailboxes;
|
||||||
event.type === 'messageCreated' ? (event.msgType === 'diplomacy' ? [] : [event.mailbox]) : event.mailboxes;
|
return viewers.some((identity) => mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
|
||||||
const diplomacyMailboxes =
|
|
||||||
event.type === 'messageCreated'
|
|
||||||
? event.msgType === 'diplomacy'
|
|
||||||
? [event.mailbox]
|
|
||||||
: []
|
|
||||||
: (event.diplomacyMailboxes ?? []);
|
|
||||||
return viewers.some(
|
|
||||||
(identity) =>
|
|
||||||
mailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)) ||
|
|
||||||
(identity.canReadDiplomacy &&
|
|
||||||
diplomacyMailboxes.some((mailbox) => isMailboxRelevant(mailbox, identity)))
|
|
||||||
)
|
|
||||||
? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() }
|
? { type: 'messagesInvalidated', refreshGrant: createRefreshGrant() }
|
||||||
: null;
|
: null;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -34,15 +34,11 @@ export const publishRealtimeReadModelChanges = async (
|
|||||||
export const publishRealtimeMessageChanges = async (
|
export const publishRealtimeMessageChanges = async (
|
||||||
redis: RedisConnector['client'],
|
redis: RedisConnector['client'],
|
||||||
profileName: string,
|
profileName: string,
|
||||||
mailboxes: readonly number[],
|
mailboxes: readonly number[]
|
||||||
diplomacyMailboxes: readonly number[] = []
|
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
if (mailboxes.length === 0 && diplomacyMailboxes.length === 0) return;
|
if (mailboxes.length === 0) return;
|
||||||
await publishRealtimeEvent(redis, profileName, {
|
await publishRealtimeEvent(redis, profileName, {
|
||||||
type: 'messagesChanged',
|
type: 'messagesChanged',
|
||||||
mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right),
|
mailboxes: [...new Set(mailboxes)].sort((left, right) => left - right),
|
||||||
...(diplomacyMailboxes.length > 0
|
|
||||||
? { diplomacyMailboxes: [...new Set(diplomacyMailboxes)].sort((left, right) => left - right) }
|
|
||||||
: {}),
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -60,7 +60,10 @@ export interface AuctionDetail {
|
|||||||
export const hasAuctionClosePassed = (
|
export const hasAuctionClosePassed = (
|
||||||
auction: { closeAt: Date; closeTick: bigint | null },
|
auction: { closeAt: Date; closeTick: bigint | null },
|
||||||
time: { now: Date; tick: number | null }
|
time: { now: Date; tick: number | null }
|
||||||
): boolean => auction.closeTick === null || time.tick === null || auction.closeTick < BigInt(time.tick);
|
): boolean =>
|
||||||
|
auction.closeTick !== null && time.tick !== null
|
||||||
|
? auction.closeTick < BigInt(time.tick)
|
||||||
|
: auction.closeAt.getTime() < time.now.getTime();
|
||||||
|
|
||||||
interface AuctionBidRow {
|
interface AuctionBidRow {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -430,6 +433,7 @@ export const auctionRouter = router({
|
|||||||
auctionId: auction.id,
|
auctionId: auction.id,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
|
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||||
tryExtendCloseDate: true,
|
tryExtendCloseDate: true,
|
||||||
});
|
});
|
||||||
throwIfCommandRejected(result);
|
throwIfCommandRejected(result);
|
||||||
@@ -444,7 +448,7 @@ export const auctionRouter = router({
|
|||||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||||
const nextCloseAt = new Date(result.closeAt);
|
const nextCloseAt = new Date(result.closeAt);
|
||||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
|
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -507,6 +511,7 @@ export const auctionRouter = router({
|
|||||||
auctionId: auction.id,
|
auctionId: auction.id,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
|
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||||
tryExtendCloseDate: true,
|
tryExtendCloseDate: true,
|
||||||
});
|
});
|
||||||
throwIfCommandRejected(result);
|
throwIfCommandRejected(result);
|
||||||
@@ -521,7 +526,7 @@ export const auctionRouter = router({
|
|||||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||||
const nextCloseAt = new Date(result.closeAt);
|
const nextCloseAt = new Date(result.closeAt);
|
||||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
|
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
@@ -645,6 +650,7 @@ export const auctionRouter = router({
|
|||||||
auctionId: auction.id,
|
auctionId: auction.id,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
amount: input.amount,
|
amount: input.amount,
|
||||||
|
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||||
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
|
tryExtendCloseDate: input.tryExtendCloseDate ?? false,
|
||||||
});
|
});
|
||||||
throwIfCommandRejected(result);
|
throwIfCommandRejected(result);
|
||||||
@@ -659,7 +665,7 @@ export const auctionRouter = router({
|
|||||||
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
const timerKeys = buildAuctionTimerKeys(ctx.profile.name);
|
||||||
const nextCloseAt = new Date(result.closeAt);
|
const nextCloseAt = new Date(result.closeAt);
|
||||||
await ctx.redis.zAdd(timerKeys.timerKey, [
|
await ctx.redis.zAdd(timerKeys.timerKey, [
|
||||||
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt, BigInt(result.closeTick)), value: String(auction.id) },
|
{ score: resolveAuctionTimerScore(gameTime, nextCloseAt), value: String(auction.id) },
|
||||||
]);
|
]);
|
||||||
|
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
import { accessAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||||
@@ -29,43 +29,9 @@ const loadWorldDate = async (db: Parameters<typeof getMyGeneral>[0]['db']) => {
|
|||||||
return world;
|
return world;
|
||||||
};
|
};
|
||||||
|
|
||||||
interface BettingClockFenceRow {
|
|
||||||
currentYear: number;
|
|
||||||
currentMonth: number;
|
|
||||||
clockPhase: string;
|
|
||||||
clockRevision: bigint;
|
|
||||||
deadlineGeneration: bigint;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lockBettingClockFence = async (db: Parameters<typeof getMyGeneral>[0]['db']): Promise<BettingClockFenceRow> => {
|
|
||||||
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
|
||||||
const rows = await db.$queryRaw<BettingClockFenceRow[]>(GamePrisma.sql`
|
|
||||||
SELECT current_year AS "currentYear",
|
|
||||||
current_month AS "currentMonth",
|
|
||||||
clock_phase AS "clockPhase",
|
|
||||||
clock_revision AS "clockRevision",
|
|
||||||
deadline_generation AS "deadlineGeneration"
|
|
||||||
FROM world_state
|
|
||||||
ORDER BY id ASC
|
|
||||||
LIMIT 1
|
|
||||||
FOR UPDATE
|
|
||||||
`);
|
|
||||||
const world = rows[0];
|
|
||||||
if (!world) {
|
|
||||||
throw new TRPCError({ code: 'PRECONDITION_FAILED', message: 'World state not found.' });
|
|
||||||
}
|
|
||||||
if (!['RUNNING', 'MANUAL', 'SUSPENDED'].includes(world.clockPhase)) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'PRECONDITION_FAILED',
|
|
||||||
message: `Nation betting is disabled while the game clock phase is ${world.clockPhase}.`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return world;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const bettingRouter = router({
|
export const bettingRouter = router({
|
||||||
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional()).query(
|
getList: accessAuthedInputProcedure(z.object({ req: z.literal('bettingNation').optional() }).optional())
|
||||||
async ({ ctx, input }) => {
|
.query(async ({ ctx, input }) => {
|
||||||
requireUserId(ctx.auth);
|
requireUserId(ctx.auth);
|
||||||
await getMyGeneral(ctx);
|
await getMyGeneral(ctx);
|
||||||
const [world, rows] = await Promise.all([
|
const [world, rows] = await Promise.all([
|
||||||
@@ -100,8 +66,7 @@ export const bettingRouter = router({
|
|||||||
year: world.currentYear,
|
year: world.currentYear,
|
||||||
month: world.currentMonth,
|
month: world.currentMonth,
|
||||||
};
|
};
|
||||||
}
|
}),
|
||||||
),
|
|
||||||
|
|
||||||
getDetail: authedProcedure
|
getDetail: authedProcedure
|
||||||
.input(z.object({ bettingId: z.number().int().positive() }))
|
.input(z.object({ bettingId: z.number().int().positive() }))
|
||||||
@@ -176,7 +141,7 @@ export const bettingRouter = router({
|
|||||||
if (betting.finished) {
|
if (betting.finished) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 종료된 베팅입니다' });
|
||||||
}
|
}
|
||||||
const world = await lockBettingClockFence(ctx.db);
|
const world = await loadWorldDate(ctx.db);
|
||||||
const yearMonth = joinYearMonth(world.currentYear, world.currentMonth);
|
const yearMonth = joinYearMonth(world.currentYear, world.currentMonth);
|
||||||
if (betting.closeYearMonth <= yearMonth) {
|
if (betting.closeYearMonth <= yearMonth) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '이미 마감된 베팅입니다' });
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ import {
|
|||||||
import type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
|
import type { GameApiContext, GeneralRow, NationRow } from '../../context.js';
|
||||||
import { insertMessage } from '../../messages/store.js';
|
import { insertMessage } from '../../messages/store.js';
|
||||||
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
|
import { purifyDiplomacyHtml } from '../../security/diplomacyHtml.js';
|
||||||
import { readDatabaseWallTime } from '../../services/wallClock.js';
|
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||||
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
|
import { accessAuthedInputProcedure, accessAuthedProcedure, router } from '../../trpc.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
import { assertNationAccess, resolveNationPermission } from '../nation/shared.js';
|
||||||
@@ -86,9 +86,8 @@ const sendDocumentNotice = async (options: {
|
|||||||
await sendMessage(store, { ...draft, msgType: 'national' });
|
await sendMessage(store, { ...draft, msgType: 'national' });
|
||||||
}
|
}
|
||||||
|
|
||||||
const messageDomain = options.includeNational ? 'messages.mailbox' : 'messages.diplomacyMailbox';
|
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
|
||||||
options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.src.nationId);
|
options.ctx.changeJournal?.mark('messages.mailbox', MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
|
||||||
options.ctx.changeJournal?.mark(messageDomain, MESSAGE_MAILBOX_NATIONAL_BASE + options.dest.nationId);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
const resolvePermissionLevel = async (ctx: Parameters<typeof getMyGeneral>[0], nationId: number) => {
|
||||||
@@ -307,6 +306,8 @@ export const diplomacyRouter = router({
|
|||||||
nationColor: destNation.color,
|
nationColor: destNation.color,
|
||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
const letterDate = (await loadCurrentGameTime(ctx.db)).now;
|
||||||
|
|
||||||
const created = await ctx.db.diplomacyLetter.create({
|
const created = await ctx.db.diplomacyLetter.create({
|
||||||
data: {
|
data: {
|
||||||
srcNationId: srcNation.id,
|
srcNationId: srcNation.id,
|
||||||
@@ -315,6 +316,7 @@ export const diplomacyRouter = router({
|
|||||||
state: 'PROPOSED',
|
state: 'PROPOSED',
|
||||||
textBrief: purifyDiplomacyHtml(input.brief),
|
textBrief: purifyDiplomacyHtml(input.brief),
|
||||||
textDetail: purifyDiplomacyHtml(input.detail),
|
textDetail: purifyDiplomacyHtml(input.detail),
|
||||||
|
date: letterDate,
|
||||||
srcSignerId: me.id,
|
srcSignerId: me.id,
|
||||||
aux: aux as GamePrisma.InputJsonValue,
|
aux: aux as GamePrisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
@@ -330,7 +332,7 @@ export const diplomacyRouter = router({
|
|||||||
src: srcTarget,
|
src: srcTarget,
|
||||||
dest: destTarget,
|
dest: destTarget,
|
||||||
text,
|
text,
|
||||||
time: created.date,
|
time: letterDate,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { id: created.id };
|
return { id: created.id };
|
||||||
@@ -369,7 +371,7 @@ export const diplomacyRouter = router({
|
|||||||
);
|
);
|
||||||
const messageSrc = buildActorTarget(me, destNation);
|
const messageSrc = buildActorTarget(me, destNation);
|
||||||
const messageDest = buildNationTarget(srcNation);
|
const messageDest = buildNationTarget(srcNation);
|
||||||
const messageTime = await readDatabaseWallTime(ctx.db);
|
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
||||||
const aux = asRecord(letter.aux);
|
const aux = asRecord(letter.aux);
|
||||||
let messageText: string;
|
let messageText: string;
|
||||||
if (input.agree) {
|
if (input.agree) {
|
||||||
@@ -456,7 +458,7 @@ export const diplomacyRouter = router({
|
|||||||
);
|
);
|
||||||
const messageSrc = buildActorTarget(me, srcNation);
|
const messageSrc = buildActorTarget(me, srcNation);
|
||||||
const messageDest = buildNationTarget(destNation);
|
const messageDest = buildNationTarget(destNation);
|
||||||
const messageTime = await readDatabaseWallTime(ctx.db);
|
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
||||||
const aux = asRecord(letter.aux);
|
const aux = asRecord(letter.aux);
|
||||||
aux.reason = {
|
aux.reason = {
|
||||||
who: me.id,
|
who: me.id,
|
||||||
@@ -517,7 +519,7 @@ export const diplomacyRouter = router({
|
|||||||
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
|
const otherNation = letter.srcNationId === me.nationId ? destNation : srcNation;
|
||||||
const messageSrc = buildActorTarget(me, actorNation);
|
const messageSrc = buildActorTarget(me, actorNation);
|
||||||
const messageDest = buildNationTarget(otherNation);
|
const messageDest = buildNationTarget(otherNation);
|
||||||
const messageTime = await readDatabaseWallTime(ctx.db);
|
const messageTime = (await loadCurrentGameTime(ctx.db)).now;
|
||||||
let resultState: 'ACTIVATED' | 'CANCELLED';
|
let resultState: 'ACTIVATED' | 'CANCELLED';
|
||||||
let messageText: string;
|
let messageText: string;
|
||||||
|
|
||||||
|
|||||||
@@ -38,8 +38,6 @@ const BUFF_KEYS: InheritBuffType[] = [
|
|||||||
const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item'];
|
const UNIQUE_ITEM_SLOT_ORDER: readonly ItemSlot[] = ['horse', 'weapon', 'book', 'item'];
|
||||||
|
|
||||||
const POSTGRES_INTEGER_MAX = 2_147_483_647;
|
const POSTGRES_INTEGER_MAX = 2_147_483_647;
|
||||||
const MIN_INHERIT_BUFF_LEVEL = 1;
|
|
||||||
const MAX_INHERIT_BUFF_LEVEL = 5;
|
|
||||||
|
|
||||||
const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
const parseBuffRecord = (raw: unknown): Record<string, number> => {
|
||||||
if (typeof raw === 'string') {
|
if (typeof raw === 'string') {
|
||||||
@@ -304,7 +302,7 @@ export const inheritRouter = router({
|
|||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
type: z.enum(BUFF_KEYS),
|
type: z.enum(BUFF_KEYS),
|
||||||
level: z.number().int(),
|
level: z.number().int().min(1).max(5),
|
||||||
})
|
})
|
||||||
)
|
)
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
@@ -312,12 +310,6 @@ export const inheritRouter = router({
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
if (input.level < MIN_INHERIT_BUFF_LEVEL || input.level > MAX_INHERIT_BUFF_LEVEL) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: '유산 강화는 1단계부터 5단계까지만 구입할 수 있습니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await requestInheritanceAction(ctx, userId, {
|
const result = await requestInheritanceAction(ctx, userId, {
|
||||||
action: 'buyHiddenBuff',
|
action: 'buyHiddenBuff',
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ import { z } from 'zod';
|
|||||||
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
import type { GameApiContext, WorldStateRow } from '../../context.js';
|
||||||
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
||||||
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
|
import { asNumber, asRecord, asStringArray } from '@sammo-ts/common';
|
||||||
import { CLOCK_OPERATION_PERSISTENCE_LOCK, GamePrisma, acquireGameSchemaAdvisoryXactLock } from '@sammo-ts/infra';
|
|
||||||
import {
|
import {
|
||||||
isWarTraitKey,
|
isWarTraitKey,
|
||||||
JOIN_PERSONALITY_TRAIT_KEYS,
|
JOIN_PERSONALITY_TRAIT_KEYS,
|
||||||
@@ -335,11 +334,6 @@ export const joinRouter = router({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
const serverId = asRecord(worldState.meta).serverId;
|
|
||||||
const history =
|
|
||||||
typeof serverId === 'string'
|
|
||||||
? await ctx.db.gameHistory.findUnique({ where: { serverId }, select: { status: true } })
|
|
||||||
: null;
|
|
||||||
const inheritConst = resolveInheritConstants(worldState);
|
const inheritConst = resolveInheritConstants(worldState);
|
||||||
const inheritTotalPoint = ctx.auth?.user.id
|
const inheritTotalPoint = ctx.auth?.user.id
|
||||||
? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous')
|
? await readInheritancePoint(ctx.db, ctx.auth.user.id, 'previous')
|
||||||
@@ -377,7 +371,6 @@ export const joinRouter = router({
|
|||||||
npcGeneralCount,
|
npcGeneralCount,
|
||||||
},
|
},
|
||||||
inherit: {
|
inherit: {
|
||||||
enabled: history?.status !== 'COMPLETED',
|
|
||||||
totalPoint: inheritTotalPoint,
|
totalPoint: inheritTotalPoint,
|
||||||
costs: {
|
costs: {
|
||||||
inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint,
|
inheritBornSpecialPoint: inheritConst.inheritBornSpecialPoint,
|
||||||
@@ -400,12 +393,15 @@ export const joinRouter = router({
|
|||||||
if (!userId) {
|
if (!userId) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
}
|
}
|
||||||
|
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||||
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
|
const commandRequestId = resolveSelectionReservationRequestId(ctx.requestId, userId);
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'selectPoolReserve',
|
type: 'selectPoolReserve',
|
||||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||||
userId,
|
userId,
|
||||||
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
|
seedOwnerIdentity: ctx.auth?.user.legacyMemberNo ?? userId,
|
||||||
|
acceptedGameAt: gameTime.now.toISOString(),
|
||||||
|
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||||
});
|
});
|
||||||
return resolveSelectionReservationCommandResult(result);
|
return resolveSelectionReservationCommandResult(result);
|
||||||
}),
|
}),
|
||||||
@@ -435,6 +431,7 @@ export const joinRouter = router({
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
|
const commandRequestId = resolveSelectionRequestId(ctx.requestId, userId, input.clientRequestId, 'create');
|
||||||
|
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'selectPoolCreate',
|
type: 'selectPoolCreate',
|
||||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||||
@@ -443,6 +440,8 @@ export const joinRouter = router({
|
|||||||
uniqueName: input.uniqueName,
|
uniqueName: input.uniqueName,
|
||||||
personality: input.personality,
|
personality: input.personality,
|
||||||
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
|
seedOwnerIdentity: auth.user.legacyMemberNo ?? userId,
|
||||||
|
acceptedGameAt: gameTime.now.toISOString(),
|
||||||
|
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||||
...(selectedIcon
|
...(selectedIcon
|
||||||
? {
|
? {
|
||||||
ownerPicture: selectedIcon.picture,
|
ownerPicture: selectedIcon.picture,
|
||||||
@@ -472,12 +471,15 @@ export const joinRouter = router({
|
|||||||
input.clientRequestId,
|
input.clientRequestId,
|
||||||
'reselect'
|
'reselect'
|
||||||
);
|
);
|
||||||
|
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||||
const result = await ctx.turnDaemon.requestCommand({
|
const result = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'selectPoolReselect',
|
type: 'selectPoolReselect',
|
||||||
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
...(commandRequestId ? { requestId: commandRequestId } : {}),
|
||||||
userId,
|
userId,
|
||||||
ownerDisplayName: auth.user.displayName,
|
ownerDisplayName: auth.user.displayName,
|
||||||
uniqueName: input.uniqueName,
|
uniqueName: input.uniqueName,
|
||||||
|
acceptedGameAt: gameTime.now.toISOString(),
|
||||||
|
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||||
});
|
});
|
||||||
return resolveSelectionCommandResult(result, 'selectPoolReselect');
|
return resolveSelectionCommandResult(result, 'selectPoolReselect');
|
||||||
}),
|
}),
|
||||||
@@ -581,46 +583,30 @@ export const joinRouter = router({
|
|||||||
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
|
message: '이 서버에서는 카카오 인증을 완료해야 장수를 생성할 수 있습니다.',
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
const worldState = await ctx.db.worldState.findFirst();
|
||||||
|
if (!worldState) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'PRECONDITION_FAILED',
|
||||||
|
message: 'World state is not initialized.',
|
||||||
|
});
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
return await ctx.db.$transaction!(async (transaction) => {
|
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
if (gameTime.tick === null) {
|
||||||
const clockRows = await transaction.$queryRaw<Array<{ clockPhase: string }>>(GamePrisma.sql`
|
throw new TRPCError({
|
||||||
SELECT clock_phase AS "clockPhase"
|
code: 'PRECONDITION_FAILED',
|
||||||
FROM world_state
|
message: 'Game clock is not initialized.',
|
||||||
ORDER BY id ASC
|
|
||||||
LIMIT 1
|
|
||||||
FOR UPDATE
|
|
||||||
`);
|
|
||||||
if (!clockRows[0]) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'PRECONDITION_FAILED',
|
|
||||||
message: 'World state is not initialized.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
if (!['PREOPEN', 'RUNNING', 'MANUAL', 'SUSPENDED', 'COMPLETED'].includes(clockRows[0].clockPhase)) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'PRECONDITION_FAILED',
|
|
||||||
message: '게임 시계를 조정하는 동안은 NPC 빙의 후보를 갱신할 수 없습니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const worldState = await transaction.worldState.findFirst();
|
|
||||||
const gameTime = await loadCurrentGameTime(transaction);
|
|
||||||
if (!worldState || gameTime.tick === null) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'PRECONDITION_FAILED',
|
|
||||||
message: 'Game clock is not initialized.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return reserveNpcPossessionCandidates({
|
|
||||||
db: transaction,
|
|
||||||
worldState,
|
|
||||||
userId: auth.user.id,
|
|
||||||
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
|
|
||||||
refresh: input.refresh,
|
|
||||||
keepIds: input.keepIds,
|
|
||||||
now: gameTime.now,
|
|
||||||
createdGameTick: gameTime.tick,
|
|
||||||
});
|
});
|
||||||
|
}
|
||||||
|
return await reserveNpcPossessionCandidates({
|
||||||
|
db: ctx.db,
|
||||||
|
worldState,
|
||||||
|
userId: auth.user.id,
|
||||||
|
ownerIdentity: auth.user.legacyMemberNo ?? auth.user.id,
|
||||||
|
refresh: input.refresh,
|
||||||
|
keepIds: input.keepIds,
|
||||||
|
now: gameTime.now,
|
||||||
|
acceptedGameTick: gameTime.tick,
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
if (error instanceof NpcPossessionError) {
|
if (error instanceof NpcPossessionError) {
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ export const lobbyRouter = router({
|
|||||||
clockMode: gameTime.mode ?? 'realtime',
|
clockMode: gameTime.mode ?? 'realtime',
|
||||||
clockRunning: gameTime.running,
|
clockRunning: gameTime.running,
|
||||||
clockStartsAt: gameTime.startsAt?.toISOString() ?? null,
|
clockStartsAt: gameTime.startsAt?.toISOString() ?? null,
|
||||||
clockRecovery: gameTime.recovery ?? null,
|
|
||||||
turnEngineRunning,
|
turnEngineRunning,
|
||||||
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
otherTextInfo: worldState.meta.otherTextInfo ?? '',
|
||||||
npcMode: worldState.config.npcMode ?? 0,
|
npcMode: worldState.config.npcMode ?? 0,
|
||||||
|
|||||||
@@ -1,19 +1,11 @@
|
|||||||
import { TRPCError } from '@trpc/server';
|
import { TRPCError } from '@trpc/server';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { asRecord, ChangeJournal } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import { writeReadModelChangeJournal } from '@sammo-ts/infra';
|
|
||||||
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
import type { UserSanctions } from '@sammo-ts/common/auth/gameToken';
|
||||||
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
import { isMessageAccessBlocked } from '@sammo-ts/common/auth/sanctions';
|
||||||
|
|
||||||
import type { GameApiContext } from '../../context.js';
|
import type { GameApiContext } from '../../context.js';
|
||||||
import {
|
import { accessAuthedInputProcedure, accessLimitAuthedInputProcedure, authedProcedure, router } from '../../trpc.js';
|
||||||
accessLimitAuthedInputProcedure,
|
|
||||||
accessWallAuthedInputProcedure,
|
|
||||||
authedProcedure,
|
|
||||||
engineAuthedProcedure,
|
|
||||||
router,
|
|
||||||
wallAuthedProcedure,
|
|
||||||
} from '../../trpc.js';
|
|
||||||
import {
|
import {
|
||||||
MESSAGE_MAILBOX_NATIONAL_BASE,
|
MESSAGE_MAILBOX_NATIONAL_BASE,
|
||||||
MESSAGE_MAILBOX_PUBLIC,
|
MESSAGE_MAILBOX_PUBLIC,
|
||||||
@@ -28,13 +20,13 @@ import {
|
|||||||
fetchOldMessagesFromMailbox,
|
fetchOldMessagesFromMailbox,
|
||||||
fetchMessageById,
|
fetchMessageById,
|
||||||
insertMessage,
|
insertMessage,
|
||||||
tombstoneMessagesWithinDeleteWindow,
|
tombstoneMessages,
|
||||||
type MessageView,
|
type MessageView,
|
||||||
} from '../../messages/store.js';
|
} from '../../messages/store.js';
|
||||||
import { getOwnedGeneral } from '../shared/general.js';
|
import { getOwnedGeneral } from '../shared/general.js';
|
||||||
import { resolveNationPermission } from '../nation/shared.js';
|
import { resolveNationPermission } from '../nation/shared.js';
|
||||||
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
|
import { respondToDiplomaticMessage } from '../../messages/diplomaticResponse.js';
|
||||||
import { executeInputEvent } from '../../inputEventBoundary.js';
|
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||||
|
|
||||||
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
const zMessageType = z.enum(['private', 'public', 'national', 'diplomacy']);
|
||||||
|
|
||||||
@@ -49,7 +41,6 @@ const redactDiplomacyMessages = (messages: MessageView[], permission: number): M
|
|||||||
return {
|
return {
|
||||||
...message,
|
...message,
|
||||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||||
option: { ...(message.option ?? {}), permissionRedacted: true },
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -77,14 +68,9 @@ const hasPenalty = (penalty: unknown, key: string): boolean => {
|
|||||||
return value === true || value === 1 || value === '1';
|
return value === true || value === 1 || value === '1';
|
||||||
};
|
};
|
||||||
|
|
||||||
const markMessageMailboxes = (
|
const markMessageMailboxes = (ctx: Pick<GameApiContext, 'changeJournal'>, mailboxes: Iterable<number>): void => {
|
||||||
ctx: Pick<GameApiContext, 'changeJournal'>,
|
|
||||||
mailboxes: Iterable<number>,
|
|
||||||
msgType?: MessageType
|
|
||||||
): void => {
|
|
||||||
const domain = msgType === 'diplomacy' ? 'messages.diplomacyMailbox' : 'messages.mailbox';
|
|
||||||
for (const mailbox of mailboxes) {
|
for (const mailbox of mailboxes) {
|
||||||
ctx.changeJournal?.mark(domain, mailbox);
|
ctx.changeJournal?.mark('messages.mailbox', mailbox);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -245,7 +231,7 @@ export const messagesRouter = router({
|
|||||||
})),
|
})),
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
readLatest: wallAuthedProcedure
|
readLatest: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
@@ -278,7 +264,7 @@ export const messagesRouter = router({
|
|||||||
`;
|
`;
|
||||||
return { ok: true };
|
return { ok: true };
|
||||||
}),
|
}),
|
||||||
delete: wallAuthedProcedure
|
delete: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
@@ -303,16 +289,17 @@ export const messagesRouter = router({
|
|||||||
if (message.payload.option?.deletable === false) {
|
if (message.payload.option?.deletable === false) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제할 수 없는 메시지입니다.' });
|
||||||
}
|
}
|
||||||
|
const { now } = await loadCurrentGameTime(ctx.db);
|
||||||
|
if (now.getTime() - message.time.getTime() > 5 * 60 * 1000) {
|
||||||
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
|
||||||
|
}
|
||||||
const receiverMessageId = message.payload.option?.receiverMessageID;
|
const receiverMessageId = message.payload.option?.receiverMessageID;
|
||||||
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
|
const shouldDeleteReceiverCopy = message.msgType === 'private' || message.msgType === 'national';
|
||||||
const ids = [
|
const ids = [
|
||||||
message.id,
|
message.id,
|
||||||
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
|
...(shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' ? [receiverMessageId] : []),
|
||||||
];
|
];
|
||||||
const deletedIds = await tombstoneMessagesWithinDeleteWindow(ctx.db, message.id, ids);
|
await tombstoneMessages(ctx.db, ids);
|
||||||
if (!deletedIds.includes(message.id)) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '5분 이내의 메시지만 삭제할 수 있습니다.' });
|
|
||||||
}
|
|
||||||
const receiverMailbox =
|
const receiverMailbox =
|
||||||
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
|
shouldDeleteReceiverCopy && typeof receiverMessageId === 'number' && message.msgType === 'private'
|
||||||
? message.payload.dest.generalId
|
? message.payload.dest.generalId
|
||||||
@@ -321,14 +308,10 @@ export const messagesRouter = router({
|
|||||||
message.msgType === 'national'
|
message.msgType === 'national'
|
||||||
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
|
? MESSAGE_MAILBOX_NATIONAL_BASE + message.payload.dest.nationId
|
||||||
: null;
|
: null;
|
||||||
markMessageMailboxes(
|
markMessageMailboxes(ctx, [message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])]);
|
||||||
ctx,
|
return { ok: true, deletedIds: ids };
|
||||||
[message.mailbox, ...(receiverMailbox === null ? [] : [receiverMailbox])],
|
|
||||||
message.msgType
|
|
||||||
);
|
|
||||||
return { ok: true, deletedIds };
|
|
||||||
}),
|
}),
|
||||||
respond: engineAuthedProcedure
|
respond: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
@@ -343,9 +326,6 @@ export const messagesRouter = router({
|
|||||||
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: '메시지가 없습니다.' });
|
||||||
}
|
}
|
||||||
const action = message.payload.option?.action;
|
const action = message.payload.option?.action;
|
||||||
if (message.payload.option?.invalid === true) {
|
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
|
|
||||||
}
|
|
||||||
if (action === 'scout' || action === 'raiseInvader') {
|
if (action === 'scout' || action === 'raiseInvader') {
|
||||||
if (!ctx.auth) {
|
if (!ctx.auth) {
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
||||||
@@ -362,80 +342,29 @@ export const messagesRouter = router({
|
|||||||
}
|
}
|
||||||
return { result: commandResult.ok, reason: commandResult.reason };
|
return { result: commandResult.ok, reason: commandResult.reason };
|
||||||
}
|
}
|
||||||
const ownsChangeJournal = !ctx.changeJournal;
|
const result = await respondToDiplomaticMessage({
|
||||||
const changeJournal = ctx.changeJournal ?? new ChangeJournal();
|
|
||||||
let journalPersisted = false;
|
|
||||||
const response = await executeInputEvent({
|
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
requestId: `messages.respond.diplomatic:${input.messageId}`,
|
actor: general,
|
||||||
eventType: 'messages.respond.diplomatic',
|
messageId: input.messageId,
|
||||||
payload: input,
|
response: input.response,
|
||||||
actorUserId: ctx.auth?.user.id,
|
|
||||||
execute: async (transaction) => {
|
|
||||||
const transactionContext = { ...ctx, db: transaction, changeJournal };
|
|
||||||
const transactionGeneral = ownsChangeJournal
|
|
||||||
? await getOwnedGeneral(transactionContext, input.generalId)
|
|
||||||
: general;
|
|
||||||
const result = await respondToDiplomaticMessage({
|
|
||||||
db: transaction,
|
|
||||||
actor: transactionGeneral,
|
|
||||||
messageId: input.messageId,
|
|
||||||
response: input.response,
|
|
||||||
});
|
|
||||||
markMessageMailboxes(transactionContext, result.affectedMailboxes);
|
|
||||||
for (const generalId of result.affectedGeneralRecordIds) {
|
|
||||||
changeJournal.mark('records.general', generalId);
|
|
||||||
}
|
|
||||||
for (const nationId of result.affectedNationIds) {
|
|
||||||
changeJournal.mark('nation.content', nationId);
|
|
||||||
}
|
|
||||||
for (const cityId of result.affectedCityIds) {
|
|
||||||
changeJournal.mark('city.content', cityId);
|
|
||||||
}
|
|
||||||
if (result.affectedCityIds.length > 0) {
|
|
||||||
changeJournal.mark('map.world');
|
|
||||||
}
|
|
||||||
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
|
|
||||||
changeJournal.mark('dashboard.global');
|
|
||||||
}
|
|
||||||
if (ownsChangeJournal) {
|
|
||||||
journalPersisted = Boolean(
|
|
||||||
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
result: result.result,
|
|
||||||
reason: result.reason,
|
|
||||||
affectedNationIds: result.affectedNationIds,
|
|
||||||
affectedCityIds: result.affectedCityIds,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
if (journalPersisted) {
|
markMessageMailboxes(ctx, result.affectedMailboxes);
|
||||||
ctx.readModelOutbox?.wake();
|
for (const generalId of result.affectedGeneralRecordIds) {
|
||||||
|
ctx.changeJournal?.mark('records.general', generalId);
|
||||||
}
|
}
|
||||||
if (response.result && (response.affectedNationIds.length > 0 || response.affectedCityIds.length > 0)) {
|
for (const nationId of result.affectedNationIds) {
|
||||||
if (!ctx.auth) {
|
ctx.changeJournal?.mark('nation.content', nationId);
|
||||||
throw new TRPCError({ code: 'UNAUTHORIZED' });
|
|
||||||
}
|
|
||||||
const synchronized = await ctx.turnDaemon.requestCommand({
|
|
||||||
type: 'syncDiplomaticResponse',
|
|
||||||
userId: ctx.auth.user.id,
|
|
||||||
generalId: general.id,
|
|
||||||
messageId: input.messageId,
|
|
||||||
nationIds: response.affectedNationIds,
|
|
||||||
cityIds: response.affectedCityIds,
|
|
||||||
});
|
|
||||||
if (!synchronized || synchronized.type !== 'syncDiplomaticResponse' || !synchronized.ok) {
|
|
||||||
const synchronizationReason =
|
|
||||||
synchronized?.type === 'syncDiplomaticResponse' ? synchronized.reason : undefined;
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'INTERNAL_SERVER_ERROR',
|
|
||||||
message: synchronizationReason ?? '외교 상태를 게임 엔진에 동기화하지 못했습니다.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return { result: response.result, reason: response.reason };
|
for (const cityId of result.affectedCityIds) {
|
||||||
|
ctx.changeJournal?.mark('city.content', cityId);
|
||||||
|
}
|
||||||
|
if (result.affectedCityIds.length > 0) {
|
||||||
|
ctx.changeJournal?.mark('map.world');
|
||||||
|
}
|
||||||
|
if (result.affectedNationIds.length > 0 || result.affectedCityIds.length > 0) {
|
||||||
|
ctx.changeJournal?.mark('dashboard.global');
|
||||||
|
}
|
||||||
|
return { result: result.result, reason: result.reason };
|
||||||
}),
|
}),
|
||||||
getOld: authedProcedure
|
getOld: authedProcedure
|
||||||
.input(
|
.input(
|
||||||
@@ -491,7 +420,7 @@ export const messagesRouter = router({
|
|||||||
...messageBuckets,
|
...messageBuckets,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
send: accessWallAuthedInputProcedure(
|
send: accessAuthedInputProcedure(
|
||||||
z.object({
|
z.object({
|
||||||
generalId: z.number().int().positive(),
|
generalId: z.number().int().positive(),
|
||||||
mailbox: z.number().int(),
|
mailbox: z.number().int(),
|
||||||
@@ -507,9 +436,7 @@ export const messagesRouter = router({
|
|||||||
}
|
}
|
||||||
|
|
||||||
const src = await buildTargetFromGeneral(ctx.db, general);
|
const src = await buildTargetFromGeneral(ctx.db, general);
|
||||||
// Compatibility-only projection. persistMessageEnvelope records and
|
const { now } = await loadCurrentGameTime(ctx.db);
|
||||||
// displays the authoritative PostgreSQL wall instant.
|
|
||||||
const now = new Date();
|
|
||||||
const validUntil = new Date('9999-12-31T00:00:00Z');
|
const validUntil = new Date('9999-12-31T00:00:00Z');
|
||||||
|
|
||||||
let msgType: MessageType;
|
let msgType: MessageType;
|
||||||
@@ -633,7 +560,7 @@ export const messagesRouter = router({
|
|||||||
: msgType === 'private'
|
: msgType === 'private'
|
||||||
? general.id
|
? general.id
|
||||||
: MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId;
|
: MESSAGE_MAILBOX_NATIONAL_BASE + general.nationId;
|
||||||
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])], msgType);
|
markMessageMailboxes(ctx, [receiverMailbox, ...(senderMailbox === null ? [] : [senderMailbox])]);
|
||||||
|
|
||||||
return { msgType, msgId: result.receiverId };
|
return { msgType, msgId: result.receiverId };
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -89,11 +89,6 @@ export const getSecretGeneralList = accessAuthedProcedure.query(async ({ ctx })
|
|||||||
name: general.name,
|
name: general.name,
|
||||||
npcState: general.npcState,
|
npcState: general.npcState,
|
||||||
injury: general.injury,
|
injury: general.injury,
|
||||||
baseStats: {
|
|
||||||
leadership: general.leadership,
|
|
||||||
strength: general.strength,
|
|
||||||
intelligence: general.intel,
|
|
||||||
},
|
|
||||||
stats: {
|
stats: {
|
||||||
leadership: woundedStat(general.leadership, general.injury),
|
leadership: woundedStat(general.leadership, general.injury),
|
||||||
strength: woundedStat(general.strength, general.injury),
|
strength: woundedStat(general.strength, general.injury),
|
||||||
|
|||||||
@@ -109,17 +109,11 @@ const resolveScenarioStat = (config: Record<string, unknown>): { max: number; np
|
|||||||
};
|
};
|
||||||
|
|
||||||
const resolveCommandEnv = (
|
const resolveCommandEnv = (
|
||||||
config: Record<string, unknown>,
|
config: Record<string, unknown>
|
||||||
worldMeta: Record<string, unknown>
|
|
||||||
): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => {
|
): { develCost: number; defaultCrewTypeId: number; maxTechLevel: number } => {
|
||||||
const constValues = asRecord(config.const ?? config.consts);
|
const constValues = asRecord(config.const ?? config.consts);
|
||||||
const configuredDevelCost = resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0);
|
|
||||||
return {
|
return {
|
||||||
develCost: resolveNumberFromKeys(
|
develCost: resolveNumberFromKeys(constValues, ['develCost', 'develcost', 'develrate'], 0),
|
||||||
worldMeta,
|
|
||||||
['develcost', 'develCost', 'develrate'],
|
|
||||||
configuredDevelCost
|
|
||||||
),
|
|
||||||
defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0),
|
defaultCrewTypeId: resolveNumberFromKeys(constValues, ['defaultCrewTypeId'], 0),
|
||||||
maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12),
|
maxTechLevel: resolveNumberFromKeys(constValues, ['maxTechLevel'], 12),
|
||||||
};
|
};
|
||||||
@@ -415,7 +409,7 @@ export const npcRouter = router({
|
|||||||
|
|
||||||
const config = asRecord(worldState.config);
|
const config = asRecord(worldState.config);
|
||||||
const stat = resolveScenarioStat(config);
|
const stat = resolveScenarioStat(config);
|
||||||
const env = resolveCommandEnv(config, worldMeta);
|
const env = resolveCommandEnv(config);
|
||||||
const unitSetName = resolveUnitSetName(config, 'che');
|
const unitSetName = resolveUnitSetName(config, 'che');
|
||||||
const nationTech = readNumber(nation.tech, 0);
|
const nationTech = readNumber(nation.tech, 0);
|
||||||
|
|
||||||
|
|||||||
@@ -5,14 +5,12 @@ import { asRecord } from '@sammo-ts/common';
|
|||||||
import type { TournamentType } from '@sammo-ts/logic';
|
import type { TournamentType } from '@sammo-ts/logic';
|
||||||
import type { TournamentState } from '../../tournament/types.js';
|
import type { TournamentState } from '../../tournament/types.js';
|
||||||
|
|
||||||
import { TournamentStore, type TournamentClockContext } from '../../tournament/store.js';
|
import { TournamentStore } from '../../tournament/store.js';
|
||||||
import { buildTournamentKeys } from '../../tournament/keys.js';
|
import { buildTournamentKeys } from '../../tournament/keys.js';
|
||||||
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
|
import { assignManualApplicantGroup } from '../../tournament/workerHelpers.js';
|
||||||
import { accessAuthedProcedure, authedProcedure, engineAuthedProcedure, procedure, router } from '../../trpc.js';
|
import { accessAuthedProcedure, authedProcedure, router } from '../../trpc.js';
|
||||||
import { getMyGeneral } from '../shared/general.js';
|
import { getMyGeneral } from '../shared/general.js';
|
||||||
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
import { loadCurrentGameTime } from '../../services/gameClock.js';
|
||||||
import { ensureActiveRedisClockFence, ensureBettingRedisClockFence } from '../../services/redisClockFence.js';
|
|
||||||
import { loadClockAdminStatus } from '../../services/clockReadiness.js';
|
|
||||||
|
|
||||||
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
const hasAdminRole = (roles: string[], profileName: string): boolean => {
|
||||||
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
if (roles.includes('superuser') || roles.includes('admin') || roles.includes('admin.superuser')) {
|
||||||
@@ -38,85 +36,13 @@ const resolveCurrentDevelCost = (worldState: { config?: unknown; meta?: unknown
|
|||||||
return resolveNumber(asRecord(worldState?.meta), ['develcost', 'develCost', 'develrate'], configured);
|
return resolveNumber(asRecord(worldState?.meta), ['develcost', 'develCost', 'develrate'], configured);
|
||||||
};
|
};
|
||||||
|
|
||||||
const adminProcedure = engineAuthedProcedure
|
const adminProcedure = authedProcedure.use(({ ctx, next }) => {
|
||||||
.use(({ ctx, next }) => {
|
const roles = ctx.auth?.user.roles ?? [];
|
||||||
const roles = ctx.auth?.user.roles ?? [];
|
if (!hasAdminRole(roles, ctx.profile.name)) {
|
||||||
if (!hasAdminRole(roles, ctx.profile.name)) {
|
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' });
|
||||||
throw new TRPCError({ code: 'FORBIDDEN', message: 'Admin permission is required.' });
|
|
||||||
}
|
|
||||||
return next();
|
|
||||||
})
|
|
||||||
.use(async ({ ctx, type, next }) => {
|
|
||||||
if (type !== 'mutation') return next({ ctx: { tournamentMutationLockHeld: false } });
|
|
||||||
// 참가·베팅은 Redis lock 안에서 ENGINE의 DB commit을 기다린다.
|
|
||||||
// 관리자도 Redis를 먼저 잡아 DB clock fence → Redis 역순 대기를 막는다.
|
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
|
||||||
return store.withMutationLock(() => next({ ctx: { tournamentMutationLockHeld: true } }));
|
|
||||||
})
|
|
||||||
.concat(procedure);
|
|
||||||
|
|
||||||
const withTournamentClockMutation = async <T>(
|
|
||||||
ctx: {
|
|
||||||
db: Parameters<typeof loadCurrentGameTime>[0];
|
|
||||||
redis: Parameters<typeof ensureActiveRedisClockFence>[0];
|
|
||||||
profile: { name: string };
|
|
||||||
tournamentMutationLockHeld?: boolean;
|
|
||||||
},
|
|
||||||
store: TournamentStore,
|
|
||||||
operation: () => Promise<T>
|
|
||||||
): Promise<T> => {
|
|
||||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
|
||||||
const fence = await ensureActiveRedisClockFence(ctx.redis, ctx.profile.name, gameTime);
|
|
||||||
if (!fence) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'PRECONDITION_FAILED',
|
|
||||||
message: 'Clock reconciliation is incomplete; tournament mutation is disabled.',
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
const clockContext: TournamentClockContext = {
|
return next();
|
||||||
phase: fence.phase,
|
});
|
||||||
revision: fence.revision,
|
|
||||||
deadlineGeneration: fence.generation,
|
|
||||||
dateToTick: gameTime.dateToTick,
|
|
||||||
};
|
|
||||||
return store.withClockContext(clockContext, () =>
|
|
||||||
ctx.tournamentMutationLockHeld ? operation() : store.withMutationLock(operation)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const withTournamentBetClockMutation = async <T>(
|
|
||||||
ctx: {
|
|
||||||
db: Parameters<typeof loadCurrentGameTime>[0];
|
|
||||||
redis: Parameters<typeof ensureBettingRedisClockFence>[0];
|
|
||||||
profile: { name: string };
|
|
||||||
},
|
|
||||||
store: TournamentStore,
|
|
||||||
operation: () => Promise<T>
|
|
||||||
): Promise<T> => {
|
|
||||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
|
||||||
const fence = await ensureBettingRedisClockFence(ctx.redis, ctx.profile.name, gameTime);
|
|
||||||
if (!fence) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'PRECONDITION_FAILED',
|
|
||||||
message: 'Clock reconciliation is incomplete; tournament betting is disabled.',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
return store.withClockContext(
|
|
||||||
{
|
|
||||||
phase: fence.phase,
|
|
||||||
revision: fence.revision,
|
|
||||||
deadlineGeneration: fence.generation,
|
|
||||||
dateToTick: gameTime.dateToTick,
|
|
||||||
},
|
|
||||||
() => store.withMutationLock(operation)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const tournamentBetCommandRequestId = (requestId: string | undefined, step: string): string | undefined =>
|
|
||||||
requestId ? `${requestId}:tournamentBet:${step}` : undefined;
|
|
||||||
|
|
||||||
const tournamentJoinCommandRequestId = (requestId: string | undefined, step: string): string | undefined =>
|
|
||||||
requestId ? `${requestId}:tournamentJoin:${step}` : undefined;
|
|
||||||
|
|
||||||
const zTournamentState = z.object({
|
const zTournamentState = z.object({
|
||||||
stage: z.number().int().min(0),
|
stage: z.number().int().min(0),
|
||||||
@@ -217,10 +143,7 @@ export const tournamentRouter = router({
|
|||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
return store.getState();
|
return store.getState();
|
||||||
}),
|
}),
|
||||||
getAdminStatus: adminProcedure.query(async ({ ctx }) => ({
|
getAdminStatus: adminProcedure.query(async () => ({ ok: true })),
|
||||||
ok: true,
|
|
||||||
clock: await loadClockAdminStatus(ctx.db),
|
|
||||||
})),
|
|
||||||
getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => {
|
getSnapshot: accessAuthedProcedure.query(async ({ ctx }) => {
|
||||||
await getMyGeneral(ctx);
|
await getMyGeneral(ctx);
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
@@ -348,7 +271,7 @@ export const tournamentRouter = router({
|
|||||||
}),
|
}),
|
||||||
setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => {
|
setState: adminProcedure.input(zTournamentState).mutation(async ({ ctx, input }) => {
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
return withTournamentClockMutation(ctx, store, async () => {
|
return store.withMutationLock(async () => {
|
||||||
await store.setState({
|
await store.setState({
|
||||||
...input,
|
...input,
|
||||||
type: input.type as TournamentType,
|
type: input.type as TournamentType,
|
||||||
@@ -358,7 +281,7 @@ export const tournamentRouter = router({
|
|||||||
}),
|
}),
|
||||||
patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => {
|
patchState: adminProcedure.input(zTournamentState.partial()).mutation(async ({ ctx, input }) => {
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
return withTournamentClockMutation(ctx, store, async () => {
|
return store.withMutationLock(async () => {
|
||||||
const current = await store.getState();
|
const current = await store.getState();
|
||||||
if (!current) {
|
if (!current) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
||||||
@@ -374,21 +297,21 @@ export const tournamentRouter = router({
|
|||||||
}),
|
}),
|
||||||
setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => {
|
setParticipants: adminProcedure.input(z.array(zParticipant)).mutation(async ({ ctx, input }) => {
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
return withTournamentClockMutation(ctx, store, async () => {
|
return store.withMutationLock(async () => {
|
||||||
await store.setParticipants(input);
|
await store.setParticipants(input);
|
||||||
return { ok: true, count: input.length };
|
return { ok: true, count: input.length };
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => {
|
setMatches: adminProcedure.input(z.array(zMatch)).mutation(async ({ ctx, input }) => {
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
return withTournamentClockMutation(ctx, store, async () => {
|
return store.withMutationLock(async () => {
|
||||||
await store.setMatches(input);
|
await store.setMatches(input);
|
||||||
return { ok: true, count: input.length };
|
return { ok: true, count: input.length };
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => {
|
setBettingEntries: adminProcedure.input(z.array(zBetEntry)).mutation(async ({ ctx, input }) => {
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
return withTournamentClockMutation(ctx, store, async () => {
|
return store.withMutationLock(async () => {
|
||||||
await store.setBettingEntries(input);
|
await store.setBettingEntries(input);
|
||||||
return { ok: true, count: input.length };
|
return { ok: true, count: input.length };
|
||||||
});
|
});
|
||||||
@@ -424,7 +347,7 @@ export const tournamentRouter = router({
|
|||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
return withTournamentClockMutation(ctx, store, async () => {
|
return store.withMutationLock(async () => {
|
||||||
await store.setParticipants(participants);
|
await store.setParticipants(participants);
|
||||||
return { ok: true, count: participants.length };
|
return { ok: true, count: participants.length };
|
||||||
});
|
});
|
||||||
@@ -468,12 +391,10 @@ export const tournamentRouter = router({
|
|||||||
|
|
||||||
return { state, totals, myTotals, totalAmount, myAmount };
|
return { state, totals, myTotals, totalAmount, myAmount };
|
||||||
}),
|
}),
|
||||||
// 참가비는 ENGINE transaction이 차감한다. API input-event transaction으로
|
join: authedProcedure.mutation(async ({ ctx }) => {
|
||||||
// 감싸면 clock advisory lock을 쥔 채 child ENGINE event를 기다리게 된다.
|
|
||||||
join: engineAuthedProcedure.mutation(async ({ ctx }) => {
|
|
||||||
const general = await getMyGeneral(ctx);
|
const general = await getMyGeneral(ctx);
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
return withTournamentClockMutation(ctx, store, async () => {
|
return store.withMutationLock(async () => {
|
||||||
const state = await store.getState();
|
const state = await store.getState();
|
||||||
if (!state || state.stage !== 1 || state.participantsLockedAt) {
|
if (!state || state.stage !== 1 || state.participantsLockedAt) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '참가 신청 기간이 아닙니다.' });
|
||||||
@@ -493,7 +414,6 @@ export const tournamentRouter = router({
|
|||||||
const develCost = resolveCurrentDevelCost(worldState);
|
const develCost = resolveCurrentDevelCost(worldState);
|
||||||
const feeResult = await ctx.turnDaemon.requestCommand({
|
const feeResult = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'adjustGeneralResources',
|
||||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'resources'),
|
|
||||||
reason: 'tournamentJoin',
|
reason: 'tournamentJoin',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
|
adjustments: [{ generalId: general.id, goldDelta: -develCost, minGoldAfter: 0 }],
|
||||||
});
|
});
|
||||||
@@ -529,7 +449,6 @@ export const tournamentRouter = router({
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
await ctx.turnDaemon.requestCommand({
|
await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'adjustGeneralResources',
|
||||||
requestId: tournamentJoinCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
|
||||||
reason: 'tournamentJoinRollback',
|
reason: 'tournamentJoinRollback',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
adjustments: [{ generalId: general.id, goldDelta: develCost }],
|
||||||
});
|
});
|
||||||
@@ -540,7 +459,7 @@ export const tournamentRouter = router({
|
|||||||
}),
|
}),
|
||||||
cancel: adminProcedure.mutation(async ({ ctx }) => {
|
cancel: adminProcedure.mutation(async ({ ctx }) => {
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
return withTournamentClockMutation(ctx, store, async () => {
|
return store.withMutationLock(async () => {
|
||||||
const state = await store.getState();
|
const state = await store.getState();
|
||||||
if (!state) {
|
if (!state) {
|
||||||
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
throw new TRPCError({ code: 'NOT_FOUND', message: 'Tournament state not found.' });
|
||||||
@@ -594,10 +513,7 @@ export const tournamentRouter = router({
|
|||||||
return { ok: true };
|
return { ok: true };
|
||||||
});
|
});
|
||||||
}),
|
}),
|
||||||
// This route delegates its game mutations to durable ENGINE input events.
|
placeBet: authedProcedure
|
||||||
// Wrapping it in the API input-event transaction would hold the clock
|
|
||||||
// advisory lock while waiting for the daemon to claim the child event.
|
|
||||||
placeBet: engineAuthedProcedure
|
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
targetId: z.number().int().positive(),
|
targetId: z.number().int().positive(),
|
||||||
@@ -607,7 +523,7 @@ export const tournamentRouter = router({
|
|||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const general = await getMyGeneral(ctx);
|
const general = await getMyGeneral(ctx);
|
||||||
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
const store = new TournamentStore(ctx.redis, buildTournamentKeys(ctx.profile.name));
|
||||||
return withTournamentBetClockMutation(ctx, store, async () => {
|
return store.withMutationLock(async () => {
|
||||||
const state = await store.getState();
|
const state = await store.getState();
|
||||||
if (!state || state.stage !== 6) {
|
if (!state || state.stage !== 6) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '베팅 기간이 아닙니다.' });
|
||||||
@@ -642,7 +558,6 @@ export const tournamentRouter = router({
|
|||||||
|
|
||||||
const adjustResult = await ctx.turnDaemon.requestCommand({
|
const adjustResult = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'adjustGeneralResources',
|
||||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'resources'),
|
|
||||||
reason: 'tournamentBet',
|
reason: 'tournamentBet',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
|
adjustments: [{ generalId: general.id, goldDelta: -input.amount, minGoldAfter: 500 }],
|
||||||
});
|
});
|
||||||
@@ -658,7 +573,6 @@ export const tournamentRouter = router({
|
|||||||
|
|
||||||
const rankResult = await ctx.turnDaemon.requestCommand({
|
const rankResult = await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralMeta',
|
type: 'adjustGeneralMeta',
|
||||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank'),
|
|
||||||
reason: 'tournamentBet',
|
reason: 'tournamentBet',
|
||||||
adjustments: [
|
adjustments: [
|
||||||
{
|
{
|
||||||
@@ -670,7 +584,6 @@ export const tournamentRouter = router({
|
|||||||
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
|
if (!rankResult || rankResult.type !== 'adjustGeneralMeta' || !rankResult.ok) {
|
||||||
await ctx.turnDaemon.requestCommand({
|
await ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'adjustGeneralResources',
|
||||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'rank-rollback-resources'),
|
|
||||||
reason: 'tournamentBetRollback',
|
reason: 'tournamentBetRollback',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||||
});
|
});
|
||||||
@@ -687,13 +600,11 @@ export const tournamentRouter = router({
|
|||||||
await Promise.all([
|
await Promise.all([
|
||||||
ctx.turnDaemon.requestCommand({
|
ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralResources',
|
type: 'adjustGeneralResources',
|
||||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-resources'),
|
|
||||||
reason: 'tournamentBetRollback',
|
reason: 'tournamentBetRollback',
|
||||||
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
adjustments: [{ generalId: general.id, goldDelta: input.amount }],
|
||||||
}),
|
}),
|
||||||
ctx.turnDaemon.requestCommand({
|
ctx.turnDaemon.requestCommand({
|
||||||
type: 'adjustGeneralMeta',
|
type: 'adjustGeneralMeta',
|
||||||
requestId: tournamentBetCommandRequestId(ctx.requestId, 'projection-rollback-rank'),
|
|
||||||
reason: 'tournamentBetRollback',
|
reason: 'tournamentBetRollback',
|
||||||
adjustments: [
|
adjustments: [
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -224,10 +224,7 @@ export const troopRouter = router({
|
|||||||
name: troop.name,
|
name: troop.name,
|
||||||
nationId: troop.nationId,
|
nationId: troop.nationId,
|
||||||
turnTime: leader?.turnTime.toISOString() ?? null,
|
turnTime: leader?.turnTime.toISOString() ?? null,
|
||||||
reservedCommands:
|
reservedCommands: reservedByLeader.get(troop.troopLeaderId) ?? [],
|
||||||
leader?.npcState === 5
|
|
||||||
? ['집합', '집합', '집합', '집합', '집합']
|
|
||||||
: (reservedByLeader.get(troop.troopLeaderId) ?? []),
|
|
||||||
leader: leader
|
leader: leader
|
||||||
? {
|
? {
|
||||||
id: leader.id,
|
id: leader.id,
|
||||||
|
|||||||
@@ -17,7 +17,6 @@ import {
|
|||||||
assertReservedTurnActionAvailable,
|
assertReservedTurnActionAvailable,
|
||||||
assertReservedTurnArgsPassLegacyBasicValidation,
|
assertReservedTurnArgsPassLegacyBasicValidation,
|
||||||
buildEquipmentTradeItemOptions,
|
buildEquipmentTradeItemOptions,
|
||||||
loadEquipmentTradeItemOrder,
|
|
||||||
parseReservedTurnArgs,
|
parseReservedTurnArgs,
|
||||||
TURN_COMMAND_NATION_COLORS,
|
TURN_COMMAND_NATION_COLORS,
|
||||||
type TurnCommandInputOptions,
|
type TurnCommandInputOptions,
|
||||||
@@ -273,7 +272,6 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
traits,
|
traits,
|
||||||
moduleBundle,
|
moduleBundle,
|
||||||
map,
|
map,
|
||||||
itemOrder,
|
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
general.cityId > 0
|
general.cityId > 0
|
||||||
? ctx.db.city.findUnique({
|
? ctx.db.city.findUnique({
|
||||||
@@ -312,9 +310,6 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
officerLevel: true,
|
officerLevel: true,
|
||||||
gold: true,
|
gold: true,
|
||||||
rice: true,
|
rice: true,
|
||||||
leadership: true,
|
|
||||||
strength: true,
|
|
||||||
intel: true,
|
|
||||||
crew: true,
|
crew: true,
|
||||||
train: true,
|
train: true,
|
||||||
atmos: true,
|
atmos: true,
|
||||||
@@ -332,7 +327,6 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
loadBattleSimTraitOptions(),
|
loadBattleSimTraitOptions(),
|
||||||
moduleBundlePromise,
|
moduleBundlePromise,
|
||||||
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
|
loadMapDefinitionByName(resolveMapName(worldState, ctx.profile.id)),
|
||||||
loadEquipmentTradeItemOrder(worldState.scenarioCode),
|
|
||||||
]);
|
]);
|
||||||
|
|
||||||
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
const nationById = new Map(nations.map((entry) => [entry.id, entry]));
|
||||||
@@ -386,17 +380,10 @@ export const getTurnCommandTable = async (ctx: GameApiContext, generalId: number
|
|||||||
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
|
troopNames: new Map(troops.map((entry) => [entry.troopLeaderId, entry.name])),
|
||||||
});
|
});
|
||||||
const items = buildEquipmentTradeItemOptions({
|
const items = buildEquipmentTradeItemOptions({
|
||||||
itemOrder,
|
|
||||||
configConst: asRecord(asRecord(worldState.config).const),
|
configConst: asRecord(asRecord(worldState.config).const),
|
||||||
itemModules: moduleBundle.itemModules,
|
itemModules: moduleBundle.itemModules,
|
||||||
currentSecurity: city?.security ?? 0,
|
currentSecurity: city?.security ?? 0,
|
||||||
generalGold: general.gold,
|
generalGold: general.gold,
|
||||||
ownedItems: {
|
|
||||||
horse: general.horseCode,
|
|
||||||
weapon: general.weaponCode,
|
|
||||||
book: general.bookCode,
|
|
||||||
item: general.itemCode,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const inputOptions: TurnCommandInputOptions = {
|
const inputOptions: TurnCommandInputOptions = {
|
||||||
cities: cities.map((entry) => ({
|
cities: cities.map((entry) => ({
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { z } from 'zod';
|
|||||||
import { asRecord } from '@sammo-ts/common';
|
import { asRecord } from '@sammo-ts/common';
|
||||||
import { GamePrisma } from '@sammo-ts/infra';
|
import { GamePrisma } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { authedProcedure, engineAuthedProcedure, router } from '../../trpc.js';
|
import { authedProcedure, router } from '../../trpc.js';
|
||||||
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
|
import { getAuthenticatedUserId, getMyGeneral } from '../shared/general.js';
|
||||||
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
|
import { loadCurrentGameTime, type CurrentGameTime } from '../../services/gameClock.js';
|
||||||
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
|
import { throwIfCommandRejected } from '../shared/turnDaemon.js';
|
||||||
@@ -102,13 +102,18 @@ export const hasPollEnded = (
|
|||||||
time: CurrentGameTime
|
time: CurrentGameTime
|
||||||
): boolean =>
|
): boolean =>
|
||||||
Boolean(poll.closed_at) ||
|
Boolean(poll.closed_at) ||
|
||||||
Boolean(poll.end_at && (poll.end_tick === null || time.tick === null || poll.end_tick < BigInt(time.tick)));
|
(poll.end_tick !== null && time.tick !== null
|
||||||
|
? poll.end_tick < BigInt(time.tick)
|
||||||
|
: Boolean(poll.end_at && poll.end_at.getTime() < time.now.getTime()));
|
||||||
|
|
||||||
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
|
const toGameTickOrNull = (time: CurrentGameTime, date: Date | null): bigint | null => {
|
||||||
if (!date) return null;
|
if (!date) return null;
|
||||||
const tick = time.dateToTick(date);
|
try {
|
||||||
if (tick === null) throw new Error('Vote GAME_TIME deadline requires an initialized game clock.');
|
const tick = time.dateToTick(date);
|
||||||
return BigInt(tick);
|
return tick === null ? null : BigInt(tick);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
type VoteListRow = {
|
type VoteListRow = {
|
||||||
@@ -287,9 +292,7 @@ export const voteRouter = router({
|
|||||||
userCnt,
|
userCnt,
|
||||||
};
|
};
|
||||||
}),
|
}),
|
||||||
// 투표·보상은 ENGINE transaction이 소유한다. API가 clock fence를 잡고
|
submitVote: authedProcedure
|
||||||
// 결과를 기다리면 같은 fence가 필요한 데몬이 진행하지 못한다.
|
|
||||||
submitVote: engineAuthedProcedure
|
|
||||||
.input(
|
.input(
|
||||||
z.object({
|
z.object({
|
||||||
voteId: z.number().int().positive(),
|
voteId: z.number().int().positive(),
|
||||||
@@ -355,6 +358,7 @@ export const voteRouter = router({
|
|||||||
voteId: input.voteId,
|
voteId: input.voteId,
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
selection: sortedSelection,
|
selection: sortedSelection,
|
||||||
|
...(gameTime.tick === null ? {} : { acceptedGameTick: gameTime.tick }),
|
||||||
});
|
});
|
||||||
throwIfCommandRejected(rewardResult);
|
throwIfCommandRejected(rewardResult);
|
||||||
|
|
||||||
@@ -365,6 +369,7 @@ export const voteRouter = router({
|
|||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: rewardResult.reason });
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx.changeJournal?.mark('front.general', general.id);
|
||||||
return { ok: true, wonLottery: rewardResult.awardedUnique };
|
return { ok: true, wonLottery: rewardResult.awardedUnique };
|
||||||
}),
|
}),
|
||||||
addComment: authedProcedure
|
addComment: authedProcedure
|
||||||
@@ -394,6 +399,8 @@ export const voteRouter = router({
|
|||||||
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
|
? await ctx.db.nation.findFirst({ where: { id: general.nationId }, select: { name: true } })
|
||||||
: null;
|
: null;
|
||||||
const nationName = nation?.name ?? '재야';
|
const nationName = nation?.name ?? '재야';
|
||||||
|
const createdAt = new Date();
|
||||||
|
|
||||||
await ctx.db.$queryRaw(GamePrisma.sql`
|
await ctx.db.$queryRaw(GamePrisma.sql`
|
||||||
INSERT INTO vote_comment (
|
INSERT INTO vote_comment (
|
||||||
vote_id,
|
vote_id,
|
||||||
@@ -411,7 +418,7 @@ export const voteRouter = router({
|
|||||||
${general.name},
|
${general.name},
|
||||||
${nationName},
|
${nationName},
|
||||||
${input.text},
|
${input.text},
|
||||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
${createdAt}
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -444,6 +451,7 @@ export const voteRouter = router({
|
|||||||
if (endAt && endAt < gameTime.now) {
|
if (endAt && endAt < gameTime.now) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
||||||
}
|
}
|
||||||
|
const operationalAt = new Date();
|
||||||
|
|
||||||
let multipleOptions = input.multipleOptions;
|
let multipleOptions = input.multipleOptions;
|
||||||
if (multipleOptions < 0) {
|
if (multipleOptions < 0) {
|
||||||
@@ -456,8 +464,7 @@ export const voteRouter = router({
|
|||||||
if (input.closePrevious) {
|
if (input.closePrevious) {
|
||||||
await ctx.db.$queryRaw(GamePrisma.sql`
|
await ctx.db.$queryRaw(GamePrisma.sql`
|
||||||
UPDATE vote_poll
|
UPDATE vote_poll
|
||||||
SET closed_at = ${gameTime.now},
|
SET closed_at = ${gameTime.now}, updated_at = ${operationalAt}
|
||||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
|
||||||
WHERE closed_at IS NULL
|
WHERE closed_at IS NULL
|
||||||
`);
|
`);
|
||||||
}
|
}
|
||||||
@@ -490,8 +497,8 @@ export const voteRouter = router({
|
|||||||
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
|
${gameTime.tick === null ? null : BigInt(gameTime.tick)},
|
||||||
${endAt},
|
${endAt},
|
||||||
${toGameTickOrNull(gameTime, endAt)},
|
${toGameTickOrNull(gameTime, endAt)},
|
||||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
${operationalAt},
|
||||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
${operationalAt}
|
||||||
)
|
)
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -566,6 +573,7 @@ export const voteRouter = router({
|
|||||||
if (endAt && endAt < gameTime.now) {
|
if (endAt && endAt < gameTime.now) {
|
||||||
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
throw new TRPCError({ code: 'BAD_REQUEST', message: '종료일이 이미 지났습니다.' });
|
||||||
}
|
}
|
||||||
|
const updatedAt = new Date();
|
||||||
|
|
||||||
if (
|
if (
|
||||||
input.title === undefined &&
|
input.title === undefined &&
|
||||||
@@ -588,7 +596,7 @@ export const voteRouter = router({
|
|||||||
reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
|
reveal_mode = COALESCE(${input.revealMode}, reveal_mode),
|
||||||
end_at = ${endAt ?? poll.end_at},
|
end_at = ${endAt ?? poll.end_at},
|
||||||
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
|
end_tick = ${endAt ? toGameTickOrNull(gameTime, endAt) : poll.end_tick},
|
||||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
updated_at = ${updatedAt}
|
||||||
WHERE id = ${input.voteId}
|
WHERE id = ${input.voteId}
|
||||||
`);
|
`);
|
||||||
|
|
||||||
@@ -601,10 +609,10 @@ export const voteRouter = router({
|
|||||||
.input(z.object({ voteId: z.number().int().positive() }))
|
.input(z.object({ voteId: z.number().int().positive() }))
|
||||||
.mutation(async ({ ctx, input }) => {
|
.mutation(async ({ ctx, input }) => {
|
||||||
const gameTime = await loadCurrentGameTime(ctx.db);
|
const gameTime = await loadCurrentGameTime(ctx.db);
|
||||||
|
const updatedAt = new Date();
|
||||||
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
const rows = await ctx.db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||||
UPDATE vote_poll
|
UPDATE vote_poll
|
||||||
SET closed_at = ${gameTime.now},
|
SET closed_at = ${gameTime.now}, updated_at = ${updatedAt}
|
||||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
|
||||||
WHERE id = ${input.voteId}
|
WHERE id = ${input.voteId}
|
||||||
RETURNING id
|
RETURNING id
|
||||||
`);
|
`);
|
||||||
|
|||||||
+10
-30
@@ -38,7 +38,6 @@ import {
|
|||||||
shouldReloadRealtimeViewerIdentity,
|
shouldReloadRealtimeViewerIdentity,
|
||||||
toPublicRealtimeEvent,
|
toPublicRealtimeEvent,
|
||||||
} from './realtime/publicEvent.js';
|
} from './realtime/publicEvent.js';
|
||||||
import { resolveNationPermission } from './router/nation/shared.js';
|
|
||||||
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
|
import { GatewayHttpAccountIconSource } from './auth/accountIconSource.js';
|
||||||
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
|
import { GatewayHttpProfileStatusSource } from './auth/profileStatusSource.js';
|
||||||
import { CachedTurnEngineStatus } from './services/turnEngineStatus.js';
|
import { CachedTurnEngineStatus } from './services/turnEngineStatus.js';
|
||||||
@@ -51,7 +50,6 @@ import { ReadModelOutboxWorker } from './realtime/outboxWorker.js';
|
|||||||
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
|
import { DeferredGeneralAccessWorker } from './services/deferredGeneralAccess.js';
|
||||||
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
|
import { WebPushOutboxWorker } from './services/webPushOutboxWorker.js';
|
||||||
import { scopeHttpIdempotencyKey } from './requestId.js';
|
import { scopeHttpIdempotencyKey } from './requestId.js';
|
||||||
import { loadClockReadiness } from './services/clockReadiness.js';
|
|
||||||
|
|
||||||
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
const extractBearerToken = (value: string | string[] | undefined): string | null => {
|
||||||
if (!value) {
|
if (!value) {
|
||||||
@@ -314,24 +312,11 @@ export const createGameApiServer = async () => {
|
|||||||
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
const loadViewerIdentity = async (): Promise<RealtimeViewerIdentity> => {
|
||||||
const general = await postgres.prisma.general.findFirst({
|
const general = await postgres.prisma.general.findFirst({
|
||||||
where: { userId: auth.user.id, npcState: 0 },
|
where: { userId: auth.user.id, npcState: 0 },
|
||||||
select: { id: true, cityId: true, nationId: true, officerLevel: true, meta: true, penalty: true },
|
select: { id: true, cityId: true, nationId: true },
|
||||||
});
|
});
|
||||||
if (!general) {
|
return general
|
||||||
return { generalId: null, cityId: null, nationId: null, canReadDiplomacy: false };
|
? { generalId: general.id, cityId: general.cityId, nationId: general.nationId }
|
||||||
}
|
: { generalId: null, cityId: null, nationId: null };
|
||||||
const nation =
|
|
||||||
general.nationId > 0
|
|
||||||
? await postgres.prisma.nation.findUnique({
|
|
||||||
where: { id: general.nationId },
|
|
||||||
select: { meta: true },
|
|
||||||
})
|
|
||||||
: null;
|
|
||||||
return {
|
|
||||||
generalId: general.id,
|
|
||||||
cityId: general.cityId,
|
|
||||||
nationId: general.nationId,
|
|
||||||
canReadDiplomacy: Boolean(nation && resolveNationPermission(general, nation.meta, false) >= 3),
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
let viewerIdentity = await loadViewerIdentity();
|
let viewerIdentity = await loadViewerIdentity();
|
||||||
|
|
||||||
@@ -435,17 +420,12 @@ export const createGameApiServer = async () => {
|
|||||||
request.raw.on('aborted', close);
|
request.raw.on('aborted', close);
|
||||||
});
|
});
|
||||||
|
|
||||||
app.get('/healthz', async (_request, reply) => {
|
app.get('/healthz', async () => ({
|
||||||
const clock = await loadClockReadiness(postgres.prisma);
|
ok: true,
|
||||||
if (!clock.reconciliationComplete) reply.code(503);
|
profile: config.profileName,
|
||||||
return {
|
postgresPool: postgres.getPoolStats(),
|
||||||
ok: clock.reconciliationComplete,
|
accountIconReconciliation: accountIconResetReconciler.getHealth(),
|
||||||
profile: config.profileName,
|
}));
|
||||||
postgresPool: postgres.getPoolStats(),
|
|
||||||
accountIconReconciliation: accountIconResetReconciler.getHealth(),
|
|
||||||
clock,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await realtimeHub.start();
|
await realtimeHub.start();
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
import { parseGameClockPhase } from '@sammo-ts/common';
|
|
||||||
|
|
||||||
import type { DatabaseClient } from '../context.js';
|
|
||||||
|
|
||||||
const safeInteger = (value: bigint, label: string): number => {
|
|
||||||
const result = Number(value);
|
|
||||||
if (!Number.isSafeInteger(result)) throw new Error(`${label} is outside the safe integer range.`);
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const loadClockReadiness = async (db: DatabaseClient) => {
|
|
||||||
if (!db.clockProjectionOutbox) {
|
|
||||||
return {
|
|
||||||
reconciliationComplete: false,
|
|
||||||
gameplayEnabled: false,
|
|
||||||
phase: null,
|
|
||||||
revision: null,
|
|
||||||
deadlineGeneration: null,
|
|
||||||
incompleteOutboxCount: null,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const [world, incompleteOutboxCount] = await Promise.all([
|
|
||||||
db.worldState.findFirst({
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true },
|
|
||||||
}),
|
|
||||||
db.clockProjectionOutbox.count({ where: { status: { not: 'APPLIED' } } }),
|
|
||||||
]);
|
|
||||||
if (!world) {
|
|
||||||
return {
|
|
||||||
reconciliationComplete: false,
|
|
||||||
gameplayEnabled: false,
|
|
||||||
phase: null,
|
|
||||||
revision: null,
|
|
||||||
deadlineGeneration: null,
|
|
||||||
incompleteOutboxCount,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const phase = parseGameClockPhase(world.clockPhase);
|
|
||||||
return {
|
|
||||||
reconciliationComplete: phase !== 'RECONCILING' && incompleteOutboxCount === 0,
|
|
||||||
gameplayEnabled: phase === 'RUNNING' || phase === 'MANUAL',
|
|
||||||
phase,
|
|
||||||
revision: safeInteger(world.clockRevision, 'clock revision'),
|
|
||||||
deadlineGeneration: safeInteger(world.deadlineGeneration, 'deadline generation'),
|
|
||||||
incompleteOutboxCount,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
export const loadClockAdminStatus = async (db: DatabaseClient) => {
|
|
||||||
const readiness = await loadClockReadiness(db);
|
|
||||||
if (!db.clockSuspension) {
|
|
||||||
return { ...readiness, latestReconciliation: null };
|
|
||||||
}
|
|
||||||
const latest = await db.clockSuspension.findFirst({
|
|
||||||
orderBy: { createdAt: 'desc' },
|
|
||||||
include: {
|
|
||||||
participants: { orderBy: { participantKey: 'asc' } },
|
|
||||||
projectionOutbox: { orderBy: { id: 'asc' } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!latest) return { ...readiness, latestReconciliation: null };
|
|
||||||
return {
|
|
||||||
...readiness,
|
|
||||||
latestReconciliation: {
|
|
||||||
id: latest.id,
|
|
||||||
source: latest.source,
|
|
||||||
policy: latest.policy,
|
|
||||||
status: latest.status,
|
|
||||||
sourceRevision: safeInteger(latest.sourceRevision, 'source revision'),
|
|
||||||
targetRevision: safeInteger(latest.targetRevision, 'target revision'),
|
|
||||||
cutTick: safeInteger(latest.cutTick, 'cut tick'),
|
|
||||||
alignedTick: latest.alignedTick === null ? null : safeInteger(latest.alignedTick, 'aligned tick'),
|
|
||||||
participantChecksumBefore: latest.participantChecksumBefore,
|
|
||||||
participantChecksumAfter: latest.participantChecksumAfter,
|
|
||||||
participants: latest.participants.map((participant) => ({
|
|
||||||
key: participant.participantKey,
|
|
||||||
policy: participant.policy,
|
|
||||||
beforeChecksum: participant.beforeChecksum,
|
|
||||||
afterChecksum: participant.afterChecksum,
|
|
||||||
affectedCount: participant.affectedCount,
|
|
||||||
})),
|
|
||||||
outbox: latest.projectionOutbox.map((entry) => ({
|
|
||||||
id: entry.id.toString(),
|
|
||||||
targetRevision: safeInteger(entry.targetRevision, 'outbox target revision'),
|
|
||||||
status: entry.status,
|
|
||||||
attempts: entry.attempts,
|
|
||||||
lastError: entry.lastError,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,26 +1,13 @@
|
|||||||
import {
|
import { GameClock, type GameClockMode } from '@sammo-ts/common';
|
||||||
GameClock,
|
|
||||||
readTurnRecovery,
|
|
||||||
inferClockPhase,
|
|
||||||
parseGameClockPhase,
|
|
||||||
type GameClockMode,
|
|
||||||
type GameClockPhase,
|
|
||||||
} from '@sammo-ts/common';
|
|
||||||
|
|
||||||
import type { DatabaseClient } from '../context.js';
|
import type { DatabaseClient } from '../context.js';
|
||||||
import { readTurnRuntimeReady } from '@sammo-ts/infra';
|
|
||||||
|
|
||||||
export interface CurrentGameTime {
|
export interface CurrentGameTime {
|
||||||
now: Date;
|
now: Date;
|
||||||
wallNow: Date;
|
wallNow: Date;
|
||||||
tick: number | null;
|
tick: number | null;
|
||||||
mode: GameClockMode | null;
|
mode: GameClockMode | null;
|
||||||
phase?: GameClockPhase | null;
|
|
||||||
revision?: number | null;
|
|
||||||
deadlineGeneration?: number | null;
|
|
||||||
running: boolean;
|
running: boolean;
|
||||||
runtimeReady?: boolean;
|
|
||||||
recovery?: { startsAt: string; endsAt: string } | null;
|
|
||||||
startsAt: Date | null;
|
startsAt: Date | null;
|
||||||
dateToTick(date: Date): number | null;
|
dateToTick(date: Date): number | null;
|
||||||
}
|
}
|
||||||
@@ -32,9 +19,6 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
|||||||
wallNow,
|
wallNow,
|
||||||
tick: null,
|
tick: null,
|
||||||
mode: null,
|
mode: null,
|
||||||
phase: null,
|
|
||||||
revision: null,
|
|
||||||
deadlineGeneration: null,
|
|
||||||
running: true,
|
running: true,
|
||||||
startsAt: null,
|
startsAt: null,
|
||||||
dateToTick: () => null,
|
dateToTick: () => null,
|
||||||
@@ -47,13 +31,7 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
|||||||
clockTick: true,
|
clockTick: true,
|
||||||
clockMode: true,
|
clockMode: true,
|
||||||
clockWallAnchor: true,
|
clockWallAnchor: true,
|
||||||
clockRecoveryStartTick: true,
|
|
||||||
clockRecoveryEndTick: true,
|
|
||||||
clockRecoveryStartWallAt: true,
|
|
||||||
tickSeconds: true,
|
tickSeconds: true,
|
||||||
clockPhase: true,
|
|
||||||
clockRevision: true,
|
|
||||||
deadlineGeneration: true,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) {
|
if (!state?.clockBaseTime || state.clockTick === null || !state.clockWallAnchor) {
|
||||||
@@ -62,68 +40,32 @@ export const loadCurrentGameTime = async (db: DatabaseClient, wallNow = new Date
|
|||||||
wallNow,
|
wallNow,
|
||||||
tick: null,
|
tick: null,
|
||||||
mode: null,
|
mode: null,
|
||||||
phase: null,
|
|
||||||
revision: null,
|
|
||||||
deadlineGeneration: null,
|
|
||||||
running: true,
|
running: true,
|
||||||
startsAt: null,
|
startsAt: null,
|
||||||
dateToTick: () => null,
|
dateToTick: () => null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime';
|
const mode: GameClockMode = state.clockMode === 'manual' ? 'manual' : 'realtime';
|
||||||
// During the dual-read migration an older profile (or a rolling-deploy
|
|
||||||
// fixture) can lack clock_phase. Preserve the existing future-anchor
|
|
||||||
// PREOPEN contract until every profile has the authoritative column.
|
|
||||||
const phase = state.clockPhase
|
|
||||||
? parseGameClockPhase(state.clockPhase)
|
|
||||||
: mode === 'realtime' && wallNow.getTime() < state.clockWallAnchor.getTime()
|
|
||||||
? 'PREOPEN'
|
|
||||||
: inferClockPhase(mode);
|
|
||||||
const storedTick = Number(state.clockTick);
|
const storedTick = Number(state.clockTick);
|
||||||
if (!Number.isSafeInteger(storedTick)) {
|
if (!Number.isSafeInteger(storedTick)) {
|
||||||
throw new Error(`world_state.clock_tick is outside the JavaScript safe integer range: ${state.clockTick}`);
|
throw new Error(`world_state.clock_tick is outside the JavaScript safe integer range: ${state.clockTick}`);
|
||||||
}
|
}
|
||||||
const revision = Number(state.clockRevision ?? 1n);
|
|
||||||
const deadlineGeneration = Number(state.deadlineGeneration ?? 1n);
|
|
||||||
if (!Number.isSafeInteger(revision) || !Number.isSafeInteger(deadlineGeneration)) {
|
|
||||||
throw new Error('world_state clock revision or deadline generation is outside the safe integer range.');
|
|
||||||
}
|
|
||||||
const clock = new GameClock({
|
const clock = new GameClock({
|
||||||
baseTime: state.clockBaseTime,
|
baseTime: state.clockBaseTime,
|
||||||
tick: storedTick,
|
tick: storedTick,
|
||||||
mode,
|
mode,
|
||||||
wallAnchor: state.clockWallAnchor,
|
wallAnchor: state.clockWallAnchor,
|
||||||
recovery: readTurnRecovery(state),
|
|
||||||
turnSeconds: state.tickSeconds,
|
turnSeconds: state.tickSeconds,
|
||||||
phase,
|
|
||||||
revision,
|
|
||||||
});
|
});
|
||||||
const runtimeReady =
|
const tick = clock.nowTick(wallNow);
|
||||||
phase !== 'RUNNING' ||
|
const running = mode === 'realtime' && wallNow.getTime() >= state.clockWallAnchor.getTime();
|
||||||
mode !== 'realtime' ||
|
|
||||||
state.clockRecoveryStartTick === undefined ||
|
|
||||||
(await readTurnRuntimeReady(db, state.clockRevision));
|
|
||||||
const tick = runtimeReady ? clock.nowTick(wallNow) : clock.tick;
|
|
||||||
const running = runtimeReady && phase === 'RUNNING' && mode === 'realtime' && wallNow >= clock.wallAnchor;
|
|
||||||
return {
|
return {
|
||||||
now: clock.tickToDate(tick),
|
now: clock.tickToDate(tick),
|
||||||
wallNow,
|
wallNow,
|
||||||
tick,
|
tick,
|
||||||
mode,
|
mode,
|
||||||
phase,
|
|
||||||
revision,
|
|
||||||
deadlineGeneration,
|
|
||||||
running,
|
running,
|
||||||
runtimeReady,
|
startsAt: mode === 'realtime' && !running ? state.clockWallAnchor : null,
|
||||||
recovery:
|
|
||||||
clock.recovery && clock.tickToWallDate(clock.recovery.endTick) > wallNow
|
|
||||||
? {
|
|
||||||
startsAt: clock.recovery.startWallAt.toISOString(),
|
|
||||||
endsAt: clock.tickToWallDate(clock.recovery.endTick).toISOString(),
|
|
||||||
}
|
|
||||||
: null,
|
|
||||||
startsAt:
|
|
||||||
phase === 'PREOPEN' || (phase === 'RUNNING' && wallNow < clock.wallAnchor) ? state.clockWallAnchor : null,
|
|
||||||
dateToTick: (date) => clock.dateToTick(date),
|
dateToTick: (date) => clock.dateToTick(date),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,86 +0,0 @@
|
|||||||
import type { CurrentGameTime } from './gameClock.js';
|
|
||||||
import type { GameClockPhase } from '@sammo-ts/common';
|
|
||||||
|
|
||||||
interface ClockFenceRedis {
|
|
||||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const BOOTSTRAP_CLOCK_FENCE_SCRIPT = `
|
|
||||||
local revision = redis.call('GET', KEYS[1])
|
|
||||||
local generation = redis.call('GET', KEYS[2])
|
|
||||||
local phase = redis.call('GET', KEYS[3])
|
|
||||||
if not revision and not generation and not phase then
|
|
||||||
redis.call('SET', KEYS[1], ARGV[1])
|
|
||||||
redis.call('SET', KEYS[2], ARGV[2])
|
|
||||||
redis.call('SET', KEYS[3], ARGV[3])
|
|
||||||
return 1
|
|
||||||
end
|
|
||||||
if revision == ARGV[1] and generation == ARGV[2] and phase == ARGV[3] then
|
|
||||||
return 2
|
|
||||||
end
|
|
||||||
return 0
|
|
||||||
`;
|
|
||||||
|
|
||||||
export interface ActiveRedisClockFence {
|
|
||||||
activeRevisionKey: string;
|
|
||||||
deadlineGenerationKey: string;
|
|
||||||
phaseKey: string;
|
|
||||||
revision: number;
|
|
||||||
generation: number;
|
|
||||||
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
|
|
||||||
}
|
|
||||||
|
|
||||||
type MutableProjectionPhase = ActiveRedisClockFence['phase'];
|
|
||||||
|
|
||||||
const ensureRedisClockFence = async (
|
|
||||||
redis: ClockFenceRedis,
|
|
||||||
profileName: string,
|
|
||||||
gameTime: CurrentGameTime,
|
|
||||||
allowedPhases: readonly GameClockPhase[]
|
|
||||||
): Promise<ActiveRedisClockFence | null> => {
|
|
||||||
if (
|
|
||||||
gameTime.runtimeReady === false ||
|
|
||||||
!gameTime.phase ||
|
|
||||||
!allowedPhases.includes(gameTime.phase) ||
|
|
||||||
(gameTime.phase !== 'RUNNING' && gameTime.phase !== 'MANUAL' && gameTime.phase !== 'SUSPENDED') ||
|
|
||||||
!Number.isSafeInteger(gameTime.revision) ||
|
|
||||||
!Number.isSafeInteger(gameTime.deadlineGeneration)
|
|
||||||
) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
const phase: MutableProjectionPhase = gameTime.phase;
|
|
||||||
const fence: ActiveRedisClockFence = {
|
|
||||||
activeRevisionKey: `sammo:${profileName}:clock:active-revision`,
|
|
||||||
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
|
|
||||||
phaseKey: `sammo:${profileName}:clock:phase`,
|
|
||||||
revision: gameTime.revision!,
|
|
||||||
generation: gameTime.deadlineGeneration!,
|
|
||||||
phase,
|
|
||||||
};
|
|
||||||
const result = await redis.eval(BOOTSTRAP_CLOCK_FENCE_SCRIPT, {
|
|
||||||
keys: [fence.activeRevisionKey, fence.deadlineGenerationKey, fence.phaseKey],
|
|
||||||
arguments: [String(fence.revision), String(fence.generation), phase],
|
|
||||||
});
|
|
||||||
return Number(result) === 1 || Number(result) === 2 ? fence : null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const ensureActiveRedisClockFence = async (
|
|
||||||
redis: ClockFenceRedis,
|
|
||||||
profileName: string,
|
|
||||||
gameTime: CurrentGameTime
|
|
||||||
): Promise<ActiveRedisClockFence | null> => {
|
|
||||||
if (!gameTime.running) return null;
|
|
||||||
return ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING']);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* User betting is allowed against a frozen tournament deadline while the game
|
|
||||||
* clock is suspended. Stage progression and settlement continue to use the
|
|
||||||
* RUNNING-only helper above.
|
|
||||||
*/
|
|
||||||
export const ensureBettingRedisClockFence = async (
|
|
||||||
redis: ClockFenceRedis,
|
|
||||||
profileName: string,
|
|
||||||
gameTime: CurrentGameTime
|
|
||||||
): Promise<ActiveRedisClockFence | null> =>
|
|
||||||
ensureRedisClockFence(redis, profileName, gameTime, ['RUNNING', 'MANUAL', 'SUSPENDED']);
|
|
||||||
@@ -1,37 +1,32 @@
|
|||||||
import { performance } from 'node:perf_hooks';
|
|
||||||
|
|
||||||
import { gatewayProfileCapabilities } from '@sammo-ts/common';
|
import { gatewayProfileCapabilities } from '@sammo-ts/common';
|
||||||
import { GamePrisma } from '@sammo-ts/infra';
|
|
||||||
|
|
||||||
import type { ProfileStatusSource } from '../auth/profileStatusSource.js';
|
import type { ProfileStatusSource } from '../auth/profileStatusSource.js';
|
||||||
|
|
||||||
interface TurnDaemonLeaseSource {
|
interface TurnDaemonLeaseSource {
|
||||||
$queryRaw<T>(query: GamePrisma.Sql): Promise<T>;
|
turnDaemonLease: {
|
||||||
|
findUnique(input: {
|
||||||
|
where: { profile: string };
|
||||||
|
select: { leaseUntil: true };
|
||||||
|
}): Promise<{ leaseUntil: Date } | null>;
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export const loadTurnEngineRunning = async (
|
export const loadTurnEngineRunning = async (
|
||||||
source: ProfileStatusSource | undefined,
|
source: ProfileStatusSource | undefined,
|
||||||
db: TurnDaemonLeaseSource,
|
db: TurnDaemonLeaseSource,
|
||||||
profileName: string,
|
profileName: string,
|
||||||
now?: Date
|
now = new Date()
|
||||||
): Promise<boolean | null> => {
|
): Promise<boolean | null> => {
|
||||||
if (!source) return null;
|
if (!source) return null;
|
||||||
try {
|
try {
|
||||||
const status = await source.get(profileName);
|
const status = await source.get(profileName);
|
||||||
if (status === null) return null;
|
if (status === null) return null;
|
||||||
if (!gatewayProfileCapabilities(status).turnsRunning) return false;
|
if (!gatewayProfileCapabilities(status).turnsRunning) return false;
|
||||||
const wallNow = now
|
const lease = await db.turnDaemonLease.findUnique({
|
||||||
? GamePrisma.sql`${now}`
|
where: { profile: profileName },
|
||||||
: GamePrisma.sql`(CURRENT_TIMESTAMP AT TIME ZONE 'UTC')`;
|
select: { leaseUntil: true },
|
||||||
const rows = await db.$queryRaw<Array<{ running: boolean }>>(GamePrisma.sql`
|
});
|
||||||
SELECT EXISTS (
|
return lease !== null && lease.leaseUntil.getTime() > now.getTime();
|
||||||
SELECT 1
|
|
||||||
FROM turn_daemon_lease
|
|
||||||
WHERE profile = ${profileName}
|
|
||||||
AND lease_until > ${wallNow}
|
|
||||||
) AS running
|
|
||||||
`);
|
|
||||||
return rows[0]?.running ?? false;
|
|
||||||
} catch {
|
} catch {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
@@ -47,7 +42,7 @@ export class CachedTurnEngineStatus {
|
|||||||
private readonly db: TurnDaemonLeaseSource,
|
private readonly db: TurnDaemonLeaseSource,
|
||||||
private readonly profileName: string,
|
private readonly profileName: string,
|
||||||
private readonly cacheMs = 2_000,
|
private readonly cacheMs = 2_000,
|
||||||
private readonly now = () => performance.now()
|
private readonly now = () => Date.now()
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
get(): Promise<boolean | null> {
|
get(): Promise<boolean | null> {
|
||||||
|
|||||||
@@ -1,13 +0,0 @@
|
|||||||
import { GamePrisma } from '@sammo-ts/infra';
|
|
||||||
|
|
||||||
import type { DatabaseClient } from '../context.js';
|
|
||||||
|
|
||||||
/** Reads the authoritative PostgreSQL UTC wall instant for business rules. */
|
|
||||||
export const readDatabaseWallTime = async (db: Pick<DatabaseClient, '$queryRaw'>): Promise<Date> => {
|
|
||||||
const rows = await db.$queryRaw<Array<{ wallNow: Date }>>(GamePrisma.sql`
|
|
||||||
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS "wallNow"
|
|
||||||
`);
|
|
||||||
const wallNow = rows[0]?.wallNow;
|
|
||||||
if (!wallNow) throw new Error('Failed to read PostgreSQL wall time.');
|
|
||||||
return new Date(wallNow);
|
|
||||||
};
|
|
||||||
@@ -1,5 +1,4 @@
|
|||||||
import { createHmac, randomUUID } from 'node:crypto';
|
import { createHmac, randomUUID } from 'node:crypto';
|
||||||
import { performance } from 'node:perf_hooks';
|
|
||||||
|
|
||||||
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
|
import type { WebPushEventEnvelopeV1, WebPushEventType } from '@sammo-ts/common';
|
||||||
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
@@ -56,13 +55,10 @@ export class WebPushOutboxWorker {
|
|||||||
`);
|
`);
|
||||||
if (rows.length === 0) return [];
|
if (rows.length === 0) return [];
|
||||||
const ids = rows.map((row) => row.id);
|
const ids = rows.map((row) => row.id);
|
||||||
await tx.$executeRaw(GamePrisma.sql`
|
await tx.webPushOutbox.updateMany({
|
||||||
UPDATE "web_push_outbox"
|
where: { id: { in: ids } },
|
||||||
SET "locked_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
data: { lockedAt: new Date(), lockOwner: this.owner, attempts: { increment: 1 } },
|
||||||
"lock_owner" = ${this.owner},
|
});
|
||||||
"attempts" = "attempts" + 1
|
|
||||||
WHERE "id" IN (${GamePrisma.join(ids)})
|
|
||||||
`);
|
|
||||||
return tx.webPushOutbox.findMany({
|
return tx.webPushOutbox.findMany({
|
||||||
where: { id: { in: ids }, lockOwner: this.owner },
|
where: { id: { in: ids }, lockOwner: this.owner },
|
||||||
orderBy: { id: 'asc' },
|
orderBy: { id: 'asc' },
|
||||||
@@ -70,18 +66,11 @@ export class WebPushOutboxWorker {
|
|||||||
});
|
});
|
||||||
|
|
||||||
for (const event of claimed) {
|
for (const event of claimed) {
|
||||||
const expired = await this.db.$executeRaw(GamePrisma.sql`
|
if (event.createdAt.getTime() <= Date.now() - MAX_EVENT_AGE_MS) {
|
||||||
UPDATE "web_push_outbox"
|
await this.db.webPushOutbox.updateMany({
|
||||||
SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
where: { id: event.id, lockOwner: this.owner },
|
||||||
"locked_at" = NULL,
|
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
|
||||||
"lock_owner" = NULL,
|
});
|
||||||
"last_error" = NULL
|
|
||||||
WHERE "id" = ${event.id}
|
|
||||||
AND "lock_owner" = ${this.owner}
|
|
||||||
AND "created_at" <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
|
||||||
- ${MAX_EVENT_AGE_MS} * INTERVAL '1 millisecond'
|
|
||||||
`);
|
|
||||||
if (expired > 0) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
@@ -105,32 +94,27 @@ export class WebPushOutboxWorker {
|
|||||||
signal: AbortSignal.timeout(5_000),
|
signal: AbortSignal.timeout(5_000),
|
||||||
});
|
});
|
||||||
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
|
if (!response.ok) throw new Error(`Gateway web push ingest failed with HTTP ${response.status}.`);
|
||||||
await this.db.$executeRaw(GamePrisma.sql`
|
await this.db.webPushOutbox.updateMany({
|
||||||
UPDATE "web_push_outbox"
|
where: { id: event.id, lockOwner: this.owner },
|
||||||
SET "delivered_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
data: { deliveredAt: new Date(), lockedAt: null, lockOwner: null, lastError: null },
|
||||||
"locked_at" = NULL,
|
});
|
||||||
"lock_owner" = NULL,
|
|
||||||
"last_error" = NULL
|
|
||||||
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
|
|
||||||
`);
|
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const attempts = event.attempts;
|
const attempts = event.attempts;
|
||||||
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
|
const delaySeconds = Math.min(300, 2 ** Math.min(attempts, 8));
|
||||||
const errorText = (error instanceof Error ? error.message : String(error)).slice(0, 500);
|
await this.db.webPushOutbox.updateMany({
|
||||||
await this.db.$executeRaw(GamePrisma.sql`
|
where: { id: event.id, lockOwner: this.owner },
|
||||||
UPDATE "web_push_outbox"
|
data: {
|
||||||
SET "available_at" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
availableAt: new Date(Date.now() + delaySeconds * 1_000),
|
||||||
+ ${delaySeconds * 1_000} * INTERVAL '1 millisecond',
|
lockedAt: null,
|
||||||
"locked_at" = NULL,
|
lockOwner: null,
|
||||||
"lock_owner" = NULL,
|
lastError: (error instanceof Error ? error.message : String(error)).slice(0, 500),
|
||||||
"last_error" = ${errorText}
|
},
|
||||||
WHERE "id" = ${event.id} AND "lock_owner" = ${this.owner}
|
});
|
||||||
`);
|
|
||||||
this.onError(error);
|
this.onError(error);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (performance.now() >= this.nextPruneAt) {
|
if (Date.now() >= this.nextPruneAt) {
|
||||||
this.nextPruneAt = performance.now() + 60_000;
|
this.nextPruneAt = Date.now() + 60_000;
|
||||||
await this.db.$executeRaw(GamePrisma.sql`
|
await this.db.$executeRaw(GamePrisma.sql`
|
||||||
WITH expired AS (
|
WITH expired AS (
|
||||||
SELECT "id"
|
SELECT "id"
|
||||||
|
|||||||
@@ -8,9 +8,6 @@ export interface TournamentKeys {
|
|||||||
sourceRevisionKey: string;
|
sourceRevisionKey: string;
|
||||||
sourceRevisionChannel: string;
|
sourceRevisionChannel: string;
|
||||||
realtimeEventChannel: string;
|
realtimeEventChannel: string;
|
||||||
activeClockRevisionKey: string;
|
|
||||||
deadlineGenerationKey: string;
|
|
||||||
clockPhaseKey: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
|
export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
|
||||||
@@ -21,7 +18,4 @@ export const buildTournamentKeys = (profileName: string): TournamentKeys => ({
|
|||||||
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
|
sourceRevisionKey: `sammo:${profileName}:tournament:source-revision`,
|
||||||
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
|
sourceRevisionChannel: `sammo:${profileName}:tournament:source-changed`,
|
||||||
realtimeEventChannel: buildGameEventChannel(profileName),
|
realtimeEventChannel: buildGameEventChannel(profileName),
|
||||||
activeClockRevisionKey: `sammo:${profileName}:clock:active-revision`,
|
|
||||||
deadlineGenerationKey: `sammo:${profileName}:clock:deadline-generation`,
|
|
||||||
clockPhaseKey: `sammo:${profileName}:clock:phase`,
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { performance } from 'node:perf_hooks';
|
import { parseTournamentSourceRevision, writeTournamentProjection } from '@sammo-ts/common';
|
||||||
import { parseTournamentSourceRevision, writeTournamentProjection, type TournamentClockFence } from '@sammo-ts/common';
|
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
|
||||||
import type { TournamentKeys } from './keys.js';
|
import type { TournamentKeys } from './keys.js';
|
||||||
@@ -38,12 +37,8 @@ const zTournamentState = z
|
|||||||
openMonth: z.number().int(),
|
openMonth: z.number().int(),
|
||||||
termSeconds: z.number(),
|
termSeconds: z.number(),
|
||||||
nextAt: z.string(),
|
nextAt: z.string(),
|
||||||
nextTick: z.number().int().safe().optional(),
|
|
||||||
clockRevision: z.number().int().positive().safe().optional(),
|
|
||||||
deadlineGeneration: z.number().int().positive().safe().optional(),
|
|
||||||
bettingId: z.number().int().optional(),
|
bettingId: z.number().int().optional(),
|
||||||
bettingCloseAt: z.string().optional(),
|
bettingCloseAt: z.string().optional(),
|
||||||
bettingCloseTick: z.number().int().safe().optional(),
|
|
||||||
winnerId: z.number().int().optional(),
|
winnerId: z.number().int().optional(),
|
||||||
bettingSettled: z.boolean().optional(),
|
bettingSettled: z.boolean().optional(),
|
||||||
rewardSettled: z.boolean().optional(),
|
rewardSettled: z.boolean().optional(),
|
||||||
@@ -136,62 +131,12 @@ const parseProjection = <T>(raw: string | null, key: string, schema: z.ZodType<T
|
|||||||
return parsed.data;
|
return parsed.data;
|
||||||
};
|
};
|
||||||
|
|
||||||
export interface TournamentClockContext {
|
|
||||||
phase: 'RUNNING' | 'MANUAL' | 'SUSPENDED';
|
|
||||||
revision: number;
|
|
||||||
deadlineGeneration: number;
|
|
||||||
dateToTick(date: Date): number | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
const parseDeadlineTick = (value: string, context: TournamentClockContext, field: string): number => {
|
|
||||||
const date = new Date(value);
|
|
||||||
if (Number.isNaN(date.getTime())) {
|
|
||||||
throw new Error(`Tournament ${field} is not a valid instant.`);
|
|
||||||
}
|
|
||||||
const tick = context.dateToTick(date);
|
|
||||||
if (tick === null || !Number.isSafeInteger(tick)) {
|
|
||||||
throw new Error(`Tournament ${field} cannot be represented in the active game clock.`);
|
|
||||||
}
|
|
||||||
return tick;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const stampTournamentClock = (state: TournamentState, context: TournamentClockContext): TournamentState => ({
|
|
||||||
...state,
|
|
||||||
nextTick: parseDeadlineTick(state.nextAt, context, 'nextAt'),
|
|
||||||
clockRevision: context.revision,
|
|
||||||
deadlineGeneration: context.deadlineGeneration,
|
|
||||||
...(state.bettingCloseAt
|
|
||||||
? { bettingCloseTick: parseDeadlineTick(state.bettingCloseAt, context, 'bettingCloseAt') }
|
|
||||||
: { bettingCloseTick: undefined }),
|
|
||||||
});
|
|
||||||
|
|
||||||
const toClockFence = (keys: TournamentKeys, context: TournamentClockContext): TournamentClockFence => ({
|
|
||||||
activeRevisionKey: keys.activeClockRevisionKey,
|
|
||||||
deadlineGenerationKey: keys.deadlineGenerationKey,
|
|
||||||
phaseKey: keys.clockPhaseKey,
|
|
||||||
revision: context.revision,
|
|
||||||
deadlineGeneration: context.deadlineGeneration,
|
|
||||||
phase: context.phase,
|
|
||||||
});
|
|
||||||
|
|
||||||
export class TournamentStore {
|
export class TournamentStore {
|
||||||
private clockContext: TournamentClockContext | null = null;
|
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
private readonly redis: RedisClientLike,
|
private readonly redis: RedisClientLike,
|
||||||
private readonly keys: TournamentKeys
|
private readonly keys: TournamentKeys
|
||||||
) {}
|
) {}
|
||||||
|
|
||||||
async withClockContext<T>(context: TournamentClockContext, operation: () => Promise<T>): Promise<T> {
|
|
||||||
const previous = this.clockContext;
|
|
||||||
this.clockContext = context;
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} finally {
|
|
||||||
this.clockContext = previous;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async withMutationLock<T>(operation: () => Promise<T>, timeoutMs = 2_000): Promise<T> {
|
async withMutationLock<T>(operation: () => Promise<T>, timeoutMs = 2_000): Promise<T> {
|
||||||
if (!this.redis.del) {
|
if (!this.redis.del) {
|
||||||
return operation();
|
return operation();
|
||||||
@@ -199,8 +144,8 @@ export class TournamentStore {
|
|||||||
|
|
||||||
const lockKey = `${this.keys.stateKey}:mutation-lock`;
|
const lockKey = `${this.keys.stateKey}:mutation-lock`;
|
||||||
const token = randomUUID();
|
const token = randomUUID();
|
||||||
const deadline = performance.now() + timeoutMs;
|
const deadline = Date.now() + timeoutMs;
|
||||||
while (performance.now() < deadline) {
|
while (Date.now() < deadline) {
|
||||||
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
const acquired = await this.redis.set(lockKey, token, { NX: true, PX: 30_000 });
|
||||||
if (acquired) {
|
if (acquired) {
|
||||||
try {
|
try {
|
||||||
@@ -229,15 +174,11 @@ export class TournamentStore {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async writeWithSourceRevision(key: string, value: unknown): Promise<string> {
|
private async writeWithSourceRevision(key: string, value: unknown): Promise<string> {
|
||||||
const fence = this.clockContext ? toClockFence(this.keys, this.clockContext) : undefined;
|
return writeTournamentProjection(this.redis, this.keys, [{ key, value }]);
|
||||||
return writeTournamentProjection(this.redis, this.keys, [{ key, value }], fence);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async setState(state: TournamentState): Promise<string> {
|
async setState(state: TournamentState): Promise<string> {
|
||||||
return this.writeWithSourceRevision(
|
return this.writeWithSourceRevision(this.keys.stateKey, state);
|
||||||
this.keys.stateKey,
|
|
||||||
this.clockContext ? stampTournamentClock(state, this.clockContext) : state
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async getParticipants(): Promise<TournamentParticipantEntry[]> {
|
async getParticipants(): Promise<TournamentParticipantEntry[]> {
|
||||||
|
|||||||
@@ -9,12 +9,8 @@ export interface TournamentState {
|
|||||||
openMonth: number;
|
openMonth: number;
|
||||||
termSeconds: number;
|
termSeconds: number;
|
||||||
nextAt: string;
|
nextAt: string;
|
||||||
nextTick?: number;
|
|
||||||
clockRevision?: number;
|
|
||||||
deadlineGeneration?: number;
|
|
||||||
bettingId?: number;
|
bettingId?: number;
|
||||||
bettingCloseAt?: string;
|
bettingCloseAt?: string;
|
||||||
bettingCloseTick?: number;
|
|
||||||
winnerId?: number;
|
winnerId?: number;
|
||||||
bettingSettled?: boolean;
|
bettingSettled?: boolean;
|
||||||
rewardSettled?: boolean;
|
rewardSettled?: boolean;
|
||||||
|
|||||||
@@ -13,10 +13,9 @@ import { DatabaseTurnDaemonTransport } from '../daemon/databaseTransport.js';
|
|||||||
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
|
import { createBestEffortResourceCloser } from '../services/bestEffortResourceCloser.js';
|
||||||
import { loadCurrentGameTime } from '../services/gameClock.js';
|
import { loadCurrentGameTime } from '../services/gameClock.js';
|
||||||
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
|
import { createPollingWorkerControl, waitForWorkerPoll } from '../services/pollingWorkerLifecycle.js';
|
||||||
import { ensureActiveRedisClockFence } from '../services/redisClockFence.js';
|
|
||||||
import type { TurnDaemonTransport } from '../daemon/transport.js';
|
import type { TurnDaemonTransport } from '../daemon/transport.js';
|
||||||
import { buildTournamentKeys } from './keys.js';
|
import { buildTournamentKeys } from './keys.js';
|
||||||
import { TournamentStore, stampTournamentClock, type TournamentClockContext } from './store.js';
|
import { TournamentStore } from './store.js';
|
||||||
import type { TournamentMatchEntry, TournamentState } from './types.js';
|
import type { TournamentMatchEntry, TournamentState } from './types.js';
|
||||||
import {
|
import {
|
||||||
applyGroupMatch,
|
applyGroupMatch,
|
||||||
@@ -596,63 +595,41 @@ export const processTournamentTick = async (options: {
|
|||||||
prisma: GamePrismaClient;
|
prisma: GamePrismaClient;
|
||||||
daemonTransport: TurnDaemonTransport;
|
daemonTransport: TurnDaemonTransport;
|
||||||
now?: () => number;
|
now?: () => number;
|
||||||
clockContext?: TournamentClockContext;
|
|
||||||
}): Promise<TournamentState | null> => {
|
}): Promise<TournamentState | null> => {
|
||||||
const { store, prisma, daemonTransport } = options;
|
const { store, prisma, daemonTransport } = options;
|
||||||
const now = options.now ?? Date.now;
|
const now = options.now ?? Date.now;
|
||||||
let processedState: TournamentState | null = null;
|
let processedState: TournamentState | null = null;
|
||||||
|
|
||||||
const processWithLock = async (): Promise<void> =>
|
await store.withMutationLock(async () => {
|
||||||
store.withMutationLock(async () => {
|
const state = await store.getState();
|
||||||
const state = await store.getState();
|
if (!state || (!state.auto && !needsSettlement(state))) {
|
||||||
if (!state || (!state.auto && !needsSettlement(state))) {
|
return;
|
||||||
return;
|
}
|
||||||
}
|
const nextAt = new Date(state.nextAt).getTime();
|
||||||
if (
|
if (state.auto && Number.isFinite(nextAt) && nextAt > now()) {
|
||||||
options.clockContext &&
|
return;
|
||||||
(state.clockRevision !== options.clockContext.revision ||
|
}
|
||||||
state.deadlineGeneration !== options.clockContext.deadlineGeneration)
|
|
||||||
) {
|
|
||||||
throw new Error('Tournament state does not match the active clock revision.');
|
|
||||||
}
|
|
||||||
const nextAt = new Date(state.nextAt).getTime();
|
|
||||||
const notDue = options.clockContext
|
|
||||||
? !Number.isSafeInteger(state.nextTick) ||
|
|
||||||
state.nextTick! > options.clockContext.dateToTick(new Date(now()))!
|
|
||||||
: Number.isFinite(nextAt) && nextAt > now();
|
|
||||||
if (state.auto && notDue) {
|
|
||||||
if (options.clockContext && !Number.isSafeInteger(state.nextTick)) {
|
|
||||||
throw new Error('Active tournament nextAt lacks the authoritative nextTick dual-write.');
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (needsSettlement(state)) {
|
if (needsSettlement(state)) {
|
||||||
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
|
processedState = (await settleTournamentOutcome({ store, daemonTransport, state })) ?? state;
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const worldState = await prisma.worldState.findFirst();
|
const worldState = await prisma.worldState.findFirst();
|
||||||
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
|
const baseSeed = (worldState?.meta as Record<string, unknown> | null)?.hiddenSeed ?? 'tournament';
|
||||||
let nextState = state;
|
let nextState = state;
|
||||||
if (isBattleStage(state.stage)) {
|
if (isBattleStage(state.stage)) {
|
||||||
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
|
nextState = await applyBattle(store, state, String(baseSeed), daemonTransport);
|
||||||
} else if (isPreBattleStage(state.stage)) {
|
} else if (isPreBattleStage(state.stage)) {
|
||||||
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now);
|
nextState = await applyPreBattleStage(store, prisma, state, String(baseSeed), daemonTransport, now);
|
||||||
}
|
}
|
||||||
processedState =
|
processedState =
|
||||||
(await settleTournamentOutcome({
|
(await settleTournamentOutcome({
|
||||||
store,
|
store,
|
||||||
daemonTransport,
|
daemonTransport,
|
||||||
state: nextState,
|
state: nextState,
|
||||||
})) ?? nextState;
|
})) ?? nextState;
|
||||||
});
|
});
|
||||||
|
|
||||||
if (options.clockContext) {
|
|
||||||
await store.withClockContext(options.clockContext, processWithLock);
|
|
||||||
} else {
|
|
||||||
await processWithLock();
|
|
||||||
}
|
|
||||||
|
|
||||||
return processedState;
|
return processedState;
|
||||||
};
|
};
|
||||||
@@ -679,60 +656,16 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
while (!control.signal.aborted) {
|
while (!control.signal.aborted) {
|
||||||
let state = await store.getState();
|
const state = await store.getState();
|
||||||
if (!state || (!state.auto && !needsSettlement(state))) {
|
if (!state || (!state.auto && !needsSettlement(state))) {
|
||||||
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gameTime = await loadCurrentGameTime(postgres.prisma);
|
|
||||||
if (gameTime.phase && gameTime.phase !== 'RUNNING') {
|
|
||||||
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const clockFence = gameTime.phase
|
|
||||||
? await ensureActiveRedisClockFence(redis.client, config.profileName, gameTime)
|
|
||||||
: null;
|
|
||||||
if (gameTime.phase && !clockFence) {
|
|
||||||
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
const clockContext: TournamentClockContext | undefined = clockFence
|
|
||||||
? {
|
|
||||||
phase: 'RUNNING',
|
|
||||||
revision: clockFence.revision,
|
|
||||||
deadlineGeneration: clockFence.generation,
|
|
||||||
dateToTick: gameTime.dateToTick,
|
|
||||||
}
|
|
||||||
: undefined;
|
|
||||||
if (
|
|
||||||
clockContext &&
|
|
||||||
(!Number.isSafeInteger(state.nextTick) ||
|
|
||||||
state.clockRevision === undefined ||
|
|
||||||
state.deadlineGeneration === undefined)
|
|
||||||
) {
|
|
||||||
state = stampTournamentClock(state, clockContext);
|
|
||||||
await store.withClockContext(clockContext, () => store.setState(state!));
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
clockContext &&
|
|
||||||
(state.clockRevision !== clockContext.revision ||
|
|
||||||
state.deadlineGeneration !== clockContext.deadlineGeneration)
|
|
||||||
) {
|
|
||||||
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
const nextAt = new Date(state.nextAt).getTime();
|
const nextAt = new Date(state.nextAt).getTime();
|
||||||
const gameNow = gameTime.now.getTime();
|
const gameNow = (await loadCurrentGameTime(postgres.prisma)).now.getTime();
|
||||||
const notDue = clockContext
|
if (state.auto && Number.isFinite(nextAt) && nextAt > gameNow) {
|
||||||
? state.nextTick! > gameTime.tick!
|
await waitForWorkerPoll(control.signal, Math.min(config.tournamentPollMs, nextAt - gameNow));
|
||||||
: Number.isFinite(nextAt) && nextAt > gameNow;
|
|
||||||
if (state.auto && notDue) {
|
|
||||||
await waitForWorkerPoll(
|
|
||||||
control.signal,
|
|
||||||
Math.min(config.tournamentPollMs, Math.max(1, nextAt - gameNow))
|
|
||||||
);
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -742,7 +675,6 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
|
|||||||
prisma: postgres.prisma,
|
prisma: postgres.prisma,
|
||||||
daemonTransport,
|
daemonTransport,
|
||||||
now: () => gameNow,
|
now: () => gameNow,
|
||||||
clockContext,
|
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : 'Unknown error';
|
const message = error instanceof Error ? error.message : 'Unknown error';
|
||||||
@@ -768,11 +700,7 @@ export const runTournamentWorker = async (options: TournamentWorkerOptions = {})
|
|||||||
lastError: message,
|
lastError: message,
|
||||||
lastErrorAt: failedAt,
|
lastErrorAt: failedAt,
|
||||||
};
|
};
|
||||||
if (clockContext) {
|
await store.setState(nextState);
|
||||||
await store.withClockContext(clockContext, () => store.setState(nextState));
|
|
||||||
} else {
|
|
||||||
await store.setState(nextState);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
await waitForWorkerPoll(control.signal, config.tournamentPollMs);
|
||||||
|
|||||||
@@ -37,10 +37,7 @@ export const nextStage = (stage: number): number => {
|
|||||||
|
|
||||||
const resolveScheduledBaseMs = (state: TournamentState): number => {
|
const resolveScheduledBaseMs = (state: TournamentState): number => {
|
||||||
const scheduled = new Date(state.nextAt).getTime();
|
const scheduled = new Date(state.nextAt).getTime();
|
||||||
if (!Number.isFinite(scheduled)) {
|
return Number.isFinite(scheduled) ? scheduled : Date.now();
|
||||||
throw new Error('Tournament GAME_TIME schedule is invalid.');
|
|
||||||
}
|
|
||||||
return scheduled;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveNextAt = (state: TournamentState): string =>
|
export const resolveNextAt = (state: TournamentState): string =>
|
||||||
|
|||||||
+52
-71
@@ -62,68 +62,61 @@ const generalActivityMiddleware = t.middleware(async ({ ctx, type, next }) => {
|
|||||||
export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string =>
|
export const scopeApiInputEventRequestId = (baseRequestId: string, path: string, batchIndex: number): string =>
|
||||||
`${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`;
|
`${baseRequestId}:${path}${batchIndex === 0 ? '' : `:batch:${batchIndex}`}`;
|
||||||
|
|
||||||
const createInputEventMiddleware = (acquireClockFence: boolean) =>
|
const inputEventMiddleware = t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
|
||||||
t.middleware(async ({ ctx, type, path, batchIndex, getRawInput, next }) => {
|
if (type !== 'mutation' || !ctx.db.$transaction) {
|
||||||
if (type !== 'mutation' || !ctx.db.$transaction) {
|
return next();
|
||||||
return next();
|
}
|
||||||
}
|
|
||||||
|
|
||||||
const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
|
const requestId = scopeApiInputEventRequestId(ctx.requestId ?? randomUUID(), path, batchIndex);
|
||||||
const payload = await getRawInput();
|
const payload = await getRawInput();
|
||||||
const changeJournal = new ChangeJournal();
|
const changeJournal = new ChangeJournal();
|
||||||
let journalPersisted = false;
|
let journalPersisted = false;
|
||||||
let executedResult: Awaited<ReturnType<typeof next>> | undefined;
|
let executedResult: Awaited<ReturnType<typeof next>> | undefined;
|
||||||
try {
|
try {
|
||||||
const response = await executeInputEvent({
|
const response = await executeInputEvent({
|
||||||
db: ctx.db,
|
db: ctx.db,
|
||||||
requestId,
|
requestId,
|
||||||
eventType: path,
|
eventType: path,
|
||||||
payload,
|
payload,
|
||||||
actorUserId: ctx.auth?.user.id,
|
actorUserId: ctx.auth?.user.id,
|
||||||
acquireClockFence,
|
execute: async (transaction) => {
|
||||||
execute: async (transaction) => {
|
const result = await next({
|
||||||
const result = await next({
|
ctx: {
|
||||||
ctx: {
|
...ctx,
|
||||||
...ctx,
|
db: transaction,
|
||||||
db: transaction,
|
changeJournal,
|
||||||
changeJournal,
|
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
|
||||||
turnDaemon: new IdempotentTurnDaemonTransport(ctx.turnDaemon, requestId),
|
},
|
||||||
},
|
|
||||||
});
|
|
||||||
if (!result.ok) {
|
|
||||||
throw result.error;
|
|
||||||
}
|
|
||||||
journalPersisted = Boolean(
|
|
||||||
await writeReadModelChangeJournal(transaction, changeJournal.snapshot())
|
|
||||||
);
|
|
||||||
executedResult = result;
|
|
||||||
return result.data;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
if (journalPersisted) {
|
|
||||||
ctx.readModelOutbox?.wake();
|
|
||||||
}
|
|
||||||
if (executedResult) {
|
|
||||||
return executedResult;
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
marker: middlewareMarker,
|
|
||||||
ok: true,
|
|
||||||
data: response,
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
if (error instanceof DuplicateInputEventError) {
|
|
||||||
throw new TRPCError({
|
|
||||||
code: 'CONFLICT',
|
|
||||||
message: error.message,
|
|
||||||
});
|
});
|
||||||
}
|
if (!result.ok) {
|
||||||
throw error;
|
throw result.error;
|
||||||
|
}
|
||||||
|
journalPersisted = Boolean(await writeReadModelChangeJournal(transaction, changeJournal.snapshot()));
|
||||||
|
executedResult = result;
|
||||||
|
return result.data;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (journalPersisted) {
|
||||||
|
ctx.readModelOutbox?.wake();
|
||||||
}
|
}
|
||||||
});
|
if (executedResult) {
|
||||||
|
return executedResult;
|
||||||
const inputEventMiddleware = createInputEventMiddleware(true);
|
}
|
||||||
const wallInputEventMiddleware = createInputEventMiddleware(false);
|
return {
|
||||||
|
marker: middlewareMarker,
|
||||||
|
ok: true,
|
||||||
|
data: response,
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof DuplicateInputEventError) {
|
||||||
|
throw new TRPCError({
|
||||||
|
code: 'CONFLICT',
|
||||||
|
message: error.message,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => {
|
const generalAccessEndpointMiddleware = t.middleware(async ({ ctx, path, input, next }) => {
|
||||||
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
|
// 실제 HTTP context는 createGameApiContext()가 이 flag를 설정한다.
|
||||||
@@ -187,15 +180,10 @@ const deferredGeneralAccessLimitMiddleware = t.middleware(async ({ ctx, next })
|
|||||||
|
|
||||||
export const router = t.router;
|
export const router = t.router;
|
||||||
export const procedure = t.procedure.use(inputEventMiddleware);
|
export const procedure = t.procedure.use(inputEventMiddleware);
|
||||||
export const wallProcedure = t.procedure.use(wallInputEventMiddleware);
|
|
||||||
export const authedProcedure: typeof procedure = t.procedure
|
export const authedProcedure: typeof procedure = t.procedure
|
||||||
.use(requireAuthMiddleware)
|
.use(requireAuthMiddleware)
|
||||||
.use(generalActivityMiddleware)
|
.use(generalActivityMiddleware)
|
||||||
.use(inputEventMiddleware);
|
.use(inputEventMiddleware);
|
||||||
export const wallAuthedProcedure: typeof procedure = t.procedure
|
|
||||||
.use(requireAuthMiddleware)
|
|
||||||
.use(generalActivityMiddleware)
|
|
||||||
.use(wallInputEventMiddleware);
|
|
||||||
|
|
||||||
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
|
// Ref의 increaseRefresh()는 로그인/제재 확인 뒤, 업무 validation과 mutation
|
||||||
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
|
// transaction보다 먼저 별도 저장된다. access middleware를 input-event보다
|
||||||
@@ -246,13 +234,6 @@ export const accessAuthedInputProcedure: typeof procedure.input = (input) =>
|
|||||||
.use(generalAccessEndpointMiddleware)
|
.use(generalAccessEndpointMiddleware)
|
||||||
.use(generalActivityMiddleware)
|
.use(generalActivityMiddleware)
|
||||||
.use(inputEventMiddleware);
|
.use(inputEventMiddleware);
|
||||||
export const accessWallAuthedInputProcedure: typeof procedure.input = (input) =>
|
|
||||||
t.procedure
|
|
||||||
.use(requireAuthMiddleware)
|
|
||||||
.input(input)
|
|
||||||
.use(generalAccessEndpointMiddleware)
|
|
||||||
.use(generalActivityMiddleware)
|
|
||||||
.use(wallInputEventMiddleware);
|
|
||||||
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
|
export const accessEngineAuthedInputProcedure: typeof procedure.input = (input) =>
|
||||||
t.procedure
|
t.procedure
|
||||||
.use(requireAuthMiddleware)
|
.use(requireAuthMiddleware)
|
||||||
|
|||||||
@@ -11,7 +11,6 @@ import { asRecord, isRecord } from '@sammo-ts/common';
|
|||||||
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
import type { ItemModule } from '@sammo-ts/logic/items/types.js';
|
||||||
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
import { resolveLegacyPurchasableItemKeys } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
import { loadScenarioDefinitionById } from '@sammo-ts/game-engine/scenario/scenarioLoader.js';
|
|
||||||
|
|
||||||
import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
|
import { loadScenarioTurnCommandProfile } from '@sammo-ts/game-engine/turn/turnCommandProfile.js';
|
||||||
|
|
||||||
@@ -80,7 +79,6 @@ export interface TurnCommandInputField {
|
|||||||
required: boolean;
|
required: boolean;
|
||||||
min?: number;
|
min?: number;
|
||||||
max?: number;
|
max?: number;
|
||||||
legacyWidthMax?: number;
|
|
||||||
step?: number;
|
step?: number;
|
||||||
defaultValue?: TurnCommandOptionValue | boolean;
|
defaultValue?: TurnCommandOptionValue | boolean;
|
||||||
constValue?: TurnCommandOptionValue;
|
constValue?: TurnCommandOptionValue;
|
||||||
@@ -121,60 +119,22 @@ const plainLegacyInfo = (value: string): string =>
|
|||||||
.replace(/\s+/gu, ' ')
|
.replace(/\s+/gu, ' ')
|
||||||
.trim();
|
.trim();
|
||||||
|
|
||||||
// JSONB는 객체 key 순서를 보존하지 않으므로 표시 순서는 배포된 원본 resource에서 읽는다.
|
|
||||||
export const loadEquipmentTradeItemOrder = async (scenarioCode: string): Promise<readonly string[] | undefined> => {
|
|
||||||
const normalized = scenarioCode.replace(/^scenario_/i, '').replace(/\.json$/i, '');
|
|
||||||
if (!/^\d+$/.test(normalized) || !Number.isSafeInteger(Number(normalized))) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
try {
|
|
||||||
const scenario = await loadScenarioDefinitionById(Number(normalized));
|
|
||||||
const configConst = scenario.config.const;
|
|
||||||
const keys = Object.values(asRecord(configConst.allItems)).flatMap((entries) => Object.keys(asRecord(entries)));
|
|
||||||
return keys.length > 0 ? keys : [...resolveLegacyPurchasableItemKeys(configConst)];
|
|
||||||
} catch (error) {
|
|
||||||
// 보존된 시즌의 resource가 없는 경우에도 현재 DB의 구매/판매 선택지는 유지한다.
|
|
||||||
if (isRecord(error) && error.code === 'ENOENT') {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildEquipmentTradeItemOptions = (options: {
|
export const buildEquipmentTradeItemOptions = (options: {
|
||||||
configConst: Record<string, unknown>;
|
configConst: Record<string, unknown>;
|
||||||
itemOrder?: readonly string[];
|
|
||||||
itemModules: readonly EquipmentTradeItemModule[];
|
itemModules: readonly EquipmentTradeItemModule[];
|
||||||
currentSecurity: number;
|
currentSecurity: number;
|
||||||
generalGold: number;
|
generalGold: number;
|
||||||
ownedItems: Readonly<Record<ItemModule['slot'], string | null>>;
|
|
||||||
}): TurnCommandInputOptions['items'] => {
|
}): TurnCommandInputOptions['items'] => {
|
||||||
const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst);
|
const purchasableItemKeys = resolveLegacyPurchasableItemKeys(options.configConst);
|
||||||
const items: TurnCommandInputOptions['items'] = {};
|
const items: TurnCommandInputOptions['items'] = {
|
||||||
const catalog = new Map(options.itemModules.map((item) => [item.key, item]));
|
horse: [{ value: 'None', label: '판매/해제' }],
|
||||||
// Ref의 소유 물품 판매는 구매 허용 목록과 독립적으로 전체 catalog에서 해석한다.
|
weapon: [{ value: 'None', label: '판매/해제' }],
|
||||||
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
|
book: [{ value: 'None', label: '판매/해제' }],
|
||||||
const ownedCode = options.ownedItems[slot];
|
item: [{ value: 'None', label: '판매/해제' }],
|
||||||
const ownedItem = ownedCode && ownedCode !== 'None' ? catalog.get(ownedCode) : undefined;
|
};
|
||||||
const slotName = STATIC_LABELS.itemType?.[slot] ?? slot;
|
|
||||||
items[slot] = [
|
|
||||||
{
|
|
||||||
value: 'None',
|
|
||||||
label: ownedItem ? `${ownedItem.name} 판매` : `${slotName} 판매`,
|
|
||||||
description: ownedItem
|
|
||||||
? `소유 물품 판매 · 판매가 ${Math.floor((ownedItem.cost ?? 0) / 2).toLocaleString()}금`
|
|
||||||
: ownedCode && ownedCode !== 'None'
|
|
||||||
? '보유 장비 정보를 확인할 수 없습니다.'
|
|
||||||
: '현재 보유한 장비가 없습니다.',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
// 원본 순서를 우선하되 구매 권한은 현재 DB 설정만 따른다. 추가된 품목은 뒤에 유지한다.
|
for (const item of options.itemModules) {
|
||||||
const orderedKeys = new Set([...(options.itemOrder ?? []), ...purchasableItemKeys]);
|
if (!item.buyable || !purchasableItemKeys.has(item.key)) {
|
||||||
for (const key of orderedKeys) {
|
|
||||||
const item = catalog.get(key);
|
|
||||||
if (!item?.buyable || !purchasableItemKeys.has(key)) {
|
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
const cost = item.cost ?? 0;
|
const cost = item.cost ?? 0;
|
||||||
@@ -368,7 +328,6 @@ const buildField = (key: string, rawSchema: unknown, required: boolean): TurnCom
|
|||||||
required,
|
required,
|
||||||
min: typeof schema.minLength === 'number' ? schema.minLength : undefined,
|
min: typeof schema.minLength === 'number' ? schema.minLength : undefined,
|
||||||
max: typeof schema.maxLength === 'number' ? schema.maxLength : undefined,
|
max: typeof schema.maxLength === 'number' ? schema.maxLength : undefined,
|
||||||
legacyWidthMax: key === 'nationName' ? 18 : undefined,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -606,7 +565,7 @@ export const assertReservedTurnArgsPassLegacyBasicValidation = (rawArgs: unknown
|
|||||||
getLegacyStringWidth(nationName) < 1 ||
|
getLegacyStringWidth(nationName) < 1 ||
|
||||||
getLegacyStringWidth(nationName) > 18)
|
getLegacyStringWidth(nationName) > 18)
|
||||||
) {
|
) {
|
||||||
throw new Error('국가명은 전각 9자 또는 반각 18자 이하여야 합니다.');
|
throwLegacyBasicTurnArgError();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -11,9 +11,6 @@ export interface GeneralTargetSource {
|
|||||||
officerLevel: number;
|
officerLevel: number;
|
||||||
gold?: number;
|
gold?: number;
|
||||||
rice?: number;
|
rice?: number;
|
||||||
leadership?: number;
|
|
||||||
strength?: number;
|
|
||||||
intel?: number;
|
|
||||||
crew?: number;
|
crew?: number;
|
||||||
train?: number;
|
train?: number;
|
||||||
atmos?: number;
|
atmos?: number;
|
||||||
@@ -67,7 +64,7 @@ export const buildRefGeneralTargetOptions = (options: {
|
|||||||
const isTroopExit = action === 'che_부대탈퇴지시';
|
const isTroopExit = action === 'che_부대탈퇴지시';
|
||||||
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
|
const availableNow = isTroopExit ? isTroopMember && entry.id !== options.actorId : undefined;
|
||||||
const label = (() => {
|
const label = (() => {
|
||||||
if (action === 'che_발령') return `${entry.name} (${cityName})`;
|
if (action === 'che_발령') return `${entry.name} (${troopLabel} · ${cityName})`;
|
||||||
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`;
|
if (action === 'che_포상' || action === 'che_몰수') return `${entry.name} (${cityName})`;
|
||||||
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
|
return `${entry.name} (${options.nationNames.get(entry.nationId) ?? '무소속'} · ${cityName})`;
|
||||||
})();
|
})();
|
||||||
@@ -83,43 +80,13 @@ export const buildRefGeneralTargetOptions = (options: {
|
|||||||
? `탑승 부대 ${troopLabel}`
|
? `탑승 부대 ${troopLabel}`
|
||||||
: '탑승 부대 없음',
|
: '탑승 부대 없음',
|
||||||
].filter((value): value is string => Boolean(value));
|
].filter((value): value is string => Boolean(value));
|
||||||
// 발령 후보는 Ref처럼 능력치와 병력 준비 상태를 함께 비교한다.
|
|
||||||
// 예약 요약에 쓰는 label은 그대로 두고, 같은 국가 후보의 상세 정보만 보강한다.
|
|
||||||
const assignmentDetails =
|
|
||||||
action === 'che_발령'
|
|
||||||
? [
|
|
||||||
entry.troopId ? `탑승 부대 ${troopLabel}` : '탑승 부대 없음',
|
|
||||||
[
|
|
||||||
entry.leadership === undefined ? null : `통솔 ${entry.leadership.toLocaleString()}`,
|
|
||||||
entry.strength === undefined ? null : `무력 ${entry.strength.toLocaleString()}`,
|
|
||||||
entry.intel === undefined ? null : `지력 ${entry.intel.toLocaleString()}`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · '),
|
|
||||||
[
|
|
||||||
entry.crew === undefined ? null : `병력 ${entry.crew.toLocaleString()}`,
|
|
||||||
entry.train === undefined ? null : `훈련 ${entry.train.toLocaleString()}`,
|
|
||||||
entry.atmos === undefined ? null : `사기 ${entry.atmos.toLocaleString()}`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · '),
|
|
||||||
[
|
|
||||||
entry.gold === undefined ? null : `금 ${entry.gold.toLocaleString()}`,
|
|
||||||
entry.rice === undefined ? null : `쌀 ${entry.rice.toLocaleString()}`,
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join(' · '),
|
|
||||||
]
|
|
||||||
.filter(Boolean)
|
|
||||||
.join('\n')
|
|
||||||
: undefined;
|
|
||||||
if (isTroopExit) {
|
if (isTroopExit) {
|
||||||
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
|
details.unshift(availableNow ? '현재 탈퇴 지시 가능' : '현재 탈퇴 지시 불가');
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
value: entry.id,
|
value: entry.id,
|
||||||
label,
|
label,
|
||||||
description: assignmentDetails ?? details.join(' · '),
|
description: details.join(' · '),
|
||||||
...(availableNow === undefined ? {} : { availableNow }),
|
...(availableNow === undefined ? {} : { availableNow }),
|
||||||
...(entry.gold === undefined ? {} : { gold: entry.gold }),
|
...(entry.gold === undefined ? {} : { gold: entry.gold }),
|
||||||
...(entry.rice === undefined ? {} : { rice: entry.rice }),
|
...(entry.rice === undefined ? {} : { rice: entry.rice }),
|
||||||
|
|||||||
@@ -56,14 +56,6 @@ const lifecycleState: TurnWorldState = {
|
|||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
lastTurnTime: new Date('2026-07-31T10:00:00.000Z'),
|
lastTurnTime: new Date('2026-07-31T10:00:00.000Z'),
|
||||||
clockBaseTime: new Date('2026-07-31T10:00:00.000Z'),
|
|
||||||
clockTick: 0,
|
|
||||||
clockMode: 'manual',
|
|
||||||
clockWallAnchor: new Date('2026-07-31T10:00:00.000Z'),
|
|
||||||
lastTurnTick: 0,
|
|
||||||
clockPhase: 'MANUAL',
|
|
||||||
clockRevision: 1,
|
|
||||||
deadlineGeneration: 1,
|
|
||||||
meta: { killturn: 24, scenarioMeta },
|
meta: { killturn: 24, scenarioMeta },
|
||||||
};
|
};
|
||||||
const lifecycleGeneral: TurnGeneral = {
|
const lifecycleGeneral: TurnGeneral = {
|
||||||
@@ -129,14 +121,6 @@ integration('account icon reset reconciliation PostgreSQL queue', () => {
|
|||||||
currentYear: lifecycleState.currentYear,
|
currentYear: lifecycleState.currentYear,
|
||||||
currentMonth: lifecycleState.currentMonth,
|
currentMonth: lifecycleState.currentMonth,
|
||||||
tickSeconds: lifecycleState.tickSeconds,
|
tickSeconds: lifecycleState.tickSeconds,
|
||||||
clockBaseTime: lifecycleState.clockBaseTime,
|
|
||||||
clockTick: BigInt(lifecycleState.clockTick ?? 0),
|
|
||||||
clockMode: lifecycleState.clockMode ?? 'manual',
|
|
||||||
clockWallAnchor: lifecycleState.clockWallAnchor,
|
|
||||||
lastTurnTick: BigInt(lifecycleState.lastTurnTick ?? 0),
|
|
||||||
clockPhase: lifecycleState.clockPhase ?? 'MANUAL',
|
|
||||||
clockRevision: BigInt(lifecycleState.clockRevision ?? 1),
|
|
||||||
deadlineGeneration: BigInt(lifecycleState.deadlineGeneration ?? 1),
|
|
||||||
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||||
meta: lifecycleState.meta as GamePrisma.InputJsonValue,
|
meta: lifecycleState.meta as GamePrisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -96,7 +96,6 @@ const buildContext = (options: {
|
|||||||
ok: true as const,
|
ok: true as const,
|
||||||
auctionId: 91,
|
auctionId: 91,
|
||||||
closeAt: '2026-07-27T00:00:00.000Z',
|
closeAt: '2026-07-27T00:00:00.000Z',
|
||||||
closeTick: 200,
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
return {
|
return {
|
||||||
@@ -104,7 +103,6 @@ const buildContext = (options: {
|
|||||||
ok: true as const,
|
ok: true as const,
|
||||||
auctionId: 91,
|
auctionId: 91,
|
||||||
closeAt: '2026-07-27T00:00:00.000Z',
|
closeAt: '2026-07-27T00:00:00.000Z',
|
||||||
closeTick: 200,
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
|
const queryRaw = vi.fn(options.queryRaw ?? (async () => []));
|
||||||
@@ -114,10 +112,14 @@ const buildContext = (options: {
|
|||||||
currentYear: 200,
|
currentYear: 200,
|
||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 3600,
|
tickSeconds: 3600,
|
||||||
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
|
...(options.clockTick === undefined
|
||||||
clockTick: BigInt(options.clockTick ?? 100),
|
? {}
|
||||||
clockMode: 'manual',
|
: {
|
||||||
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
|
clockBaseTime: new Date('2026-07-26T00:00:00.000Z'),
|
||||||
|
clockTick: BigInt(options.clockTick),
|
||||||
|
clockMode: 'manual',
|
||||||
|
clockWallAnchor: new Date('2026-07-26T00:00:00.000Z'),
|
||||||
|
}),
|
||||||
config: {
|
config: {
|
||||||
const: {
|
const: {
|
||||||
auctionName: ['청룡', '백호', '주작', '현무'],
|
auctionName: ['청룡', '백호', '주작', '현무'],
|
||||||
@@ -192,7 +194,7 @@ describe('auction router actor and permission boundaries', () => {
|
|||||||
tick: 72_000_001,
|
tick: 72_000_001,
|
||||||
})
|
})
|
||||||
).toBe(true);
|
).toBe(true);
|
||||||
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(true);
|
expect(hasAuctionClosePassed({ closeAt, closeTick: null }, { now: closeAt, tick: null })).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects unauthenticated auction reads', async () => {
|
it('rejects unauthenticated auction reads', async () => {
|
||||||
@@ -421,6 +423,7 @@ describe('auction router actor and permission boundaries', () => {
|
|||||||
auctionId: 31,
|
auctionId: 31,
|
||||||
generalId: 7,
|
generalId: 7,
|
||||||
amount: 110,
|
amount: 110,
|
||||||
|
acceptedGameTick: 100,
|
||||||
tryExtendCloseDate: false,
|
tryExtendCloseDate: false,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -438,7 +441,6 @@ describe('auction router actor and permission boundaries', () => {
|
|||||||
detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
|
detail: { title: '쌀 100 경매', amount: 100, startBidAmount: 500, isReverse: false },
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
closeAt: new Date(Date.now() + 60 * 60_000),
|
closeAt: new Date(Date.now() + 60 * 60_000),
|
||||||
closeTick: 200n,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -499,6 +501,7 @@ describe('auction router actor and permission boundaries', () => {
|
|||||||
auctionId: 31,
|
auctionId: 31,
|
||||||
generalId: 7,
|
generalId: 7,
|
||||||
amount: 500,
|
amount: 500,
|
||||||
|
acceptedGameTick: 100,
|
||||||
tryExtendCloseDate: true,
|
tryExtendCloseDate: true,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -74,7 +74,6 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
detail: { amount: 100 },
|
detail: { amount: 100 },
|
||||||
status,
|
status,
|
||||||
closeAt,
|
closeAt,
|
||||||
closeTick: 0n,
|
|
||||||
...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}),
|
...(status === 'FINALIZING' ? { finalizingAt: new Date(Date.now() - 30_000) } : {}),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -83,15 +82,11 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
return auction;
|
return auction;
|
||||||
};
|
};
|
||||||
|
|
||||||
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string => {
|
const requestIdFor = (auction: { id: number; closeAt: Date; closeTick?: bigint | null }): string =>
|
||||||
if (auction.closeTick === null || auction.closeTick === undefined) {
|
buildAuctionFinalizeRequestId(auction.id, {
|
||||||
throw new Error(`auction ${auction.id} fixture requires closeTick`);
|
|
||||||
}
|
|
||||||
return buildAuctionFinalizeRequestId(auction.id, {
|
|
||||||
closeAt: auction.closeAt,
|
closeAt: auction.closeAt,
|
||||||
closeTick: auction.closeTick,
|
closeTick: auction.closeTick ?? null,
|
||||||
});
|
});
|
||||||
};
|
|
||||||
|
|
||||||
const memoryRedis = () => ({
|
const memoryRedis = () => ({
|
||||||
zRangeByScore: vi.fn(async () => []),
|
zRangeByScore: vi.fn(async () => []),
|
||||||
@@ -113,7 +108,6 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
nowTick: 0,
|
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
await expect(
|
await expect(
|
||||||
@@ -124,7 +118,6 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
nowTick: 0,
|
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
|
|
||||||
@@ -167,7 +160,6 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
nowTick: 0,
|
|
||||||
})
|
})
|
||||||
).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`);
|
).rejects.toThrow(`Conflicting durable auction finalization event: ${requestId}`);
|
||||||
|
|
||||||
@@ -188,13 +180,7 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
requestId,
|
requestId,
|
||||||
target: 'ENGINE',
|
target: 'ENGINE',
|
||||||
eventType: 'auctionFinalize',
|
eventType: 'auctionFinalize',
|
||||||
payload: {
|
payload: { type: 'auctionFinalize', requestId, auctionId: auction.id },
|
||||||
type: 'auctionFinalize',
|
|
||||||
requestId,
|
|
||||||
auctionId: auction.id,
|
|
||||||
expectedCloseAt: auction.closeAt.toISOString(),
|
|
||||||
expectedCloseTick: Number(auction.closeTick),
|
|
||||||
},
|
|
||||||
status: 'FAILED',
|
status: 'FAILED',
|
||||||
attempts: 3,
|
attempts: 3,
|
||||||
error: 'simulated terminal failure',
|
error: 'simulated terminal failure',
|
||||||
@@ -210,7 +196,6 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
nowTick: 0,
|
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
await expect(
|
await expect(
|
||||||
@@ -221,7 +206,6 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
nowTick: 0,
|
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
await expect(
|
await expect(
|
||||||
@@ -246,14 +230,13 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
nowTick: 0,
|
|
||||||
})
|
})
|
||||||
).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`);
|
).rejects.toThrow(`Auction finalization recovery exhausted: ${auction.id}`);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('creates a new generation after an earlier close was extended', async () => {
|
it('creates a new generation after an earlier close was extended', async () => {
|
||||||
const auction = await createAuction('OPEN');
|
const auction = await createAuction('OPEN');
|
||||||
const priorRequestId = `auction:finalize:${auction.id}:tick:-1`;
|
const priorRequestId = `auction:finalize:${auction.id}:${auction.closeAt.getTime() - 300_000}`;
|
||||||
await connector.prisma.inputEvent.create({
|
await connector.prisma.inputEvent.create({
|
||||||
data: {
|
data: {
|
||||||
requestId: priorRequestId,
|
requestId: priorRequestId,
|
||||||
@@ -280,7 +263,6 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
nowTick: 0,
|
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
|
|
||||||
@@ -317,14 +299,6 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
currentMonth: 1,
|
currentMonth: 1,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
lastTurnTime: new Date('2026-07-31T11:00:00.000Z'),
|
lastTurnTime: new Date('2026-07-31T11:00:00.000Z'),
|
||||||
clockBaseTime: new Date('2026-07-31T11:00:00.000Z'),
|
|
||||||
clockTick: 0,
|
|
||||||
clockMode: 'realtime',
|
|
||||||
clockWallAnchor: new Date('2026-07-31T11:00:00.000Z'),
|
|
||||||
lastTurnTick: 0,
|
|
||||||
clockPhase: 'RUNNING',
|
|
||||||
clockRevision: 1,
|
|
||||||
deadlineGeneration: 1,
|
|
||||||
meta: { killturn: 24, scenarioMeta },
|
meta: { killturn: 24, scenarioMeta },
|
||||||
};
|
};
|
||||||
const buildGeneral = (options: {
|
const buildGeneral = (options: {
|
||||||
@@ -407,14 +381,6 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
currentYear: state.currentYear,
|
currentYear: state.currentYear,
|
||||||
currentMonth: state.currentMonth,
|
currentMonth: state.currentMonth,
|
||||||
tickSeconds: state.tickSeconds,
|
tickSeconds: state.tickSeconds,
|
||||||
clockBaseTime: state.clockBaseTime,
|
|
||||||
clockTick: BigInt(state.clockTick ?? 0),
|
|
||||||
clockMode: state.clockMode ?? 'realtime',
|
|
||||||
clockWallAnchor: state.clockWallAnchor,
|
|
||||||
lastTurnTick: BigInt(state.lastTurnTick ?? 0),
|
|
||||||
clockPhase: state.clockPhase ?? 'RUNNING',
|
|
||||||
clockRevision: BigInt(state.clockRevision ?? 1),
|
|
||||||
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
|
|
||||||
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||||
meta: state.meta as GamePrisma.InputJsonValue,
|
meta: state.meta as GamePrisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
@@ -449,8 +415,6 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
amount: 200,
|
amount: 200,
|
||||||
eventId: `auction-durable-bid:${auction.id}`,
|
eventId: `auction-durable-bid:${auction.id}`,
|
||||||
eventAt: new Date(),
|
eventAt: new Date(),
|
||||||
occurredGameTick: 0n,
|
|
||||||
requestedAtWall: new Date(),
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const requestId = requestIdFor(auction);
|
const requestId = requestIdFor(auction);
|
||||||
@@ -461,7 +425,6 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(auction.id),
|
id: String(auction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
nowTick: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
const world = new InMemoryTurnWorld(state, snapshot, { schedule });
|
||||||
@@ -530,7 +493,6 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
detail: { remainCloseDateExtensionCnt: 1 },
|
detail: { remainCloseDateExtensionCnt: 1 },
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
closeAt: logicalPastCloseAt,
|
closeAt: logicalPastCloseAt,
|
||||||
closeTick: 0n,
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
extensionAuctionId = extensionAuction.id;
|
extensionAuctionId = extensionAuction.id;
|
||||||
@@ -543,8 +505,6 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
amount: 50,
|
amount: 50,
|
||||||
eventId: `auction-extension-bid:${extensionAuction.id}`,
|
eventId: `auction-extension-bid:${extensionAuction.id}`,
|
||||||
eventAt: new Date(),
|
eventAt: new Date(),
|
||||||
occurredGameTick: 0n,
|
|
||||||
requestedAtWall: new Date(),
|
|
||||||
meta: { tryExtendCloseDate: true },
|
meta: { tryExtendCloseDate: true },
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -556,7 +516,6 @@ liveDescribe('auction worker durable recovery', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: String(extensionAuction.id),
|
id: String(extensionAuction.id),
|
||||||
nowMs: Date.now(),
|
nowMs: Date.now(),
|
||||||
nowTick: 0,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
let reopened: { status: string; closeAt: Date } | null = null;
|
let reopened: { status: string; closeAt: Date } | null = null;
|
||||||
|
|||||||
@@ -2,7 +2,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
|
|
||||||
import type { GamePrismaClient } from '@sammo-ts/infra';
|
import type { GamePrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { popDueAuctionIds, processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js';
|
import { processDueAuctionId, reconcilePendingAuctionTimers } from '../src/auction/worker.js';
|
||||||
import { resolveAuctionSeedScore } from '../src/auction/scheduler.js';
|
import { resolveAuctionSeedScore } from '../src/auction/scheduler.js';
|
||||||
|
|
||||||
const buildRedis = () => ({
|
const buildRedis = () => ({
|
||||||
@@ -33,7 +33,7 @@ const buildDb = (options: {
|
|||||||
$executeRaw: vi.fn(async () => options.updated),
|
$executeRaw: vi.fn(async () => options.updated),
|
||||||
auction: {
|
auction: {
|
||||||
findUnique: vi.fn(async () =>
|
findUnique: vi.fn(async () =>
|
||||||
options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? 72_000_000n } : null
|
options.auction ? { ...options.auction, closeTick: options.auction.closeTick ?? null } : null
|
||||||
),
|
),
|
||||||
},
|
},
|
||||||
inputEvent: {
|
inputEvent: {
|
||||||
@@ -55,38 +55,6 @@ const buildDb = (options: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('auction worker clock-shift race', () => {
|
describe('auction worker clock-shift race', () => {
|
||||||
it('uses one Redis script for revision, generation, phase, due-read, and removal', async () => {
|
|
||||||
const redis = { ...buildRedis(), eval: vi.fn(async () => ['7', '9']) };
|
|
||||||
await expect(
|
|
||||||
popDueAuctionIds(redis, 'auction-timer', 123_456, 100, {
|
|
||||||
activeRevisionKey: 'clock-revision',
|
|
||||||
deadlineGenerationKey: 'deadline-generation',
|
|
||||||
phaseKey: 'clock-phase',
|
|
||||||
revision: 4,
|
|
||||||
generation: 8,
|
|
||||||
})
|
|
||||||
).resolves.toEqual(['7', '9']);
|
|
||||||
expect(redis.eval).toHaveBeenCalledWith(expect.stringContaining('ZRANGEBYSCORE'), {
|
|
||||||
keys: ['auction-timer', 'clock-revision', 'deadline-generation', 'clock-phase'],
|
|
||||||
arguments: ['4', '8', '123456', '100'],
|
|
||||||
});
|
|
||||||
expect(redis.zRangeByScore).not.toHaveBeenCalled();
|
|
||||||
expect(redis.zRem).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('returns no due member when the atomic Redis clock fence rejects the pop', async () => {
|
|
||||||
const redis = { ...buildRedis(), eval: vi.fn(async () => ['__CLOCK_FENCE__']) };
|
|
||||||
await expect(
|
|
||||||
popDueAuctionIds(redis, 'auction-timer', 123_456, 100, {
|
|
||||||
activeRevisionKey: 'clock-revision',
|
|
||||||
deadlineGenerationKey: 'deadline-generation',
|
|
||||||
phaseKey: 'clock-phase',
|
|
||||||
revision: 4,
|
|
||||||
generation: 8,
|
|
||||||
})
|
|
||||||
).resolves.toEqual([]);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('seeds OPEN at its deadline but retries FINALIZING at the current logical tick', () => {
|
it('seeds OPEN at its deadline but retries FINALIZING at the current logical tick', () => {
|
||||||
const now = new Date('2026-07-30T12:00:00.000Z');
|
const now = new Date('2026-07-30T12:00:00.000Z');
|
||||||
const time = {
|
const time = {
|
||||||
@@ -233,12 +201,11 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||||
nowTick: 36_000_000,
|
|
||||||
})
|
})
|
||||||
).resolves.toBe('RESCHEDULED');
|
).resolves.toBe('RESCHEDULED');
|
||||||
|
|
||||||
expect(redis.zAdd).toHaveBeenCalledTimes(1);
|
expect(redis.zAdd).toHaveBeenCalledTimes(1);
|
||||||
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: 72_000_000, value: '7' }]);
|
expect(redis.zAdd).toHaveBeenCalledWith('timer', [{ score: closeAt.getTime(), value: '7' }]);
|
||||||
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -268,7 +235,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
it('leaves OPEN untouched and creates one durable command before recording history', async () => {
|
it('leaves OPEN untouched and creates one durable command before recording history', async () => {
|
||||||
const redis = buildRedis();
|
const redis = buildRedis();
|
||||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||||
const requestId = 'auction:finalize:7:tick:72000000';
|
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||||
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
|
const { db, transaction } = buildDb({ updated: 0, auction: { status: 'OPEN', closeAt } });
|
||||||
const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
|
const nowMs = new Date('2026-07-30T12:00:00.000Z').getTime();
|
||||||
|
|
||||||
@@ -280,7 +247,6 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs,
|
nowMs,
|
||||||
nowTick: 72_000_000,
|
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
|
|
||||||
@@ -295,7 +261,6 @@ describe('auction worker clock-shift race', () => {
|
|||||||
requestId,
|
requestId,
|
||||||
auctionId: 7,
|
auctionId: 7,
|
||||||
expectedCloseAt: closeAt.toISOString(),
|
expectedCloseAt: closeAt.toISOString(),
|
||||||
expectedCloseTick: 72_000_000,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -353,7 +318,6 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||||
nowTick: 72_000_000,
|
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
|
|
||||||
@@ -366,7 +330,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => {
|
it('reuses the same pending OPEN-generation event after a worker retry or restart', async () => {
|
||||||
const redis = buildRedis();
|
const redis = buildRedis();
|
||||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||||
const requestId = 'auction:finalize:7:tick:72000000';
|
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||||
const { db, transaction } = buildDb({
|
const { db, transaction } = buildDb({
|
||||||
updated: 0,
|
updated: 0,
|
||||||
auction: { status: 'OPEN', closeAt },
|
auction: { status: 'OPEN', closeAt },
|
||||||
@@ -380,7 +344,6 @@ describe('auction worker clock-shift race', () => {
|
|||||||
requestId,
|
requestId,
|
||||||
auctionId: 7,
|
auctionId: 7,
|
||||||
expectedCloseAt: closeAt.toISOString(),
|
expectedCloseAt: closeAt.toISOString(),
|
||||||
expectedCloseTick: 72_000_000,
|
|
||||||
},
|
},
|
||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
result: null,
|
result: null,
|
||||||
@@ -396,7 +359,6 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||||
nowTick: 72_000_000,
|
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
expect(transaction.inputEvent.create).not.toHaveBeenCalled();
|
||||||
@@ -417,7 +379,6 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: logicalNowMs,
|
nowMs: logicalNowMs,
|
||||||
nowTick: 72_000_000,
|
|
||||||
historyNowMs: operationalNowMs,
|
historyNowMs: operationalNowMs,
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -427,12 +388,12 @@ describe('auction worker clock-shift race', () => {
|
|||||||
it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => {
|
it('repairs a pre-existing FINALIZING auction without creating a duplicate command', async () => {
|
||||||
const redis = buildRedis();
|
const redis = buildRedis();
|
||||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||||
const requestId = 'auction:finalize:7:tick:72000000';
|
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||||
const existingEvent = {
|
const existingEvent = {
|
||||||
requestId,
|
requestId,
|
||||||
target: 'ENGINE' as const,
|
target: 'ENGINE' as const,
|
||||||
eventType: 'auctionFinalize',
|
eventType: 'auctionFinalize',
|
||||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
|
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
|
||||||
status: 'PENDING' as const,
|
status: 'PENDING' as const,
|
||||||
result: null,
|
result: null,
|
||||||
};
|
};
|
||||||
@@ -450,7 +411,6 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||||
nowTick: 72_000_000,
|
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
|
|
||||||
@@ -463,7 +423,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
it('creates one bounded successor after a terminal event failure', async () => {
|
it('creates one bounded successor after a terminal event failure', async () => {
|
||||||
const redis = buildRedis();
|
const redis = buildRedis();
|
||||||
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
const closeAt = new Date('2026-07-30T11:00:00.000Z');
|
||||||
const requestId = 'auction:finalize:7:tick:72000000';
|
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||||
const retryRequestId = `${requestId}:retry:1`;
|
const retryRequestId = `${requestId}:retry:1`;
|
||||||
const { db, transaction } = buildDb({
|
const { db, transaction } = buildDb({
|
||||||
updated: 0,
|
updated: 0,
|
||||||
@@ -473,7 +433,7 @@ describe('auction worker clock-shift race', () => {
|
|||||||
requestId,
|
requestId,
|
||||||
target: 'ENGINE',
|
target: 'ENGINE',
|
||||||
eventType: 'auctionFinalize',
|
eventType: 'auctionFinalize',
|
||||||
payload: { type: 'auctionFinalize', requestId, auctionId: 7, expectedCloseTick: 72_000_000 },
|
payload: { type: 'auctionFinalize', requestId, auctionId: 7 },
|
||||||
status: 'FAILED',
|
status: 'FAILED',
|
||||||
result: null,
|
result: null,
|
||||||
},
|
},
|
||||||
@@ -488,7 +448,6 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||||
nowTick: 72_000_000,
|
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
|
|
||||||
@@ -502,7 +461,6 @@ describe('auction worker clock-shift race', () => {
|
|||||||
requestId: retryRequestId,
|
requestId: retryRequestId,
|
||||||
auctionId: 7,
|
auctionId: 7,
|
||||||
expectedCloseAt: closeAt.toISOString(),
|
expectedCloseAt: closeAt.toISOString(),
|
||||||
expectedCloseTick: 72_000_000,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -510,23 +468,19 @@ describe('auction worker clock-shift race', () => {
|
|||||||
|
|
||||||
it('uses the close deadline as the generation so a reopened auction gets a new command', async () => {
|
it('uses the close deadline as the generation so a reopened auction gets a new command', async () => {
|
||||||
const redis = buildRedis();
|
const redis = buildRedis();
|
||||||
|
const previousCloseAt = new Date('2026-07-30T11:00:00.000Z');
|
||||||
const closeAt = new Date('2026-07-30T11:30:00.000Z');
|
const closeAt = new Date('2026-07-30T11:30:00.000Z');
|
||||||
const previousRequestId = 'auction:finalize:7:tick:36000000';
|
const previousRequestId = `auction:finalize:7:${previousCloseAt.getTime()}`;
|
||||||
const requestId = 'auction:finalize:7:tick:72000000';
|
const requestId = `auction:finalize:7:${closeAt.getTime()}`;
|
||||||
const { db, transaction } = buildDb({
|
const { db, transaction } = buildDb({
|
||||||
updated: 0,
|
updated: 0,
|
||||||
auction: { status: 'OPEN', closeAt, closeTick: 72_000_000n },
|
auction: { status: 'OPEN', closeAt },
|
||||||
existingEvents: [
|
existingEvents: [
|
||||||
{
|
{
|
||||||
requestId: previousRequestId,
|
requestId: previousRequestId,
|
||||||
target: 'ENGINE',
|
target: 'ENGINE',
|
||||||
eventType: 'auctionFinalize',
|
eventType: 'auctionFinalize',
|
||||||
payload: {
|
payload: { type: 'auctionFinalize', requestId: previousRequestId, auctionId: 7 },
|
||||||
type: 'auctionFinalize',
|
|
||||||
requestId: previousRequestId,
|
|
||||||
auctionId: 7,
|
|
||||||
expectedCloseTick: 36_000_000,
|
|
||||||
},
|
|
||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
result: { type: 'auctionFinalize', ok: false, auctionId: 7 },
|
result: { type: 'auctionFinalize', ok: false, auctionId: 7 },
|
||||||
},
|
},
|
||||||
@@ -541,7 +495,6 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||||
nowTick: 72_000_000,
|
|
||||||
})
|
})
|
||||||
).resolves.toBe('PENDING');
|
).resolves.toBe('PENDING');
|
||||||
|
|
||||||
@@ -555,7 +508,6 @@ describe('auction worker clock-shift race', () => {
|
|||||||
requestId,
|
requestId,
|
||||||
auctionId: 7,
|
auctionId: 7,
|
||||||
expectedCloseAt: closeAt.toISOString(),
|
expectedCloseAt: closeAt.toISOString(),
|
||||||
expectedCloseTick: 72_000_000,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -575,7 +527,6 @@ describe('auction worker clock-shift race', () => {
|
|||||||
historyKey: 'history',
|
historyKey: 'history',
|
||||||
id: '7',
|
id: '7',
|
||||||
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
nowMs: new Date('2026-07-30T12:00:00.000Z').getTime(),
|
||||||
nowTick: 72_000_000,
|
|
||||||
})
|
})
|
||||||
).rejects.toThrow('event insert failed');
|
).rejects.toThrow('event insert failed');
|
||||||
|
|
||||||
|
|||||||
@@ -1,87 +0,0 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
|
||||||
|
|
||||||
import type { DatabaseClient } from '../src/context.js';
|
|
||||||
import { loadClockAdminStatus, loadClockReadiness } from '../src/services/clockReadiness.js';
|
|
||||||
|
|
||||||
describe('clock reconciliation readiness', () => {
|
|
||||||
it('fails closed when the reconciliation schema is not available', async () => {
|
|
||||||
const db = {} as DatabaseClient;
|
|
||||||
await expect(loadClockReadiness(db)).resolves.toEqual({
|
|
||||||
reconciliationComplete: false,
|
|
||||||
gameplayEnabled: false,
|
|
||||||
phase: null,
|
|
||||||
revision: null,
|
|
||||||
deadlineGeneration: null,
|
|
||||||
incompleteOutboxCount: null,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('blocks readiness for RECONCILING or incomplete outbox state', async () => {
|
|
||||||
const db = {
|
|
||||||
worldState: {
|
|
||||||
findFirst: vi.fn(async () => ({
|
|
||||||
clockPhase: 'RECONCILING',
|
|
||||||
clockRevision: 9n,
|
|
||||||
deadlineGeneration: 4n,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
clockProjectionOutbox: { count: vi.fn(async () => 1) },
|
|
||||||
} as unknown as DatabaseClient;
|
|
||||||
await expect(loadClockReadiness(db)).resolves.toMatchObject({
|
|
||||||
reconciliationComplete: false,
|
|
||||||
gameplayEnabled: false,
|
|
||||||
phase: 'RECONCILING',
|
|
||||||
revision: 9,
|
|
||||||
deadlineGeneration: 4,
|
|
||||||
incompleteOutboxCount: 1,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes participant checksums and incomplete outbox detail to admins', async () => {
|
|
||||||
const db = {
|
|
||||||
worldState: {
|
|
||||||
findFirst: vi.fn(async () => ({
|
|
||||||
clockPhase: 'RUNNING',
|
|
||||||
clockRevision: 3n,
|
|
||||||
deadlineGeneration: 2n,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
clockProjectionOutbox: { count: vi.fn(async () => 0) },
|
|
||||||
clockSuspension: {
|
|
||||||
findFirst: vi.fn(async () => ({
|
|
||||||
id: 'maintenance-1',
|
|
||||||
source: 'MAINTENANCE',
|
|
||||||
policy: 'EXACT',
|
|
||||||
status: 'APPLIED',
|
|
||||||
sourceRevision: 2n,
|
|
||||||
targetRevision: 3n,
|
|
||||||
cutTick: 100n,
|
|
||||||
alignedTick: 130n,
|
|
||||||
participantChecksumBefore: 'before-all',
|
|
||||||
participantChecksumAfter: 'after-all',
|
|
||||||
participants: [
|
|
||||||
{
|
|
||||||
participantKey: 'general-turn',
|
|
||||||
policy: 'SHIFT',
|
|
||||||
beforeChecksum: 'before',
|
|
||||||
afterChecksum: 'after',
|
|
||||||
affectedCount: 2,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
projectionOutbox: [{ id: 8n, targetRevision: 3n, status: 'APPLIED', attempts: 1, lastError: null }],
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
} as unknown as DatabaseClient;
|
|
||||||
|
|
||||||
await expect(loadClockAdminStatus(db)).resolves.toMatchObject({
|
|
||||||
reconciliationComplete: true,
|
|
||||||
latestReconciliation: {
|
|
||||||
id: 'maintenance-1',
|
|
||||||
participantChecksumBefore: 'before-all',
|
|
||||||
participantChecksumAfter: 'after-all',
|
|
||||||
participants: [{ key: 'general-turn', policy: 'SHIFT', affectedCount: 2 }],
|
|
||||||
outbox: [{ id: '8', status: 'APPLIED' }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
@@ -10,7 +10,6 @@ import { describe, expect, it } from 'vitest';
|
|||||||
import {
|
import {
|
||||||
assertReservedTurnArgsPassLegacyBasicValidation,
|
assertReservedTurnArgsPassLegacyBasicValidation,
|
||||||
buildEquipmentTradeItemOptions,
|
buildEquipmentTradeItemOptions,
|
||||||
loadEquipmentTradeItemOrder,
|
|
||||||
buildTurnCommandInputFields,
|
buildTurnCommandInputFields,
|
||||||
parseReservedTurnArgs,
|
parseReservedTurnArgs,
|
||||||
sanitizeReservedTurnArgs,
|
sanitizeReservedTurnArgs,
|
||||||
@@ -113,22 +112,12 @@ describe('turn command argument input', () => {
|
|||||||
).toThrow('턴이 입력되지 않았습니다.');
|
).toThrow('턴이 입력되지 않았습니다.');
|
||||||
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ month: '12', year: 0 })).not.toThrow();
|
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ month: '12', year: 0 })).not.toThrow();
|
||||||
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '0' })).not.toThrow();
|
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '0' })).not.toThrow();
|
||||||
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ nationName: '가나다라마바사아자차' })).toThrow(
|
|
||||||
'국가명은 전각 9자 또는 반각 18자 이하여야 합니다.'
|
|
||||||
);
|
|
||||||
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ year: '0x10' })).toThrow(
|
expect(() => assertReservedTurnArgsPassLegacyBasicValidation({ year: '0x10' })).toThrow(
|
||||||
'턴이 입력되지 않았습니다.'
|
'턴이 입력되지 않았습니다.'
|
||||||
);
|
);
|
||||||
await expect(parseReservedTurnArgs('nation', 'che_국호변경', { nationName: '0' })).rejects.toBeDefined();
|
await expect(parseReservedTurnArgs('nation', 'che_국호변경', { nationName: '0' })).rejects.toBeDefined();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('exposes the Ref fullwidth nation-name limit to command input clients', async () => {
|
|
||||||
const specs = await loadGeneralTurnCommandSpecs(['che_건국']);
|
|
||||||
expect(buildTurnCommandInputFields(specs[0]!)).toContainEqual(
|
|
||||||
expect.objectContaining({ key: 'nationName', legacyWidthMax: 18 })
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('recursively sanitizes reserved command strings like Ref before command parsing', async () => {
|
it('recursively sanitizes reserved command strings like Ref before command parsing', async () => {
|
||||||
expect(
|
expect(
|
||||||
sanitizeReservedTurnArgs({
|
sanitizeReservedTurnArgs({
|
||||||
@@ -223,109 +212,20 @@ describe('turn command argument input', () => {
|
|||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('preserves scenario order while filtering unavailable catalog entries in every slot', () => {
|
|
||||||
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
|
|
||||||
const items = buildEquipmentTradeItemOptions({
|
|
||||||
configConst: {
|
|
||||||
allItems: { [slot]: { z_last_code: 0, missing: 0, unique: 1, blocked: 0, a_first_code: -1 } },
|
|
||||||
},
|
|
||||||
itemModules: [
|
|
||||||
{ ...buildShopItem('a_first_code', '첫 코드'), slot },
|
|
||||||
{ ...buildShopItem('z_last_code', '마지막 코드'), slot },
|
|
||||||
{ ...buildShopItem('unique', '유니크'), slot },
|
|
||||||
{ ...buildShopItem('blocked', '비매품'), slot, buyable: false },
|
|
||||||
],
|
|
||||||
currentSecurity: 5000,
|
|
||||||
generalGold: 1000,
|
|
||||||
ownedItems: { horse: null, weapon: null, book: null, item: null },
|
|
||||||
});
|
|
||||||
expect(items[slot].map((item) => item.value)).toEqual(['None', 'z_last_code', 'a_first_code']);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('restores original scenario order after DB key reordering without restoring removed purchase permissions', async () => {
|
|
||||||
const scenario = await loadScenarioDefinitionById(2701);
|
|
||||||
const pool = scenario.config.const.allItems as Record<string, Record<string, number>>;
|
|
||||||
const originalKeys = Object.keys(pool.item!);
|
|
||||||
const buyableKeys = originalKeys.filter((key) => pool.item![key]! <= 0);
|
|
||||||
const removedKey = buyableKeys[1]!;
|
|
||||||
const reorderedPool = Object.fromEntries(Object.entries(pool.item!).reverse());
|
|
||||||
reorderedPool[removedKey] = 1;
|
|
||||||
reorderedPool.runtime_added = 0;
|
|
||||||
const items = buildEquipmentTradeItemOptions({
|
|
||||||
configConst: { allItems: { item: reorderedPool } },
|
|
||||||
itemOrder: await loadEquipmentTradeItemOrder('scenario_2701.json'),
|
|
||||||
itemModules: [...buyableKeys]
|
|
||||||
.reverse()
|
|
||||||
.concat('runtime_added')
|
|
||||||
.map((key) => buildShopItem(key, key)),
|
|
||||||
currentSecurity: 5000,
|
|
||||||
generalGold: 1000,
|
|
||||||
ownedItems: { horse: null, weapon: null, book: null, item: null },
|
|
||||||
});
|
|
||||||
expect(items.item.map((item) => item.value)).toEqual([
|
|
||||||
'None',
|
|
||||||
...buyableKeys.filter((key) => key !== removedKey),
|
|
||||||
'runtime_added',
|
|
||||||
]);
|
|
||||||
expect((await loadEquipmentTradeItemOrder('1'))?.filter((key) => key.startsWith('che_치료'))).toEqual([
|
|
||||||
'che_치료_환약',
|
|
||||||
]);
|
|
||||||
expect(await loadEquipmentTradeItemOrder('unknown')).toBeUndefined();
|
|
||||||
expect(await loadEquipmentTradeItemOrder('999999')).toBeUndefined();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps owned unique equipment sales outside the purchase pool in every slot', async () => {
|
|
||||||
for (const slot of ['horse', 'weapon', 'book', 'item'] as const) {
|
|
||||||
const owned = { ...buildShopItem('owned_unique', '보유 유니크'), slot, buyable: false, cost: 10001 };
|
|
||||||
const items = buildEquipmentTradeItemOptions({
|
|
||||||
configConst: {},
|
|
||||||
itemModules: [owned],
|
|
||||||
currentSecurity: 0,
|
|
||||||
generalGold: 0,
|
|
||||||
ownedItems: { horse: null, weapon: null, book: null, item: null, [slot]: owned.key },
|
|
||||||
});
|
|
||||||
expect(items[slot]).toEqual([
|
|
||||||
{ value: 'None', label: '보유 유니크 판매', description: '소유 물품 판매 · 판매가 5,000금' },
|
|
||||||
]);
|
|
||||||
await expect(
|
|
||||||
parseReservedTurnArgs('general', 'che_장비매매', { itemType: slot, itemCode: 'None' })
|
|
||||||
).resolves.toEqual({ itemType: slot, itemCode: 'None' });
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => {
|
it('limits equipment trade options to the Ref default items when a scenario omits allItems', () => {
|
||||||
const items = buildEquipmentTradeItemOptions({
|
const items = buildEquipmentTradeItemOptions({
|
||||||
ownedItems: { horse: null, weapon: null, book: null, item: null },
|
|
||||||
configConst: {},
|
configConst: {},
|
||||||
itemModules: [
|
itemModules: [buildShopItem('che_치료_환약', '환약'), buildShopItem('event_전투특기_격노', '격노의 비급')],
|
||||||
buildShopItem('event_전투특기_격노', '격노의 비급'),
|
|
||||||
buildShopItem('che_계략_향낭', '향낭'),
|
|
||||||
buildShopItem('che_계략_이추', '이추'),
|
|
||||||
buildShopItem('che_훈련_청주', '청주'),
|
|
||||||
buildShopItem('che_사기_탁주', '탁주'),
|
|
||||||
buildShopItem('che_저격_수극', '수극'),
|
|
||||||
buildShopItem('che_치료_환약', '환약'),
|
|
||||||
],
|
|
||||||
currentSecurity: 5000,
|
currentSecurity: 5000,
|
||||||
generalGold: 1000,
|
generalGold: 1000,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(items.item.map((item) => item.value)).toEqual([
|
expect(items.item.map((item) => item.value)).toEqual(['None', 'che_치료_환약']);
|
||||||
'None',
|
|
||||||
'che_치료_환약',
|
|
||||||
'che_저격_수극',
|
|
||||||
'che_사기_탁주',
|
|
||||||
'che_훈련_청주',
|
|
||||||
'che_계략_이추',
|
|
||||||
'che_계략_향낭',
|
|
||||||
]);
|
|
||||||
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
|
expect(items.item[1]?.description).toBe('현재 구입 가능 · 가격 100 · 환약 · 설명');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
|
it('shows only zero-count buyable items selected by an explicit scenario pool', () => {
|
||||||
const items = buildEquipmentTradeItemOptions({
|
const items = buildEquipmentTradeItemOptions({
|
||||||
ownedItems: { horse: null, weapon: null, book: null, item: null },
|
|
||||||
configConst: {
|
configConst: {
|
||||||
allItems: {
|
allItems: {
|
||||||
item: {
|
item: {
|
||||||
|
|||||||
@@ -67,9 +67,6 @@ describe('Ref command general targets', () => {
|
|||||||
gold: 100,
|
gold: 100,
|
||||||
rice: 200,
|
rice: 200,
|
||||||
crew: 900,
|
crew: 900,
|
||||||
leadership: 100,
|
|
||||||
strength: 95,
|
|
||||||
intel: 0,
|
|
||||||
train: 80,
|
train: 80,
|
||||||
atmos: 70,
|
atmos: 70,
|
||||||
troopId: 3,
|
troopId: 3,
|
||||||
@@ -91,18 +88,11 @@ describe('Ref command general targets', () => {
|
|||||||
description: expect.stringContaining('탑승 부대 청룡대'),
|
description: expect.stringContaining('탑승 부대 청룡대'),
|
||||||
});
|
});
|
||||||
expect(detailed.generalTargets.che_발령?.map((entry) => entry.label)).toEqual([
|
expect(detailed.generalTargets.che_발령?.map((entry) => entry.label)).toEqual([
|
||||||
'본인 (업)',
|
'본인 (부대 없음 · 업)',
|
||||||
'부대원 (업)',
|
'부대원 (청룡대 · 업)',
|
||||||
'부대장 (업)',
|
'부대장 (청룡대 (부대장) · 업)',
|
||||||
]);
|
]);
|
||||||
expect(detailed.generalTargets.che_발령?.[1]?.description).toBe(
|
expect(detailed.generalTargets.che_발령?.[1]?.description).not.toContain('탑승 부대');
|
||||||
'탑승 부대 청룡대\n통솔 100 · 무력 95 · 지력 0\n병력 900 · 훈련 80 · 사기 70\n금 100 · 쌀 200'
|
|
||||||
);
|
|
||||||
expect(detailed.generalTargets.che_발령?.[0]?.description).toBe(
|
|
||||||
'탑승 부대 없음\n병력 1,000\n금 5,000 · 쌀 4,000'
|
|
||||||
);
|
|
||||||
expect(detailed.generalTargets.che_발령?.[2]?.description).toContain('청룡대 (부대장)');
|
|
||||||
expect(detailed.generalTargets.che_증여?.[1]?.description).not.toContain('통솔');
|
|
||||||
expect(detailed.generalTargets.che_포상?.map((entry) => entry.label)).toEqual([
|
expect(detailed.generalTargets.che_포상?.map((entry) => entry.label)).toEqual([
|
||||||
'본인 (업)',
|
'본인 (업)',
|
||||||
'부대원 (업)',
|
'부대원 (업)',
|
||||||
|
|||||||
@@ -67,7 +67,6 @@ const buildAuth = (id: string, displayName: string, legacyMemberNo: number): Gam
|
|||||||
any: {
|
any: {
|
||||||
ban: { expire: 4_102_444_800, value: 1 },
|
ban: { expire: 4_102_444_800, value: 1 },
|
||||||
expired: { expire: 1, value: 9 },
|
expired: { expire: 1, value: 9 },
|
||||||
expiredWall: { expire: Date.parse('2026-08-01T00:00:00.000Z') / 1000, value: 9 },
|
|
||||||
},
|
},
|
||||||
hwe: {
|
hwe: {
|
||||||
ban: { expire: 4_102_444_800, value: 2 },
|
ban: { expire: 4_102_444_800, value: 2 },
|
||||||
@@ -150,9 +149,8 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
await seedScenarioToDatabase({
|
await seedScenarioToDatabase({
|
||||||
scenarioId: 2,
|
scenarioId: 2,
|
||||||
databaseUrl: databaseUrl!,
|
databaseUrl: databaseUrl!,
|
||||||
now: new Date('2026-07-30T12:00:00.000Z'),
|
now: new Date('2099-07-30T12:00:00.000Z'),
|
||||||
installOptions: {
|
installOptions: {
|
||||||
openAt: new Date(Date.now() + 86_400_000),
|
|
||||||
turnTermMinutes: 5,
|
turnTermMinutes: 5,
|
||||||
npcMode: 0,
|
npcMode: 0,
|
||||||
showImgLevel: 3,
|
showImgLevel: 3,
|
||||||
@@ -229,14 +227,6 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
create: { userId, key, value },
|
create: { userId, key, value },
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
// 이 fixture는 특기 없는 일반 생성/reload를 검증한다. 1% 천재 추첨은 별도 계약이다.
|
|
||||||
const seededWorld = await db.worldState.findFirstOrThrow();
|
|
||||||
await db.worldState.update({
|
|
||||||
where: { id: seededWorld.id },
|
|
||||||
data: {
|
|
||||||
meta: { ...(seededWorld.meta as Record<string, GamePrisma.InputJsonValue>), genius: 0 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await startRuntime('create-general-integration-daemon');
|
await startRuntime('create-general-integration-daemon');
|
||||||
}, 60_000);
|
}, 60_000);
|
||||||
|
|
||||||
@@ -299,7 +289,6 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
},
|
},
|
||||||
});
|
});
|
||||||
expect(created.affinity).toBeGreaterThanOrEqual(1);
|
expect(created.affinity).toBeGreaterThanOrEqual(1);
|
||||||
expect(created.penalty).toEqual({ ban: 2, chat: 3 });
|
|
||||||
expect(created.affinity).toBeLessThanOrEqual(150);
|
expect(created.affinity).toBeLessThanOrEqual(150);
|
||||||
expect(created.meta).toMatchObject({
|
expect(created.meta).toMatchObject({
|
||||||
createdBy: 'join',
|
createdBy: 'join',
|
||||||
@@ -316,9 +305,6 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
throw new Error('created general must have an initial access timestamp');
|
throw new Error('created general must have an initial access timestamp');
|
||||||
}
|
}
|
||||||
expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
|
expect(createdAccess.lastRefresh).toEqual(acceptedEvent.createdAt);
|
||||||
expect(acceptedEvent.processingGameTick).toBeLessThan(0n);
|
|
||||||
expect(created.turnTick).toBeGreaterThanOrEqual(0n);
|
|
||||||
expect(await db.worldState.findFirst()).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0n });
|
|
||||||
expect(
|
expect(
|
||||||
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
|
new Date((created.meta as Record<string, unknown>).prestart_delete_after as string).getTime() -
|
||||||
acceptedEvent.createdAt.getTime()
|
acceptedEvent.createdAt.getTime()
|
||||||
@@ -567,91 +553,4 @@ integration('generic general creation through the durable turn daemon', () => {
|
|||||||
expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })])
|
expect.arrayContaining([expect.objectContaining({ userId: 'forged-owner' })])
|
||||||
);
|
);
|
||||||
}, 10_000);
|
}, 10_000);
|
||||||
it.each([
|
|
||||||
{ phase: 'SUSPENDED', united: 0, finalized: false },
|
|
||||||
{ phase: 'SUSPENDED', united: 2, finalized: true },
|
|
||||||
{ phase: 'COMPLETED', united: 3, finalized: true },
|
|
||||||
])(
|
|
||||||
'creates a visitor in $phase/$united without reopening season records',
|
|
||||||
async ({ phase, united, finalized }) => {
|
|
||||||
await stopRuntime('prepare frozen participation');
|
|
||||||
const original = await db.worldState.findFirstOrThrow();
|
|
||||||
const history = await db.gameHistory.findUniqueOrThrow({ where: { serverId: profile } });
|
|
||||||
const visitorId = `visitor-${phase}-${united}`;
|
|
||||||
const auth = buildAuth(visitorId, '방문자', 7000 + united);
|
|
||||||
await db.inheritancePoint.create({ data: { userId: visitorId, key: 'previous', value: 5000 } });
|
|
||||||
await db.worldState.update({
|
|
||||||
where: { id: original.id },
|
|
||||||
data: {
|
|
||||||
clockPhase: phase,
|
|
||||||
meta: {
|
|
||||||
...(original.meta as Record<string, GamePrisma.InputJsonValue>),
|
|
||||||
isUnited: united,
|
|
||||||
isunited: united,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await db.gameHistory.update({
|
|
||||||
where: { serverId: profile },
|
|
||||||
data: { status: finalized ? 'COMPLETED' : 'OPEN' },
|
|
||||||
});
|
|
||||||
const readRecords = async () => ({
|
|
||||||
points: await db.inheritancePoint.findMany({ where: { userId: visitorId }, orderBy: { id: 'asc' } }),
|
|
||||||
logs: await db.inheritanceLog.count({ where: { userId: visitorId } }),
|
|
||||||
baseline: await db.gameInheritanceBaseline.count({ where: { serverId: profile, userId: visitorId } }),
|
|
||||||
hall: await db.hallOfFame.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
|
|
||||||
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
|
|
||||||
oldGenerals: await db.oldGeneral.count({ where: { serverId: profile } }),
|
|
||||||
});
|
|
||||||
const before = await readRecords();
|
|
||||||
try {
|
|
||||||
await startRuntime(`visitor-${phase}-${united}`);
|
|
||||||
const caller = appRouter.createCaller(buildContext(`visitor-${phase}-${united}`, auth));
|
|
||||||
expect((await caller.join.getConfig()).inherit.enabled).toBe(!finalized);
|
|
||||||
const input = {
|
|
||||||
name: `방문${united}`,
|
|
||||||
pic: false,
|
|
||||||
leadership: 55,
|
|
||||||
strength: 55,
|
|
||||||
intel: 55,
|
|
||||||
character: 'che_안전' as const,
|
|
||||||
};
|
|
||||||
if (finalized) {
|
|
||||||
await expect(
|
|
||||||
appRouter
|
|
||||||
.createCaller(buildContext(`visitor-paid-${united}`, auth))
|
|
||||||
.join.createGeneral({ ...input, inheritTurntimeZone: 7 })
|
|
||||||
).rejects.toMatchObject({ code: 'BAD_REQUEST' });
|
|
||||||
expect(await readRecords()).toEqual(before);
|
|
||||||
}
|
|
||||||
const result = await caller.join.createGeneral(input);
|
|
||||||
expect(await db.general.findUniqueOrThrow({ where: { id: result.generalId } })).toMatchObject({
|
|
||||||
userId: visitorId,
|
|
||||||
npcState: 0,
|
|
||||||
});
|
|
||||||
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
|
|
||||||
clockPhase: phase,
|
|
||||||
clockTick: original.clockTick,
|
|
||||||
lastTurnTick: original.lastTurnTick,
|
|
||||||
});
|
|
||||||
if (finalized) expect(await readRecords()).toEqual(before);
|
|
||||||
else
|
|
||||||
expect(
|
|
||||||
await db.gameInheritanceBaseline.count({ where: { serverId: profile, userId: visitorId } })
|
|
||||||
).toBe(1);
|
|
||||||
await stopRuntime('verify visitor persisted');
|
|
||||||
await startRuntime(`visitor-reload-${phase}-${united}`);
|
|
||||||
expect(runtime?.world.getGeneralById(result.generalId)?.userId).toBe(visitorId);
|
|
||||||
} finally {
|
|
||||||
await stopRuntime('restore participation fixture');
|
|
||||||
await db.worldState.update({
|
|
||||||
where: { id: original.id },
|
|
||||||
data: { clockPhase: original.clockPhase, meta: original.meta! },
|
|
||||||
});
|
|
||||||
await db.gameHistory.update({ where: { serverId: profile }, data: { status: history.status } });
|
|
||||||
await startRuntime('participation-fixture-restored');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
30_000
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,7 +63,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
|
|||||||
changes.some(
|
changes.some(
|
||||||
(change) =>
|
(change) =>
|
||||||
Array.isArray(change) &&
|
Array.isArray(change) &&
|
||||||
(change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
|
change[0] === 'messages.mailbox' &&
|
||||||
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -94,10 +94,7 @@ integration('diplomacy document message persistence', () => {
|
|||||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: requestPrefix } } });
|
||||||
await deleteFixtureOutboxes();
|
await deleteFixtureOutboxes();
|
||||||
await db.readModelRevision.deleteMany({
|
await db.readModelRevision.deleteMany({
|
||||||
where: {
|
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
||||||
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
|
|
||||||
entityId: { in: [...fixtureMailboxes] },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -578,10 +575,7 @@ integration('diplomacy document message persistence', () => {
|
|||||||
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
|
await expect(db.message.count({ where: { mailbox: { in: [...fixtureMailboxes] } } })).resolves.toBe(0);
|
||||||
await expect(
|
await expect(
|
||||||
db.readModelRevision.count({
|
db.readModelRevision.count({
|
||||||
where: {
|
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
||||||
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
|
|
||||||
entityId: { in: [...fixtureMailboxes] },
|
|
||||||
},
|
|
||||||
})
|
})
|
||||||
).resolves.toBe(0);
|
).resolves.toBe(0);
|
||||||
await expect(
|
await expect(
|
||||||
|
|||||||
@@ -81,7 +81,7 @@ const isFixtureOutboxPayload = (payload: unknown): boolean => {
|
|||||||
changes.some(
|
changes.some(
|
||||||
(change) =>
|
(change) =>
|
||||||
Array.isArray(change) &&
|
Array.isArray(change) &&
|
||||||
(change[0] === 'messages.mailbox' || change[0] === 'messages.diplomacyMailbox') &&
|
change[0] === 'messages.mailbox' &&
|
||||||
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
fixtureMailboxes.includes(change[1] as (typeof fixtureMailboxes)[number])
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
@@ -101,10 +101,7 @@ const cleanup = async (): Promise<void> => {
|
|||||||
});
|
});
|
||||||
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
await db.inputEvent.deleteMany({ where: { actorUserId: userId } });
|
||||||
await db.readModelRevision.deleteMany({
|
await db.readModelRevision.deleteMany({
|
||||||
where: {
|
where: { domain: 'messages.mailbox', entityId: { in: [...fixtureMailboxes] } },
|
||||||
domain: { in: ['messages.mailbox', 'messages.diplomacyMailbox'] },
|
|
||||||
entityId: { in: [...fixtureMailboxes] },
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
|
const outboxes = await db.readModelOutbox.findMany({ select: { id: true, payload: true } });
|
||||||
const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id);
|
const outboxIds = outboxes.filter(({ payload }) => isFixtureOutboxPayload(payload)).map(({ id }) => id);
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
|
import { MAX_SAFE_GAME_TICK } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import type { RedisConnector } from '@sammo-ts/infra';
|
import type { RedisConnector } from '@sammo-ts/infra';
|
||||||
|
|
||||||
@@ -87,7 +88,7 @@ const storedLetter = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const buildContext = (officerLevel = 12, letter: Record<string, unknown> = storedLetter) => {
|
const buildContext = (officerLevel = 12, letter: Record<string, unknown> = storedLetter) => {
|
||||||
const create = vi.fn(async () => ({ id: 9, date: new Date('2026-07-31T00:00:00.000Z') }));
|
const create = vi.fn(async () => ({ id: 9 }));
|
||||||
let messageId = 100;
|
let messageId = 100;
|
||||||
const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]);
|
const queryRaw = vi.fn(async (..._args: unknown[]) => [{ id: messageId++ }]);
|
||||||
const db = {
|
const db = {
|
||||||
@@ -158,9 +159,17 @@ describe('diplomacy HTML API boundary', () => {
|
|||||||
textBrief: '<p><strong>공개</strong></p>',
|
textBrief: '<p><strong>공개</strong></p>',
|
||||||
textDetail:
|
textDetail:
|
||||||
'<ul><li>조건</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>',
|
'<ul><li>조건</li></ul><a href="https://example.com" target="_blank" rel="noopener noreferrer nofollow">자료</a>',
|
||||||
|
date: new Date('0185-01-01T00:00:00.000Z'),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
expect(fixture.queryRaw).toHaveBeenCalledTimes(2);
|
expect(fixture.queryRaw).toHaveBeenCalledTimes(2);
|
||||||
|
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toEqual(
|
||||||
|
expect.arrayContaining([9002, 'diplomacy', 9001, 9002])
|
||||||
|
);
|
||||||
|
expect(fixture.queryRaw.mock.calls[1]?.slice(1)).toEqual(expect.arrayContaining([9001, 'diplomacy']));
|
||||||
|
expect(fixture.queryRaw.mock.calls[0]?.slice(1)).toContain(BigInt(MAX_SAFE_GAME_TICK));
|
||||||
|
expect(fixture.queryRaw.mock.calls[0]?.find((value) => typeof value === 'string' && value.includes('text')))
|
||||||
|
.toContain('새로운 외교 문서 #9가 준비되었습니다. 외교부에서 확인해주세요.');
|
||||||
});
|
});
|
||||||
|
|
||||||
it('purifies legacy stored rows on every read while preserving secret redaction', async () => {
|
it('purifies legacy stored rows on every read while preserving secret redaction', async () => {
|
||||||
|
|||||||
@@ -3,10 +3,7 @@ import { describe, expect, it, vi } from 'vitest';
|
|||||||
import type { DatabaseClient } from '../src/context.js';
|
import type { DatabaseClient } from '../src/context.js';
|
||||||
import { loadCurrentGameTime } from '../src/services/gameClock.js';
|
import { loadCurrentGameTime } from '../src/services/gameClock.js';
|
||||||
|
|
||||||
const buildDatabase = (
|
const buildDatabase = (mode: 'realtime' | 'manual' = 'realtime'): DatabaseClient =>
|
||||||
mode: 'realtime' | 'manual' = 'realtime',
|
|
||||||
phase: 'PREOPEN' | 'RUNNING' | 'MANUAL' = mode === 'manual' ? 'MANUAL' : 'PREOPEN'
|
|
||||||
): DatabaseClient =>
|
|
||||||
({
|
({
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: vi.fn(async () => ({
|
findFirst: vi.fn(async () => ({
|
||||||
@@ -15,90 +12,11 @@ const buildDatabase = (
|
|||||||
clockMode: mode,
|
clockMode: mode,
|
||||||
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
|
clockWallAnchor: new Date('2026-08-21T11:00:00.000Z'),
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
clockPhase: phase,
|
|
||||||
clockRevision: 1n,
|
|
||||||
deadlineGeneration: 1n,
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
}) as unknown as DatabaseClient;
|
}) as unknown as DatabaseClient;
|
||||||
|
|
||||||
describe('current game time projection', () => {
|
describe('current game time projection', () => {
|
||||||
it('keeps independent workers frozen until this clock revision has a ready daemon', async () => {
|
|
||||||
const row = {
|
|
||||||
clockBaseTime: new Date('2026-09-06T00:00:00Z'),
|
|
||||||
clockTick: 0n,
|
|
||||||
clockMode: 'realtime',
|
|
||||||
clockWallAnchor: new Date('2026-09-06T00:00:00Z'),
|
|
||||||
tickSeconds: 3600,
|
|
||||||
clockPhase: 'RUNNING',
|
|
||||||
clockRevision: 2n,
|
|
||||||
deadlineGeneration: 2n,
|
|
||||||
clockRecoveryStartTick: 0n,
|
|
||||||
clockRecoveryEndTick: 288_000_000n,
|
|
||||||
clockRecoveryStartWallAt: new Date('2026-09-06T04:00:00Z'),
|
|
||||||
};
|
|
||||||
let ready = false;
|
|
||||||
const db = {
|
|
||||||
worldState: { findFirst: vi.fn(async () => row) },
|
|
||||||
$queryRaw: vi.fn(async () => [{ ready }]),
|
|
||||||
} as unknown as DatabaseClient;
|
|
||||||
const now = new Date('2026-09-06T05:00:00Z');
|
|
||||||
expect(await loadCurrentGameTime(db, now)).toMatchObject({ tick: 0, running: false, runtimeReady: false });
|
|
||||||
ready = true;
|
|
||||||
expect(await loadCurrentGameTime(db, now)).toMatchObject({
|
|
||||||
tick: 72_000_000,
|
|
||||||
running: true,
|
|
||||||
runtimeReady: true,
|
|
||||||
recovery: { startsAt: '2026-09-06T04:00:00.000Z', endsAt: '2026-09-06T08:00:00.000Z' },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('exposes waiting, 2x and normal speed boundaries from a reloaded recovery', async () => {
|
|
||||||
const db = {
|
|
||||||
worldState: {
|
|
||||||
findFirst: vi.fn(async () => ({
|
|
||||||
clockBaseTime: new Date('2026-09-07T00:00:00Z'),
|
|
||||||
clockTick: 6000000n,
|
|
||||||
clockMode: 'realtime',
|
|
||||||
clockWallAnchor: new Date('2026-09-07T00:35:00Z'),
|
|
||||||
tickSeconds: 3600,
|
|
||||||
clockPhase: 'RUNNING',
|
|
||||||
clockRevision: 2n,
|
|
||||||
deadlineGeneration: 2n,
|
|
||||||
clockRecoveryStartTick: 6000000n,
|
|
||||||
clockRecoveryEndTick: 36000000n,
|
|
||||||
clockRecoveryStartWallAt: new Date('2026-09-07T00:35:00Z'),
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
$queryRaw: vi.fn(async () => [{ ready: true }]),
|
|
||||||
} as unknown as DatabaseClient;
|
|
||||||
for (const now of ['00:24:00', '00:34:59.999']) {
|
|
||||||
expect(await loadCurrentGameTime(db, new Date(`2026-09-07T${now}Z`))).toMatchObject({
|
|
||||||
tick: 6000000,
|
|
||||||
running: false,
|
|
||||||
startsAt: new Date('2026-09-07T00:35:00Z'),
|
|
||||||
recovery: { startsAt: '2026-09-07T00:35:00.000Z', endsAt: '2026-09-07T01:00:00.000Z' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
expect(await loadCurrentGameTime(db, new Date('2026-09-07T00:35:00.001Z'))).toMatchObject({
|
|
||||||
tick: 6000020,
|
|
||||||
running: true,
|
|
||||||
startsAt: null,
|
|
||||||
});
|
|
||||||
expect(await loadCurrentGameTime(db, new Date('2026-09-07T00:59:59.999Z'))).toMatchObject({ tick: 35999980 });
|
|
||||||
expect(await loadCurrentGameTime(db, new Date('2026-09-07T01:00:00Z'))).toMatchObject({
|
|
||||||
tick: 36000000,
|
|
||||||
running: true,
|
|
||||||
recovery: null,
|
|
||||||
});
|
|
||||||
expect(await loadCurrentGameTime(db, new Date('2026-09-07T01:00:00.001Z'))).toMatchObject({ tick: 36000010 });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('holds an invader restart until its future turn boundary', async () => {
|
|
||||||
const db = buildDatabase('realtime', 'RUNNING');
|
|
||||||
const result = await loadCurrentGameTime(db, new Date('2026-08-21T10:59:59Z'));
|
|
||||||
expect(result).toMatchObject({ tick: 0, running: false, startsAt: new Date('2026-08-21T11:00:00Z') });
|
|
||||||
});
|
|
||||||
it('projects negative realtime ticks until the future opening anchor', async () => {
|
it('projects negative realtime ticks until the future opening anchor', async () => {
|
||||||
const db = buildDatabase();
|
const db = buildDatabase();
|
||||||
|
|
||||||
@@ -108,15 +26,11 @@ describe('current game time projection', () => {
|
|||||||
wallNow: new Date('2026-08-21T10:30:00.000Z'),
|
wallNow: new Date('2026-08-21T10:30:00.000Z'),
|
||||||
tick: -108_000_000,
|
tick: -108_000_000,
|
||||||
mode: 'realtime',
|
mode: 'realtime',
|
||||||
phase: 'PREOPEN',
|
|
||||||
running: false,
|
running: false,
|
||||||
startsAt: new Date('2026-08-21T11:00:00.000Z'),
|
startsAt: new Date('2026-08-21T11:00:00.000Z'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const opened = await loadCurrentGameTime(
|
const opened = await loadCurrentGameTime(db, new Date('2026-08-21T11:00:05.000Z'));
|
||||||
buildDatabase('realtime', 'RUNNING'),
|
|
||||||
new Date('2026-08-21T11:00:05.000Z')
|
|
||||||
);
|
|
||||||
expect(opened).toMatchObject({
|
expect(opened).toMatchObject({
|
||||||
now: new Date('2026-08-21T11:00:05.000Z'),
|
now: new Date('2026-08-21T11:00:05.000Z'),
|
||||||
tick: 300_000,
|
tick: 300_000,
|
||||||
|
|||||||
@@ -326,14 +326,18 @@ describe('general access tracking', () => {
|
|||||||
: [{ id: 41 }];
|
: [{ id: 41 }];
|
||||||
}),
|
}),
|
||||||
$executeRaw: vi.fn(async (query: unknown) => {
|
$executeRaw: vi.fn(async (query: unknown) => {
|
||||||
const sql = (query as { sql?: string }).sql ?? '';
|
if (((query as { sql?: string }).sql ?? '').includes('INSERT INTO input_event')) {
|
||||||
if (sql.includes('INSERT INTO input_event')) {
|
|
||||||
events.push('input-event-create');
|
events.push('input-event-create');
|
||||||
}
|
}
|
||||||
if (sql.includes("status = 'FAILED'")) events.push('input-event-failed');
|
|
||||||
return 1;
|
return 1;
|
||||||
}),
|
}),
|
||||||
$executeRawUnsafe: vi.fn(async () => 0),
|
$executeRawUnsafe: vi.fn(async () => 0),
|
||||||
|
inputEvent: {
|
||||||
|
update: vi.fn(async (args: { data: { status: string } }) => {
|
||||||
|
if (args.data.status === 'FAILED') events.push('input-event-failed');
|
||||||
|
return {};
|
||||||
|
}),
|
||||||
|
},
|
||||||
};
|
};
|
||||||
const db = {
|
const db = {
|
||||||
general: {
|
general: {
|
||||||
|
|||||||
@@ -91,7 +91,7 @@ describe('IdempotentTurnDaemonTransport', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reuses a rolling-upgrade vote event after legacy acceptance coordinates are removed', async () => {
|
it('reuses a successful vote event when only the retry acceptance tick has changed', async () => {
|
||||||
const persistedPayload = {
|
const persistedPayload = {
|
||||||
type: 'voteReward' as const,
|
type: 'voteReward' as const,
|
||||||
requestId: 'vote-reward',
|
requestId: 'vote-reward',
|
||||||
@@ -101,14 +101,6 @@ describe('IdempotentTurnDaemonTransport', () => {
|
|||||||
selection: [0],
|
selection: [0],
|
||||||
acceptedGameTick: 100,
|
acceptedGameTick: 100,
|
||||||
};
|
};
|
||||||
const currentCommand = {
|
|
||||||
type: 'voteReward' as const,
|
|
||||||
requestId: 'vote-reward',
|
|
||||||
userId: 'user-7',
|
|
||||||
voteId: 1,
|
|
||||||
generalId: 7,
|
|
||||||
selection: [0],
|
|
||||||
};
|
|
||||||
const create = async () => {
|
const create = async () => {
|
||||||
throw Object.assign(new Error('duplicate'), { code: 'P2002' });
|
throw Object.assign(new Error('duplicate'), { code: 'P2002' });
|
||||||
};
|
};
|
||||||
@@ -123,14 +115,18 @@ describe('IdempotentTurnDaemonTransport', () => {
|
|||||||
);
|
);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
transport.sendCommand(currentCommand)
|
transport.sendCommand({
|
||||||
|
...persistedPayload,
|
||||||
|
acceptedGameTick: 101,
|
||||||
|
})
|
||||||
).resolves.toBe('vote-reward');
|
).resolves.toBe('vote-reward');
|
||||||
|
|
||||||
for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) {
|
for (const changedIdentity of [{ selection: [1] }, { voteId: 2 }, { generalId: 8 }]) {
|
||||||
await expect(
|
await expect(
|
||||||
transport.sendCommand({
|
transport.sendCommand({
|
||||||
...currentCommand,
|
...persistedPayload,
|
||||||
...changedIdentity,
|
...changedIdentity,
|
||||||
|
acceptedGameTick: 101,
|
||||||
})
|
})
|
||||||
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
|
).rejects.toBeInstanceOf(ConflictingTurnDaemonCommandError);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -64,14 +64,6 @@ const state: TurnWorldState = {
|
|||||||
currentMonth: 4,
|
currentMonth: 4,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
lastTurnTime: new Date('2026-08-19T00:00:00.000Z'),
|
lastTurnTime: new Date('2026-08-19T00:00:00.000Z'),
|
||||||
clockBaseTime: new Date('2026-08-19T00:00:00.000Z'),
|
|
||||||
clockTick: 0,
|
|
||||||
clockMode: 'manual',
|
|
||||||
clockWallAnchor: new Date('2026-08-19T00:00:00.000Z'),
|
|
||||||
lastTurnTick: 0,
|
|
||||||
clockPhase: 'MANUAL',
|
|
||||||
clockRevision: 1,
|
|
||||||
deadlineGeneration: 1,
|
|
||||||
meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta },
|
meta: { hiddenSeed: 'inherit-owner-message', isunited: 0, scenarioMeta },
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -184,14 +176,6 @@ integration('inherit owner lookup private messages', () => {
|
|||||||
currentYear: 200,
|
currentYear: 200,
|
||||||
currentMonth: 4,
|
currentMonth: 4,
|
||||||
tickSeconds: 600,
|
tickSeconds: 600,
|
||||||
clockBaseTime: state.clockBaseTime,
|
|
||||||
clockTick: BigInt(state.clockTick ?? 0),
|
|
||||||
clockMode: state.clockMode ?? 'manual',
|
|
||||||
clockWallAnchor: state.clockWallAnchor,
|
|
||||||
lastTurnTick: BigInt(state.lastTurnTick ?? 0),
|
|
||||||
clockPhase: state.clockPhase ?? 'MANUAL',
|
|
||||||
clockRevision: BigInt(state.clockRevision ?? 1),
|
|
||||||
deadlineGeneration: BigInt(state.deadlineGeneration ?? 1),
|
|
||||||
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
config: JSON.parse(JSON.stringify(scenarioConfig)) as GamePrisma.InputJsonValue,
|
||||||
meta: state.meta as GamePrisma.InputJsonValue,
|
meta: state.meta as GamePrisma.InputJsonValue,
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -285,21 +285,6 @@ describe('inherit router actor and permission boundaries', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects an out-of-range inheritance buff level with a user-facing message', async () => {
|
|
||||||
const fixture = buildContext({});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
appRouter.createCaller(fixture.context).inherit.buyHiddenBuff({
|
|
||||||
type: 'warAvoidRatio',
|
|
||||||
level: 6,
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({
|
|
||||||
code: 'BAD_REQUEST',
|
|
||||||
message: '유산 강화는 1단계부터 5단계까지만 구입할 수 있습니다.',
|
|
||||||
});
|
|
||||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('reports the Ref S100 stat-reset ban and maps the authoritative daemon rejection', async () => {
|
it('reports the Ref S100 stat-reset ban and maps the authoritative daemon rejection', async () => {
|
||||||
const fixture = buildContext({
|
const fixture = buildContext({
|
||||||
configMap: { targetGeneralPool: 'SPoolUnderU100' },
|
configMap: { targetGeneralPool: 'SPoolUnderU100' },
|
||||||
|
|||||||
@@ -18,7 +18,6 @@ import {
|
|||||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
const integration = describe.skipIf(!databaseUrl);
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
const journalGeneralIds = [9_980_081, 9_980_082] as const;
|
const journalGeneralIds = [9_980_081, 9_980_082] as const;
|
||||||
const clockScenarioCode = 'input-event-boundary';
|
|
||||||
|
|
||||||
const journalBoundaryRouter = router({
|
const journalBoundaryRouter = router({
|
||||||
mutate: procedure
|
mutate: procedure
|
||||||
@@ -57,21 +56,6 @@ integration('API input event boundary', () => {
|
|||||||
await db.readModelRevision.deleteMany({
|
await db.readModelRevision.deleteMany({
|
||||||
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
|
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
|
||||||
});
|
});
|
||||||
await db.worldState.deleteMany({ where: { scenarioCode: clockScenarioCode } });
|
|
||||||
const base = new Date('2099-09-03T00:00:00.000Z');
|
|
||||||
await db.worldState.create({
|
|
||||||
data: {
|
|
||||||
scenarioCode: clockScenarioCode,
|
|
||||||
currentYear: 200,
|
|
||||||
currentMonth: 1,
|
|
||||||
tickSeconds: 600,
|
|
||||||
clockBaseTime: base,
|
|
||||||
clockTick: 0n,
|
|
||||||
clockMode: 'realtime',
|
|
||||||
clockWallAnchor: base,
|
|
||||||
clockPhase: 'RUNNING',
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
@@ -84,7 +68,6 @@ integration('API input event boundary', () => {
|
|||||||
await db.readModelRevision.deleteMany({
|
await db.readModelRevision.deleteMany({
|
||||||
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
|
where: { domain: 'front.general', entityId: { in: [...journalGeneralIds] } },
|
||||||
});
|
});
|
||||||
await db.worldState.deleteMany({ where: { scenarioCode: clockScenarioCode } });
|
|
||||||
await close?.();
|
await close?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -535,10 +518,6 @@ integration('API input event boundary', () => {
|
|||||||
expect(event.actorUserId).toBe('user-7');
|
expect(event.actorUserId).toBe('user-7');
|
||||||
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
|
expect(event.createdAt.getTime()).toBeGreaterThanOrEqual(acceptedWindowStart);
|
||||||
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
|
expect(event.createdAt.getTime()).toBeLessThanOrEqual(acceptedWindowEnd);
|
||||||
expect(event.acceptedGameTick).toBeNull();
|
|
||||||
expect(event.acceptedClockRevision).toBeNull();
|
|
||||||
expect(event.acceptedDeadlineGeneration).toBeNull();
|
|
||||||
expect(event.processingGameTick).toBeNull();
|
|
||||||
await expect(
|
await expect(
|
||||||
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 })
|
transport.sendCommand({ type: 'vacation', requestId, userId: 'user-7', generalId: 7 })
|
||||||
).resolves.toBe(requestId);
|
).resolves.toBe(requestId);
|
||||||
|
|||||||
@@ -3,7 +3,7 @@ import { z } from 'zod';
|
|||||||
|
|
||||||
import type { GameApiContext } from '../src/context.js';
|
import type { GameApiContext } from '../src/context.js';
|
||||||
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
|
import { createApiInputPayloadIdentity } from '../src/inputEventBoundary.js';
|
||||||
import { procedure, router, wallProcedure } from '../src/trpc.js';
|
import { procedure, router } from '../src/trpc.js';
|
||||||
|
|
||||||
const testRouter = router({
|
const testRouter = router({
|
||||||
mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => {
|
mutate: procedure.input(z.object({ fail: z.boolean().optional().default(false) })).mutation(({ ctx, input }) => {
|
||||||
@@ -14,14 +14,6 @@ const testRouter = router({
|
|||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
const wallTestRouter = router({
|
|
||||||
mutate: wallProcedure.input(z.object({})).mutation(({ ctx }) => {
|
|
||||||
(ctx as GameApiContext & { testOrder: string[] }).testOrder.push('handler');
|
|
||||||
ctx.changeJournal?.mark('front.general', 7);
|
|
||||||
return { ok: true };
|
|
||||||
}),
|
|
||||||
});
|
|
||||||
|
|
||||||
const createContext = (payload: unknown = {}) => {
|
const createContext = (payload: unknown = {}) => {
|
||||||
const order: string[] = [];
|
const order: string[] = [];
|
||||||
const queryRaw = vi.fn(async (query: { sql?: string }) => {
|
const queryRaw = vi.fn(async (query: { sql?: string }) => {
|
||||||
@@ -36,9 +28,6 @@ const createContext = (payload: unknown = {}) => {
|
|||||||
status: 'PENDING',
|
status: 'PENDING',
|
||||||
result: null,
|
result: null,
|
||||||
attempts: 0,
|
attempts: 0,
|
||||||
acceptedGameTick: 100n,
|
|
||||||
acceptedClockRevision: 3n,
|
|
||||||
acceptedDeadlineGeneration: 2n,
|
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
}
|
}
|
||||||
@@ -47,19 +36,8 @@ const createContext = (payload: unknown = {}) => {
|
|||||||
});
|
});
|
||||||
const transaction = {
|
const transaction = {
|
||||||
$queryRaw: queryRaw,
|
$queryRaw: queryRaw,
|
||||||
$executeRaw: vi.fn(async (query: { sql?: string }) => {
|
$executeRaw: vi.fn(async () => {
|
||||||
const sql = query.sql ?? '';
|
order.push('accepted');
|
||||||
order.push(
|
|
||||||
sql.includes('pg_advisory_xact_lock')
|
|
||||||
? 'clock-fence'
|
|
||||||
: sql.includes("status = 'PROCESSING'")
|
|
||||||
? 'processing'
|
|
||||||
: sql.includes("status = 'SUCCEEDED'")
|
|
||||||
? 'succeeded'
|
|
||||||
: sql.includes("status = 'FAILED'")
|
|
||||||
? 'failed'
|
|
||||||
: 'accepted'
|
|
||||||
);
|
|
||||||
return 1;
|
return 1;
|
||||||
}),
|
}),
|
||||||
$executeRawUnsafe: vi.fn(async (statement: string) => {
|
$executeRawUnsafe: vi.fn(async (statement: string) => {
|
||||||
@@ -110,7 +88,6 @@ describe('API input-event change journal boundary', () => {
|
|||||||
|
|
||||||
expect(fixture.order).toEqual([
|
expect(fixture.order).toEqual([
|
||||||
'transaction-begin',
|
'transaction-begin',
|
||||||
'clock-fence',
|
|
||||||
'accepted',
|
'accepted',
|
||||||
'locked',
|
'locked',
|
||||||
'processing',
|
'processing',
|
||||||
@@ -135,7 +112,6 @@ describe('API input-event change journal boundary', () => {
|
|||||||
|
|
||||||
expect(fixture.order).toEqual([
|
expect(fixture.order).toEqual([
|
||||||
'transaction-begin',
|
'transaction-begin',
|
||||||
'clock-fence',
|
|
||||||
'accepted',
|
'accepted',
|
||||||
'locked',
|
'locked',
|
||||||
'processing',
|
'processing',
|
||||||
@@ -150,24 +126,4 @@ describe('API input-event change journal boundary', () => {
|
|||||||
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
||||||
expect(fixture.wake).not.toHaveBeenCalled();
|
expect(fixture.wake).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('keeps a WALL-only mutation durable without acquiring the GAME clock fence', async () => {
|
|
||||||
const fixture = createContext();
|
|
||||||
|
|
||||||
await expect(wallTestRouter.createCaller(fixture.context).mutate({})).resolves.toEqual({ ok: true });
|
|
||||||
|
|
||||||
expect(fixture.order).toEqual([
|
|
||||||
'transaction-begin',
|
|
||||||
'accepted',
|
|
||||||
'locked',
|
|
||||||
'processing',
|
|
||||||
'savepoint',
|
|
||||||
'handler',
|
|
||||||
'journal',
|
|
||||||
'succeeded',
|
|
||||||
'savepoint-release',
|
|
||||||
'commit',
|
|
||||||
'wake',
|
|
||||||
]);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -42,7 +42,6 @@ const buildContext = (
|
|||||||
turnDaemonLease: {
|
turnDaemonLease: {
|
||||||
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })),
|
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') })),
|
||||||
},
|
},
|
||||||
$queryRaw: vi.fn(async () => [{ running: true }]),
|
|
||||||
} as unknown as DatabaseClient,
|
} as unknown as DatabaseClient,
|
||||||
profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) },
|
profileStatusSource: { get: vi.fn(async () => 'RUNNING' as const) },
|
||||||
}) as unknown as GameApiContext;
|
}) as unknown as GameApiContext;
|
||||||
|
|||||||
@@ -1,82 +0,0 @@
|
|||||||
import { afterAll, describe, expect, it, vi } from 'vitest';
|
|
||||||
import { createGamePostgresConnector } from '@sammo-ts/infra';
|
|
||||||
import { fetchMessagesFromMailbox, fetchOldMessagesFromMailbox } from '../src/messages/store.js';
|
|
||||||
|
|
||||||
vi.mock('../src/services/gameClock.js', () => ({
|
|
||||||
loadCurrentGameTime: vi.fn(async () => ({ tick: 100 })),
|
|
||||||
}));
|
|
||||||
|
|
||||||
// SQL is mocked; this client is never connected. Keep the real delegate types.
|
|
||||||
const connector = createGamePostgresConnector({ url: 'postgresql://localhost:1/message_lifecycle_unit' });
|
|
||||||
const db = connector.prisma;
|
|
||||||
const queryRaw = vi.spyOn(db, '$queryRaw');
|
|
||||||
afterAll(() => connector.disconnect());
|
|
||||||
|
|
||||||
const target = { generalId: 1, generalName: '장수', nationId: 1, nationName: '국가', color: '#000', icon: '' };
|
|
||||||
|
|
||||||
for (const older of [false, true]) {
|
|
||||||
describe(older ? 'older message lifecycle' : 'recent message lifecycle', () => {
|
|
||||||
it.each([
|
|
||||||
['PENDING', 101n, 'pending', false],
|
|
||||||
['PENDING', 100n, 'expired', true],
|
|
||||||
['PENDING', 99n, 'expired', true],
|
|
||||||
['PENDING', null, 'pending', false],
|
|
||||||
['RESOLVED', 101n, 'resolved', true],
|
|
||||||
['RESOLVED', 99n, 'resolved', true],
|
|
||||||
['CANCELLED', 101n, 'unavailable', true],
|
|
||||||
['UNKNOWN', 101n, 'unavailable', true],
|
|
||||||
[null, null, undefined, undefined],
|
|
||||||
])('preserves body for status %s and deadline %s', async (status, deadline, state, used) => {
|
|
||||||
const row = {
|
|
||||||
id: 10,
|
|
||||||
mailbox: 1,
|
|
||||||
type: 'diplomacy',
|
|
||||||
src: 2,
|
|
||||||
dest: 1,
|
|
||||||
time: new Date('0200-01-01T00:00:00Z'),
|
|
||||||
created_at_wall: new Date('2026-09-10T00:00:00Z'),
|
|
||||||
action_status: status,
|
|
||||||
expires_game_tick: deadline,
|
|
||||||
message: {
|
|
||||||
src: target,
|
|
||||||
dest: target,
|
|
||||||
text: '210년 1월까지 불가침 제의',
|
|
||||||
option: { action: 'noAggression' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
queryRaw.mockResolvedValue([row]);
|
|
||||||
const options = { db, mailbox: 1, msgType: 'diplomacy' as const, limit: 20 };
|
|
||||||
const result = older
|
|
||||||
? await fetchOldMessagesFromMailbox({ ...options, toSeq: 11 })
|
|
||||||
: await fetchMessagesFromMailbox({ ...options, fromSeq: 0 });
|
|
||||||
expect(result[0]?.text).toBe(row.message.text);
|
|
||||||
expect(result[0]?.option?.invalid).toBeUndefined();
|
|
||||||
expect(result[0]?.option?.actionState).toBe(state);
|
|
||||||
expect(result[0]?.option?.used).toBe(used);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps actual deletion authoritative even when an action remains', async () => {
|
|
||||||
queryRaw.mockResolvedValue([
|
|
||||||
{
|
|
||||||
id: 10,
|
|
||||||
type: 'diplomacy',
|
|
||||||
time: new Date(),
|
|
||||||
created_at_wall: new Date(),
|
|
||||||
action_status: 'RESOLVED',
|
|
||||||
expires_game_tick: 99n,
|
|
||||||
message: {
|
|
||||||
src: target,
|
|
||||||
dest: target,
|
|
||||||
text: '삭제된 메시지입니다.',
|
|
||||||
option: { action: 'noAggression', invalid: true },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
const options = { db, mailbox: 1, msgType: 'diplomacy' as const, limit: 20 };
|
|
||||||
const result = older
|
|
||||||
? await fetchOldMessagesFromMailbox({ ...options, toSeq: 11 })
|
|
||||||
: await fetchMessagesFromMailbox({ ...options, fromSeq: 0 });
|
|
||||||
expect(result[0]).toMatchObject({ text: '삭제된 메시지입니다.', option: { invalid: true, used: true } });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
@@ -1,7 +1,7 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
import { createGamePostgresConnector, persistMessageEnvelope, type GamePrismaClient } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
|
|
||||||
import { tombstoneMessages, tombstoneMessagesWithinDeleteWindow } from '../src/messages/store.js';
|
import { tombstoneMessages } from '../src/messages/store.js';
|
||||||
|
|
||||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
const integration = describe.skipIf(!databaseUrl);
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
@@ -23,51 +23,6 @@ integration('message deletion tombstone persistence', () => {
|
|||||||
|
|
||||||
afterAll(async () => close?.());
|
afterAll(async () => close?.());
|
||||||
|
|
||||||
it('persists an ordinary wall-time envelope without creating a game action', async () => {
|
|
||||||
const rollback = new Error('rollback ordinary message envelope fixture');
|
|
||||||
await expect(
|
|
||||||
db.$transaction(async (transaction) => {
|
|
||||||
const target = {
|
|
||||||
generalId: 7,
|
|
||||||
generalName: '보낸이',
|
|
||||||
nationId: 0,
|
|
||||||
nationName: '재야',
|
|
||||||
color: '#000000',
|
|
||||||
icon: '',
|
|
||||||
};
|
|
||||||
const id = await persistMessageEnvelope(
|
|
||||||
transaction,
|
|
||||||
{
|
|
||||||
mailbox: 9999,
|
|
||||||
msgType: 'public',
|
|
||||||
srcId: target.generalId,
|
|
||||||
destId: 9999,
|
|
||||||
time: new Date('0200-01-01T00:00:00.000Z'),
|
|
||||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
|
||||||
payload: {
|
|
||||||
src: target,
|
|
||||||
dest: target,
|
|
||||||
text: '일반 메시지는 WALL_TIME envelope만 저장한다.',
|
|
||||||
option: {},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
null
|
|
||||||
);
|
|
||||||
|
|
||||||
const message = await transaction.message.findUniqueOrThrow({
|
|
||||||
where: { id },
|
|
||||||
include: { action: true },
|
|
||||||
});
|
|
||||||
expect(message.createdAtWall).toBeInstanceOf(Date);
|
|
||||||
expect(message.deleteUntilWall.getTime() - message.createdAtWall.getTime()).toBe(5 * 60_000);
|
|
||||||
expect(message.occurredGameTick).toBeNull();
|
|
||||||
expect(message.action).toBeNull();
|
|
||||||
|
|
||||||
throw rollback;
|
|
||||||
})
|
|
||||||
).rejects.toBe(rollback);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps sender and receiver rows readable while replacing their bodies', async () => {
|
it('keeps sender and receiver rows readable while replacing their bodies', async () => {
|
||||||
const rollback = new Error('rollback message tombstone fixture');
|
const rollback = new Error('rollback message tombstone fixture');
|
||||||
await expect(
|
await expect(
|
||||||
@@ -115,7 +70,6 @@ integration('message deletion tombstone persistence', () => {
|
|||||||
expect(rows).toHaveLength(2);
|
expect(rows).toHaveLength(2);
|
||||||
for (const row of rows) {
|
for (const row of rows) {
|
||||||
expect(row.validUntil).toEqual(validUntil);
|
expect(row.validUntil).toEqual(validUntil);
|
||||||
expect(row.tombstonedAtWall).not.toBeNull();
|
|
||||||
expect(row.message).toMatchObject({
|
expect(row.message).toMatchObject({
|
||||||
text: '삭제된 메시지입니다.',
|
text: '삭제된 메시지입니다.',
|
||||||
option: { invalid: true },
|
option: { invalid: true },
|
||||||
@@ -127,43 +81,4 @@ integration('message deletion tombstone persistence', () => {
|
|||||||
})
|
})
|
||||||
).rejects.toBe(rollback);
|
).rejects.toBe(rollback);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the DB wall deadline even when the game clock is not advancing', async () => {
|
|
||||||
const rollback = new Error('rollback wall deletion fixture');
|
|
||||||
await expect(
|
|
||||||
db.$transaction(async (transaction) => {
|
|
||||||
const [{ now_wall: nowWall }] = await transaction.$queryRaw<Array<{ now_wall: Date }>>`
|
|
||||||
SELECT CURRENT_TIMESTAMP AT TIME ZONE 'UTC' AS now_wall
|
|
||||||
`;
|
|
||||||
const draft = (text: string) => ({
|
|
||||||
mailbox: 7,
|
|
||||||
type: 'private' as const,
|
|
||||||
src: 7,
|
|
||||||
dest: 8,
|
|
||||||
time: new Date('0200-01-01T00:00:00.000Z'),
|
|
||||||
validUntil: new Date('9999-12-31T00:00:00.000Z'),
|
|
||||||
createdAtWall: nowWall,
|
|
||||||
message: {
|
|
||||||
src: { generalId: 7 },
|
|
||||||
dest: { generalId: 8 },
|
|
||||||
text,
|
|
||||||
option: {},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const deletable = await transaction.message.create({
|
|
||||||
data: { ...draft('future wall deadline'), deleteUntilWall: new Date(nowWall.getTime() + 60_000) },
|
|
||||||
});
|
|
||||||
const expired = await transaction.message.create({
|
|
||||||
data: { ...draft('past wall deadline'), deleteUntilWall: new Date(nowWall.getTime() - 60_000) },
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(
|
|
||||||
await tombstoneMessagesWithinDeleteWindow(transaction, deletable.id, [deletable.id])
|
|
||||||
).toEqual([deletable.id]);
|
|
||||||
expect(await tombstoneMessagesWithinDeleteWindow(transaction, expired.id, [expired.id])).toEqual([]);
|
|
||||||
|
|
||||||
throw rollback;
|
|
||||||
})
|
|
||||||
).rejects.toBe(rollback);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -128,69 +128,64 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
expect(result.canRespondDiplomacy).toBe(false);
|
expect(result.canRespondDiplomacy).toBe(false);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each([null, 'RESOLVED'])(
|
it('redacts recent and old diplomacy content below secret permission 3', async () => {
|
||||||
'redacts recent and old diplomacy content below secret permission 3 (%s)',
|
const diplomacyRow = {
|
||||||
async (actionStatus) => {
|
id: 19,
|
||||||
const diplomacyRow = {
|
mailbox: 9001,
|
||||||
id: 19,
|
type: 'diplomacy',
|
||||||
action_status: actionStatus,
|
src: 9002,
|
||||||
expires_game_tick: null,
|
dest: 9001,
|
||||||
mailbox: 9001,
|
time: new Date(),
|
||||||
type: 'diplomacy',
|
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||||
src: 9002,
|
message: {
|
||||||
dest: 9001,
|
src: {
|
||||||
time: new Date(),
|
generalId: 8,
|
||||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
generalName: '외교관',
|
||||||
message: {
|
nationId: 2,
|
||||||
src: {
|
nationName: '촉',
|
||||||
generalId: 8,
|
color: '#000000',
|
||||||
generalName: '외교관',
|
icon: '',
|
||||||
nationId: 2,
|
|
||||||
nationName: '촉',
|
|
||||||
color: '#000000',
|
|
||||||
icon: '',
|
|
||||||
},
|
|
||||||
dest: {
|
|
||||||
generalId: 0,
|
|
||||||
generalName: '',
|
|
||||||
nationId: 1,
|
|
||||||
nationName: '위',
|
|
||||||
color: '#ffffff',
|
|
||||||
icon: '',
|
|
||||||
},
|
|
||||||
text: '보이면 안 되는 외교 본문',
|
|
||||||
option: { action: 'noAggression' },
|
|
||||||
},
|
},
|
||||||
};
|
dest: {
|
||||||
const queryRaw = vi.fn(async () => [diplomacyRow]);
|
generalId: 0,
|
||||||
const { caller } = buildContext({
|
generalName: '',
|
||||||
$queryRaw: queryRaw,
|
nationId: 1,
|
||||||
nation: {
|
nationName: '위',
|
||||||
findMany: vi.fn(async () => []),
|
color: '#ffffff',
|
||||||
findUnique: vi.fn(async () => ({ meta: {} })),
|
icon: '',
|
||||||
},
|
},
|
||||||
});
|
text: '보이면 안 되는 외교 본문',
|
||||||
|
option: { action: 'noAggression' },
|
||||||
|
},
|
||||||
|
};
|
||||||
|
const queryRaw = vi.fn(async () => [diplomacyRow]);
|
||||||
|
const { caller } = buildContext({
|
||||||
|
$queryRaw: queryRaw,
|
||||||
|
nation: {
|
||||||
|
findMany: vi.fn(async () => []),
|
||||||
|
findUnique: vi.fn(async () => ({ meta: {} })),
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
const recent = await caller.messages.getRecent({ generalId: general.id });
|
const recent = await caller.messages.getRecent({ generalId: general.id });
|
||||||
const old = await caller.messages.getOld({
|
const old = await caller.messages.getOld({
|
||||||
generalId: general.id,
|
generalId: general.id,
|
||||||
type: 'diplomacy',
|
type: 'diplomacy',
|
||||||
to: 20,
|
to: 20,
|
||||||
});
|
});
|
||||||
|
|
||||||
expect(recent.permission).toBe(2);
|
expect(recent.permission).toBe(2);
|
||||||
expect(recent.diplomacy[0]).toMatchObject({
|
expect(recent.diplomacy[0]).toMatchObject({
|
||||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||||
option: { action: 'noAggression', permissionRedacted: true },
|
option: { action: 'noAggression' },
|
||||||
});
|
});
|
||||||
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
expect(recent.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
||||||
expect(old.diplomacy[0]).toMatchObject({
|
expect(old.diplomacy[0]).toMatchObject({
|
||||||
text: '조회 권한이 없는 외교 메시지입니다.',
|
text: '조회 권한이 없는 외교 메시지입니다.',
|
||||||
option: { action: 'noAggression', permissionRedacted: true },
|
option: { action: 'noAggression' },
|
||||||
});
|
});
|
||||||
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
expect(old.diplomacy[0]?.option).not.toHaveProperty('invalid');
|
||||||
}
|
});
|
||||||
);
|
|
||||||
|
|
||||||
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
|
it('forces a non-diplomat foreign nation target back to the owned nation mailbox', async () => {
|
||||||
const queryRaw = vi.fn(async () => [{ id: 51 }]);
|
const queryRaw = vi.fn(async () => [{ id: 51 }]);
|
||||||
@@ -215,7 +210,7 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.msgType).toBe('national');
|
expect(result.msgType).toBe('national');
|
||||||
expect(queryRaw).toHaveBeenCalledOnce();
|
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('journals committed message mailbox copies instead of publishing before commit', async () => {
|
it('journals committed message mailbox copies instead of publishing before commit', async () => {
|
||||||
@@ -233,84 +228,6 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
expect(redis.publish).not.toHaveBeenCalled();
|
expect(redis.publish).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['PREOPEN', 'SUSPENDED', 'RECONCILING'] as const)(
|
|
||||||
'keeps ordinary public messages available while the game clock is %s',
|
|
||||||
async (clockPhase) => {
|
|
||||||
const queryRaw = vi.fn(async () => [{ id: 52 }]);
|
|
||||||
const { caller } = buildContext({
|
|
||||||
$queryRaw: queryRaw,
|
|
||||||
worldState: { findFirst: vi.fn(async () => ({ clockPhase })) },
|
|
||||||
});
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
caller.messages.send({ generalId: general.id, mailbox: 9999, text: `${clockPhase} 공개 메시지` })
|
|
||||||
).resolves.toMatchObject({ msgType: 'public' });
|
|
||||||
expect(queryRaw).toHaveBeenCalledOnce();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
it.each(['SUSPENDED', 'RECONCILING'] as const)(
|
|
||||||
'keeps a received recruitment letter visible with its frozen game deadline while the clock is %s',
|
|
||||||
async (clockPhase) => {
|
|
||||||
const scoutRow = {
|
|
||||||
id: 54,
|
|
||||||
mailbox: general.id,
|
|
||||||
type: 'private',
|
|
||||||
src: 8,
|
|
||||||
dest: general.id,
|
|
||||||
time: new Date('0200-01-01T00:00:00.000Z'),
|
|
||||||
created_at_wall: new Date('2026-09-03T15:00:00.000Z'),
|
|
||||||
action_status: 'PENDING',
|
|
||||||
expires_game_tick: 200n,
|
|
||||||
message: {
|
|
||||||
src: {
|
|
||||||
generalId: 8,
|
|
||||||
generalName: '등용권유자',
|
|
||||||
nationId: 2,
|
|
||||||
nationName: '촉',
|
|
||||||
color: '#000',
|
|
||||||
icon: '',
|
|
||||||
},
|
|
||||||
dest: {
|
|
||||||
generalId: general.id,
|
|
||||||
generalName: general.name,
|
|
||||||
nationId: general.nationId,
|
|
||||||
nationName: '위',
|
|
||||||
color: '#fff',
|
|
||||||
icon: '',
|
|
||||||
},
|
|
||||||
text: '등용 권유 서신',
|
|
||||||
option: { action: 'scout' },
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const { caller } = buildContext({
|
|
||||||
$queryRaw: vi.fn(async () => [scoutRow]),
|
|
||||||
worldState: {
|
|
||||||
findFirst: vi.fn(async () => ({
|
|
||||||
clockBaseTime: new Date('0200-01-01T00:00:00.000Z'),
|
|
||||||
clockTick: 100n,
|
|
||||||
clockMode: 'realtime',
|
|
||||||
clockWallAnchor: new Date('2026-09-03T15:00:00.000Z'),
|
|
||||||
tickSeconds: 600,
|
|
||||||
clockPhase,
|
|
||||||
clockRevision: 9n,
|
|
||||||
deadlineGeneration: 4n,
|
|
||||||
})),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await caller.messages.getRecent({ generalId: general.id });
|
|
||||||
|
|
||||||
expect(result.private[0]).toMatchObject({
|
|
||||||
id: scoutRow.id,
|
|
||||||
text: '등용 권유 서신',
|
|
||||||
option: { action: 'scout' },
|
|
||||||
time: '2026-09-03 15:00:00',
|
|
||||||
});
|
|
||||||
expect(result.private[0]?.option).not.toMatchObject({ invalid: true });
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
|
it('allows an ambassador to target a foreign nation mailbox as diplomacy', async () => {
|
||||||
const ambassador = {
|
const ambassador = {
|
||||||
...general,
|
...general,
|
||||||
@@ -342,7 +259,7 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.msgType).toBe('diplomacy');
|
expect(result.msgType).toBe('diplomacy');
|
||||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9002, 'diplomacy']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('allows a ruler to send a recruitment advertisement to the wanderer mailbox', async () => {
|
it('allows a ruler to send a recruitment advertisement to the wanderer mailbox', async () => {
|
||||||
@@ -371,10 +288,11 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result.msgType).toBe('diplomacy');
|
expect(result.msgType).toBe('diplomacy');
|
||||||
|
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9000, 'diplomacy']));
|
||||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||||
expect(changeJournal.snapshot()).toEqual([
|
expect(changeJournal.snapshot()).toEqual([
|
||||||
{ domain: 'messages.diplomacyMailbox', entityId: 9000 },
|
{ domain: 'messages.mailbox', entityId: 9000 },
|
||||||
{ domain: 'messages.diplomacyMailbox', entityId: 9001 },
|
{ domain: 'messages.mailbox', entityId: 9001 },
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -396,6 +314,7 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
|
|
||||||
expect(result.msgType).toBe('national');
|
expect(result.msgType).toBe('national');
|
||||||
expect(queryRaw).toHaveBeenCalledTimes(1);
|
expect(queryRaw).toHaveBeenCalledTimes(1);
|
||||||
|
expect(queryRaw.mock.calls[0]?.slice(1)).toEqual(expect.arrayContaining([9001, 'national']));
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => {
|
it('shows a wanderer the advertisement stored in mailbox 9000 without diplomacy redaction', async () => {
|
||||||
@@ -636,50 +555,44 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('invalidates a recent owned message and its receiver copy', async () => {
|
it('invalidates a recent owned message and its receiver copy', async () => {
|
||||||
let rawCall = 0;
|
const queryRaw = vi.fn(async () => [
|
||||||
const queryRaw = vi.fn(async () => {
|
{
|
||||||
rawCall += 1;
|
id: 21,
|
||||||
if (rawCall > 1) return [{ id: 21 }, { id: 22 }];
|
mailbox: general.id,
|
||||||
return [
|
type: 'private',
|
||||||
{
|
src: general.id,
|
||||||
id: 21,
|
dest: 8,
|
||||||
mailbox: general.id,
|
time: new Date(),
|
||||||
type: 'private',
|
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||||
src: general.id,
|
message: {
|
||||||
dest: 8,
|
src: {
|
||||||
time: new Date(),
|
generalId: general.id,
|
||||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
generalName: general.name,
|
||||||
message: {
|
nationId: 1,
|
||||||
src: {
|
nationName: '위',
|
||||||
generalId: general.id,
|
color: '#fff',
|
||||||
generalName: general.name,
|
icon: '',
|
||||||
nationId: 1,
|
|
||||||
nationName: '위',
|
|
||||||
color: '#fff',
|
|
||||||
icon: '',
|
|
||||||
},
|
|
||||||
dest: {
|
|
||||||
generalId: 8,
|
|
||||||
generalName: '받는이',
|
|
||||||
nationId: 2,
|
|
||||||
nationName: '촉',
|
|
||||||
color: '#000',
|
|
||||||
icon: '',
|
|
||||||
},
|
|
||||||
text: '삭제할 메시지',
|
|
||||||
option: { receiverMessageID: 22 },
|
|
||||||
},
|
},
|
||||||
|
dest: {
|
||||||
|
generalId: 8,
|
||||||
|
generalName: '받는이',
|
||||||
|
nationId: 2,
|
||||||
|
nationName: '촉',
|
||||||
|
color: '#000',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
text: '삭제할 메시지',
|
||||||
|
option: { receiverMessageID: 22 },
|
||||||
},
|
},
|
||||||
];
|
},
|
||||||
});
|
]);
|
||||||
const changeJournal = new ChangeJournal();
|
const changeJournal = new ChangeJournal();
|
||||||
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
|
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw }, { changeJournal });
|
||||||
|
|
||||||
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
|
const result = await caller.messages.delete({ generalId: general.id, messageId: 21 });
|
||||||
|
|
||||||
expect(result.deletedIds).toEqual([21, 22]);
|
expect(result.deletedIds).toEqual([21, 22]);
|
||||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
expect(executeRaw).toHaveBeenCalledOnce();
|
||||||
expect(executeRaw).not.toHaveBeenCalled();
|
|
||||||
expect(updateMany).not.toHaveBeenCalled();
|
expect(updateMany).not.toHaveBeenCalled();
|
||||||
expect(changeJournal.snapshot()).toEqual([
|
expect(changeJournal.snapshot()).toEqual([
|
||||||
{ domain: 'messages.mailbox', entityId: 7 },
|
{ domain: 'messages.mailbox', entityId: 7 },
|
||||||
@@ -688,49 +601,43 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
|
it('lets the sender delete a manual diplomacy copy without deleting the receiver copy', async () => {
|
||||||
let rawCall = 0;
|
const queryRaw = vi.fn(async () => [
|
||||||
const queryRaw = vi.fn(async () => {
|
{
|
||||||
rawCall += 1;
|
id: 25,
|
||||||
if (rawCall > 1) return [{ id: 25 }];
|
mailbox: 9001,
|
||||||
return [
|
type: 'diplomacy',
|
||||||
{
|
src: 9001,
|
||||||
id: 25,
|
dest: 9002,
|
||||||
mailbox: 9001,
|
time: new Date(),
|
||||||
type: 'diplomacy',
|
valid_until: new Date('9999-12-31T00:00:00Z'),
|
||||||
src: 9001,
|
message: {
|
||||||
dest: 9002,
|
src: {
|
||||||
time: new Date(),
|
generalId: general.id,
|
||||||
valid_until: new Date('9999-12-31T00:00:00Z'),
|
generalName: general.name,
|
||||||
message: {
|
nationId: 1,
|
||||||
src: {
|
nationName: '위',
|
||||||
generalId: general.id,
|
color: '#fff',
|
||||||
generalName: general.name,
|
icon: '',
|
||||||
nationId: 1,
|
|
||||||
nationName: '위',
|
|
||||||
color: '#fff',
|
|
||||||
icon: '',
|
|
||||||
},
|
|
||||||
dest: {
|
|
||||||
generalId: 0,
|
|
||||||
generalName: '',
|
|
||||||
nationId: 2,
|
|
||||||
nationName: '촉',
|
|
||||||
color: '#000',
|
|
||||||
icon: '',
|
|
||||||
},
|
|
||||||
text: '일반 외교 메시지',
|
|
||||||
option: { receiverMessageID: 26 },
|
|
||||||
},
|
},
|
||||||
|
dest: {
|
||||||
|
generalId: 0,
|
||||||
|
generalName: '',
|
||||||
|
nationId: 2,
|
||||||
|
nationName: '촉',
|
||||||
|
color: '#000',
|
||||||
|
icon: '',
|
||||||
|
},
|
||||||
|
text: '일반 외교 메시지',
|
||||||
|
option: { receiverMessageID: 26 },
|
||||||
},
|
},
|
||||||
];
|
},
|
||||||
});
|
]);
|
||||||
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw });
|
const { caller, executeRaw, updateMany } = buildContext({ $queryRaw: queryRaw });
|
||||||
|
|
||||||
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
|
const result = await caller.messages.delete({ generalId: general.id, messageId: 25 });
|
||||||
|
|
||||||
expect(result.deletedIds).toEqual([25]);
|
expect(result.deletedIds).toEqual([25]);
|
||||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
expect(executeRaw).toHaveBeenCalledOnce();
|
||||||
expect(executeRaw).not.toHaveBeenCalled();
|
|
||||||
expect(updateMany).not.toHaveBeenCalled();
|
expect(updateMany).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -806,11 +713,8 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
action,
|
action,
|
||||||
reason: 'success',
|
reason: 'success',
|
||||||
}));
|
}));
|
||||||
const transaction = vi.fn(async () => {
|
|
||||||
throw new Error('ENGINE message response must not enter the API input-event transaction.');
|
|
||||||
});
|
|
||||||
const { caller } = buildContext(
|
const { caller } = buildContext(
|
||||||
{ $queryRaw: vi.fn(async () => [messageRow]), $transaction: transaction },
|
{ $queryRaw: vi.fn(async () => [messageRow]) },
|
||||||
{ turnDaemon: { requestCommand } }
|
{ turnDaemon: { requestCommand } }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -830,7 +734,6 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
response: true,
|
response: true,
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
expect(transaction).not.toHaveBeenCalled();
|
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -842,7 +745,6 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
proposerCurrentNationId?: number;
|
proposerCurrentNationId?: number;
|
||||||
proposerNationMeta?: Record<string, unknown>;
|
proposerNationMeta?: Record<string, unknown>;
|
||||||
diplomacyState?: number;
|
diplomacyState?: number;
|
||||||
invalid?: boolean;
|
|
||||||
response?: boolean;
|
response?: boolean;
|
||||||
cities?: Array<{ id: number; nationId: number; frontState: number }>;
|
cities?: Array<{ id: number; nationId: number; frontState: number }>;
|
||||||
}) => {
|
}) => {
|
||||||
@@ -897,7 +799,6 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
text: '외교 제안',
|
text: '외교 제안',
|
||||||
option: {
|
option: {
|
||||||
action,
|
action,
|
||||||
...(options?.invalid ? { invalid: true } : {}),
|
|
||||||
...(action === 'noAggression' ? { year: 201, month: 2 } : {}),
|
...(action === 'noAggression' ? { year: 201, month: 2 } : {}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -963,18 +864,8 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
const nationUpdate = vi.fn(async () => ({}));
|
const nationUpdate = vi.fn(async () => ({}));
|
||||||
const logCreateMany = vi.fn(async () => ({ count: 1 }));
|
const logCreateMany = vi.fn(async () => ({ count: 1 }));
|
||||||
const messageUpdateMany = vi.fn(async () => ({ count: 1 }));
|
const messageUpdateMany = vi.fn(async () => ({ count: 1 }));
|
||||||
const messageActionUpdateMany = vi.fn(async () => ({ count: 1 }));
|
|
||||||
const cityUpdate = vi.fn(async () => ({}));
|
const cityUpdate = vi.fn(async () => ({}));
|
||||||
const changeJournal = new ChangeJournal();
|
const changeJournal = new ChangeJournal();
|
||||||
const requestCommand = vi.fn(async (command: { type: string; generalId: number; messageId: number }) => ({
|
|
||||||
type: 'syncDiplomaticResponse' as const,
|
|
||||||
ok: true,
|
|
||||||
generalId: command.generalId,
|
|
||||||
messageId: command.messageId,
|
|
||||||
nations: 2,
|
|
||||||
diplomacy: 2,
|
|
||||||
cities: 0,
|
|
||||||
}));
|
|
||||||
const { caller } = buildContext(
|
const { caller } = buildContext(
|
||||||
{
|
{
|
||||||
general: {
|
general: {
|
||||||
@@ -1050,22 +941,13 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
currentYear: 200,
|
currentYear: 200,
|
||||||
currentMonth: 3,
|
currentMonth: 3,
|
||||||
config: { environment: { mapName: 'che' } },
|
config: { environment: { mapName: 'che' } },
|
||||||
clockBaseTime: new Date('0200-03-01T00:00:00.000Z'),
|
|
||||||
clockTick: 1_000n,
|
|
||||||
clockMode: 'manual',
|
|
||||||
clockWallAnchor: new Date('2026-09-03T00:00:00.000Z'),
|
|
||||||
tickSeconds: 600,
|
|
||||||
clockPhase: 'RUNNING',
|
|
||||||
clockRevision: 1n,
|
|
||||||
deadlineGeneration: 1n,
|
|
||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
logEntry: { createMany: logCreateMany },
|
logEntry: { createMany: logCreateMany },
|
||||||
message: { updateMany: messageUpdateMany },
|
message: { updateMany: messageUpdateMany },
|
||||||
messageAction: { updateMany: messageActionUpdateMany },
|
|
||||||
$queryRaw: queryRaw,
|
$queryRaw: queryRaw,
|
||||||
},
|
},
|
||||||
{ changeJournal, turnDaemon: { requestCommand } }
|
{ changeJournal }
|
||||||
);
|
);
|
||||||
return {
|
return {
|
||||||
caller,
|
caller,
|
||||||
@@ -1078,25 +960,9 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
messageUpdateMany,
|
messageUpdateMany,
|
||||||
cityUpdate,
|
cityUpdate,
|
||||||
changeJournal,
|
changeJournal,
|
||||||
requestCommand,
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
it('rejects a response to a tombstoned diplomatic prompt before any state mutation', async () => {
|
|
||||||
const setup = buildDiplomaticContext({ invalid: true });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
setup.caller.messages.respond({
|
|
||||||
generalId: setup.actor.id,
|
|
||||||
messageId: 31,
|
|
||||||
response: true,
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ code: 'BAD_REQUEST', message: '삭제된 메시지에는 응답할 수 없습니다.' });
|
|
||||||
expect(setup.diplomacyUpdate).not.toHaveBeenCalled();
|
|
||||||
expect(setup.messageUpdateMany).not.toHaveBeenCalled();
|
|
||||||
expect(setup.requestCommand).not.toHaveBeenCalled();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => {
|
it('accepts a diplomatic prompt atomically and applies the legacy non-aggression effects', async () => {
|
||||||
const setup = buildDiplomaticContext();
|
const setup = buildDiplomaticContext();
|
||||||
|
|
||||||
@@ -1107,14 +973,6 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
expect(result).toEqual({ result: true, reason: 'success' });
|
expect(result).toEqual({ result: true, reason: 'success' });
|
||||||
expect(setup.requestCommand).toHaveBeenCalledWith({
|
|
||||||
type: 'syncDiplomaticResponse',
|
|
||||||
userId: auth.user.id,
|
|
||||||
generalId: setup.actor.id,
|
|
||||||
messageId: 31,
|
|
||||||
nationIds: [1, 2],
|
|
||||||
cityIds: [],
|
|
||||||
});
|
|
||||||
expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2);
|
expect(setup.diplomacyUpdate).toHaveBeenCalledTimes(2);
|
||||||
expect(setup.nationUpdate).toHaveBeenCalledWith(
|
expect(setup.nationUpdate).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({
|
expect.objectContaining({
|
||||||
@@ -1129,7 +987,7 @@ describe('messages router missing-flow compatibility', () => {
|
|||||||
);
|
);
|
||||||
expect(setup.messageUpdateMany).toHaveBeenCalledWith({
|
expect(setup.messageUpdateMany).toHaveBeenCalledWith({
|
||||||
where: { id: { in: [31] } },
|
where: { id: { in: [31] } },
|
||||||
data: { validUntil: expect.any(Date), validUntilTick: 1_000n },
|
data: { validUntil: expect.any(Date), validUntilTick: 0n },
|
||||||
});
|
});
|
||||||
expect(setup.queryRaw).toHaveBeenCalledTimes(9);
|
expect(setup.queryRaw).toHaveBeenCalledTimes(9);
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -13,16 +13,13 @@ const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
|||||||
const integration = describe.skipIf(!databaseUrl);
|
const integration = describe.skipIf(!databaseUrl);
|
||||||
const bettingId = 990_071;
|
const bettingId = 990_071;
|
||||||
const concurrentBettingId = 990_072;
|
const concurrentBettingId = 990_072;
|
||||||
const phaseBettingId = 990_073;
|
|
||||||
const generalId = 9_971;
|
const generalId = 9_971;
|
||||||
const otherGeneralId = 9_972;
|
const otherGeneralId = 9_972;
|
||||||
const phaseGeneralId = 9_973;
|
|
||||||
const nationId = 990_071;
|
const nationId = 990_071;
|
||||||
const otherNationId = 990_072;
|
const otherNationId = 990_072;
|
||||||
const userId = 'nation-betting-router-user';
|
const userId = 'nation-betting-router-user';
|
||||||
const otherUserId = 'nation-betting-router-other-user';
|
const otherUserId = 'nation-betting-router-other-user';
|
||||||
const noGeneralUserId = 'nation-betting-router-no-general-user';
|
const noGeneralUserId = 'nation-betting-router-no-general-user';
|
||||||
const phaseUserId = 'nation-betting-router-phase-user';
|
|
||||||
|
|
||||||
const auth: GameSessionTokenPayload = {
|
const auth: GameSessionTokenPayload = {
|
||||||
version: 1,
|
version: 1,
|
||||||
@@ -61,17 +58,6 @@ const noGeneralAuth: GameSessionTokenPayload = {
|
|||||||
},
|
},
|
||||||
};
|
};
|
||||||
|
|
||||||
const phaseAuth: GameSessionTokenPayload = {
|
|
||||||
...auth,
|
|
||||||
sessionId: 'nation-betting-router-phase-session',
|
|
||||||
user: {
|
|
||||||
...auth.user,
|
|
||||||
id: phaseUserId,
|
|
||||||
username: 'phase-bettor',
|
|
||||||
displayName: 'Phase Bettor',
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
integration('nation betting router', () => {
|
integration('nation betting router', () => {
|
||||||
let db: GamePrismaClient;
|
let db: GamePrismaClient;
|
||||||
let closeDb: (() => Promise<void>) | undefined;
|
let closeDb: (() => Promise<void>) | undefined;
|
||||||
@@ -104,14 +90,12 @@ integration('nation betting router', () => {
|
|||||||
await connector.connect();
|
await connector.connect();
|
||||||
db = connector.prisma;
|
db = connector.prisma;
|
||||||
closeDb = () => connector.disconnect();
|
closeDb = () => connector.disconnect();
|
||||||
await db.inputEvent.deleteMany({
|
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
||||||
where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
|
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||||
});
|
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
|
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
|
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
|
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
|
|
||||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
|
|
||||||
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||||
|
|
||||||
await db.nation.createMany({
|
await db.nation.createMany({
|
||||||
@@ -154,17 +138,6 @@ integration('nation betting router', () => {
|
|||||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
||||||
meta: {},
|
meta: {},
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: phaseGeneralId,
|
|
||||||
userId: phaseUserId,
|
|
||||||
name: '정지중베팅장수',
|
|
||||||
nationId,
|
|
||||||
cityId: 1,
|
|
||||||
npcState: 0,
|
|
||||||
officerLevel: 0,
|
|
||||||
turnTime: new Date('0200-01-01T00:00:00.000Z'),
|
|
||||||
meta: {},
|
|
||||||
},
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
const world = await db.worldState.create({
|
const world = await db.worldState.create({
|
||||||
@@ -196,17 +169,6 @@ integration('nation betting router', () => {
|
|||||||
],
|
],
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
await db.nationBetting.create({
|
|
||||||
data: {
|
|
||||||
id: phaseBettingId,
|
|
||||||
name: '정지 중 베팅',
|
|
||||||
selectCount: 1,
|
|
||||||
requiresInheritancePoint: true,
|
|
||||||
openYearMonth: 2_400,
|
|
||||||
closeYearMonth: 2_424,
|
|
||||||
candidates: [{ title: '베팅국', info: '', isHtml: true, aux: { nation: nationId } }],
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await db.nationBetting.create({
|
await db.nationBetting.create({
|
||||||
data: {
|
data: {
|
||||||
id: concurrentBettingId,
|
id: concurrentBettingId,
|
||||||
@@ -222,20 +184,17 @@ integration('nation betting router', () => {
|
|||||||
data: [
|
data: [
|
||||||
{ userId, key: 'previous', value: 1_000 },
|
{ userId, key: 'previous', value: 1_000 },
|
||||||
{ userId: otherUserId, key: 'previous', value: 500 },
|
{ userId: otherUserId, key: 'previous', value: 500 },
|
||||||
{ userId: phaseUserId, key: 'previous', value: 500 },
|
|
||||||
],
|
],
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
afterAll(async () => {
|
afterAll(async () => {
|
||||||
await db.inputEvent.deleteMany({
|
await db.inputEvent.deleteMany({ where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId] } } });
|
||||||
where: { actorUserId: { in: [userId, otherUserId, noGeneralUserId, phaseUserId] } },
|
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId] } } });
|
||||||
});
|
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId] } } });
|
||||||
await db.nationBetting.deleteMany({ where: { id: { in: [bettingId, concurrentBettingId, phaseBettingId] } } });
|
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||||
await db.rankData.deleteMany({ where: { generalId: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
|
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId] } } });
|
||||||
await db.inheritanceLog.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
|
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId] } } });
|
||||||
await db.inheritancePoint.deleteMany({ where: { userId: { in: [userId, otherUserId, phaseUserId] } } });
|
|
||||||
await db.general.deleteMany({ where: { id: { in: [generalId, otherGeneralId, phaseGeneralId] } } });
|
|
||||||
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
await db.nation.deleteMany({ where: { id: { in: [nationId, otherNationId] } } });
|
||||||
await db.worldState.delete({ where: { id: worldStateId } });
|
await db.worldState.delete({ where: { id: worldStateId } });
|
||||||
await closeDb?.();
|
await closeDb?.();
|
||||||
@@ -331,51 +290,6 @@ integration('nation betting router', () => {
|
|||||||
).toMatchObject({ value: 250 });
|
).toMatchObject({ value: 250 });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts nation betting during suspension but rejects it during reconciliation', async () => {
|
|
||||||
const before = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
|
||||||
const frozenTick = before.clockTick;
|
|
||||||
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'SUSPENDED' } });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
appRouter.createCaller(buildContext('nation-betting-suspended', phaseAuth)).betting.bet({
|
|
||||||
bettingId: phaseBettingId,
|
|
||||||
bettingType: [0],
|
|
||||||
amount: 100,
|
|
||||||
})
|
|
||||||
).resolves.toEqual({ result: true });
|
|
||||||
await expect(
|
|
||||||
db.inheritancePoint.findUniqueOrThrow({
|
|
||||||
where: { userId_key: { userId: phaseUserId, key: 'previous' } },
|
|
||||||
})
|
|
||||||
).resolves.toMatchObject({ value: 400 });
|
|
||||||
await expect(
|
|
||||||
db.nationBet.findFirstOrThrow({ where: { bettingId: phaseBettingId, userId: phaseUserId } })
|
|
||||||
).resolves.toMatchObject({ amount: 100 });
|
|
||||||
await expect(db.worldState.findUniqueOrThrow({ where: { id: worldStateId } })).resolves.toMatchObject({
|
|
||||||
clockPhase: 'SUSPENDED',
|
|
||||||
clockTick: frozenTick,
|
|
||||||
});
|
|
||||||
|
|
||||||
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'RECONCILING' } });
|
|
||||||
await expect(
|
|
||||||
appRouter.createCaller(buildContext('nation-betting-reconciling', phaseAuth)).betting.bet({
|
|
||||||
bettingId: phaseBettingId,
|
|
||||||
bettingType: [0],
|
|
||||||
amount: 50,
|
|
||||||
})
|
|
||||||
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
|
||||||
await expect(
|
|
||||||
db.inheritancePoint.findUniqueOrThrow({
|
|
||||||
where: { userId_key: { userId: phaseUserId, key: 'previous' } },
|
|
||||||
})
|
|
||||||
).resolves.toMatchObject({ value: 400 });
|
|
||||||
await expect(
|
|
||||||
db.nationBet.findFirstOrThrow({ where: { bettingId: phaseBettingId, userId: phaseUserId } })
|
|
||||||
).resolves.toMatchObject({ amount: 100 });
|
|
||||||
|
|
||||||
await db.worldState.update({ where: { id: worldStateId }, data: { clockPhase: 'RUNNING' } });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('requires authentication and an owned player general for every betting operation', async () => {
|
it('requires authentication and an owned player general for every betting operation', async () => {
|
||||||
await expect(
|
await expect(
|
||||||
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({
|
appRouter.createCaller(buildContext('nation-betting-anonymous-list', null)).betting.getList({
|
||||||
|
|||||||
@@ -154,7 +154,6 @@ describe('nation general and secret office permissions', () => {
|
|||||||
const ally = general({
|
const ally = general({
|
||||||
id: 3,
|
id: 3,
|
||||||
userId: 'u3',
|
userId: 'u3',
|
||||||
injury: 25,
|
|
||||||
gold: 3000,
|
gold: 3000,
|
||||||
crew: 200,
|
crew: 200,
|
||||||
train: 80,
|
train: 80,
|
||||||
@@ -167,10 +166,6 @@ describe('nation general and secret office permissions', () => {
|
|||||||
expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
|
expect(result.viewer).toEqual({ generalId: 2, permission: 2 });
|
||||||
expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]);
|
expect(result.generals.map((g) => g.id)).toEqual([1, 2, 3]);
|
||||||
expect(result.generals.find((entry) => entry.id === 3)?.experienceLevel).toBe(200);
|
expect(result.generals.find((entry) => entry.id === 3)?.experienceLevel).toBe(200);
|
||||||
expect(result.generals.find((entry) => entry.id === 3)).toMatchObject({
|
|
||||||
baseStats: { leadership: 70, strength: 60, intelligence: 50 },
|
|
||||||
stats: { leadership: 52, strength: 45, intelligence: 37 },
|
|
||||||
});
|
|
||||||
expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 });
|
expect(result.summary).toMatchObject({ gold: 5000, crew: 800, generalCount: 3 });
|
||||||
expect(result.generals[0]?.reservedCommands).toEqual([
|
expect(result.generals[0]?.reservedCommands).toEqual([
|
||||||
{ action: 'che_징병', args: { crewType: 1, amount: 300 } },
|
{ action: 'che_징병', args: { crewType: 1, amount: 300 } },
|
||||||
|
|||||||
@@ -161,20 +161,6 @@ describe('NPC policy router', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('uses the live world development cost instead of the scenario snapshot', async () => {
|
|
||||||
const fixture = createContext({
|
|
||||||
world: {
|
|
||||||
...baseWorld,
|
|
||||||
config: { ...baseWorld.config, const: { develCost: 100 } },
|
|
||||||
meta: { ...baseWorld.meta, develcost: 42 } as typeof baseWorld.meta & { develcost: number },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await appRouter.createCaller(fixture.context).npc.getPolicy();
|
|
||||||
|
|
||||||
expect(result.zeroPolicy.reqNPCDevelGold).toBe(1_260);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => {
|
it('lets a secret-level reader load the page while mapping authoritative ENGINE rejection', async () => {
|
||||||
const reader = { ...baseGeneral, officerLevel: 2 };
|
const reader = { ...baseGeneral, officerLevel: 2 };
|
||||||
const requestCommand = vi.fn(async () => ({
|
const requestCommand = vi.fn(async () => ({
|
||||||
|
|||||||
@@ -379,7 +379,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
attempts: 1,
|
attempts: 1,
|
||||||
actorUserId: userId,
|
actorUserId: userId,
|
||||||
});
|
});
|
||||||
expect(access.lastRefresh?.getTime()).toBe(event.createdAt.getTime());
|
expect(access.lastRefresh?.getTime()).toBe(runtime!.world.getGameNow(event.createdAt).getTime());
|
||||||
const logs = await db.logEntry.findMany({
|
const logs = await db.logEntry.findMany({
|
||||||
where: {
|
where: {
|
||||||
OR: [
|
OR: [
|
||||||
@@ -420,7 +420,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
});
|
});
|
||||||
}, 45_000);
|
}, 45_000);
|
||||||
|
|
||||||
it('revalidates a queued token at the authoritative daemon processing tick', async () => {
|
it('keeps a token accepted in logical time until the queued ENGINE event finishes', async () => {
|
||||||
const reservation = await appRouter
|
const reservation = await appRouter
|
||||||
.createCaller(buildContext('npc-possession-delayed-token', delayedAuth))
|
.createCaller(buildContext('npc-possession-delayed-token', delayedAuth))
|
||||||
.join.listPossessCandidates({});
|
.join.listPossessCandidates({});
|
||||||
@@ -440,13 +440,13 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
).rejects.toMatchObject({ code: 'TIMEOUT' });
|
).rejects.toMatchObject({ code: 'TIMEOUT' });
|
||||||
|
|
||||||
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
const event = await db.inputEvent.findUniqueOrThrow({ where: { requestId } });
|
||||||
expect(event.acceptedGameTick).toBeNull();
|
const acceptedGameAt = new Date(
|
||||||
expect(event.processingGameTick).toBeNull();
|
(event.payload as { acceptedGameAt?: string }).acceptedGameAt ?? 'invalid accepted game time'
|
||||||
expect(event.payload).not.toHaveProperty('acceptedGameAt');
|
);
|
||||||
const queuedAtTick = (await db.worldState.findFirstOrThrow({ orderBy: { id: 'asc' } })).clockTick!;
|
expect(acceptedGameAt.toString()).not.toBe('Invalid Date');
|
||||||
await db.npcSelectionToken.update({
|
await db.npcSelectionToken.update({
|
||||||
where: { ownerUserId: delayedUserId },
|
where: { ownerUserId: delayedUserId },
|
||||||
data: { validUntilTick: queuedAtTick },
|
data: { validUntil: acceptedGameAt },
|
||||||
});
|
});
|
||||||
await db.worldState.updateMany({
|
await db.worldState.updateMany({
|
||||||
data: { clockTick: { increment: 1 } },
|
data: { clockTick: { increment: 1 } },
|
||||||
@@ -470,13 +470,12 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
await startRuntime('npc-possession-delayed-retry-daemon');
|
await startRuntime('npc-possession-delayed-retry-daemon');
|
||||||
await expect(
|
await expect(
|
||||||
appRouter.createCaller(buildContext('npc-possession-delayed-retry', delayedAuth)).join.possessGeneral(input)
|
appRouter.createCaller(buildContext('npc-possession-delayed-retry', delayedAuth)).join.possessGeneral(input)
|
||||||
).rejects.toMatchObject({ code: 'PRECONDITION_FAILED', message: '유효한 장수 목록이 없습니다.' });
|
).resolves.toEqual({ ok: true, generalId: candidate.id });
|
||||||
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
await expect(db.inputEvent.findUniqueOrThrow({ where: { requestId } })).resolves.toMatchObject({
|
||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
processingGameTick: expect.anything(),
|
|
||||||
});
|
});
|
||||||
expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(0);
|
expect(await db.general.count({ where: { userId: delayedUserId } })).toBe(1);
|
||||||
}, 45_000);
|
}, 45_000);
|
||||||
|
|
||||||
it('serializes durable enqueue before a token refresh can replace its nonce', async () => {
|
it('serializes durable enqueue before a token refresh can replace its nonce', async () => {
|
||||||
@@ -671,7 +670,7 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
|
|
||||||
await db.npcSelectionToken.update({
|
await db.npcSelectionToken.update({
|
||||||
where: { ownerUserId: rejectedUserId },
|
where: { ownerUserId: rejectedUserId },
|
||||||
data: { validUntilTick: -1n },
|
data: { validUntil: new Date('2000-01-01T00:00:00.000Z') },
|
||||||
});
|
});
|
||||||
await expect(
|
await expect(
|
||||||
appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({
|
appRouter.createCaller(buildContext('npc-possession-reject-expired', rejectedAuth)).join.possessGeneral({
|
||||||
@@ -716,59 +715,4 @@ integration('mode 1 NPC possession through token reservation and the durable dae
|
|||||||
expect(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0);
|
expect(await db.general.count({ where: { userId: rejectedUserId } })).toBe(0);
|
||||||
expect(runtime!.world.listGenerals().some(({ userId: owner }) => owner === rejectedUserId)).toBe(false);
|
expect(runtime!.world.listGenerals().some(({ userId: owner }) => owner === rejectedUserId)).toBe(false);
|
||||||
}, 45_000);
|
}, 45_000);
|
||||||
it.each(['SUSPENDED', 'COMPLETED'])(
|
|
||||||
'reserves and possesses an NPC in %s without changing finalized records',
|
|
||||||
async (phase) => {
|
|
||||||
await stopRuntime('prepare frozen possession');
|
|
||||||
const original = await db.worldState.findFirstOrThrow();
|
|
||||||
const history = await db.gameHistory.findUniqueOrThrow({ where: { serverId: profile } });
|
|
||||||
const visitorId = `npc-visitor-${phase}`;
|
|
||||||
const visitor = buildAuth(visitorId, '축하방문자', phase === 'SUSPENDED' ? 801 : 802);
|
|
||||||
await db.worldState.update({
|
|
||||||
where: { id: original.id },
|
|
||||||
data: {
|
|
||||||
clockPhase: phase,
|
|
||||||
meta: { ...(original.meta as Record<string, GamePrisma.InputJsonValue>), isUnited: 2, isunited: 2 },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await db.gameHistory.update({ where: { serverId: profile }, data: { status: 'COMPLETED' } });
|
|
||||||
const records = async () => ({
|
|
||||||
hall: await db.hallOfFame.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
|
|
||||||
old: await db.oldGeneral.findMany({ where: { serverId: profile }, orderBy: { id: 'asc' } }),
|
|
||||||
points: await db.inheritancePoint.findMany({ where: { userId: visitorId } }),
|
|
||||||
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
|
|
||||||
});
|
|
||||||
const before = await records();
|
|
||||||
try {
|
|
||||||
await startRuntime(`npc-visitor-${phase}`);
|
|
||||||
const caller = appRouter.createCaller(buildContext(`npc-visitor-${phase}`, visitor));
|
|
||||||
const candidates = await caller.join.listPossessCandidates({});
|
|
||||||
const picked = candidates.candidates[0]!;
|
|
||||||
expect(picked).toBeDefined();
|
|
||||||
await caller.join.possessGeneral({ generalId: picked.id, tokenNonce: candidates.tokenNonce });
|
|
||||||
expect(await db.general.findUniqueOrThrow({ where: { id: picked.id } })).toMatchObject({
|
|
||||||
userId: visitorId,
|
|
||||||
npcState: 1,
|
|
||||||
});
|
|
||||||
expect(await records()).toEqual(before);
|
|
||||||
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
|
|
||||||
clockPhase: phase,
|
|
||||||
clockTick: original.clockTick,
|
|
||||||
lastTurnTick: original.lastTurnTick,
|
|
||||||
});
|
|
||||||
await stopRuntime('verify frozen possession reload');
|
|
||||||
await startRuntime(`npc-visitor-reload-${phase}`);
|
|
||||||
expect(runtime!.world.getGeneralById(picked.id)?.userId).toBe(visitorId);
|
|
||||||
} finally {
|
|
||||||
await stopRuntime('restore frozen possession');
|
|
||||||
await db.worldState.update({
|
|
||||||
where: { id: original.id },
|
|
||||||
data: { clockPhase: original.clockPhase, meta: original.meta! },
|
|
||||||
});
|
|
||||||
await db.gameHistory.update({ where: { serverId: profile }, data: { status: history.status } });
|
|
||||||
await startRuntime('frozen-possession-restored');
|
|
||||||
}
|
|
||||||
},
|
|
||||||
30_000
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,5 +1,4 @@
|
|||||||
import { describe, expect, it } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
import { resolveNationPermission } from '../src/router/nation/shared.js';
|
|
||||||
|
|
||||||
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
|
import { createEmptyRealtimeReadModelChanges, type RealtimeEvent } from '@sammo-ts/common';
|
||||||
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
|
import { MESSAGE_MAILBOX_NATIONAL_BASE } from '@sammo-ts/logic';
|
||||||
@@ -10,7 +9,7 @@ import {
|
|||||||
toPublicRealtimeEvent as convertPublicRealtimeEvent,
|
toPublicRealtimeEvent as convertPublicRealtimeEvent,
|
||||||
} from '../src/realtime/publicEvent.js';
|
} from '../src/realtime/publicEvent.js';
|
||||||
|
|
||||||
const viewer = { generalId: 7, cityId: 3, nationId: 2, canReadDiplomacy: true } as const;
|
const viewer = { generalId: 7, cityId: 3, nationId: 2 } as const;
|
||||||
const refreshGrant = 'opaque-grant';
|
const refreshGrant = 'opaque-grant';
|
||||||
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
|
const toPublicRealtimeEvent = (event: RealtimeEvent, identities: Parameters<typeof convertPublicRealtimeEvent>[1]) =>
|
||||||
convertPublicRealtimeEvent(event, identities, () => refreshGrant);
|
convertPublicRealtimeEvent(event, identities, () => refreshGrant);
|
||||||
@@ -199,37 +198,6 @@ describe('public realtime event privacy boundary', () => {
|
|||||||
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
|
expect(toPublicRealtimeEvent({ ...event, mailbox: MESSAGE_MAILBOX_NATIONAL_BASE + 8 }, [viewer])).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('suppresses diplomacy-only wake-ups for viewers without secret-message access', () => {
|
|
||||||
const mailbox = MESSAGE_MAILBOX_NATIONAL_BASE + viewer.nationId;
|
|
||||||
const blockedViewer = { ...viewer, canReadDiplomacy: false };
|
|
||||||
expect(
|
|
||||||
toPublicRealtimeEvent(
|
|
||||||
{
|
|
||||||
type: 'messageCreated',
|
|
||||||
at: '2026-09-04T00:00:00Z',
|
|
||||||
mailbox,
|
|
||||||
msgType: 'diplomacy',
|
|
||||||
messageId: 1,
|
|
||||||
senderId: 2,
|
|
||||||
},
|
|
||||||
[blockedViewer]
|
|
||||||
)
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [
|
|
||||||
blockedViewer,
|
|
||||||
])
|
|
||||||
).toBeNull();
|
|
||||||
expect(
|
|
||||||
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [mailbox], diplomacyMailboxes: [mailbox] }, [
|
|
||||||
blockedViewer,
|
|
||||||
])
|
|
||||||
).toEqual({ type: 'messagesInvalidated', refreshGrant });
|
|
||||||
expect(
|
|
||||||
toPublicRealtimeEvent({ type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [mailbox] }, [viewer])
|
|
||||||
).toEqual({ type: 'messagesInvalidated', refreshGrant });
|
|
||||||
});
|
|
||||||
|
|
||||||
it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => {
|
it('redacts durable mailbox wake-ups to one viewer-safe boolean event', () => {
|
||||||
const event: RealtimeEvent = {
|
const event: RealtimeEvent = {
|
||||||
type: 'messagesChanged',
|
type: 'messagesChanged',
|
||||||
@@ -257,12 +225,6 @@ describe('public realtime event privacy boundary', () => {
|
|||||||
viewer
|
viewer
|
||||||
)
|
)
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
expect(
|
|
||||||
shouldReloadRealtimeViewerIdentity(
|
|
||||||
turnEvent({ ...createEmptyRealtimeReadModelChanges(), nationIds: [viewer.nationId] }),
|
|
||||||
viewer
|
|
||||||
)
|
|
||||||
).toBe(true);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('merges previous and committed identities across an ownership transition', () => {
|
it('merges previous and committed identities across an ownership transition', () => {
|
||||||
@@ -289,28 +251,3 @@ describe('public realtime event privacy boundary', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('diplomatic notification recipient roles', () => {
|
|
||||||
it.each([
|
|
||||||
['군주', 12, 'normal', {}, true],
|
|
||||||
['외교권자', 1, 'ambassador', {}, true],
|
|
||||||
['조언자', 1, 'auditor', {}, true],
|
|
||||||
['일반 장수', 1, 'normal', {}, false],
|
|
||||||
['수뇌', 11, 'normal', {}, false],
|
|
||||||
['외교 금지', 1, 'ambassador', { noAmbassador: 1 }, false],
|
|
||||||
] as const)(
|
|
||||||
'%s receives only eligible nation diplomacy invalidations',
|
|
||||||
(_, officerLevel, permission, penalty, allowed) => {
|
|
||||||
const canReadDiplomacy =
|
|
||||||
resolveNationPermission({ nationId: 2, officerLevel, meta: { permission }, penalty }, {}, false) >= 3;
|
|
||||||
expect(canReadDiplomacy).toBe(allowed);
|
|
||||||
const event: RealtimeEvent = { type: 'messagesChanged', mailboxes: [], diplomacyMailboxes: [9002] };
|
|
||||||
expect(toPublicRealtimeEvent(event, [{ ...viewer, canReadDiplomacy }])).toEqual(
|
|
||||||
allowed ? { type: 'messagesInvalidated', refreshGrant } : null
|
|
||||||
);
|
|
||||||
expect(
|
|
||||||
toPublicRealtimeEvent({ ...event, diplomacyMailboxes: [9003] }, [{ ...viewer, canReadDiplomacy }])
|
|
||||||
).toBeNull();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -12,7 +12,6 @@ const payload = (
|
|||||||
| 'access.general'
|
| 'access.general'
|
||||||
| 'dashboard.global'
|
| 'dashboard.global'
|
||||||
| 'messages.mailbox'
|
| 'messages.mailbox'
|
||||||
| 'messages.diplomacyMailbox'
|
|
||||||
| 'tournament'
|
| 'tournament'
|
||||||
| 'betting'
|
| 'betting'
|
||||||
) => ({
|
) => ({
|
||||||
@@ -20,11 +19,7 @@ const payload = (
|
|||||||
changes: [
|
changes: [
|
||||||
[
|
[
|
||||||
domain,
|
domain,
|
||||||
domain === 'front.general' || domain === 'access.general'
|
domain === 'front.general' || domain === 'access.general' ? 7 : domain === 'messages.mailbox' ? 9999 : 0,
|
||||||
? 7
|
|
||||||
: domain === 'messages.mailbox' || domain === 'messages.diplomacyMailbox'
|
|
||||||
? 9999
|
|
||||||
: 0,
|
|
||||||
'1',
|
'1',
|
||||||
],
|
],
|
||||||
],
|
],
|
||||||
@@ -32,16 +27,15 @@ const payload = (
|
|||||||
|
|
||||||
const createFixture = (rows: readonly object[]) => {
|
const createFixture = (rows: readonly object[]) => {
|
||||||
const queryRaw = vi.fn().mockResolvedValueOnce(rows).mockResolvedValue([]);
|
const queryRaw = vi.fn().mockResolvedValueOnce(rows).mockResolvedValue([]);
|
||||||
const executeRaw = vi.fn().mockResolvedValue(1);
|
const updateMany = vi.fn().mockResolvedValue({ count: 1 });
|
||||||
const incr = vi.fn().mockResolvedValue(41);
|
const incr = vi.fn().mockResolvedValue(41);
|
||||||
const publish = vi.fn().mockResolvedValue(1);
|
const publish = vi.fn().mockResolvedValue(1);
|
||||||
const db = {
|
const db = {
|
||||||
$queryRaw: queryRaw,
|
$queryRaw: queryRaw,
|
||||||
$executeRaw: executeRaw,
|
readModelOutbox: { updateMany },
|
||||||
readModelOutbox: {},
|
|
||||||
} as unknown as ReadModelOutboxDatabase;
|
} as unknown as ReadModelOutboxDatabase;
|
||||||
const redis = { incr, publish } as unknown as RedisConnector['client'];
|
const redis = { incr, publish } as unknown as RedisConnector['client'];
|
||||||
return { db, redis, queryRaw, executeRaw, incr, publish };
|
return { db, redis, queryRaw, updateMany, incr, publish };
|
||||||
};
|
};
|
||||||
|
|
||||||
describe('ReadModelOutboxWorker', () => {
|
describe('ReadModelOutboxWorker', () => {
|
||||||
@@ -53,7 +47,7 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
worker.start();
|
worker.start();
|
||||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||||
await worker.stop();
|
await worker.stop();
|
||||||
|
|
||||||
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({
|
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toMatchObject({
|
||||||
@@ -70,7 +64,7 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
worker.start();
|
worker.start();
|
||||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||||
await worker.stop();
|
await worker.stop();
|
||||||
|
|
||||||
expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision');
|
expect(fixture.incr).toHaveBeenCalledWith('sammo:che:default:read-model:revision');
|
||||||
@@ -80,7 +74,9 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
revision: 41,
|
revision: 41,
|
||||||
changes: { frontStatusActorIds: [7] },
|
changes: { frontStatusActorIds: [7] },
|
||||||
});
|
});
|
||||||
expect((fixture.executeRaw.mock.calls[0]?.[0] as { sql: string }).sql).toContain('"delivered_at"');
|
expect(fixture.updateMany).toHaveBeenCalledWith(
|
||||||
|
expect.objectContaining({ where: { id: 11n, lockOwner: 'worker-test', deliveredAt: null } })
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)(
|
it.each(['access.general', 'dashboard.global', 'tournament', 'betting'] as const)(
|
||||||
@@ -93,7 +89,7 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
worker.start();
|
worker.start();
|
||||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||||
await worker.stop();
|
await worker.stop();
|
||||||
|
|
||||||
expect(fixture.incr).not.toHaveBeenCalled();
|
expect(fixture.incr).not.toHaveBeenCalled();
|
||||||
@@ -109,7 +105,7 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
worker.start();
|
worker.start();
|
||||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||||
await worker.stop();
|
await worker.stop();
|
||||||
|
|
||||||
expect(fixture.incr).not.toHaveBeenCalled();
|
expect(fixture.incr).not.toHaveBeenCalled();
|
||||||
@@ -119,25 +115,6 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('labels diplomacy-only mailbox wake-ups for viewer permission filtering', async () => {
|
|
||||||
const fixture = createFixture([{ id: 15n, payload: payload('messages.diplomacyMailbox'), attempts: 1 }]);
|
|
||||||
const worker = new ReadModelOutboxWorker(fixture.db, fixture.redis, 'che:default', {
|
|
||||||
owner: 'worker-test',
|
|
||||||
intervalMs: 60_000,
|
|
||||||
});
|
|
||||||
|
|
||||||
worker.start();
|
|
||||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
|
||||||
await worker.stop();
|
|
||||||
|
|
||||||
expect(fixture.incr).not.toHaveBeenCalled();
|
|
||||||
expect(JSON.parse(String(fixture.publish.mock.calls[0]?.[1]))).toEqual({
|
|
||||||
type: 'messagesChanged',
|
|
||||||
mailboxes: [],
|
|
||||||
diplomacyMailboxes: [9999],
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => {
|
it('coalesces repeated wakeups into one trailing batch and waits for it on shutdown', async () => {
|
||||||
let releaseFirst: (() => void) | undefined;
|
let releaseFirst: (() => void) | undefined;
|
||||||
const first = new Promise<readonly object[]>((resolve) => {
|
const first = new Promise<readonly object[]>((resolve) => {
|
||||||
@@ -173,15 +150,22 @@ describe('ReadModelOutboxWorker', () => {
|
|||||||
});
|
});
|
||||||
|
|
||||||
worker.start();
|
worker.start();
|
||||||
await vi.waitFor(() => expect(fixture.executeRaw).toHaveBeenCalledTimes(1));
|
await vi.waitFor(() => expect(fixture.updateMany).toHaveBeenCalledTimes(1));
|
||||||
await worker.stop();
|
await worker.stop();
|
||||||
|
|
||||||
expect(onError).toHaveBeenCalledWith(
|
expect(onError).toHaveBeenCalledWith(
|
||||||
expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' })
|
expect.objectContaining({ message: '1 read-model outbox delivery attempt(s) failed.' })
|
||||||
);
|
);
|
||||||
const releaseQuery = fixture.executeRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
|
expect(fixture.updateMany).toHaveBeenCalledWith(
|
||||||
expect(releaseQuery.sql).toContain('"available_at"');
|
expect.objectContaining({
|
||||||
expect(releaseQuery.values).toContainEqual(expect.stringContaining('redis unavailable'));
|
where: { id: 13n, lockOwner: 'worker-test', deliveredAt: null },
|
||||||
|
data: expect.objectContaining({
|
||||||
|
lockedAt: null,
|
||||||
|
lockOwner: null,
|
||||||
|
lastError: expect.stringContaining('redis unavailable'),
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('prunes only a bounded retention batch on the lower-frequency cadence', async () => {
|
it('prunes only a bounded retention batch on the lower-frequency cadence', async () => {
|
||||||
|
|||||||
@@ -710,7 +710,7 @@ describe('appRouter', () => {
|
|||||||
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
).rejects.toMatchObject({ code: 'FORBIDDEN' });
|
||||||
});
|
});
|
||||||
|
|
||||||
it('queues selection-pool reservation without pre-assigning an API game coordinate', async () => {
|
it('queues selection-pool reservation with the authenticated actor and server logical time', async () => {
|
||||||
const transport = new InMemoryTurnDaemonTransport();
|
const transport = new InMemoryTurnDaemonTransport();
|
||||||
const requestId = 'select-pool-reserve-http';
|
const requestId = 'select-pool-reserve-http';
|
||||||
const commandRequestId = `select-pool:user-1:${requestId}:reserve`;
|
const commandRequestId = `select-pool:user-1:${requestId}:reserve`;
|
||||||
@@ -741,6 +741,8 @@ describe('appRouter', () => {
|
|||||||
requestId: commandRequestId,
|
requestId: commandRequestId,
|
||||||
userId: 'user-1',
|
userId: 'user-1',
|
||||||
seedOwnerIdentity: 'user-1',
|
seedOwnerIdentity: 'user-1',
|
||||||
|
acceptedGameAt,
|
||||||
|
acceptedGameTick: 0,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
@@ -142,9 +142,8 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
await seedScenarioToDatabase({
|
await seedScenarioToDatabase({
|
||||||
scenarioId: 903,
|
scenarioId: 903,
|
||||||
databaseUrl: databaseUrl!,
|
databaseUrl: databaseUrl!,
|
||||||
now: new Date('2026-07-30T12:00:00.000Z'),
|
now: new Date('2099-07-30T12:00:00.000Z'),
|
||||||
installOptions: {
|
installOptions: {
|
||||||
openAt: new Date(Date.now() + 86_400_000),
|
|
||||||
turnTermMinutes: 5,
|
turnTermMinutes: 5,
|
||||||
npcMode: 2,
|
npcMode: 2,
|
||||||
showImgLevel: 3,
|
showImgLevel: 3,
|
||||||
@@ -228,17 +227,19 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
.listGeneralPoolCandidates(new Date(firstReservation.validUntil))
|
.listGeneralPoolCandidates(new Date(firstReservation.validUntil))
|
||||||
?.some((candidate) => reservedNames.has(candidate.uniqueName))
|
?.some((candidate) => reservedNames.has(candidate.uniqueName))
|
||||||
).toBe(false);
|
).toBe(false);
|
||||||
const reserveEvent = await db.inputEvent.findUniqueOrThrow({
|
await expect(
|
||||||
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
|
db.inputEvent.findUniqueOrThrow({
|
||||||
});
|
where: { requestId: `select-pool:${userId}:select-pool-reserve-a:reserve` },
|
||||||
expect(reserveEvent).toMatchObject({
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
eventType: 'selectPoolReserve',
|
eventType: 'selectPoolReserve',
|
||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
actorUserId: userId,
|
actorUserId: userId,
|
||||||
processingGameTick: expect.anything(),
|
payload: {
|
||||||
|
acceptedGameAt: expect.any(String),
|
||||||
|
acceptedGameTick: expect.any(Number),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(reserveEvent.payload).not.toHaveProperty('acceptedGameAt');
|
|
||||||
expect(reserveEvent.payload).not.toHaveProperty('acceptedGameTick');
|
|
||||||
|
|
||||||
const createRequestIds = ['select-pool-create-a', 'select-pool-create-b'] as const;
|
const createRequestIds = ['select-pool-create-a', 'select-pool-create-b'] as const;
|
||||||
const attempts = await Promise.allSettled([
|
const attempts = await Promise.allSettled([
|
||||||
@@ -275,9 +276,6 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
result: { type: 'selectPoolCreate', ok: true, generalId: initial.id },
|
result: { type: 'selectPoolCreate', ok: true, generalId: initial.id },
|
||||||
});
|
});
|
||||||
expect(acceptedEvent.processingGameTick).toBeLessThan(0n);
|
|
||||||
expect(initial.turnTick).toBeGreaterThanOrEqual(0n);
|
|
||||||
expect(await db.worldState.findFirst()).toMatchObject({ clockPhase: 'PREOPEN', clockTick: 0n });
|
|
||||||
if (!initialAccess.lastRefresh) {
|
if (!initialAccess.lastRefresh) {
|
||||||
throw new Error('selected general must have an initial access timestamp');
|
throw new Error('selected general must have an initial access timestamp');
|
||||||
}
|
}
|
||||||
@@ -359,7 +357,6 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' });
|
).rejects.toMatchObject({ message: '아직 다시 고를 수 없습니다' });
|
||||||
|
|
||||||
const cooledAt = '2026-07-29T00:00:00.000Z';
|
const cooledAt = '2026-07-29T00:00:00.000Z';
|
||||||
const cooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
|
|
||||||
await expect(
|
await expect(
|
||||||
turnDaemon.requestCommand({
|
turnDaemon.requestCommand({
|
||||||
type: 'patchGeneral',
|
type: 'patchGeneral',
|
||||||
@@ -369,7 +366,6 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
meta: {
|
meta: {
|
||||||
next_change: cooledAt,
|
next_change: cooledAt,
|
||||||
nextChangeAt: cooledAt,
|
nextChangeAt: cooledAt,
|
||||||
next_change_tick: cooledTick,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
@@ -384,16 +380,16 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
.createCaller(buildContext('select-pool-reselect'))
|
.createCaller(buildContext('select-pool-reselect'))
|
||||||
.join.reselectPoolGeneral({ uniqueName: target.uniqueName })
|
.join.reselectPoolGeneral({ uniqueName: target.uniqueName })
|
||||||
).resolves.toEqual({ ok: true, generalId: initial.id });
|
).resolves.toEqual({ ok: true, generalId: initial.id });
|
||||||
const reselectionEvent = await db.inputEvent.findUniqueOrThrow({
|
await expect(
|
||||||
where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' },
|
db.inputEvent.findUniqueOrThrow({ where: { requestId: 'select-pool-reselect:join.reselectPoolGeneral' } })
|
||||||
});
|
).resolves.toMatchObject({
|
||||||
expect(reselectionEvent).toMatchObject({
|
|
||||||
eventType: 'selectPoolReselect',
|
eventType: 'selectPoolReselect',
|
||||||
actorUserId: userId,
|
actorUserId: userId,
|
||||||
processingGameTick: expect.anything(),
|
payload: {
|
||||||
|
acceptedGameAt: expect.any(String),
|
||||||
|
acceptedGameTick: expect.any(Number),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameAt');
|
|
||||||
expect(reselectionEvent.payload).not.toHaveProperty('acceptedGameTick');
|
|
||||||
|
|
||||||
const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } });
|
const updated = await db.general.findUniqueOrThrow({ where: { id: initial.id } });
|
||||||
expect(updated).toMatchObject({
|
expect(updated).toMatchObject({
|
||||||
@@ -459,7 +455,6 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue },
|
data: { config: { ...fullConfig, maxGeneral: 1 } as GamePrisma.InputJsonValue },
|
||||||
});
|
});
|
||||||
const secondCooledAt = '2026-07-28T00:00:00.000Z';
|
const secondCooledAt = '2026-07-28T00:00:00.000Z';
|
||||||
const secondCooledTick = runtime!.world.dateToGameTick(runtime!.world.getGameNow(new Date())) - 1;
|
|
||||||
await turnDaemon.requestCommand({
|
await turnDaemon.requestCommand({
|
||||||
type: 'patchGeneral',
|
type: 'patchGeneral',
|
||||||
requestId: 'select-pool-full-cooldown-patch',
|
requestId: 'select-pool-full-cooldown-patch',
|
||||||
@@ -468,7 +463,6 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
meta: {
|
meta: {
|
||||||
next_change: secondCooledAt,
|
next_change: secondCooledAt,
|
||||||
nextChangeAt: secondCooledAt,
|
nextChangeAt: secondCooledAt,
|
||||||
next_change_tick: secondCooledTick,
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -577,19 +571,21 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
.join.selectPoolGeneral(stableInput);
|
.join.selectPoolGeneral(stableInput);
|
||||||
expect(retried).toEqual(first);
|
expect(retried).toEqual(first);
|
||||||
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1);
|
expect(await db.general.count({ where: { userId: otherUserId } })).toBe(1);
|
||||||
const stableEvent = await db.inputEvent.findUniqueOrThrow({
|
await expect(
|
||||||
where: {
|
db.inputEvent.findUniqueOrThrow({
|
||||||
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
|
where: {
|
||||||
},
|
requestId: `select-pool:${otherUserId}:${stableClientRequestId}:create`,
|
||||||
});
|
},
|
||||||
expect(stableEvent).toMatchObject({
|
})
|
||||||
|
).resolves.toMatchObject({
|
||||||
status: 'SUCCEEDED',
|
status: 'SUCCEEDED',
|
||||||
attempts: 1,
|
attempts: 1,
|
||||||
actorUserId: otherUserId,
|
actorUserId: otherUserId,
|
||||||
processingGameTick: expect.anything(),
|
payload: {
|
||||||
|
acceptedGameAt: expect.any(String),
|
||||||
|
acceptedGameTick: expect.any(Number),
|
||||||
|
},
|
||||||
});
|
});
|
||||||
expect(stableEvent.payload).not.toHaveProperty('acceptedGameAt');
|
|
||||||
expect(stableEvent.payload).not.toHaveProperty('acceptedGameTick');
|
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
|
|
||||||
it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => {
|
it('rolls back a hard failure and retries the same ENGINE event exactly once', async () => {
|
||||||
@@ -664,50 +660,4 @@ integration('scenario 903 select pool through the durable turn daemon', () => {
|
|||||||
error: null,
|
error: null,
|
||||||
});
|
});
|
||||||
}, 30_000);
|
}, 30_000);
|
||||||
it.each(['SUSPENDED', 'COMPLETED'])(
|
|
||||||
'creates from the selection pool while %s',
|
|
||||||
async (phase) => {
|
|
||||||
await runtime!.lifecycle.stop('prepare frozen pool creation');
|
|
||||||
await daemonLoop;
|
|
||||||
await runtime!.close();
|
|
||||||
const original = await db.worldState.findFirstOrThrow();
|
|
||||||
await db.worldState.update({ where: { id: original.id }, data: { clockPhase: phase } });
|
|
||||||
await db.gameHistory.update({ where: { serverId: profile }, data: { status: 'COMPLETED' } });
|
|
||||||
runtime = await createTurnDaemonRuntime({
|
|
||||||
profile,
|
|
||||||
databaseUrl: databaseUrl!,
|
|
||||||
enableDatabaseFlush: true,
|
|
||||||
enableLeaseHeartbeat: false,
|
|
||||||
leaseOwnerId: `frozen-pool-${phase}`,
|
|
||||||
});
|
|
||||||
turnDaemon = new DatabaseTurnDaemonTransport(db, 10_000);
|
|
||||||
daemonLoop = runtime.lifecycle.start();
|
|
||||||
await turnDaemon.requestStatus(10_000);
|
|
||||||
const visitorId = `pool-visitor-${phase}`;
|
|
||||||
const visitorAuth = { ...auth, user: { ...auth.user, id: visitorId } };
|
|
||||||
const readRecords = async () => ({
|
|
||||||
hall: await db.hallOfFame.count({ where: { serverId: profile } }),
|
|
||||||
old: await db.oldGeneral.count({ where: { serverId: profile } }),
|
|
||||||
points: await db.inheritancePoint.findMany({ where: { userId: visitorId } }),
|
|
||||||
results: await db.inheritanceResult.count({ where: { serverId: profile } }),
|
|
||||||
});
|
|
||||||
const before = await readRecords();
|
|
||||||
const candidates = await appRouter
|
|
||||||
.createCaller(buildContext(`pool-reserve-${phase}`, visitorAuth))
|
|
||||||
.join.getSelectionPool();
|
|
||||||
const result = await appRouter
|
|
||||||
.createCaller(buildContext(`pool-create-${phase}`, visitorAuth))
|
|
||||||
.join.selectPoolGeneral({ uniqueName: candidates.candidates[0]!.uniqueName, personality: 'che_안전' });
|
|
||||||
expect(await db.general.findUniqueOrThrow({ where: { id: result.generalId } })).toMatchObject({
|
|
||||||
userId: visitorId,
|
|
||||||
});
|
|
||||||
expect(await readRecords()).toEqual(before);
|
|
||||||
expect(await db.worldState.findUniqueOrThrow({ where: { id: original.id } })).toMatchObject({
|
|
||||||
clockPhase: phase,
|
|
||||||
clockTick: original.clockTick,
|
|
||||||
lastTurnTick: original.lastTurnTick,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
30_000
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,232 +0,0 @@
|
|||||||
import { it, expect } from 'vitest';
|
|
||||||
import {
|
|
||||||
createGamePostgresConnector,
|
|
||||||
createRedisConnector,
|
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
|
||||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
|
||||||
} from '@sammo-ts/infra';
|
|
||||||
import { appRouter } from '../src/router.js';
|
|
||||||
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
|
|
||||||
import { buildTournamentKeys } from '../src/tournament/keys.js';
|
|
||||||
import type { GameApiContext } from '../src/context.js';
|
|
||||||
|
|
||||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
|
||||||
const integration = it.skipIf(!databaseUrl || !process.env.REDIS_URL);
|
|
||||||
|
|
||||||
integration.each([
|
|
||||||
'setState',
|
|
||||||
'patchState',
|
|
||||||
'setParticipants',
|
|
||||||
'setMatches',
|
|
||||||
'setBettingEntries',
|
|
||||||
'seedParticipants',
|
|
||||||
'cancel',
|
|
||||||
])(
|
|
||||||
'serializes %s behind a joining user without holding the ENGINE clock lock',
|
|
||||||
async (adminAction) => {
|
|
||||||
const url = databaseUrl!;
|
|
||||||
const connector = createGamePostgresConnector({ url });
|
|
||||||
const redisConnector = createRedisConnector({ url: process.env.REDIS_URL! });
|
|
||||||
await connector.connect();
|
|
||||||
await redisConnector.connect();
|
|
||||||
const db = connector.prisma;
|
|
||||||
const redis = redisConnector.client;
|
|
||||||
const profileName = 'che:tournament-lock-integration';
|
|
||||||
const keys = buildTournamentKeys(profileName);
|
|
||||||
const redisKeys = [...Object.values(keys), `${keys.stateKey}:mutation-lock`];
|
|
||||||
const prefix = 'integration:tournament-lock:';
|
|
||||||
const nextAt = new Date().toISOString();
|
|
||||||
let notifyJoin: () => void;
|
|
||||||
const joinAtDaemon = new Promise<void>((resolve) => {
|
|
||||||
notifyJoin = resolve;
|
|
||||||
});
|
|
||||||
let notifyAdmin: () => void;
|
|
||||||
const adminAtRedis = new Promise<void>((resolve) => {
|
|
||||||
notifyAdmin = resolve;
|
|
||||||
});
|
|
||||||
const transport = new DatabaseTurnDaemonTransport(db, 4_000);
|
|
||||||
try {
|
|
||||||
await redis.del(redisKeys);
|
|
||||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: prefix } } });
|
|
||||||
await db.worldState.deleteMany({ where: { id: -991900 } });
|
|
||||||
await db.general.deleteMany({ where: { id: 991900 } });
|
|
||||||
await db.turnDaemonLease.deleteMany({ where: { profile: profileName } });
|
|
||||||
await db.turnDaemonLease.create({
|
|
||||||
data: {
|
|
||||||
profile: profileName,
|
|
||||||
ownerId: 'audit',
|
|
||||||
leaseUntil: new Date(Date.now() + 60000),
|
|
||||||
clockReady: true,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await db.worldState.create({
|
|
||||||
data: {
|
|
||||||
id: -991900,
|
|
||||||
scenarioCode: 'audit',
|
|
||||||
currentYear: 200,
|
|
||||||
currentMonth: 1,
|
|
||||||
tickSeconds: 60,
|
|
||||||
clockBaseTime: new Date(),
|
|
||||||
clockTick: 0n,
|
|
||||||
clockWallAnchor: new Date(),
|
|
||||||
clockPhase: 'RUNNING',
|
|
||||||
clockRevision: 1n,
|
|
||||||
config: { const: { develCost: 10 } },
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await db.general.create({
|
|
||||||
data: { id: 991900, userId: 'audit-user', name: 'audit', turnTime: new Date(), gold: 1000 },
|
|
||||||
});
|
|
||||||
await redis.set(
|
|
||||||
keys.stateKey,
|
|
||||||
JSON.stringify({
|
|
||||||
stage: 1,
|
|
||||||
phase: 0,
|
|
||||||
type: 0,
|
|
||||||
auto: false,
|
|
||||||
openYear: 200,
|
|
||||||
openMonth: 1,
|
|
||||||
termSeconds: 60,
|
|
||||||
nextAt,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const base: Partial<GameApiContext> = {
|
|
||||||
db,
|
|
||||||
redis,
|
|
||||||
profile: { id: 'che', scenario: 'tournament-lock-integration', name: profileName },
|
|
||||||
auth: {
|
|
||||||
version: 1,
|
|
||||||
profile: profileName,
|
|
||||||
issuedAt: new Date().toISOString(),
|
|
||||||
expiresAt: '2999-01-01T00:00:00Z',
|
|
||||||
sessionId: 'audit',
|
|
||||||
user: { id: 'audit-user', username: 'audit', displayName: 'audit', roles: [] },
|
|
||||||
sanctions: {},
|
|
||||||
},
|
|
||||||
turnDaemon: {
|
|
||||||
sendCommand: transport.sendCommand.bind(transport),
|
|
||||||
requestStatus: transport.requestStatus.bind(transport),
|
|
||||||
requestCommand: async (command) => {
|
|
||||||
notifyJoin!();
|
|
||||||
await adminAtRedis;
|
|
||||||
const requestId = await transport.sendCommand(command);
|
|
||||||
return db.$transaction(async (tx) => {
|
|
||||||
// 이전 순서에서는 관리자가 DB lock을 보유하여 별도 ENGINE 연결이 막힌다.
|
|
||||||
await tx.$executeRawUnsafe("SET LOCAL lock_timeout = '500ms'");
|
|
||||||
await acquireGameSchemaAdvisoryXactLock(tx, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
|
||||||
if (command.type !== 'adjustGeneralResources') throw new Error('unexpected command');
|
|
||||||
const result = {
|
|
||||||
type: 'adjustGeneralResources' as const,
|
|
||||||
ok: true as const,
|
|
||||||
processed: 1,
|
|
||||||
missing: 0,
|
|
||||||
totalGoldDelta: -10,
|
|
||||||
totalRiceDelta: 0,
|
|
||||||
};
|
|
||||||
await tx.inputEvent.update({ where: { requestId }, data: { status: 'SUCCEEDED', result } });
|
|
||||||
return result;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const joinContext = { ...base, requestId: `${prefix}join` } as GameApiContext;
|
|
||||||
const adminRedis = new Proxy(redis, {
|
|
||||||
get(target, property) {
|
|
||||||
if (property === 'set')
|
|
||||||
return async (...args: Parameters<typeof redis.set>) => {
|
|
||||||
if (args[0] === `${keys.stateKey}:mutation-lock`) notifyAdmin!();
|
|
||||||
return redis.set(...args);
|
|
||||||
};
|
|
||||||
const value = Reflect.get(target, property);
|
|
||||||
return typeof value === 'function' ? value.bind(target) : value;
|
|
||||||
},
|
|
||||||
});
|
|
||||||
const adminContext = {
|
|
||||||
...base,
|
|
||||||
requestId: `${prefix}admin`,
|
|
||||||
redis: adminRedis,
|
|
||||||
auth: { ...base.auth!, user: { ...base.auth!.user, roles: ['admin'] } },
|
|
||||||
} as GameApiContext;
|
|
||||||
const callAdmin = async (context: GameApiContext) => {
|
|
||||||
const caller = appRouter.createCaller(context).tournament;
|
|
||||||
switch (adminAction) {
|
|
||||||
case 'setState':
|
|
||||||
return caller.setState({
|
|
||||||
stage: 1,
|
|
||||||
phase: 0,
|
|
||||||
type: 0,
|
|
||||||
auto: false,
|
|
||||||
openYear: 200,
|
|
||||||
openMonth: 1,
|
|
||||||
termSeconds: 60,
|
|
||||||
nextAt,
|
|
||||||
});
|
|
||||||
case 'patchState':
|
|
||||||
return caller.patchState({ auto: true });
|
|
||||||
case 'setParticipants':
|
|
||||||
return caller.setParticipants([]);
|
|
||||||
case 'setMatches':
|
|
||||||
return caller.setMatches([]);
|
|
||||||
case 'setBettingEntries':
|
|
||||||
return caller.setBettingEntries([]);
|
|
||||||
case 'seedParticipants':
|
|
||||||
return caller.seedParticipants({ generalIds: [991900] });
|
|
||||||
default:
|
|
||||||
return caller.cancel();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
const join = appRouter.createCaller(joinContext).tournament.join();
|
|
||||||
const joinOutcome = join.then(
|
|
||||||
(value) => ({ ok: true, value }),
|
|
||||||
(error) => ({ ok: false, error })
|
|
||||||
);
|
|
||||||
await joinAtDaemon;
|
|
||||||
const admin = callAdmin(adminContext);
|
|
||||||
const adminOutcome = admin.then(
|
|
||||||
(value) => ({ ok: true, value }),
|
|
||||||
(error) => ({ ok: false, error })
|
|
||||||
);
|
|
||||||
await adminAtRedis;
|
|
||||||
const [joined, managed] = await Promise.all([joinOutcome, adminOutcome]);
|
|
||||||
expect(joined).toMatchObject({ ok: true, value: { ok: true, count: 1 } });
|
|
||||||
expect(managed).toMatchObject({ ok: true, value: { ok: true } });
|
|
||||||
const revision = await redis.get(keys.sourceRevisionKey);
|
|
||||||
const engineCount = await db.inputEvent.count({
|
|
||||||
where: { requestId: { startsWith: prefix }, target: 'ENGINE' },
|
|
||||||
});
|
|
||||||
// API 입력 원장은 유지한다. 동일 요청 재실행은 Redis/환불 command를 다시 쓰지 않는다.
|
|
||||||
await expect(callAdmin(adminContext)).resolves.toMatchObject({ ok: true });
|
|
||||||
expect(await redis.get(keys.sourceRevisionKey)).toBe(revision);
|
|
||||||
expect(await db.inputEvent.count({ where: { requestId: { startsWith: prefix }, target: 'ENGINE' } })).toBe(
|
|
||||||
engineCount
|
|
||||||
);
|
|
||||||
const inputEvent = await db.inputEvent.findUniqueOrThrow({
|
|
||||||
where: { requestId: `${prefix}admin:tournament.${adminAction}` },
|
|
||||||
});
|
|
||||||
expect(inputEvent).toMatchObject({ target: 'API', status: 'SUCCEEDED', attempts: 1 });
|
|
||||||
expect(await redis.get(`${keys.stateKey}:mutation-lock`)).toBeNull();
|
|
||||||
const retryContext = { ...adminContext, requestId: `${prefix}retry` };
|
|
||||||
const beforeFailure = await redis.get(keys.sourceRevisionKey);
|
|
||||||
await db.worldState.update({ where: { id: -991900 }, data: { clockPhase: 'SUSPENDED' } });
|
|
||||||
await expect(callAdmin(retryContext)).rejects.toMatchObject({ code: 'PRECONDITION_FAILED' });
|
|
||||||
expect(await redis.get(`${keys.stateKey}:mutation-lock`)).toBeNull();
|
|
||||||
expect(await redis.get(keys.sourceRevisionKey)).toBe(beforeFailure);
|
|
||||||
await db.worldState.update({ where: { id: -991900 }, data: { clockPhase: 'RUNNING' } });
|
|
||||||
await expect(callAdmin(retryContext)).resolves.toMatchObject({ ok: true });
|
|
||||||
expect(
|
|
||||||
await db.inputEvent.findUniqueOrThrow({
|
|
||||||
where: { requestId: `${prefix}retry:tournament.${adminAction}` },
|
|
||||||
})
|
|
||||||
).toMatchObject({ status: 'SUCCEEDED', attempts: 2 });
|
|
||||||
} finally {
|
|
||||||
await redis.del(redisKeys);
|
|
||||||
await db.inputEvent.deleteMany({ where: { requestId: { startsWith: prefix } } });
|
|
||||||
await db.general.deleteMany({ where: { id: 991900 } });
|
|
||||||
await db.worldState.deleteMany({ where: { id: -991900 } });
|
|
||||||
await db.turnDaemonLease.deleteMany({ where: { profile: profileName } });
|
|
||||||
await redisConnector.disconnect();
|
|
||||||
await connector.disconnect();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
15000
|
|
||||||
);
|
|
||||||
@@ -1,4 +1,4 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
|
import type { TurnDaemonCommand, TurnDaemonCommandResult } from '@sammo-ts/common';
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
@@ -30,31 +30,11 @@ class MemoryRedis {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> {
|
async eval(_script: string, options: { keys: string[]; arguments: string[] }): Promise<string> {
|
||||||
if (options.keys.length === 3 && options.keys[0]?.endsWith(':clock:active-revision')) {
|
const [valueKey, revisionKey] = options.keys;
|
||||||
const current = options.keys.map((key) => this.values.get(key));
|
const [value] = options.arguments;
|
||||||
if (current.every((value) => value === undefined)) {
|
|
||||||
options.keys.forEach((key, index) => this.values.set(key, options.arguments[index]!));
|
|
||||||
return '1';
|
|
||||||
}
|
|
||||||
return current.every((value, index) => value === options.arguments[index]) ? '2' : '0';
|
|
||||||
}
|
|
||||||
const fenced = options.keys.at(-1)?.endsWith(':clock:phase') === true;
|
|
||||||
const writeCount = options.keys.length - (fenced ? 4 : 1);
|
|
||||||
if (fenced) {
|
|
||||||
const clockKeys = options.keys.slice(-3);
|
|
||||||
const expected = options.arguments.slice(-3);
|
|
||||||
if (!clockKeys.every((key, index) => this.values.get(key) === expected[index])) {
|
|
||||||
return '__CLOCK_FENCE__';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const valueKey = options.keys[0];
|
|
||||||
const revisionKey = options.keys[writeCount];
|
|
||||||
const value = options.arguments[0];
|
|
||||||
if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments');
|
if (!valueKey || !revisionKey || value === undefined) throw new Error('invalid eval arguments');
|
||||||
const revision = Number(this.values.get(revisionKey) ?? '0') + 1;
|
const revision = Number(this.values.get(revisionKey) ?? '0') + 1;
|
||||||
for (let index = 0; index < writeCount; index += 1) {
|
this.values.set(valueKey, value);
|
||||||
this.values.set(options.keys[index]!, options.arguments[index]!);
|
|
||||||
}
|
|
||||||
this.values.set(revisionKey, String(revision));
|
this.values.set(revisionKey, String(revision));
|
||||||
return String(revision);
|
return String(revision);
|
||||||
}
|
}
|
||||||
@@ -151,8 +131,6 @@ const buildContext = (options: {
|
|||||||
develCost?: number;
|
develCost?: number;
|
||||||
currentDevelCost?: number;
|
currentDevelCost?: number;
|
||||||
rankRows?: Array<{ generalId: number; type: string; value: number }>;
|
rankRows?: Array<{ generalId: number; type: string; value: number }>;
|
||||||
clockPhase?: 'PREOPEN' | 'RUNNING' | 'MANUAL' | 'SUSPENDED' | 'RECONCILING';
|
|
||||||
requestId?: string;
|
|
||||||
}): GameApiContext => {
|
}): GameApiContext => {
|
||||||
const db = {
|
const db = {
|
||||||
general: {
|
general: {
|
||||||
@@ -166,21 +144,12 @@ const buildContext = (options: {
|
|||||||
},
|
},
|
||||||
worldState: {
|
worldState: {
|
||||||
findFirst: async () => ({
|
findFirst: async () => ({
|
||||||
clockBaseTime: new Date('2026-01-01T00:00:00.000Z'),
|
|
||||||
clockTick: 0n,
|
|
||||||
clockMode: 'realtime',
|
|
||||||
clockWallAnchor: new Date('2026-01-01T00:00:00.000Z'),
|
|
||||||
clockPhase: options.clockPhase ?? 'RUNNING',
|
|
||||||
clockRevision: 1n,
|
|
||||||
deadlineGeneration: 1n,
|
|
||||||
tickSeconds: 60,
|
|
||||||
config: { const: { develCost: options.develCost ?? 200 } },
|
config: { const: { develCost: options.develCost ?? 200 } },
|
||||||
...(options.currentDevelCost === undefined ? {} : { meta: { develcost: options.currentDevelCost } }),
|
...(options.currentDevelCost === undefined ? {} : { meta: { develcost: options.currentDevelCost } }),
|
||||||
}),
|
}),
|
||||||
},
|
},
|
||||||
} as unknown as DatabaseClient;
|
} as unknown as DatabaseClient;
|
||||||
return {
|
return {
|
||||||
requestId: options.requestId,
|
|
||||||
db,
|
db,
|
||||||
redis: options.redis as unknown as RedisConnector['client'],
|
redis: options.redis as unknown as RedisConnector['client'],
|
||||||
turnDaemon: options.transport,
|
turnDaemon: options.transport,
|
||||||
@@ -277,33 +246,15 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
nextAt: '2026-07-26T01:00:00.000Z',
|
nextAt: '2026-07-26T01:00:00.000Z',
|
||||||
});
|
});
|
||||||
await redis.set('sammo:che:default:tournament:participants', '[]');
|
await redis.set('sammo:che:default:tournament:participants', '[]');
|
||||||
const context = buildContext({
|
const caller = appRouter.createCaller(
|
||||||
redis,
|
buildContext({ redis, transport, generals: [general], userId: 'user-1', develCost: 200 })
|
||||||
transport,
|
);
|
||||||
generals: [general],
|
|
||||||
userId: 'user-1',
|
|
||||||
develCost: 200,
|
|
||||||
requestId: 'http:tournament-join',
|
|
||||||
});
|
|
||||||
const outerApiTransaction = vi.fn(async () => {
|
|
||||||
throw new Error('tournament join must not hold an API transaction while waiting for the daemon');
|
|
||||||
});
|
|
||||||
Object.assign(context.db, { $transaction: outerApiTransaction });
|
|
||||||
const caller = appRouter.createCaller(context);
|
|
||||||
|
|
||||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||||
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
await expect(caller.tournament.join()).resolves.toEqual({ ok: true, count: 1 });
|
||||||
|
|
||||||
expect(transport.gold.get(general.id)).toBe(1_800);
|
expect(transport.gold.get(general.id)).toBe(1_800);
|
||||||
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
|
expect(transport.commands.filter((command) => command.type === 'adjustGeneralResources')).toHaveLength(1);
|
||||||
expect(transport.commands).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
type: 'adjustGeneralResources',
|
|
||||||
requestId: 'http:tournament-join:tournamentJoin:resources',
|
|
||||||
reason: 'tournamentJoin',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
|
||||||
expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0);
|
expect(transport.commands.filter((command) => command.type === 'setMySetting')).toHaveLength(0);
|
||||||
const snapshot = await caller.tournament.getSnapshot();
|
const snapshot = await caller.tournament.getSnapshot();
|
||||||
expect(snapshot.participants).toHaveLength(1);
|
expect(snapshot.participants).toHaveLength(1);
|
||||||
@@ -390,83 +341,6 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
expect(transport.gold.get(general.id)).toBe(2_400);
|
expect(transport.gold.get(general.id)).toBe(2_400);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('accepts a tournament bet against the frozen game deadline while the clock is suspended', async () => {
|
|
||||||
const redis = new MemoryRedis();
|
|
||||||
const transport = new TournamentTransport();
|
|
||||||
const general = buildGeneral(1, 'user-1', 3_000);
|
|
||||||
transport.gold.set(general.id, general.gold);
|
|
||||||
await setTournamentFixture(redis, {
|
|
||||||
stage: 6,
|
|
||||||
phase: 0,
|
|
||||||
type: 0,
|
|
||||||
auto: true,
|
|
||||||
openYear: 193,
|
|
||||||
openMonth: 1,
|
|
||||||
termSeconds: 60,
|
|
||||||
nextAt: '2026-07-26T01:00:00.000Z',
|
|
||||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
const context = buildContext({
|
|
||||||
redis,
|
|
||||||
transport,
|
|
||||||
generals: [general],
|
|
||||||
userId: 'user-1',
|
|
||||||
clockPhase: 'SUSPENDED',
|
|
||||||
requestId: 'http:suspended-tournament-bet',
|
|
||||||
});
|
|
||||||
const outerApiTransaction = vi.fn(async () => {
|
|
||||||
throw new Error('tournament bet must not hold an API transaction while waiting for the daemon');
|
|
||||||
});
|
|
||||||
Object.assign(context.db, { $transaction: outerApiTransaction });
|
|
||||||
const caller = appRouter.createCaller(context);
|
|
||||||
|
|
||||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 600 })).resolves.toEqual({ ok: true });
|
|
||||||
expect(transport.gold.get(general.id)).toBe(2_400);
|
|
||||||
expect((await caller.tournament.getBettingSummary()).myAmount).toBe(600);
|
|
||||||
expect(transport.commands).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
type: 'adjustGeneralResources',
|
|
||||||
requestId: 'http:suspended-tournament-bet:tournamentBet:resources',
|
|
||||||
reason: 'tournamentBet',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
expect(outerApiTransaction).not.toHaveBeenCalled();
|
|
||||||
expect(transport.commands).toContainEqual(
|
|
||||||
expect.objectContaining({
|
|
||||||
type: 'adjustGeneralMeta',
|
|
||||||
requestId: 'http:suspended-tournament-bet:tournamentBet:rank',
|
|
||||||
reason: 'tournamentBet',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('rejects a tournament bet during reconciliation without debiting gold', async () => {
|
|
||||||
const redis = new MemoryRedis();
|
|
||||||
const transport = new TournamentTransport();
|
|
||||||
const general = buildGeneral(1, 'user-1', 3_000);
|
|
||||||
transport.gold.set(general.id, general.gold);
|
|
||||||
await setTournamentFixture(redis, {
|
|
||||||
stage: 6,
|
|
||||||
phase: 0,
|
|
||||||
type: 0,
|
|
||||||
auto: true,
|
|
||||||
openYear: 193,
|
|
||||||
openMonth: 1,
|
|
||||||
termSeconds: 60,
|
|
||||||
nextAt: '2026-07-26T01:00:00.000Z',
|
|
||||||
bettingCloseAt: '2099-01-01T00:00:00.000Z',
|
|
||||||
});
|
|
||||||
const caller = appRouter.createCaller(
|
|
||||||
buildContext({ redis, transport, generals: [general], userId: 'user-1', clockPhase: 'RECONCILING' })
|
|
||||||
);
|
|
||||||
|
|
||||||
await expect(caller.tournament.placeBet({ targetId: 11, amount: 600 })).rejects.toMatchObject({
|
|
||||||
code: 'PRECONDITION_FAILED',
|
|
||||||
});
|
|
||||||
expect(transport.gold.get(general.id)).toBe(3_000);
|
|
||||||
expect(transport.commands).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('keeps another user from reading my bet identity and requires that user to own a general', async () => {
|
it('keeps another user from reading my bet identity and requires that user to own a general', async () => {
|
||||||
const redis = new MemoryRedis();
|
const redis = new MemoryRedis();
|
||||||
const transport = new TournamentTransport();
|
const transport = new TournamentTransport();
|
||||||
@@ -520,10 +394,7 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
roles: ['admin.tournament:che:default'],
|
roles: ['admin.tournament:che:default'],
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
await expect(adminCaller.tournament.getAdminStatus()).resolves.toMatchObject({
|
await expect(adminCaller.tournament.getAdminStatus()).resolves.toEqual({ ok: true });
|
||||||
ok: true,
|
|
||||||
clock: { reconciliationComplete: false, latestReconciliation: null },
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('applies the admin role boundary to every tournament mutation', async () => {
|
it('applies the admin role boundary to every tournament mutation', async () => {
|
||||||
@@ -608,7 +479,9 @@ describe('tournament router permissions and mutations', () => {
|
|||||||
])
|
])
|
||||||
).resolves.toEqual({ ok: true, count: 1 });
|
).resolves.toEqual({ ok: true, count: 1 });
|
||||||
await expect(
|
await expect(
|
||||||
caller.tournament.setBettingEntries([{ generalId: general.id, targetId: rival.id, amount: 100 }])
|
caller.tournament.setBettingEntries([
|
||||||
|
{ generalId: general.id, targetId: rival.id, amount: 100 },
|
||||||
|
])
|
||||||
).resolves.toEqual({ ok: true, count: 1 });
|
).resolves.toEqual({ ok: true, count: 1 });
|
||||||
await expect(caller.tournament.seedParticipants({ generalIds: [general.id, rival.id] })).resolves.toEqual({
|
await expect(caller.tournament.seedParticipants({ generalIds: [general.id, rival.id] })).resolves.toEqual({
|
||||||
ok: true,
|
ok: true,
|
||||||
|
|||||||
@@ -41,9 +41,6 @@ integration('TournamentStore Redis source revision', () => {
|
|||||||
keys.matchesKey,
|
keys.matchesKey,
|
||||||
keys.bettingKey,
|
keys.bettingKey,
|
||||||
keys.sourceRevisionKey,
|
keys.sourceRevisionKey,
|
||||||
keys.activeClockRevisionKey,
|
|
||||||
keys.deadlineGenerationKey,
|
|
||||||
keys.clockPhaseKey,
|
|
||||||
]);
|
]);
|
||||||
if (subscriber) {
|
if (subscriber) {
|
||||||
await subscriber.client.unsubscribe(keys.sourceRevisionChannel);
|
await subscriber.client.unsubscribe(keys.sourceRevisionChannel);
|
||||||
@@ -127,48 +124,4 @@ integration('TournamentStore Redis source revision', () => {
|
|||||||
}),
|
}),
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('dual-writes deadline ticks and rejects a Redis clock revision race atomically', async () => {
|
|
||||||
const store = new TournamentStore(connector.client, keys);
|
|
||||||
await Promise.all([
|
|
||||||
connector.client.set(keys.activeClockRevisionKey, '7'),
|
|
||||||
connector.client.set(keys.deadlineGenerationKey, '3'),
|
|
||||||
connector.client.set(keys.clockPhaseKey, 'RUNNING'),
|
|
||||||
]);
|
|
||||||
const clockContext = {
|
|
||||||
phase: 'RUNNING' as const,
|
|
||||||
revision: 7,
|
|
||||||
deadlineGeneration: 3,
|
|
||||||
dateToTick: (date: Date) => Math.trunc(date.getTime() / 1_000),
|
|
||||||
};
|
|
||||||
const nextAt = '2026-09-03T10:00:00.000Z';
|
|
||||||
await store.withClockContext(clockContext, () =>
|
|
||||||
store.setState({
|
|
||||||
stage: 6,
|
|
||||||
phase: 0,
|
|
||||||
type: 0,
|
|
||||||
auto: true,
|
|
||||||
openYear: 200,
|
|
||||||
openMonth: 1,
|
|
||||||
termSeconds: 10,
|
|
||||||
nextAt,
|
|
||||||
bettingCloseAt: '2026-09-03T09:59:50.000Z',
|
|
||||||
})
|
|
||||||
);
|
|
||||||
await expect(store.getState()).resolves.toMatchObject({
|
|
||||||
nextTick: Math.trunc(new Date(nextAt).getTime() / 1_000),
|
|
||||||
bettingCloseTick: Math.trunc(new Date('2026-09-03T09:59:50.000Z').getTime() / 1_000),
|
|
||||||
clockRevision: 7,
|
|
||||||
deadlineGeneration: 3,
|
|
||||||
});
|
|
||||||
|
|
||||||
const beforeRevision = await store.getSourceRevision();
|
|
||||||
await connector.client.set(keys.activeClockRevisionKey, '8');
|
|
||||||
await expect(
|
|
||||||
store.withClockContext(clockContext, () =>
|
|
||||||
store.setMatches([{ id: 99, stage: 7, roundIndex: 0, attackerId: 1, defenderId: 2 }])
|
|
||||||
)
|
|
||||||
).rejects.toThrow('clock revision fence failed');
|
|
||||||
await expect(store.getSourceRevision()).resolves.toBe(beforeRevision);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -282,18 +282,6 @@ describe('troop router permissions and mutations', () => {
|
|||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
it('shows all five visible turns as assembly for a managed NPC troop leader', async () => {
|
|
||||||
const fixture = buildContext({
|
|
||||||
me: buildGeneral({ troopId: 1, npcState: 5, meta: { killturn: 70 } }),
|
|
||||||
turns: [{ generalId: 1, turnIdx: 0, actionCode: '휴식' }],
|
|
||||||
result: null,
|
|
||||||
});
|
|
||||||
|
|
||||||
const result = await appRouter.createCaller(fixture.context).troop.getList();
|
|
||||||
|
|
||||||
expect(result.troops[0]?.reservedCommands).toEqual(['집합', '집합', '집합', '집합', '집합']);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('creates a troop only for the general owned by the authenticated user', async () => {
|
it('creates a troop only for the general owned by the authenticated user', async () => {
|
||||||
const { context, requestCommand } = buildContext({
|
const { context, requestCommand } = buildContext({
|
||||||
result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' },
|
result: { type: 'troopCreate', ok: true, generalId: 1, troopId: 1, troopName: '백마대' },
|
||||||
|
|||||||
@@ -5,8 +5,10 @@ import { CachedTurnEngineStatus, loadTurnEngineRunning } from '../src/services/t
|
|||||||
describe('turn engine status projection', () => {
|
describe('turn engine status projection', () => {
|
||||||
it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => {
|
it('maps Gateway profile capabilities and keeps unavailable status unknown', async () => {
|
||||||
const activeLease = {
|
const activeLease = {
|
||||||
$queryRaw: vi.fn(async () => [{ running: true }]),
|
turnDaemonLease: {
|
||||||
} as any;
|
findUnique: vi.fn(async () => ({ leaseUntil: new Date('2026-08-24T00:01:00.000Z') })),
|
||||||
|
},
|
||||||
|
};
|
||||||
const now = new Date('2026-08-24T00:00:00.000Z');
|
const now = new Date('2026-08-24T00:00:00.000Z');
|
||||||
await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe(
|
await expect(loadTurnEngineRunning({ get: async () => 'RUNNING' }, activeLease, 'che:default', now)).resolves.toBe(
|
||||||
true
|
true
|
||||||
@@ -34,7 +36,7 @@ describe('turn engine status projection', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
loadTurnEngineRunning(
|
loadTurnEngineRunning(
|
||||||
source,
|
source,
|
||||||
{ $queryRaw: async () => [{ running: false }] } as any,
|
{ turnDaemonLease: { findUnique: async () => null } },
|
||||||
'che:default',
|
'che:default',
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
@@ -42,7 +44,11 @@ describe('turn engine status projection', () => {
|
|||||||
await expect(
|
await expect(
|
||||||
loadTurnEngineRunning(
|
loadTurnEngineRunning(
|
||||||
source,
|
source,
|
||||||
{ $queryRaw: async () => [{ running: false }] } as any,
|
{
|
||||||
|
turnDaemonLease: {
|
||||||
|
findUnique: async () => ({ leaseUntil: new Date('2026-08-23T23:59:59.999Z') }),
|
||||||
|
},
|
||||||
|
},
|
||||||
'che:default',
|
'che:default',
|
||||||
now
|
now
|
||||||
)
|
)
|
||||||
@@ -52,10 +58,10 @@ describe('turn engine status projection', () => {
|
|||||||
it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => {
|
it('coalesces concurrent heartbeat reads and refreshes after the bounded cache window', async () => {
|
||||||
let now = 1_000;
|
let now = 1_000;
|
||||||
const get = vi.fn(async () => 'RUNNING' as const);
|
const get = vi.fn(async () => 'RUNNING' as const);
|
||||||
const queryRaw = vi.fn(async () => [{ running: true }]);
|
const findUnique = vi.fn(async () => ({ leaseUntil: new Date('2099-01-01T00:00:00.000Z') }));
|
||||||
const cache = new CachedTurnEngineStatus(
|
const cache = new CachedTurnEngineStatus(
|
||||||
{ get },
|
{ get },
|
||||||
{ $queryRaw: queryRaw } as any,
|
{ turnDaemonLease: { findUnique } },
|
||||||
'che:default',
|
'che:default',
|
||||||
2_000,
|
2_000,
|
||||||
() => now
|
() => now
|
||||||
@@ -69,6 +75,6 @@ describe('turn engine status projection', () => {
|
|||||||
now += 1;
|
now += 1;
|
||||||
await expect(cache.get()).resolves.toBe(true);
|
await expect(cache.get()).resolves.toBe(true);
|
||||||
expect(get).toHaveBeenCalledTimes(2);
|
expect(get).toHaveBeenCalledTimes(2);
|
||||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
expect(findUnique).toHaveBeenCalledTimes(2);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -1,15 +1,9 @@
|
|||||||
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
import { afterAll, beforeAll, describe, expect, it } from 'vitest';
|
||||||
|
|
||||||
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
import type { GameSessionTokenPayload } from '@sammo-ts/common/auth/gameToken';
|
||||||
import {
|
import { createGamePostgresConnector, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
|
||||||
createGamePostgresConnector,
|
|
||||||
tryGameSchemaAdvisoryXactLock,
|
|
||||||
type GamePrismaClient,
|
|
||||||
} from '@sammo-ts/infra';
|
|
||||||
|
|
||||||
import type { GameApiContext } from '../src/context.js';
|
import type { GameApiContext } from '../src/context.js';
|
||||||
import { DatabaseTurnDaemonTransport } from '../src/daemon/databaseTransport.js';
|
|
||||||
import { appRouter } from '../src/router.js';
|
import { appRouter } from '../src/router.js';
|
||||||
|
|
||||||
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
const databaseUrl = process.env.INPUT_EVENT_DATABASE_URL;
|
||||||
@@ -85,63 +79,6 @@ integration('vote comment operational timestamp', () => {
|
|||||||
await closeDb?.();
|
await closeDb?.();
|
||||||
});
|
});
|
||||||
|
|
||||||
it('lets a separate ENGINE transaction claim the clock fence while submitVote waits', async () => {
|
|
||||||
const requestId = 'integration:vote-comment-timestamp:submit';
|
|
||||||
const engineRequestId = `${requestId}:vote.submitVote:engine:0:voteReward`;
|
|
||||||
const transport = new DatabaseTurnDaemonTransport(db, 2_000);
|
|
||||||
const context: Partial<GameApiContext> = {
|
|
||||||
requestId,
|
|
||||||
db,
|
|
||||||
auth,
|
|
||||||
profile: { id: 'che', scenario: 'vote-comment-timestamp', name: 'che:vote-comment-timestamp' },
|
|
||||||
turnDaemon: {
|
|
||||||
sendCommand: transport.sendCommand.bind(transport),
|
|
||||||
requestStatus: transport.requestStatus.bind(transport),
|
|
||||||
requestCommand: async (command) => {
|
|
||||||
// 실제 DB transport의 durable 접수와 별도 connection의 clock fence를
|
|
||||||
// 검증한다. 보상 계산 자체는 voteReward suite가 검증한다.
|
|
||||||
const acceptedId = await transport.sendCommand(command);
|
|
||||||
expect(acceptedId).toBe(engineRequestId);
|
|
||||||
await db.$transaction(async (transaction) => {
|
|
||||||
expect(await tryGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK)).toBe(
|
|
||||||
true
|
|
||||||
);
|
|
||||||
const event = await transaction.inputEvent.findUniqueOrThrow({
|
|
||||||
where: { requestId: acceptedId },
|
|
||||||
});
|
|
||||||
expect(event).toMatchObject({
|
|
||||||
target: 'ENGINE',
|
|
||||||
status: 'PENDING',
|
|
||||||
actorUserId: fixtureUserId,
|
|
||||||
});
|
|
||||||
await transaction.inputEvent.update({
|
|
||||||
where: { requestId: acceptedId },
|
|
||||||
data: {
|
|
||||||
status: 'SUCCEEDED',
|
|
||||||
result: {
|
|
||||||
type: 'voteReward',
|
|
||||||
ok: true,
|
|
||||||
voteId: fixtureId,
|
|
||||||
generalId: fixtureId,
|
|
||||||
awardedUnique: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
return transport.requestCommand(command);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
const caller = appRouter.createCaller(context as GameApiContext);
|
|
||||||
|
|
||||||
await expect(caller.vote.submitVote({ voteId: fixtureId, selection: [0] })).resolves.toEqual({
|
|
||||||
ok: true,
|
|
||||||
wonLottery: false,
|
|
||||||
});
|
|
||||||
expect(await db.inputEvent.count({ where: { requestId: `${requestId}:vote.submitVote` } })).toBe(0);
|
|
||||||
expect(await db.inputEvent.count({ where: { requestId: engineRequestId } })).toBe(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => {
|
it('stores current writers and rollback-compatible vote defaults as UTC wall time in KST', async () => {
|
||||||
const [session] = await db.$queryRaw<Array<{ timeZone: string }>>`
|
const [session] = await db.$queryRaw<Array<{ timeZone: string }>>`
|
||||||
SELECT current_setting('TIMEZONE') AS "timeZone"
|
SELECT current_setting('TIMEZONE') AS "timeZone"
|
||||||
|
|||||||
@@ -216,40 +216,6 @@ const buildContext = (options: {
|
|||||||
};
|
};
|
||||||
|
|
||||||
describe('vote router actor and permission boundaries', () => {
|
describe('vote router actor and permission boundaries', () => {
|
||||||
it('waits for the ENGINE vote without holding an outer API transaction', async () => {
|
|
||||||
const fixture = buildContext({ requestId: 'vote-boundary' });
|
|
||||||
const transaction = vi.fn(async () => {
|
|
||||||
throw new Error('API transaction blocks the vote ENGINE clock fence');
|
|
||||||
});
|
|
||||||
Object.assign(fixture.context.db, { $transaction: transaction });
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
|
|
||||||
).resolves.toEqual({ ok: true, wonLottery: false });
|
|
||||||
expect(transaction).not.toHaveBeenCalled();
|
|
||||||
expect(fixture.requestCommand).toHaveBeenCalledWith(
|
|
||||||
expect.objectContaining({
|
|
||||||
requestId: 'vote-boundary:vote.submitVote:engine:0:voteReward',
|
|
||||||
userId: 'user-1',
|
|
||||||
generalId: 7,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
{ auth: null, code: 'UNAUTHORIZED' },
|
|
||||||
{ auth: { ...buildAuth(), sanctions: { bannedUntil: '2999-01-01T00:00:00Z' } }, code: 'FORBIDDEN' },
|
|
||||||
])(
|
|
||||||
'rejects voting before dispatch when authentication or sanctions disallow access: %j',
|
|
||||||
async ({ auth, code }) => {
|
|
||||||
const fixture = buildContext({ auth });
|
|
||||||
await expect(
|
|
||||||
appRouter.createCaller(fixture.context).vote.submitVote({ voteId: 1, selection: [0] })
|
|
||||||
).rejects.toMatchObject({ code });
|
|
||||||
expect(fixture.requestCommand).not.toHaveBeenCalled();
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => {
|
it('keeps a poll open at its exact Ref end tick and closes it after that tick', () => {
|
||||||
const now = new Date('2026-07-26T00:00:00Z');
|
const now = new Date('2026-07-26T00:00:00Z');
|
||||||
const time = {
|
const time = {
|
||||||
@@ -264,7 +230,13 @@ describe('vote router actor and permission boundaries', () => {
|
|||||||
|
|
||||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 100n }, time)).toBe(false);
|
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 100n }, time)).toBe(false);
|
||||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 99n }, time)).toBe(true);
|
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: 99n }, time)).toBe(true);
|
||||||
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(true);
|
expect(hasPollEnded({ closed_at: null, end_at: now, end_tick: null }, { ...time, tick: null })).toBe(false);
|
||||||
|
expect(
|
||||||
|
hasPollEnded(
|
||||||
|
{ closed_at: null, end_at: now, end_tick: null },
|
||||||
|
{ ...time, now: new Date(now.getTime() + 1), tick: null }
|
||||||
|
)
|
||||||
|
).toBe(true);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('rejects unauthenticated survey access', async () => {
|
it('rejects unauthenticated survey access', async () => {
|
||||||
@@ -289,11 +261,12 @@ describe('vote router actor and permission boundaries', () => {
|
|||||||
voteId: 1,
|
voteId: 1,
|
||||||
generalId: 7,
|
generalId: 7,
|
||||||
selection: [0],
|
selection: [0],
|
||||||
|
acceptedGameTick: 100,
|
||||||
});
|
});
|
||||||
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
|
expect(fixture.queryRaw.mock.calls.some(([query]) => sqlText(query).includes('INSERT INTO vote ('))).toBe(
|
||||||
false
|
false
|
||||||
);
|
);
|
||||||
expect(fixture.changeJournal.snapshot()).toEqual([]);
|
expect(fixture.changeJournal.snapshot()).toEqual([{ domain: 'front.general', entityId: 7 }]);
|
||||||
expect(fixture.redisIncr).not.toHaveBeenCalled();
|
expect(fixture.redisIncr).not.toHaveBeenCalled();
|
||||||
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
expect(fixture.redisPublish).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
@@ -328,6 +301,8 @@ describe('vote router actor and permission boundaries', () => {
|
|||||||
const auth = buildAuth(['admin.survey.open']);
|
const auth = buildAuth(['admin.survey.open']);
|
||||||
const fixture = buildContext({ auth });
|
const fixture = buildContext({ auth });
|
||||||
const caller = appRouter.createCaller(fixture.context);
|
const caller = appRouter.createCaller(fixture.context);
|
||||||
|
const windowStart = Date.now();
|
||||||
|
|
||||||
await expect(caller.vote.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true });
|
await expect(caller.vote.addComment({ voteId: 1, text: '시각 댓글' })).resolves.toEqual({ ok: true });
|
||||||
await expect(
|
await expect(
|
||||||
caller.vote.createPoll({
|
caller.vote.createPoll({
|
||||||
@@ -339,6 +314,8 @@ describe('vote router actor and permission boundaries', () => {
|
|||||||
).resolves.toEqual({ ok: true });
|
).resolves.toEqual({ ok: true });
|
||||||
await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).resolves.toEqual({ ok: true });
|
await expect(caller.vote.updatePoll({ voteId: 1, title: '시각 설문 수정' })).resolves.toEqual({ ok: true });
|
||||||
await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true });
|
await expect(caller.vote.closePoll({ voteId: 1 })).resolves.toEqual({ ok: true });
|
||||||
|
const windowEnd = Date.now();
|
||||||
|
|
||||||
const mutationQueries = fixture.queryRaw.mock.calls
|
const mutationQueries = fixture.queryRaw.mock.calls
|
||||||
.map(([query]) => query)
|
.map(([query]) => query)
|
||||||
.filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(query)));
|
.filter((query) => /INSERT INTO vote_comment|INSERT INTO vote_poll|UPDATE vote_poll/.test(sqlText(query)));
|
||||||
@@ -348,24 +325,30 @@ describe('vote router actor and permission boundaries', () => {
|
|||||||
const closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL'));
|
const closePreviousUpdate = pollUpdates.find((query) => sqlText(query).includes('WHERE closed_at IS NULL'));
|
||||||
const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE'));
|
const editPollUpdate = pollUpdates.find((query) => sqlText(query).includes('title = COALESCE'));
|
||||||
const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id'));
|
const closePollUpdate = pollUpdates.find((query) => sqlText(query).includes('RETURNING id'));
|
||||||
const expectDbWallClock = (query: GamePrisma.Sql | undefined): void => {
|
const expectCurrentDateAt = (query: GamePrisma.Sql | undefined, index: number): Date => {
|
||||||
expect(query).toBeDefined();
|
expect(query).toBeDefined();
|
||||||
expect(sqlText(query!)).toContain("CURRENT_TIMESTAMP AT TIME ZONE 'UTC'");
|
const value = query?.values.at(index);
|
||||||
|
expect(value).toBeInstanceOf(Date);
|
||||||
|
expect((value as Date).getTime()).toBeGreaterThanOrEqual(windowStart);
|
||||||
|
expect((value as Date).getTime()).toBeLessThanOrEqual(windowEnd);
|
||||||
|
return value as Date;
|
||||||
};
|
};
|
||||||
|
|
||||||
expect(sqlText(commentInsert!)).toContain('created_at');
|
expect(sqlText(commentInsert!)).toContain('created_at');
|
||||||
expectDbWallClock(commentInsert);
|
expectCurrentDateAt(commentInsert, -1);
|
||||||
expect(sqlText(pollInsert!)).toContain('created_at');
|
expect(sqlText(pollInsert!)).toContain('created_at');
|
||||||
expect(sqlText(pollInsert!)).toContain('updated_at');
|
expect(sqlText(pollInsert!)).toContain('updated_at');
|
||||||
expectDbWallClock(pollInsert);
|
const pollCreatedAt = expectCurrentDateAt(pollInsert, -2);
|
||||||
|
const pollUpdatedAt = expectCurrentDateAt(pollInsert, -1);
|
||||||
|
expect(pollUpdatedAt).toBe(pollCreatedAt);
|
||||||
|
|
||||||
expect(pollUpdates).toHaveLength(3);
|
expect(pollUpdates).toHaveLength(3);
|
||||||
expect(sqlText(closePreviousUpdate!)).toContain('updated_at');
|
expect(sqlText(closePreviousUpdate!)).toContain('updated_at');
|
||||||
expectDbWallClock(closePreviousUpdate);
|
expect(expectCurrentDateAt(closePreviousUpdate, -1)).toBe(pollCreatedAt);
|
||||||
expect(sqlText(editPollUpdate!)).toContain('updated_at');
|
expect(sqlText(editPollUpdate!)).toContain('updated_at');
|
||||||
expectDbWallClock(editPollUpdate);
|
expectCurrentDateAt(editPollUpdate, -2);
|
||||||
expect(sqlText(closePollUpdate!)).toContain('updated_at');
|
expect(sqlText(closePollUpdate!)).toContain('updated_at');
|
||||||
expectDbWallClock(closePollUpdate);
|
expectCurrentDateAt(closePollUpdate, -2);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('reports the current world develcost as the legacy five-times survey reward', async () => {
|
it('reports the current world develcost as the legacy five-times survey reward', async () => {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import { describe, expect, it, vi } from 'vitest';
|
import { describe, expect, it, vi } from 'vitest';
|
||||||
|
|
||||||
import type { GameApiContext } from '../src/context.js';
|
import type { GameApiContext } from '../src/context.js';
|
||||||
import { loadWorldMap, buildRevisionedBaseMapCacheKey, resolveMapUniqueItemLimit } from '../src/maps/worldMap.js';
|
import { loadWorldMap, buildRevisionedBaseMapCacheKey } from '../src/maps/worldMap.js';
|
||||||
import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js';
|
import { readMapWorldSourceRevision } from '../src/maps/worldMapSourceRevision.js';
|
||||||
|
|
||||||
const revisionRow = (overrides: Record<string, unknown> = {}) => ({
|
const revisionRow = (overrides: Record<string, unknown> = {}) => ({
|
||||||
@@ -18,14 +18,16 @@ describe('world map revision cache', () => {
|
|||||||
db: { $queryRaw: queryRaw },
|
db: { $queryRaw: queryRaw },
|
||||||
} as unknown as GameApiContext;
|
} as unknown as GameApiContext;
|
||||||
|
|
||||||
await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe('sammo:map:base:hwe:scenario_2400:pg12');
|
await expect(buildRevisionedBaseMapCacheKey(ctx)).resolves.toBe(
|
||||||
|
'sammo:map:base:hwe:scenario_2400:pg12'
|
||||||
|
);
|
||||||
await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe(
|
await expect(buildRevisionedBaseMapCacheKey(ctx, 'public')).resolves.toBe(
|
||||||
'sammo:map:public:hwe:scenario_2400:pg12'
|
'sammo:map:public:hwe:scenario_2400:pg12'
|
||||||
);
|
);
|
||||||
expect(queryRaw).toHaveBeenCalledTimes(2);
|
expect(queryRaw).toHaveBeenCalledTimes(2);
|
||||||
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
|
const statement = queryRaw.mock.calls[0]?.[0] as { sql: string; values: unknown[] };
|
||||||
expect(statement.sql).toContain('read_model_revision_meta');
|
expect(statement.sql).toContain('read_model_revision_meta');
|
||||||
expect(statement.sql).toContain('revision."domain" = \'map.world\'');
|
expect(statement.sql).toContain("revision.\"domain\" = 'map.world'");
|
||||||
expect(statement.values).toEqual([]);
|
expect(statement.values).toEqual([]);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -96,7 +98,6 @@ describe('world map revision cache', () => {
|
|||||||
const first = await loadWorldMap(ctx, { generalId: 7, useCache: true });
|
const first = await loadWorldMap(ctx, { generalId: 7, useCache: true });
|
||||||
const second = await loadWorldMap(ctx, { generalId: 8, useCache: true });
|
const second = await loadWorldMap(ctx, { generalId: 8, useCache: true });
|
||||||
|
|
||||||
expect(first?.uniqueItemLimit).toEqual({ count: 0, until: null });
|
|
||||||
expect(first).toMatchObject({ myCity: 3, myNation: 2, spyList: { 5: 9 } });
|
expect(first).toMatchObject({ myCity: 3, myNation: 2, spyList: { 5: 9 } });
|
||||||
expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } });
|
expect(second).toMatchObject({ myCity: 4, myNation: 3, spyList: { 6: 8 } });
|
||||||
expect(redis.set).toHaveBeenCalledTimes(1);
|
expect(redis.set).toHaveBeenCalledTimes(1);
|
||||||
@@ -105,12 +106,6 @@ describe('world map revision cache', () => {
|
|||||||
expect(shared).not.toHaveProperty('shownByGeneralList');
|
expect(shared).not.toHaveProperty('shownByGeneralList');
|
||||||
expect(shared).not.toHaveProperty('myCity');
|
expect(shared).not.toHaveProperty('myCity');
|
||||||
expect(shared).not.toHaveProperty('myNation');
|
expect(shared).not.toHaveProperty('myNation');
|
||||||
// 배포 전 캐시는 새 안내 필드를 포함하도록 DB에서 다시 만든다.
|
|
||||||
delete shared.uniqueItemLimit;
|
|
||||||
cache.set(cache.keys().next().value!, JSON.stringify(shared));
|
|
||||||
const refreshed = await loadWorldMap(ctx, { generalId: 7, useCache: true });
|
|
||||||
expect(refreshed?.uniqueItemLimit).toEqual({ count: 0, until: null });
|
|
||||||
expect(redis.set).toHaveBeenCalledTimes(2);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('does not read or write Redis when PostgreSQL revision authority is unavailable', async () => {
|
it('does not read or write Redis when PostgreSQL revision authority is unavailable', async () => {
|
||||||
@@ -126,9 +121,7 @@ describe('world map revision cache', () => {
|
|||||||
redis,
|
redis,
|
||||||
db: {
|
db: {
|
||||||
$queryRaw: queryRaw,
|
$queryRaw: queryRaw,
|
||||||
worldState: {
|
worldState: { findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })) },
|
||||||
findFirst: vi.fn(async () => ({ currentYear: 185, currentMonth: 1, config: {}, meta: {} })),
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
} as unknown as GameApiContext;
|
} as unknown as GameApiContext;
|
||||||
|
|
||||||
@@ -137,49 +130,3 @@ describe('world map revision cache', () => {
|
|||||||
expect(redis.set).not.toHaveBeenCalled();
|
expect(redis.set).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
describe('map unique ownership limit', () => {
|
|
||||||
const state = (currentYear: number, constValues: Record<string, unknown> = {}) => ({
|
|
||||||
currentYear,
|
|
||||||
meta: { scenarioMeta: { startYear: 180 } },
|
|
||||||
config: { const: { allItems: { horse: {}, weapon: {}, book: {}, item: {} }, ...constValues } },
|
|
||||||
});
|
|
||||||
|
|
||||||
it.each([
|
|
||||||
[179, 1, 182],
|
|
||||||
[181, 1, 182],
|
|
||||||
[182, 1, 182],
|
|
||||||
[183, 2, 189],
|
|
||||||
[189, 2, 189],
|
|
||||||
[190, 3, 199],
|
|
||||||
[200, 4, null],
|
|
||||||
])('projects year %i and the inclusive last month', (year, count, untilYear) => {
|
|
||||||
expect(resolveMapUniqueItemLimit(state(year!))).toEqual({
|
|
||||||
count,
|
|
||||||
until: untilYear === null ? null : { year: untilYear, month: 12 },
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
it('uses custom thresholds, skips unchanged caps and respects the slot pool', () => {
|
|
||||||
expect(
|
|
||||||
resolveMapUniqueItemLimit(
|
|
||||||
state(181, {
|
|
||||||
maxUniqueItemLimit: [
|
|
||||||
[-1, 1],
|
|
||||||
[2, 1],
|
|
||||||
[4, 3],
|
|
||||||
[8, 4],
|
|
||||||
],
|
|
||||||
})
|
|
||||||
)
|
|
||||||
).toEqual({ count: 1, until: { year: 183, month: 12 } });
|
|
||||||
expect(
|
|
||||||
resolveMapUniqueItemLimit(
|
|
||||||
state(183, {
|
|
||||||
allItems: { horse: {}, weapon: {} },
|
|
||||||
})
|
|
||||||
)
|
|
||||||
).toEqual({ count: 2, until: null });
|
|
||||||
expect(resolveMapUniqueItemLimit(state(181, { allItems: {} }))).toEqual({ count: 0, until: null });
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|||||||
@@ -42,14 +42,6 @@
|
|||||||
"types": "./dist/turn/databaseHooks.d.ts",
|
"types": "./dist/turn/databaseHooks.d.ts",
|
||||||
"default": "./dist/turn/databaseHooks.js"
|
"default": "./dist/turn/databaseHooks.js"
|
||||||
},
|
},
|
||||||
"./turn/clockReconciliation.js": {
|
|
||||||
"types": "./dist/turn/clockReconciliation.d.ts",
|
|
||||||
"default": "./dist/turn/clockReconciliation.js"
|
|
||||||
},
|
|
||||||
"./turn/clockProjectionOutbox.js": {
|
|
||||||
"types": "./dist/turn/clockProjectionOutbox.d.ts",
|
|
||||||
"default": "./dist/turn/clockProjectionOutbox.js"
|
|
||||||
},
|
|
||||||
"./turn/inMemoryWorld.js": {
|
"./turn/inMemoryWorld.js": {
|
||||||
"types": "./dist/turn/inMemoryWorld.d.ts",
|
"types": "./dist/turn/inMemoryWorld.d.ts",
|
||||||
"default": "./dist/turn/inMemoryWorld.js"
|
"default": "./dist/turn/inMemoryWorld.js"
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
|
||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
|
|
||||||
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
@@ -61,27 +60,27 @@ export const hasAuctionClosePassed = (
|
|||||||
auction: { closeAt: Date; closeTick: bigint | null },
|
auction: { closeAt: Date; closeTick: bigint | null },
|
||||||
now: Date,
|
now: Date,
|
||||||
nowTick: number | null
|
nowTick: number | null
|
||||||
): boolean => {
|
): boolean =>
|
||||||
void now;
|
auction.closeTick !== null && nowTick !== null
|
||||||
return auction.closeTick === null || nowTick === null || auction.closeTick < BigInt(nowTick);
|
? auction.closeTick < BigInt(nowTick)
|
||||||
};
|
: auction.closeAt.getTime() < now.getTime();
|
||||||
|
|
||||||
export const resolveAuctionBidTiming = (
|
export const resolveAuctionBidTiming = (
|
||||||
world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
|
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
|
||||||
processingGameTick: number
|
processingNow: Date,
|
||||||
): { bidAt: Date; bidTick: number } => {
|
acceptedGameTick?: number
|
||||||
if (!Number.isSafeInteger(processingGameTick)) {
|
): { bidAt: Date; bidTick: number } =>
|
||||||
throw new Error('Auction bid requires an authoritative daemon processing game tick.');
|
acceptedGameTick === undefined
|
||||||
}
|
? { bidAt: processingNow, bidTick: world.dateToGameTick(processingNow) }
|
||||||
return { bidAt: world.gameTickToDate(processingGameTick), bidTick: processingGameTick };
|
: { bidAt: world.gameTickToDate(acceptedGameTick), bidTick: acceptedGameTick };
|
||||||
};
|
|
||||||
|
|
||||||
export const hasAuctionBidClosePassed = (
|
export const hasAuctionBidClosePassed = (
|
||||||
auction: { closeAt: Date; closeTick: bigint | null },
|
auction: { closeAt: Date; closeTick: bigint | null },
|
||||||
world: Pick<InMemoryTurnWorld, 'gameTickToDate'>,
|
world: Pick<InMemoryTurnWorld, 'dateToGameTick' | 'gameTickToDate'>,
|
||||||
processingGameTick: number
|
processingNow: Date,
|
||||||
|
acceptedGameTick?: number
|
||||||
): boolean => {
|
): boolean => {
|
||||||
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingGameTick);
|
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, acceptedGameTick);
|
||||||
return hasAuctionClosePassed(auction, bidAt, bidTick);
|
return hasAuctionClosePassed(auction, bidAt, bidTick);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -107,7 +106,7 @@ export const buildAuctionOutbidRefundMessage = (options: {
|
|||||||
nationId: options.bidder.nationId,
|
nationId: options.bidder.nationId,
|
||||||
nationName: options.nation?.name ?? '재야',
|
nationName: options.nation?.name ?? '재야',
|
||||||
color: options.nation?.color ?? '#000000',
|
color: options.nation?.color ?? '#000000',
|
||||||
icon: resolveMessageTargetIcon(options.bidder),
|
icon: options.bidder.picture ?? '',
|
||||||
},
|
},
|
||||||
text: `${options.auctionId}번 ${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`,
|
text: `${options.auctionId}번 ${options.title ?? '경매'}에 상회입찰자가 나타났습니다.`,
|
||||||
time: new Date(options.time.getTime()),
|
time: new Date(options.time.getTime()),
|
||||||
@@ -269,15 +268,8 @@ export const createAuctionBidder = async (options: {
|
|||||||
reason: '경매가 종료되었습니다.',
|
reason: '경매가 종료되었습니다.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
const convertedProcessingTick = Reflect.get(command, 'processingGameTick');
|
const processingNow = world.getGameNow(new Date());
|
||||||
if (typeof convertedProcessingTick !== 'number' || !Number.isSafeInteger(convertedProcessingTick)) {
|
const { bidAt, bidTick } = resolveAuctionBidTiming(world, processingNow, command.acceptedGameTick);
|
||||||
throw new Error('auctionBid requires an authoritative daemon processing game tick.');
|
|
||||||
}
|
|
||||||
const requestedAtWall = Reflect.get(command, 'requestedAtWall');
|
|
||||||
if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
|
|
||||||
throw new Error('auctionBid requires its durable input-event wall occurrence.');
|
|
||||||
}
|
|
||||||
const { bidAt, bidTick } = resolveAuctionBidTiming(world, convertedProcessingTick);
|
|
||||||
if (hasAuctionClosePassed(auction, bidAt, bidTick)) {
|
if (hasAuctionClosePassed(auction, bidAt, bidTick)) {
|
||||||
return {
|
return {
|
||||||
type: 'auctionBid',
|
type: 'auctionBid',
|
||||||
@@ -511,24 +503,13 @@ export const createAuctionBidder = async (options: {
|
|||||||
const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => {
|
const persistBid = async (tx: GamePrisma.TransactionClient): Promise<void> => {
|
||||||
await tx.$executeRaw(
|
await tx.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
INSERT INTO auction_bid (
|
INSERT INTO auction_bid (auction_id, general_id, amount, event_id, event_at, meta)
|
||||||
auction_id,
|
|
||||||
general_id,
|
|
||||||
amount,
|
|
||||||
event_id,
|
|
||||||
event_at,
|
|
||||||
occurred_game_tick,
|
|
||||||
requested_at_wall,
|
|
||||||
meta
|
|
||||||
)
|
|
||||||
VALUES (
|
VALUES (
|
||||||
${command.auctionId},
|
${command.auctionId},
|
||||||
${command.generalId},
|
${command.generalId},
|
||||||
${command.amount},
|
${command.amount},
|
||||||
${eventId},
|
${eventId},
|
||||||
${eventAt},
|
${eventAt},
|
||||||
${BigInt(bidTick)},
|
|
||||||
${requestedAtWall},
|
|
||||||
${JSON.stringify({
|
${JSON.stringify({
|
||||||
tryExtendCloseDate: command.tryExtendCloseDate ?? true,
|
tryExtendCloseDate: command.tryExtendCloseDate ?? true,
|
||||||
...(auction.type === 'UNIQUE_ITEM'
|
...(auction.type === 'UNIQUE_ITEM'
|
||||||
@@ -548,7 +529,7 @@ export const createAuctionBidder = async (options: {
|
|||||||
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
|
close_tick = ${BigInt(world.dateToGameTick(nextCloseAt))},
|
||||||
latest_event_id = ${eventId},
|
latest_event_id = ${eventId},
|
||||||
latest_event_at = ${eventAt},
|
latest_event_at = ${eventAt},
|
||||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
updated_at = ${eventAt}
|
||||||
WHERE id = ${command.auctionId}
|
WHERE id = ${command.auctionId}
|
||||||
AND status = 'OPEN'
|
AND status = 'OPEN'
|
||||||
AND latest_event_id = ${auction.latestEventId}
|
AND latest_event_id = ${auction.latestEventId}
|
||||||
@@ -568,7 +549,7 @@ export const createAuctionBidder = async (options: {
|
|||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
UPDATE inheritance_point
|
UPDATE inheritance_point
|
||||||
SET value = value - ${morePoint},
|
SET value = value - ${morePoint},
|
||||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
updated_at = ${eventAt}
|
||||||
WHERE user_id = ${userId}
|
WHERE user_id = ${userId}
|
||||||
AND key = 'previous'
|
AND key = 'previous'
|
||||||
AND value >= ${morePoint}
|
AND value >= ${morePoint}
|
||||||
@@ -599,16 +580,11 @@ export const createAuctionBidder = async (options: {
|
|||||||
await tx.$executeRaw(
|
await tx.$executeRaw(
|
||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
INSERT INTO inheritance_point (user_id, key, value, updated_at)
|
INSERT INTO inheritance_point (user_id, key, value, updated_at)
|
||||||
VALUES (
|
VALUES (${prevUserId}, 'previous', ${highestBid.amount}, ${eventAt})
|
||||||
${prevUserId},
|
|
||||||
'previous',
|
|
||||||
${highestBid.amount},
|
|
||||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
|
||||||
)
|
|
||||||
ON CONFLICT (user_id, key)
|
ON CONFLICT (user_id, key)
|
||||||
DO UPDATE SET
|
DO UPDATE SET
|
||||||
value = inheritance_point.value + EXCLUDED.value,
|
value = inheritance_point.value + EXCLUDED.value,
|
||||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
updated_at = EXCLUDED.updated_at
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
await tx.$executeRaw(
|
await tx.$executeRaw(
|
||||||
@@ -728,7 +704,6 @@ export const createAuctionBidder = async (options: {
|
|||||||
ok: true,
|
ok: true,
|
||||||
auctionId: command.auctionId,
|
auctionId: command.auctionId,
|
||||||
closeAt: nextCloseAt.toISOString(),
|
closeAt: nextCloseAt.toISOString(),
|
||||||
closeTick: world.dateToGameTick(nextCloseAt),
|
|
||||||
};
|
};
|
||||||
},
|
},
|
||||||
close: async (): Promise<void> => {
|
close: async (): Promise<void> => {
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { resolveMessageTargetIcon } from '@sammo-ts/logic';
|
|
||||||
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, GamePrisma } from '@sammo-ts/infra';
|
||||||
import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic';
|
import { ActionLogger, ItemLoader, LogFormat, isItemKey, type MessageDraft } from '@sammo-ts/logic';
|
||||||
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
import { resolveLegacyCompatibleUniqueConfig } from '@sammo-ts/logic/rewards/legacyUniqueItemPool.js';
|
||||||
@@ -92,17 +91,21 @@ export const isAuctionFinalizeGenerationCurrent = (
|
|||||||
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
||||||
command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'>
|
command: Pick<Extract<TurnDaemonCommand, { type: 'auctionFinalize' }>, 'expectedCloseAt' | 'expectedCloseTick'>
|
||||||
): boolean => {
|
): boolean => {
|
||||||
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
|
if (command.expectedCloseTick !== undefined) {
|
||||||
|
return auction.closeTick !== null && auction.closeTick === BigInt(command.expectedCloseTick);
|
||||||
|
}
|
||||||
|
if (command.expectedCloseAt !== undefined) {
|
||||||
|
return auction.closeAt.getTime() === new Date(command.expectedCloseAt).getTime();
|
||||||
|
}
|
||||||
|
return true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const hasAuctionFinalizeDeadlineArrived = (
|
export const hasAuctionFinalizeDeadlineArrived = (
|
||||||
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
auction: Pick<AuctionRow, 'closeAt' | 'closeTick'>,
|
||||||
now: Date,
|
now: Date,
|
||||||
nowTick: number
|
nowTick: number
|
||||||
): boolean => {
|
): boolean =>
|
||||||
void now;
|
auction.closeTick === null ? auction.closeAt.getTime() <= now.getTime() : auction.closeTick <= BigInt(nowTick);
|
||||||
return auction.closeTick !== null && auction.closeTick <= BigInt(nowTick);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const buildAuctionBidderSystemMessage = (options: {
|
export const buildAuctionBidderSystemMessage = (options: {
|
||||||
bidder: TurnGeneral;
|
bidder: TurnGeneral;
|
||||||
@@ -125,7 +128,7 @@ export const buildAuctionBidderSystemMessage = (options: {
|
|||||||
nationId: options.bidder.nationId,
|
nationId: options.bidder.nationId,
|
||||||
nationName: options.nation?.name ?? '재야',
|
nationName: options.nation?.name ?? '재야',
|
||||||
color: options.nation?.color ?? '#000000',
|
color: options.nation?.color ?? '#000000',
|
||||||
icon: resolveMessageTargetIcon(options.bidder),
|
icon: options.bidder.picture ?? '',
|
||||||
},
|
},
|
||||||
text: options.text,
|
text: options.text,
|
||||||
time: new Date(options.time.getTime()),
|
time: new Date(options.time.getTime()),
|
||||||
@@ -290,11 +293,7 @@ export const createAuctionFinalizer = async (options: {
|
|||||||
return { type: 'auctionFinalize', ok: true, auctionId };
|
return { type: 'auctionFinalize', ok: true, auctionId };
|
||||||
}
|
}
|
||||||
|
|
||||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
const now = world.getGameNow(new Date());
|
||||||
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
|
||||||
throw new Error('auctionFinalize requires an authoritative daemon processing game tick.');
|
|
||||||
}
|
|
||||||
const now = world.gameTickToDate(processingGameTick);
|
|
||||||
if (auction.status === 'OPEN') {
|
if (auction.status === 'OPEN') {
|
||||||
if (!isAuctionFinalizeGenerationCurrent(auction, command)) {
|
if (!isAuctionFinalizeGenerationCurrent(auction, command)) {
|
||||||
return {
|
return {
|
||||||
@@ -304,7 +303,8 @@ export const createAuctionFinalizer = async (options: {
|
|||||||
reason: '경매 마감 세대가 변경되었습니다.',
|
reason: '경매 마감 세대가 변경되었습니다.',
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
if (!hasAuctionFinalizeDeadlineArrived(auction, now, processingGameTick)) {
|
const nowTick = world.dateToGameTick(now);
|
||||||
|
if (!hasAuctionFinalizeDeadlineArrived(auction, now, nowTick)) {
|
||||||
return {
|
return {
|
||||||
type: 'auctionFinalize',
|
type: 'auctionFinalize',
|
||||||
ok: false,
|
ok: false,
|
||||||
@@ -316,8 +316,8 @@ export const createAuctionFinalizer = async (options: {
|
|||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
UPDATE auction
|
UPDATE auction
|
||||||
SET status = 'FINALIZING',
|
SET status = 'FINALIZING',
|
||||||
finalizing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
finalizing_at = ${now},
|
||||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
updated_at = ${now}
|
||||||
WHERE id = ${auctionId}
|
WHERE id = ${auctionId}
|
||||||
AND status = 'OPEN'
|
AND status = 'OPEN'
|
||||||
`
|
`
|
||||||
@@ -364,8 +364,8 @@ export const createAuctionFinalizer = async (options: {
|
|||||||
GamePrisma.sql`
|
GamePrisma.sql`
|
||||||
UPDATE auction
|
UPDATE auction
|
||||||
SET status = ${status},
|
SET status = ${status},
|
||||||
finished_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
finished_at = ${now},
|
||||||
updated_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
updated_at = ${now}
|
||||||
WHERE id = ${auctionId}
|
WHERE id = ${auctionId}
|
||||||
`
|
`
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -94,11 +94,7 @@ const openResourceAuction = async (
|
|||||||
return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`);
|
return fail(`기본 ${hostResource === 'rice' ? '쌀' : '금'} ${minimumResource}은 거래할 수 없습니다.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
const now = world.getGameNow(new Date());
|
||||||
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
|
||||||
throw new Error('auctionOpen requires an authoritative daemon processing game tick.');
|
|
||||||
}
|
|
||||||
const now = world.gameTickToDate(processingGameTick);
|
|
||||||
const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60));
|
const turnMinutes = Math.max(1, Math.round(world.getState().tickSeconds / 60));
|
||||||
const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
|
const closeAt = new Date(now.getTime() + closeTurnCnt * turnMinutes * 60_000);
|
||||||
const auction = await db.auction.create({
|
const auction = await db.auction.create({
|
||||||
@@ -117,7 +113,7 @@ const openResourceAuction = async (
|
|||||||
},
|
},
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
closeAt,
|
closeAt,
|
||||||
openTick: BigInt(processingGameTick),
|
openTick: BigInt(world.dateToGameTick(now)),
|
||||||
closeTick: BigInt(world.dateToGameTick(closeAt)),
|
closeTick: BigInt(world.dateToGameTick(closeAt)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -129,7 +125,6 @@ const openResourceAuction = async (
|
|||||||
ok: true,
|
ok: true,
|
||||||
auctionId: auction.id,
|
auctionId: auction.id,
|
||||||
closeAt: closeAt.toISOString(),
|
closeAt: closeAt.toISOString(),
|
||||||
closeTick: world.dateToGameTick(closeAt),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -225,16 +220,8 @@ const openUniqueAuction = async (
|
|||||||
}
|
}
|
||||||
|
|
||||||
const state = world.getState();
|
const state = world.getState();
|
||||||
const processingGameTick = Reflect.get(command, 'processingGameTick');
|
|
||||||
if (typeof processingGameTick !== 'number' || !Number.isSafeInteger(processingGameTick)) {
|
|
||||||
throw new Error('auctionOpen requires an authoritative daemon processing game tick.');
|
|
||||||
}
|
|
||||||
const requestedAtWall = Reflect.get(command, 'requestedAtWall');
|
|
||||||
if (!(requestedAtWall instanceof Date) || Number.isNaN(requestedAtWall.getTime())) {
|
|
||||||
throw new Error('auctionOpen requires its durable input-event wall occurrence.');
|
|
||||||
}
|
|
||||||
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
|
const turnMinutes = Math.max(1, Math.round(state.tickSeconds / 60));
|
||||||
const now = world.gameTickToDate(processingGameTick);
|
const now = world.getGameNow(new Date());
|
||||||
const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES);
|
const closeMinutes = Math.max(MIN_AUCTION_CLOSE_MINUTES, turnMinutes * COEFF_AUCTION_CLOSE_MINUTES);
|
||||||
const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
|
const closeAt = new Date(now.getTime() + closeMinutes * 60_000);
|
||||||
const extensionLimitMinutes = Math.max(
|
const extensionLimitMinutes = Math.max(
|
||||||
@@ -266,7 +253,7 @@ const openUniqueAuction = async (
|
|||||||
},
|
},
|
||||||
status: 'OPEN',
|
status: 'OPEN',
|
||||||
closeAt,
|
closeAt,
|
||||||
openTick: BigInt(processingGameTick),
|
openTick: BigInt(world.dateToGameTick(now)),
|
||||||
closeTick: BigInt(world.dateToGameTick(closeAt)),
|
closeTick: BigInt(world.dateToGameTick(closeAt)),
|
||||||
latestEventId: eventId,
|
latestEventId: eventId,
|
||||||
latestEventAt: now,
|
latestEventAt: now,
|
||||||
@@ -276,8 +263,6 @@ const openUniqueAuction = async (
|
|||||||
amount: command.amount,
|
amount: command.amount,
|
||||||
eventId,
|
eventId,
|
||||||
eventAt: now,
|
eventAt: now,
|
||||||
occurredGameTick: BigInt(processingGameTick),
|
|
||||||
requestedAtWall,
|
|
||||||
meta: buildInitialUniqueAuctionBidMeta(alias, command.amount),
|
meta: buildInitialUniqueAuctionBidMeta(alias, command.amount),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
@@ -323,7 +308,6 @@ const openUniqueAuction = async (
|
|||||||
ok: true,
|
ok: true,
|
||||||
auctionId: auction.id,
|
auctionId: auction.id,
|
||||||
closeAt: closeAt.toISOString(),
|
closeAt: closeAt.toISOString(),
|
||||||
closeTick: world.dateToGameTick(closeAt),
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
@@ -20,8 +20,6 @@ export * from './turn/engineStateManager.js';
|
|||||||
export * from './turn/inMemoryStateStore.js';
|
export * from './turn/inMemoryStateStore.js';
|
||||||
export * from './turn/inMemoryTurnProcessor.js';
|
export * from './turn/inMemoryTurnProcessor.js';
|
||||||
export * from './turn/databaseHooks.js';
|
export * from './turn/databaseHooks.js';
|
||||||
export * from './turn/clockReconciliation.js';
|
|
||||||
export * from './turn/clockProjectionOutbox.js';
|
|
||||||
export * from './turn/joinCreateGeneralService.js';
|
export * from './turn/joinCreateGeneralService.js';
|
||||||
export * from './turn/npcPossessionService.js';
|
export * from './turn/npcPossessionService.js';
|
||||||
export * from './turn/selectPoolService.js';
|
export * from './turn/selectPoolService.js';
|
||||||
|
|||||||
@@ -1,6 +1,5 @@
|
|||||||
import { randomUUID } from 'node:crypto';
|
import { randomUUID } from 'node:crypto';
|
||||||
import { performance } from 'node:perf_hooks';
|
import { GamePrisma, type GamePrismaClient } from '@sammo-ts/infra';
|
||||||
import { GamePrisma, readInputEventClockCoordinate, type GamePrismaClient } from '@sammo-ts/infra';
|
|
||||||
|
|
||||||
import { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js';
|
import { normalizeTurnDaemonCommand } from '../turn/commandRegistry.js';
|
||||||
import type {
|
import type {
|
||||||
@@ -11,9 +10,8 @@ import type {
|
|||||||
TurnDaemonStatus,
|
TurnDaemonStatus,
|
||||||
} from './types.js';
|
} from './types.js';
|
||||||
|
|
||||||
|
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
||||||
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
const delay = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
const serializeResult = (value: unknown): string =>
|
|
||||||
JSON.stringify(value, (_key, item: unknown) => (typeof item === 'bigint' ? item.toString() : item)) ?? 'null';
|
|
||||||
|
|
||||||
export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, TurnDaemonCommandResponder {
|
export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, TurnDaemonCommandResponder {
|
||||||
private readonly localQueue: TurnDaemonCommand[] = [];
|
private readonly localQueue: TurnDaemonCommand[] = [];
|
||||||
@@ -33,18 +31,12 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
|
|
||||||
async drain(): Promise<TurnDaemonCommand[]> {
|
async drain(): Promise<TurnDaemonCommand[]> {
|
||||||
const local = this.localQueue.splice(0, this.localQueue.length);
|
const local = this.localQueue.splice(0, this.localQueue.length);
|
||||||
// 종료 직후 실행하지 않을 명령을 PROCESSING으로 선점하면 새 daemon은
|
|
||||||
// lease 만료까지 기다려야 한다. 종료 batch에서는 DB 명령을 가져오지 않는다.
|
|
||||||
if (local.some((command) => command.type === 'shutdown')) {
|
|
||||||
return local;
|
|
||||||
}
|
|
||||||
const remote = await this.claimPending();
|
const remote = await this.claimPending();
|
||||||
return local.concat(remote);
|
return local.concat(remote);
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
|
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||||
const deadline = timeoutMs === null ? null : performance.now() + Math.max(0, timeoutMs);
|
while (deadlineMs === null || Date.now() < deadlineMs) {
|
||||||
while (deadline === null || performance.now() < deadline) {
|
|
||||||
const local = this.localQueue.shift();
|
const local = this.localQueue.shift();
|
||||||
if (local) {
|
if (local) {
|
||||||
return local;
|
return local;
|
||||||
@@ -53,7 +45,7 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
if (remote[0]) {
|
if (remote[0]) {
|
||||||
return remote[0];
|
return remote[0];
|
||||||
}
|
}
|
||||||
const remaining = deadline === null ? 100 : Math.max(1, Math.min(100, deadline - performance.now()));
|
const remaining = deadlineMs === null ? 100 : Math.max(1, Math.min(100, deadlineMs - Date.now()));
|
||||||
await delay(remaining);
|
await delay(remaining);
|
||||||
}
|
}
|
||||||
return null;
|
return null;
|
||||||
@@ -92,56 +84,30 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const terminal = event.attempts >= this.maxAttempts;
|
const terminal = event.attempts >= this.maxAttempts;
|
||||||
await transaction.$executeRaw(GamePrisma.sql`
|
await transaction.inputEvent.updateMany({
|
||||||
UPDATE input_event
|
where: {
|
||||||
SET status = ${terminal ? 'FAILED' : 'PENDING'}::"InputEventStatus",
|
requestId,
|
||||||
processing_at = NULL,
|
target: 'ENGINE',
|
||||||
locked_by = NULL,
|
status: 'PROCESSING',
|
||||||
lease_until = NULL,
|
lockedBy: this.workerId,
|
||||||
completed_at = CASE
|
attempts: event.attempts,
|
||||||
WHEN ${terminal} THEN CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
},
|
||||||
ELSE NULL
|
data: {
|
||||||
END,
|
status: terminal ? 'FAILED' : 'PENDING',
|
||||||
result = NULL,
|
processingAt: null,
|
||||||
error = ${message}
|
lockedBy: null,
|
||||||
WHERE request_id = ${requestId}
|
leaseUntil: null,
|
||||||
AND target = 'ENGINE'::"InputEventTarget"
|
completedAt: terminal ? new Date() : null,
|
||||||
AND status = 'PROCESSING'::"InputEventStatus"
|
result: GamePrisma.DbNull,
|
||||||
AND locked_by = ${this.workerId}
|
error: message,
|
||||||
AND attempts = ${event.attempts}
|
},
|
||||||
`);
|
});
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> {
|
private async claimPending(limit = 100): Promise<TurnDaemonCommand[]> {
|
||||||
await this.recoverExpiredLeases();
|
await this.recoverExpiredLeases();
|
||||||
return this.db.$transaction(async (transaction) => {
|
return this.db.$transaction(async (transaction) => {
|
||||||
const claimCoordinate = await readInputEventClockCoordinate(transaction);
|
|
||||||
const world = await transaction.worldState.findFirst({
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true, clockTick: true },
|
|
||||||
});
|
|
||||||
// 가오픈도 장수 생성·삭제·거병·예약 등 사용자 명령은 처리한다.
|
|
||||||
// 자동 턴의 RUNNING/MANUAL gate는 TurnDaemonLifecycle이 별도로 지킨다.
|
|
||||||
const gameplayAllowed =
|
|
||||||
!world ||
|
|
||||||
world.clockPhase === 'PREOPEN' ||
|
|
||||||
world.clockPhase === 'RUNNING' ||
|
|
||||||
world.clockPhase === 'MANUAL';
|
|
||||||
const suspendedTournamentBetCommand = world?.clockPhase === 'SUSPENDED';
|
|
||||||
const currentRevision = world?.clockRevision ?? null;
|
|
||||||
const maintenanceSuspended =
|
|
||||||
world?.clockPhase === 'SUSPENDED' &&
|
|
||||||
Boolean(
|
|
||||||
await transaction.clockSuspension.findFirst({
|
|
||||||
where: {
|
|
||||||
status: 'SUSPENDED',
|
|
||||||
source: 'MAINTENANCE',
|
|
||||||
sourceRevision: world.clockRevision,
|
|
||||||
},
|
|
||||||
select: { id: true },
|
|
||||||
})
|
|
||||||
);
|
|
||||||
const rows = await transaction.$queryRaw<
|
const rows = await transaction.$queryRaw<
|
||||||
Array<{
|
Array<{
|
||||||
sequence: bigint;
|
sequence: bigint;
|
||||||
@@ -149,9 +115,6 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
eventType: string;
|
eventType: string;
|
||||||
payload: unknown;
|
payload: unknown;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
acceptedGameTick: bigint | null;
|
|
||||||
acceptedClockRevision: bigint | null;
|
|
||||||
acceptedDeadlineGeneration: bigint | null;
|
|
||||||
}>
|
}>
|
||||||
>(GamePrisma.sql`
|
>(GamePrisma.sql`
|
||||||
SELECT
|
SELECT
|
||||||
@@ -159,78 +122,10 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
"request_id" AS "requestId",
|
"request_id" AS "requestId",
|
||||||
"event_type" AS "eventType",
|
"event_type" AS "eventType",
|
||||||
"payload",
|
"payload",
|
||||||
"created_at" AS "createdAt",
|
"created_at" AS "createdAt"
|
||||||
"accepted_game_tick" AS "acceptedGameTick",
|
|
||||||
"accepted_clock_revision" AS "acceptedClockRevision",
|
|
||||||
"accepted_deadline_generation" AS "acceptedDeadlineGeneration"
|
|
||||||
FROM "input_event"
|
FROM "input_event"
|
||||||
WHERE "target" = 'ENGINE'::"InputEventTarget"
|
WHERE "target" = 'ENGINE'::"InputEventTarget"
|
||||||
AND "status" = 'PENDING'::"InputEventStatus"
|
AND "status" = 'PENDING'::"InputEventStatus"
|
||||||
AND (
|
|
||||||
${gameplayAllowed}
|
|
||||||
OR "event_type" = 'getStatus'
|
|
||||||
OR (
|
|
||||||
${world?.clockPhase === 'SUSPENDED' || world?.clockPhase === 'COMPLETED'}
|
|
||||||
AND "event_type" IN (
|
|
||||||
'joinCreateGeneral', 'npcPossessGeneral',
|
|
||||||
'selectPoolReserve', 'selectPoolCreate', 'selectPoolReselect'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
OR (
|
|
||||||
${suspendedTournamentBetCommand}
|
|
||||||
AND "event_type" IN ('adjustGeneralResources', 'adjustGeneralMeta')
|
|
||||||
AND "payload" ->> 'reason' IN ('tournamentBet', 'tournamentBetRollback')
|
|
||||||
)
|
|
||||||
OR (
|
|
||||||
${maintenanceSuspended}
|
|
||||||
AND "event_type" IN (
|
|
||||||
'inheritanceAction',
|
|
||||||
'dropItem',
|
|
||||||
'changePermission',
|
|
||||||
'appoint',
|
|
||||||
'setNationSetting',
|
|
||||||
'setNpcPolicy',
|
|
||||||
'shiftSchedule'
|
|
||||||
)
|
|
||||||
)
|
|
||||||
OR (
|
|
||||||
${maintenanceSuspended}
|
|
||||||
AND "event_type" = 'messageRespond'
|
|
||||||
AND "payload" ->> 'messageId' ~ '^[1-9][0-9]*$'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM "message_action" AS pending_action
|
|
||||||
WHERE pending_action."message_id" = ("input_event"."payload" ->> 'messageId')::integer
|
|
||||||
AND pending_action."status" = 'PENDING'
|
|
||||||
AND pending_action."clock_revision" = ${currentRevision}
|
|
||||||
AND pending_action."deadline_generation" = ${world?.deadlineGeneration ?? null}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
OR (
|
|
||||||
${world?.clockPhase === 'SUSPENDED'}
|
|
||||||
AND "event_type" = 'messageRespond'
|
|
||||||
AND "payload" ->> 'messageId' ~ '^[1-9][0-9]*$'
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM "message" AS pending_message
|
|
||||||
JOIN "message_action" AS pending_action
|
|
||||||
ON pending_action."message_id" = pending_message."id"
|
|
||||||
WHERE pending_message."id" = ("input_event"."payload" ->> 'messageId')::integer
|
|
||||||
AND pending_message."message" #>> '{option,action}' = 'raiseInvader'
|
|
||||||
AND pending_action."action_type" = 'raiseInvader'
|
|
||||||
AND pending_action."status" = 'PENDING'
|
|
||||||
AND pending_action."clock_revision" = ${currentRevision}
|
|
||||||
AND pending_action."deadline_generation" = ${world?.deadlineGeneration ?? null}
|
|
||||||
)
|
|
||||||
AND EXISTS (
|
|
||||||
SELECT 1
|
|
||||||
FROM "clock_suspension" AS active_suspension
|
|
||||||
WHERE active_suspension."status" = 'SUSPENDED'
|
|
||||||
AND active_suspension."source" = 'UNIFICATION_WAIT'
|
|
||||||
AND active_suspension."source_revision" = ${currentRevision}
|
|
||||||
)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
ORDER BY "sequence" ASC
|
ORDER BY "sequence" ASC
|
||||||
FOR UPDATE SKIP LOCKED
|
FOR UPDATE SKIP LOCKED
|
||||||
LIMIT ${limit}
|
LIMIT ${limit}
|
||||||
@@ -238,93 +133,41 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
return [];
|
return [];
|
||||||
}
|
}
|
||||||
const appliedSuspensions = currentRevision
|
await transaction.inputEvent.updateMany({
|
||||||
? await transaction.clockSuspension.findMany({
|
where: {
|
||||||
where: { status: 'APPLIED', targetRevision: { lte: currentRevision } },
|
sequence: { in: rows.map((row) => row.sequence) },
|
||||||
orderBy: { sourceRevision: 'asc' },
|
target: 'ENGINE',
|
||||||
select: { sourceRevision: true, targetRevision: true, shiftTicks: true },
|
status: 'PENDING',
|
||||||
})
|
},
|
||||||
: [];
|
data: {
|
||||||
const convertTick = (row: (typeof rows)[number]): bigint | null | undefined => {
|
status: 'PROCESSING',
|
||||||
if (row.eventType === 'getStatus') return row.acceptedGameTick ?? claimCoordinate.gameTick;
|
processingAt: new Date(),
|
||||||
if (row.acceptedGameTick === null || row.acceptedClockRevision === null || currentRevision === null) {
|
lockedBy: this.workerId,
|
||||||
return claimCoordinate.gameTick;
|
leaseUntil: new Date(Date.now() + this.leaseDurationMs),
|
||||||
}
|
attempts: { increment: 1 },
|
||||||
if (row.acceptedClockRevision > currentRevision) return undefined;
|
},
|
||||||
let revision = row.acceptedClockRevision;
|
});
|
||||||
let tick = row.acceptedGameTick;
|
|
||||||
while (revision < currentRevision) {
|
|
||||||
const step = appliedSuspensions.find((entry) => entry.sourceRevision === revision);
|
|
||||||
if (!step || step.shiftTicks === null || step.targetRevision !== revision + 1n) return undefined;
|
|
||||||
tick += step.shiftTicks;
|
|
||||||
revision = step.targetRevision;
|
|
||||||
}
|
|
||||||
return tick;
|
|
||||||
};
|
|
||||||
const processableRows = rows
|
|
||||||
.map((row) => ({ row, processingGameTick: convertTick(row) }))
|
|
||||||
.filter(
|
|
||||||
(entry): entry is { row: (typeof rows)[number]; processingGameTick: bigint | null } =>
|
|
||||||
entry.processingGameTick !== undefined
|
|
||||||
);
|
|
||||||
for (const { row, processingGameTick } of processableRows) {
|
|
||||||
await transaction.$executeRaw(GamePrisma.sql`
|
|
||||||
UPDATE input_event
|
|
||||||
SET status = 'PROCESSING'::"InputEventStatus",
|
|
||||||
processing_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
|
||||||
accepted_game_tick = COALESCE(accepted_game_tick, ${processingGameTick}),
|
|
||||||
accepted_clock_revision = COALESCE(accepted_clock_revision, ${currentRevision}),
|
|
||||||
accepted_deadline_generation = COALESCE(
|
|
||||||
accepted_deadline_generation,
|
|
||||||
${world?.deadlineGeneration ?? null}
|
|
||||||
),
|
|
||||||
processing_game_tick = ${processingGameTick},
|
|
||||||
processing_clock_revision = ${currentRevision},
|
|
||||||
processing_deadline_generation = ${world?.deadlineGeneration ?? null},
|
|
||||||
locked_by = ${this.workerId},
|
|
||||||
lease_until = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
|
||||||
+ ${this.leaseDurationMs} * INTERVAL '1 millisecond',
|
|
||||||
attempts = attempts + 1
|
|
||||||
WHERE sequence = ${row.sequence}
|
|
||||||
`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const commands: TurnDaemonCommand[] = [];
|
const commands: TurnDaemonCommand[] = [];
|
||||||
for (const { row, processingGameTick } of processableRows) {
|
for (const row of rows) {
|
||||||
const command = normalizeTurnDaemonCommand({
|
const command = normalizeTurnDaemonCommand({
|
||||||
requestId: row.requestId,
|
requestId: row.requestId,
|
||||||
sentAt: row.createdAt.toISOString(),
|
sentAt: row.createdAt.toISOString(),
|
||||||
command: row.payload as TurnDaemonCommand,
|
command: row.payload as TurnDaemonCommand,
|
||||||
});
|
});
|
||||||
if (!command) {
|
if (!command) {
|
||||||
await transaction.$executeRaw(GamePrisma.sql`
|
await transaction.inputEvent.update({
|
||||||
UPDATE input_event
|
where: { sequence: row.sequence },
|
||||||
SET status = 'FAILED'::"InputEventStatus",
|
data: {
|
||||||
error = ${`Invalid command payload for ${row.eventType}`},
|
status: 'FAILED',
|
||||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
error: `Invalid command payload for ${row.eventType}`,
|
||||||
locked_by = NULL,
|
completedAt: new Date(),
|
||||||
lease_until = NULL
|
lockedBy: null,
|
||||||
WHERE sequence = ${row.sequence}
|
leaseUntil: null,
|
||||||
`);
|
},
|
||||||
|
});
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (processingGameTick !== null) {
|
|
||||||
const value = Number(processingGameTick);
|
|
||||||
if (!Number.isSafeInteger(value)) {
|
|
||||||
await transaction.$executeRaw(GamePrisma.sql`
|
|
||||||
UPDATE input_event
|
|
||||||
SET status = 'FAILED'::"InputEventStatus",
|
|
||||||
error = 'Converted processing game tick is outside the safe integer range.',
|
|
||||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
|
||||||
locked_by = NULL,
|
|
||||||
lease_until = NULL
|
|
||||||
WHERE sequence = ${row.sequence}
|
|
||||||
`);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
Reflect.set(command, 'processingGameTick', value);
|
|
||||||
}
|
|
||||||
Reflect.set(command, 'requestedAtWall', row.createdAt);
|
|
||||||
commands.push(command);
|
commands.push(command);
|
||||||
}
|
}
|
||||||
return commands;
|
return commands;
|
||||||
@@ -332,20 +175,23 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async complete(requestId: string, result: unknown): Promise<void> {
|
private async complete(requestId: string, result: unknown): Promise<void> {
|
||||||
const completed = await this.db.$executeRaw(GamePrisma.sql`
|
const completed = await this.db.inputEvent.updateMany({
|
||||||
UPDATE input_event
|
where: {
|
||||||
SET status = 'SUCCEEDED'::"InputEventStatus",
|
requestId,
|
||||||
result = CAST(${serializeResult(result)} AS jsonb),
|
target: 'ENGINE',
|
||||||
completed_at = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
status: 'PROCESSING',
|
||||||
error = NULL,
|
lockedBy: this.workerId,
|
||||||
locked_by = NULL,
|
},
|
||||||
lease_until = NULL
|
data: {
|
||||||
WHERE request_id = ${requestId}
|
status: 'SUCCEEDED',
|
||||||
AND target = 'ENGINE'::"InputEventTarget"
|
result: asJson(result),
|
||||||
AND status = 'PROCESSING'::"InputEventStatus"
|
completedAt: new Date(),
|
||||||
AND locked_by = ${this.workerId}
|
error: null,
|
||||||
`);
|
lockedBy: null,
|
||||||
if (completed > 0) {
|
leaseUntil: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
if (completed.count > 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// Database hooks commit mutation results atomically with game state and
|
// Database hooks commit mutation results atomically with game state and
|
||||||
@@ -366,15 +212,19 @@ export class DatabaseTurnDaemonCommandQueue implements TurnDaemonControlQueue, T
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async recoverExpiredLeases(): Promise<void> {
|
private async recoverExpiredLeases(): Promise<void> {
|
||||||
await this.db.$executeRaw(GamePrisma.sql`
|
const now = new Date();
|
||||||
UPDATE input_event
|
await this.db.inputEvent.updateMany({
|
||||||
SET status = 'PENDING'::"InputEventStatus",
|
where: {
|
||||||
processing_at = NULL,
|
target: 'ENGINE',
|
||||||
locked_by = NULL,
|
status: 'PROCESSING',
|
||||||
lease_until = NULL
|
leaseUntil: { lt: now },
|
||||||
WHERE target = 'ENGINE'::"InputEventTarget"
|
},
|
||||||
AND status = 'PROCESSING'::"InputEventStatus"
|
data: {
|
||||||
AND lease_until < CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
status: 'PENDING',
|
||||||
`);
|
processingAt: null,
|
||||||
|
lockedBy: null,
|
||||||
|
leaseUntil: null,
|
||||||
|
},
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,8 +24,8 @@ export class TurnDaemonLeaseUnavailableError extends Error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export class TurnDaemonLeaseLostError extends Error {
|
export class TurnDaemonLeaseLostError extends Error {
|
||||||
constructor(profile: string, reason?: string) {
|
constructor(profile: string) {
|
||||||
super(`Turn daemon lease was lost for profile "${profile}".${reason ? ` ${reason}` : ''}`);
|
super(`Turn daemon lease was lost for profile "${profile}".`);
|
||||||
this.name = 'TurnDaemonLeaseLostError';
|
this.name = 'TurnDaemonLeaseLostError';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -50,7 +50,6 @@ export class DatabaseTurnDaemonLease {
|
|||||||
private expiryTimer: NodeJS.Timeout | null = null;
|
private expiryTimer: NodeJS.Timeout | null = null;
|
||||||
private renewalInFlight = false;
|
private renewalInFlight = false;
|
||||||
private lost = false;
|
private lost = false;
|
||||||
private lossReason: string | undefined;
|
|
||||||
|
|
||||||
private constructor(
|
private constructor(
|
||||||
db: GamePrismaClient,
|
db: GamePrismaClient,
|
||||||
@@ -87,24 +86,23 @@ export class DatabaseTurnDaemonLease {
|
|||||||
VALUES (
|
VALUES (
|
||||||
${this.profile},
|
${this.profile},
|
||||||
${this.ownerId},
|
${this.ownerId},
|
||||||
(CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
|
CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
|
||||||
1,
|
1,
|
||||||
CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
CURRENT_TIMESTAMP
|
||||||
)
|
)
|
||||||
ON CONFLICT ("profile") DO UPDATE
|
ON CONFLICT ("profile") DO UPDATE
|
||||||
SET
|
SET
|
||||||
"owner_id" = EXCLUDED."owner_id",
|
"owner_id" = EXCLUDED."owner_id",
|
||||||
"clock_ready" = FALSE,
|
|
||||||
"lease_until" = EXCLUDED."lease_until",
|
"lease_until" = EXCLUDED."lease_until",
|
||||||
"fencing_epoch" = CASE
|
"fencing_epoch" = CASE
|
||||||
WHEN "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
|
WHEN "turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
|
||||||
THEN "turn_daemon_lease"."fencing_epoch"
|
THEN "turn_daemon_lease"."fencing_epoch"
|
||||||
ELSE "turn_daemon_lease"."fencing_epoch" + 1
|
ELSE "turn_daemon_lease"."fencing_epoch" + 1
|
||||||
END,
|
END,
|
||||||
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
"heartbeat_at" = CURRENT_TIMESTAMP
|
||||||
WHERE
|
WHERE
|
||||||
"turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
|
"turn_daemon_lease"."owner_id" = EXCLUDED."owner_id"
|
||||||
OR "turn_daemon_lease"."lease_until" <= CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
OR "turn_daemon_lease"."lease_until" <= CURRENT_TIMESTAMP
|
||||||
RETURNING "profile", "owner_id", "fencing_epoch"
|
RETURNING "profile", "owner_id", "fencing_epoch"
|
||||||
`);
|
`);
|
||||||
const row = rows[0];
|
const row = rows[0];
|
||||||
@@ -117,7 +115,6 @@ export class DatabaseTurnDaemonLease {
|
|||||||
fencingEpoch: BigInt(row.fencing_epoch),
|
fencingEpoch: BigInt(row.fencing_epoch),
|
||||||
};
|
};
|
||||||
this.lost = false;
|
this.lost = false;
|
||||||
this.lossReason = undefined;
|
|
||||||
this.scheduleExpiryWatchdog(requestStartedAt);
|
this.scheduleExpiryWatchdog(requestStartedAt);
|
||||||
if (this.heartbeatEnabled) {
|
if (this.heartbeatEnabled) {
|
||||||
this.startHeartbeat();
|
this.startHeartbeat();
|
||||||
@@ -129,22 +126,10 @@ export class DatabaseTurnDaemonLease {
|
|||||||
return this.token ? { ...this.token } : null;
|
return this.token ? { ...this.token } : null;
|
||||||
}
|
}
|
||||||
|
|
||||||
async markClockReady(): Promise<void> {
|
|
||||||
await this.db.$transaction(async (db) => {
|
|
||||||
await this.assertActive(db);
|
|
||||||
const token = this.getToken()!;
|
|
||||||
await db.turnDaemonLease.update({ where: { profile: token.profile }, data: { clockReady: true } });
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
isLost(): boolean {
|
isLost(): boolean {
|
||||||
return this.lost;
|
return this.lost;
|
||||||
}
|
}
|
||||||
|
|
||||||
getLossError(): TurnDaemonLeaseLostError {
|
|
||||||
return new TurnDaemonLeaseLostError(this.profile, this.lossReason);
|
|
||||||
}
|
|
||||||
|
|
||||||
async renew(): Promise<boolean> {
|
async renew(): Promise<boolean> {
|
||||||
const token = this.token;
|
const token = this.token;
|
||||||
if (!token || this.lost || this.renewalInFlight) {
|
if (!token || this.lost || this.renewalInFlight) {
|
||||||
@@ -156,17 +141,17 @@ export class DatabaseTurnDaemonLease {
|
|||||||
const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
|
const rows = await this.db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
|
||||||
UPDATE "turn_daemon_lease"
|
UPDATE "turn_daemon_lease"
|
||||||
SET
|
SET
|
||||||
"lease_until" = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
|
"lease_until" = CURRENT_TIMESTAMP + (${this.leaseDurationMs} * INTERVAL '1 millisecond'),
|
||||||
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
"heartbeat_at" = CURRENT_TIMESTAMP
|
||||||
WHERE
|
WHERE
|
||||||
"profile" = ${token.profile}
|
"profile" = ${token.profile}
|
||||||
AND "owner_id" = ${token.ownerId}
|
AND "owner_id" = ${token.ownerId}
|
||||||
AND "fencing_epoch" = ${token.fencingEpoch}
|
AND "fencing_epoch" = ${token.fencingEpoch}
|
||||||
AND "lease_until" > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
AND "lease_until" > CURRENT_TIMESTAMP
|
||||||
RETURNING "profile", "owner_id", "fencing_epoch"
|
RETURNING "profile", "owner_id", "fencing_epoch"
|
||||||
`);
|
`);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
this.markLost('Heartbeat renewal rejected: lease expired or owner/epoch changed.');
|
this.markLost();
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
if (this.lost) {
|
if (this.lost) {
|
||||||
@@ -182,7 +167,7 @@ export class DatabaseTurnDaemonLease {
|
|||||||
async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> {
|
async assertActive(transaction?: GamePrisma.TransactionClient): Promise<void> {
|
||||||
const token = this.token;
|
const token = this.token;
|
||||||
if (!token || this.lost) {
|
if (!token || this.lost) {
|
||||||
throw this.getLossError();
|
throw new TurnDaemonLeaseLostError(this.profile);
|
||||||
}
|
}
|
||||||
const db = transaction ?? this.db;
|
const db = transaction ?? this.db;
|
||||||
const rows = await db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
|
const rows = await db.$queryRaw<LeaseRow[]>(GamePrisma.sql`
|
||||||
@@ -192,12 +177,12 @@ export class DatabaseTurnDaemonLease {
|
|||||||
"profile" = ${token.profile}
|
"profile" = ${token.profile}
|
||||||
AND "owner_id" = ${token.ownerId}
|
AND "owner_id" = ${token.ownerId}
|
||||||
AND "fencing_epoch" = ${token.fencingEpoch}
|
AND "fencing_epoch" = ${token.fencingEpoch}
|
||||||
AND "lease_until" > CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
AND "lease_until" > CURRENT_TIMESTAMP
|
||||||
FOR UPDATE
|
FOR UPDATE
|
||||||
`);
|
`);
|
||||||
if (rows.length === 0) {
|
if (rows.length === 0) {
|
||||||
this.markLost('Transaction fencing rejected: lease expired or owner/epoch changed.');
|
this.markLost();
|
||||||
throw this.getLossError();
|
throw new TurnDaemonLeaseLostError(this.profile);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -211,8 +196,7 @@ export class DatabaseTurnDaemonLease {
|
|||||||
}
|
}
|
||||||
await this.db.$executeRaw(GamePrisma.sql`
|
await this.db.$executeRaw(GamePrisma.sql`
|
||||||
UPDATE "turn_daemon_lease"
|
UPDATE "turn_daemon_lease"
|
||||||
SET "lease_until" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC',
|
SET "lease_until" = CURRENT_TIMESTAMP, "heartbeat_at" = CURRENT_TIMESTAMP
|
||||||
"heartbeat_at" = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
|
||||||
WHERE
|
WHERE
|
||||||
"profile" = ${token.profile}
|
"profile" = ${token.profile}
|
||||||
AND "owner_id" = ${token.ownerId}
|
AND "owner_id" = ${token.ownerId}
|
||||||
@@ -234,10 +218,8 @@ export class DatabaseTurnDaemonLease {
|
|||||||
}
|
}
|
||||||
const intervalMs = Math.max(250, Math.floor(this.leaseDurationMs / 3));
|
const intervalMs = Math.max(250, Math.floor(this.leaseDurationMs / 3));
|
||||||
this.heartbeatTimer = setInterval(() => {
|
this.heartbeatTimer = setInterval(() => {
|
||||||
void this.renew().catch((error: unknown) => {
|
void this.renew().catch(() => {
|
||||||
this.markLost(
|
this.markLost();
|
||||||
`Heartbeat database request failed (${error instanceof Error ? error.name : 'unknown error'}).`
|
|
||||||
);
|
|
||||||
});
|
});
|
||||||
}, intervalMs);
|
}, intervalMs);
|
||||||
this.heartbeatTimer.unref();
|
this.heartbeatTimer.unref();
|
||||||
@@ -256,9 +238,7 @@ export class DatabaseTurnDaemonLease {
|
|||||||
this.stopExpiryWatchdog();
|
this.stopExpiryWatchdog();
|
||||||
const remainingMs = Math.max(0, this.leaseDurationMs - (performance.now() - requestStartedAt));
|
const remainingMs = Math.max(0, this.leaseDurationMs - (performance.now() - requestStartedAt));
|
||||||
this.expiryTimer = setTimeout(() => {
|
this.expiryTimer = setTimeout(() => {
|
||||||
this.markLost(
|
this.markLost();
|
||||||
`Heartbeat deadline exceeded (${this.leaseDurationMs}ms; renewal in flight: ${this.renewalInFlight}).`
|
|
||||||
);
|
|
||||||
}, remainingMs);
|
}, remainingMs);
|
||||||
this.expiryTimer.unref();
|
this.expiryTimer.unref();
|
||||||
}
|
}
|
||||||
@@ -270,9 +250,7 @@ export class DatabaseTurnDaemonLease {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private markLost(reason: string): void {
|
private markLost(): void {
|
||||||
if (this.lost) return;
|
|
||||||
this.lossReason = reason;
|
|
||||||
this.lost = true;
|
this.lost = true;
|
||||||
this.stopHeartbeat();
|
this.stopHeartbeat();
|
||||||
this.stopExpiryWatchdog();
|
this.stopExpiryWatchdog();
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js';
|
import type { TurnDaemonCommand, TurnDaemonControlQueue } from './types.js';
|
||||||
|
|
||||||
type Waiter = {
|
type Waiter = {
|
||||||
timeoutMs: number | null;
|
deadlineMs: number | null;
|
||||||
resolve: (command: TurnDaemonCommand | null) => void;
|
resolve: (command: TurnDaemonCommand | null) => void;
|
||||||
timeoutId?: ReturnType<typeof setTimeout>;
|
timeoutId?: ReturnType<typeof setTimeout>;
|
||||||
};
|
};
|
||||||
@@ -32,14 +32,14 @@ export class InMemoryControlQueue implements TurnDaemonControlQueue {
|
|||||||
return drained;
|
return drained;
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
|
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||||
if (this.queue.length > 0) {
|
if (this.queue.length > 0) {
|
||||||
return this.queue.shift() ?? null;
|
return this.queue.shift() ?? null;
|
||||||
}
|
}
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
const waiter: Waiter = { timeoutMs, resolve };
|
const waiter: Waiter = { deadlineMs, resolve };
|
||||||
if (timeoutMs !== null) {
|
if (deadlineMs !== null) {
|
||||||
const delay = Math.max(0, timeoutMs);
|
const delay = Math.max(0, deadlineMs - Date.now());
|
||||||
waiter.timeoutId = setTimeout(() => {
|
waiter.timeoutId = setTimeout(() => {
|
||||||
this.removeWaiter(waiter);
|
this.removeWaiter(waiter);
|
||||||
resolve(null);
|
resolve(null);
|
||||||
|
|||||||
@@ -92,14 +92,14 @@ export class RedisTurnDaemonCommandStream implements TurnDaemonControlQueue, Tur
|
|||||||
return drained.concat(remote);
|
return drained.concat(remote);
|
||||||
}
|
}
|
||||||
|
|
||||||
async waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null> {
|
async waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null> {
|
||||||
if (this.localQueue.length > 0) {
|
if (this.localQueue.length > 0) {
|
||||||
return this.localQueue.shift() ?? null;
|
return this.localQueue.shift() ?? null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const blockMs = timeoutMs === null ? 0 : Math.max(0, timeoutMs);
|
const blockMs = deadlineMs === null ? 0 : Math.max(0, deadlineMs - Date.now());
|
||||||
const cappedBlockMs = timeoutMs === null ? 0 : Math.min(blockMs, 1000);
|
const cappedBlockMs = deadlineMs === null ? 0 : Math.min(blockMs, 1000);
|
||||||
if (timeoutMs !== null && blockMs === 0) {
|
if (deadlineMs !== null && blockMs === 0) {
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -90,20 +90,7 @@ export class TurnDaemonLifecycle {
|
|||||||
|
|
||||||
start(): Promise<void> {
|
start(): Promise<void> {
|
||||||
if (!this.loopPromise) {
|
if (!this.loopPromise) {
|
||||||
this.loopPromise = this.runLoop().catch(async (error: unknown) => {
|
this.loopPromise = this.runLoop();
|
||||||
// 초기화와 pause gate 실패도 관리자에게 즉시 남긴다. lease를
|
|
||||||
// 잃은 runtime은 대기 상태로 살아 있으면 안전하게 재개할 수 없다.
|
|
||||||
this.status.state = 'stopping';
|
|
||||||
this.status.running = false;
|
|
||||||
this.status.paused = true;
|
|
||||||
this.status.lastError = error instanceof Error ? error.message : 'Unknown lifecycle error.';
|
|
||||||
try {
|
|
||||||
await this.hooks?.onRunError?.(error);
|
|
||||||
} catch {
|
|
||||||
// 장애 기록 실패가 원래 종료 원인을 덮어쓰지 않게 한다.
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
return this.loopPromise;
|
return this.loopPromise;
|
||||||
}
|
}
|
||||||
@@ -171,25 +158,6 @@ export class TurnDaemonLifecycle {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const nowMs = this.clock.nowMs();
|
|
||||||
const wallNow = new Date(nowMs);
|
|
||||||
let gameClock = await this.stateStore.loadGameClock?.(wallNow);
|
|
||||||
if (gameClock?.phase === 'PREOPEN' && this.stateStore.promotePreopenAtOpening) {
|
|
||||||
await this.stateStore.promotePreopenAtOpening(wallNow);
|
|
||||||
gameClock = await this.stateStore.loadGameClock?.(wallNow);
|
|
||||||
}
|
|
||||||
if (gameClock?.phase && gameClock.phase !== 'RUNNING' && gameClock.phase !== 'MANUAL') {
|
|
||||||
this.status.nextTurnTime = undefined;
|
|
||||||
await this.clock.sleepMs(500);
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
if (gameClock?.mode === 'realtime' && gameClock.startsAt && wallNow < gameClock.startsAt) {
|
|
||||||
this.status.nextTurnTime = gameClock.startsAt.toISOString();
|
|
||||||
await this.clock.sleepMs(Math.min(500, gameClock.startsAt.getTime() - nowMs));
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
// 수동 실행 요청도 가오픈·정지·재조정의 턴 실행 gate를 통과해야 한다.
|
|
||||||
// 사용자 명령 처리는 루프 시작에서 계속하되 시간 진행은 여기서 분리한다.
|
|
||||||
if (this.pendingRun) {
|
if (this.pendingRun) {
|
||||||
await this.runOnce(this.pendingRun);
|
await this.runOnce(this.pendingRun);
|
||||||
this.pendingRun = null;
|
this.pendingRun = null;
|
||||||
@@ -201,6 +169,10 @@ export class TurnDaemonLifecycle {
|
|||||||
await this.clock.sleepMs(200);
|
await this.clock.sleepMs(200);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nowMs = this.clock.nowMs();
|
||||||
|
const wallNow = new Date(nowMs);
|
||||||
|
const gameClock = await this.stateStore.loadGameClock?.(wallNow);
|
||||||
if (gameClock?.mode === 'manual') {
|
if (gameClock?.mode === 'manual') {
|
||||||
// Ref observes all generals due before one monthly boundary in
|
// Ref observes all generals due before one monthly boundary in
|
||||||
// a single snapshot. Manual mode advances directly to that
|
// a single snapshot. Manual mode advances directly to that
|
||||||
@@ -237,10 +209,7 @@ export class TurnDaemonLifecycle {
|
|||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const wallDeadline = await this.stateStore.projectGameDeadline?.(nextRunTime);
|
const command = await this.controlQueue.waitUntil(nowMs + (nextTurnMs - gameNowMs));
|
||||||
const command = await this.controlQueue.waitFor(
|
|
||||||
Math.max(0, wallDeadline ? wallDeadline.getTime() - nowMs : nextTurnMs - gameNowMs)
|
|
||||||
);
|
|
||||||
if (command) {
|
if (command) {
|
||||||
await this.handleCommand(command);
|
await this.handleCommand(command);
|
||||||
}
|
}
|
||||||
@@ -286,7 +255,7 @@ export class TurnDaemonLifecycle {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private async waitForResume(): Promise<void> {
|
private async waitForResume(): Promise<void> {
|
||||||
const command = await this.controlQueue.waitFor(null);
|
const command = await this.controlQueue.waitUntil(null);
|
||||||
if (command) {
|
if (command) {
|
||||||
await this.handleCommand(command);
|
await this.handleCommand(command);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import type {
|
|||||||
TurnRunResult,
|
TurnRunResult,
|
||||||
} from '@sammo-ts/common';
|
} from '@sammo-ts/common';
|
||||||
import type { GamePrisma } from '@sammo-ts/infra';
|
import type { GamePrisma } from '@sammo-ts/infra';
|
||||||
import type { GameClockMode, GameClockPhase } from '@sammo-ts/common';
|
import type { GameClockMode } from '@sammo-ts/common';
|
||||||
|
|
||||||
export type {
|
export type {
|
||||||
RunReason,
|
RunReason,
|
||||||
@@ -29,12 +29,6 @@ export interface TurnDaemonCommandHandler {
|
|||||||
|
|
||||||
export interface TurnDaemonCommandExecutionContext {
|
export interface TurnDaemonCommandExecutionContext {
|
||||||
db?: GamePrisma.TransactionClient;
|
db?: GamePrisma.TransactionClient;
|
||||||
clockOperationAuthority?: {
|
|
||||||
kind: 'DAEMON';
|
|
||||||
profileName: string;
|
|
||||||
ownerId: string;
|
|
||||||
fencingEpoch: bigint;
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnDaemonCommandResponder {
|
export interface TurnDaemonCommandResponder {
|
||||||
@@ -66,25 +60,16 @@ export interface TurnStateStore {
|
|||||||
loadCheckpoint(): Promise<TurnCheckpoint | undefined>;
|
loadCheckpoint(): Promise<TurnCheckpoint | undefined>;
|
||||||
saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void>;
|
saveCheckpoint(checkpoint?: TurnCheckpoint): Promise<void>;
|
||||||
shouldHaltScheduledRuns?(): Promise<boolean>;
|
shouldHaltScheduledRuns?(): Promise<boolean>;
|
||||||
loadGameClock?(wallNow?: Date): Promise<{
|
loadGameClock?(wallNow?: Date): Promise<{ mode: GameClockMode; now: Date }>;
|
||||||
mode: GameClockMode;
|
|
||||||
now: Date;
|
|
||||||
phase?: GameClockPhase;
|
|
||||||
revision?: number;
|
|
||||||
deadlineGeneration?: number;
|
|
||||||
startsAt?: Date;
|
|
||||||
}>;
|
|
||||||
promotePreopenAtOpening?(wallNow: Date): Promise<boolean>;
|
|
||||||
shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>;
|
shouldRebaseRealtimeBacklog?(wallNow: Date): Promise<boolean>;
|
||||||
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
|
rebaseRealtimeBacklog?(wallNow: Date): Promise<RealtimeBacklogRebaseResult | null>;
|
||||||
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
|
advanceGameClockTo?(target: Date, wallNow: Date): Promise<void>;
|
||||||
projectGameDeadline?(gameTime: Date): Promise<Date>;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface TurnDaemonControlQueue {
|
export interface TurnDaemonControlQueue {
|
||||||
enqueue(command: TurnDaemonCommand): void;
|
enqueue(command: TurnDaemonCommand): void;
|
||||||
drain(): Promise<TurnDaemonCommand[]>;
|
drain(): Promise<TurnDaemonCommand[]>;
|
||||||
waitFor(timeoutMs: number | null): Promise<TurnDaemonCommand | null>;
|
waitUntil(deadlineMs: number | null): Promise<TurnDaemonCommand | null>;
|
||||||
getDepth(): number;
|
getDepth(): number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -571,10 +571,7 @@ const cancelGameInTransaction = async (
|
|||||||
return { ...resultFromPersisted(created), alreadyApplied: false };
|
return { ...resultFromPersisted(created), alreadyApplied: false };
|
||||||
};
|
};
|
||||||
|
|
||||||
export const cancelGame = async (
|
export const cancelGame = async (request: GameCancellationRequest): Promise<GameCancellationResult> => {
|
||||||
request: GameCancellationRequest,
|
|
||||||
connectorFactory: typeof createGamePostgresConnector = createGamePostgresConnector
|
|
||||||
): Promise<GameCancellationResult> => {
|
|
||||||
if (!request.reason.trim()) throw new Error('Game cancellation reason is required.');
|
if (!request.reason.trim()) throw new Error('Game cancellation reason is required.');
|
||||||
if (!GAME_CANCELLATION_HISTORY_MODES.includes(request.historyMode)) throw new Error('Invalid history mode.');
|
if (!GAME_CANCELLATION_HISTORY_MODES.includes(request.historyMode)) throw new Error('Invalid history mode.');
|
||||||
if (!GAME_CANCELLATION_GENERAL_MODES.includes(request.generalMode)) throw new Error('Invalid general mode.');
|
if (!GAME_CANCELLATION_GENERAL_MODES.includes(request.generalMode)) throw new Error('Invalid general mode.');
|
||||||
@@ -583,7 +580,7 @@ export const cancelGame = async (
|
|||||||
earnedPoint: 0,
|
earnedPoint: 0,
|
||||||
earnedPointRetentionPercent: request.earnedPointRetentionPercent,
|
earnedPointRetentionPercent: request.earnedPointRetentionPercent,
|
||||||
});
|
});
|
||||||
const connector = connectorFactory({ url: request.databaseUrl });
|
const connector = createGamePostgresConnector({ url: request.databaseUrl });
|
||||||
await connector.connect();
|
await connector.connect();
|
||||||
try {
|
try {
|
||||||
return await connector.prisma.$transaction(
|
return await connector.prisma.$transaction(
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import {
|
|||||||
type InputJsonValue,
|
type InputJsonValue,
|
||||||
type TurnEngineEventCreateManyInput,
|
type TurnEngineEventCreateManyInput,
|
||||||
} from '@sammo-ts/infra';
|
} from '@sammo-ts/infra';
|
||||||
import { GameClock, asNumber, asRecord, type GameClockMode, type GameClockPhase } from '@sammo-ts/common';
|
import { GameClock, asNumber, asRecord, type GameClockMode } from '@sammo-ts/common';
|
||||||
import {
|
import {
|
||||||
buildScenarioBootstrap,
|
buildScenarioBootstrap,
|
||||||
resolveScenarioGeneralDeathMonth,
|
resolveScenarioGeneralDeathMonth,
|
||||||
@@ -68,8 +68,6 @@ export interface ScenarioSeedOptions {
|
|||||||
generalPoolOptions?: GeneralPoolLoaderOptions;
|
generalPoolOptions?: GeneralPoolLoaderOptions;
|
||||||
resetTables?: boolean;
|
resetTables?: boolean;
|
||||||
now?: Date;
|
now?: Date;
|
||||||
/** 실제 설치 시각. now는 예약된 게임 달력 기준일일 수 있다. */
|
|
||||||
wallNow?: Date;
|
|
||||||
tickSeconds?: number;
|
tickSeconds?: number;
|
||||||
gameClockMode?: GameClockMode;
|
gameClockMode?: GameClockMode;
|
||||||
installOptions?: ScenarioInstallOptions;
|
installOptions?: ScenarioInstallOptions;
|
||||||
@@ -96,17 +94,6 @@ export const calculateInitialTurnTick = (
|
|||||||
return clock.addTicks(baseTick, offsetTicks);
|
return clock.addTicks(baseTick, offsetTicks);
|
||||||
};
|
};
|
||||||
|
|
||||||
export const resolveInitialClockPhase = (
|
|
||||||
mode: GameClockMode,
|
|
||||||
seededAtWall: Date,
|
|
||||||
scheduledOpenAtWall: Date
|
|
||||||
): GameClockPhase => {
|
|
||||||
if (mode === 'manual') {
|
|
||||||
return 'MANUAL';
|
|
||||||
}
|
|
||||||
return scheduledOpenAtWall.getTime() > seededAtWall.getTime() ? 'PREOPEN' : 'RUNNING';
|
|
||||||
};
|
|
||||||
|
|
||||||
const formatDateTime = (date: Date): string => {
|
const formatDateTime = (date: Date): string => {
|
||||||
const pad = (value: number): string => String(value).padStart(2, '0');
|
const pad = (value: number): string => String(value).padStart(2, '0');
|
||||||
return [
|
return [
|
||||||
@@ -242,34 +229,19 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
: (options.tickSeconds ?? DEFAULT_TICK_SECONDS);
|
: (options.tickSeconds ?? DEFAULT_TICK_SECONDS);
|
||||||
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
|
const turnTermMinutes = Math.max(1, Math.round(tickSeconds / 60));
|
||||||
const sync = install?.sync ?? false;
|
const sync = install?.sync ?? false;
|
||||||
|
const startState = resolveStartState(scenario.startYear ?? null, now, turnTermMinutes, sync);
|
||||||
const gameClockMode = options.gameClockMode ?? 'realtime';
|
const gameClockMode = options.gameClockMode ?? 'realtime';
|
||||||
// A realtime season prepared before its formal opening must not consume
|
// A realtime season prepared before its formal opening must not consume
|
||||||
// wall time while users are only allowed to edit reserved commands.
|
// wall time while users are only allowed to edit reserved commands.
|
||||||
const wallNow = gameClockMode === 'manual' ? now : (options.wallNow ?? now);
|
const initialClockWallAnchor = install?.openAt && install.openAt.getTime() > now.getTime() ? install.openAt : now;
|
||||||
const requestedOpening = install?.openAt && install.openAt.getTime() > wallNow.getTime() ? install.openAt : wallNow;
|
|
||||||
// Opening is an exact wall instant, independent of the calendar's 12-turn
|
|
||||||
// grouping. Only the initial year/month uses the legacy calendar alignment.
|
|
||||||
const initialClockWallAnchor = requestedOpening;
|
|
||||||
const startState = resolveStartState(
|
|
||||||
scenario.startYear ?? null,
|
|
||||||
gameClockMode === 'manual' ? now : requestedOpening,
|
|
||||||
turnTermMinutes,
|
|
||||||
sync
|
|
||||||
);
|
|
||||||
const initialClockPhase = resolveInitialClockPhase(gameClockMode, wallNow, initialClockWallAnchor);
|
|
||||||
const initialClock = new GameClock({
|
const initialClock = new GameClock({
|
||||||
baseTime: gameClockMode === 'manual' ? startState.startTime : initialClockWallAnchor,
|
baseTime: startState.startTime,
|
||||||
tick: 0,
|
tick: 0,
|
||||||
mode: gameClockMode,
|
mode: gameClockMode,
|
||||||
wallAnchor: initialClockWallAnchor,
|
wallAnchor: initialClockWallAnchor,
|
||||||
turnSeconds: tickSeconds,
|
turnSeconds: tickSeconds,
|
||||||
phase: initialClockPhase,
|
|
||||||
revision: 1,
|
|
||||||
});
|
});
|
||||||
// The formal opening wall instant is always logical tick zero. PREOPEN may
|
const initialClockTick = initialClock.dateToTick(now);
|
||||||
// project signed negative observed ticks, but executable seed schedules are
|
|
||||||
// derived from this zero coordinate rather than from the seed wall time.
|
|
||||||
const initialClockTick = 0;
|
|
||||||
|
|
||||||
const { seed, warnings } = buildScenarioBootstrap({
|
const { seed, warnings } = buildScenarioBootstrap({
|
||||||
scenario: scenarioDefinition,
|
scenario: scenarioDefinition,
|
||||||
@@ -327,9 +299,9 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
// monthly pre-handler recalculates the same value at each boundary.
|
// monthly pre-handler recalculates the same value at each boundary.
|
||||||
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
|
develcost: (startState.currentYear - (scenario.startYear ?? startState.currentYear) + 10) * 2,
|
||||||
starttime: formatDateTime(startState.startTime),
|
starttime: formatDateTime(startState.startTime),
|
||||||
turntime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
|
turntime: formatDateTime(now),
|
||||||
opentime: formatDateTime(initialClockWallAnchor),
|
opentime: formatDateTime(now),
|
||||||
lastTurnTime: formatDateTime(gameClockMode === 'manual' ? now : initialClock.baseTime),
|
lastTurnTime: formatDateTime(now),
|
||||||
};
|
};
|
||||||
|
|
||||||
const firstGameIdx =
|
const firstGameIdx =
|
||||||
@@ -355,10 +327,6 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
}
|
}
|
||||||
|
|
||||||
worldMeta.hiddenSeed = hiddenSeed;
|
worldMeta.hiddenSeed = hiddenSeed;
|
||||||
worldMeta.seededAtWall = wallNow.toISOString();
|
|
||||||
worldMeta.scheduledOpenAtWall = initialClockWallAnchor.toISOString();
|
|
||||||
worldMeta.projectedGameDateAtOpening = initialClock.baseTime.toISOString();
|
|
||||||
worldMeta.calendarStart = startState.startTime.toISOString();
|
|
||||||
|
|
||||||
if (install?.preopenAt) {
|
if (install?.preopenAt) {
|
||||||
worldMeta.preopenAt = formatDateTime(install.preopenAt);
|
worldMeta.preopenAt = formatDateTime(install.preopenAt);
|
||||||
@@ -452,9 +420,6 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
clockMode: gameClockMode,
|
clockMode: gameClockMode,
|
||||||
clockWallAnchor: initialClock.wallAnchor,
|
clockWallAnchor: initialClock.wallAnchor,
|
||||||
lastTurnTick: BigInt(initialClockTick),
|
lastTurnTick: BigInt(initialClockTick),
|
||||||
clockPhase: initialClockPhase,
|
|
||||||
clockRevision: 1n,
|
|
||||||
deadlineGeneration: 1n,
|
|
||||||
config: asJson({ ...scenarioConfig, ...worldConfig }),
|
config: asJson({ ...scenarioConfig, ...worldConfig }),
|
||||||
meta: asJson(worldMeta),
|
meta: asJson(worldMeta),
|
||||||
},
|
},
|
||||||
@@ -625,14 +590,13 @@ export const seedScenarioToDatabase = async (options: ScenarioSeedOptions): Prom
|
|||||||
weaponCode: general.weapon ?? 'None',
|
weaponCode: general.weapon ?? 'None',
|
||||||
bookCode: general.book ?? 'None',
|
bookCode: general.book ?? 'None',
|
||||||
itemCode: general.item ?? 'None',
|
itemCode: general.item ?? 'None',
|
||||||
turnTime: initialClock.tickToDate(
|
turnTime: new Date(
|
||||||
calculateInitialTurnTick(
|
now.getTime() +
|
||||||
initialClock,
|
Math.floor(
|
||||||
initialClockTick,
|
(typeof general.meta.initialTurnOffsetMicros === 'number'
|
||||||
typeof general.meta.initialTurnOffsetMicros === 'number'
|
? general.meta.initialTurnOffsetMicros
|
||||||
? general.meta.initialTurnOffsetMicros
|
: 0) / 1_000
|
||||||
: 0
|
)
|
||||||
)
|
|
||||||
),
|
),
|
||||||
turnTick: BigInt(
|
turnTick: BigInt(
|
||||||
calculateInitialTurnTick(
|
calculateInitialTurnTick(
|
||||||
|
|||||||
@@ -1,4 +1,3 @@
|
|||||||
import { areSeasonRecordsFinalized } from '../turn/seasonRecords.js';
|
|
||||||
import { JosaUtil, asRecord } from '@sammo-ts/common';
|
import { JosaUtil, asRecord } from '@sammo-ts/common';
|
||||||
import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra';
|
import { createGamePostgresConnector, type GamePrisma } from '@sammo-ts/infra';
|
||||||
import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic';
|
import { ActionLogger, LogFormat, type TournamentType, type TriggerValue } from '@sammo-ts/logic';
|
||||||
@@ -254,8 +253,7 @@ export const createTournamentRewardFinalizer = async (options: {
|
|||||||
}))
|
}))
|
||||||
.filter((entry) => !!entry.userId);
|
.filter((entry) => !!entry.userId);
|
||||||
|
|
||||||
const recordsFinalized = await areSeasonRecordsFinalized(db, world.getState().meta.serverId);
|
for (const entry of pointUpdates) {
|
||||||
for (const entry of recordsFinalized ? [] : pointUpdates) {
|
|
||||||
await db.inheritancePoint.upsert({
|
await db.inheritancePoint.upsert({
|
||||||
where: {
|
where: {
|
||||||
userId_key: { userId: entry.userId!, key: 'tournament' },
|
userId_key: { userId: entry.userId!, key: 'tournament' },
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ import type { ImmediateGeneralActionExecutor } from './reservedTurnHandler.js';
|
|||||||
import { buildCommandEnv } from './reservedTurnCommands.js';
|
import { buildCommandEnv } from './reservedTurnCommands.js';
|
||||||
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
|
import type { InMemoryReservedTurnStore } from './reservedTurnStore.js';
|
||||||
import type { TurnEvent } from './types.js';
|
import type { TurnEvent } from './types.js';
|
||||||
import { reconcileClockSuspensionInTransaction, type ClockReconciliationResult } from './clockReconciliation.js';
|
|
||||||
|
|
||||||
type ActionableMessageType = 'scout' | 'raiseInvader';
|
type ActionableMessageType = 'scout' | 'raiseInvader';
|
||||||
|
|
||||||
@@ -18,10 +17,6 @@ interface MessageRow {
|
|||||||
type: string;
|
type: string;
|
||||||
time: Date;
|
time: Date;
|
||||||
validUntil: Date;
|
validUntil: Date;
|
||||||
actionType: string;
|
|
||||||
actionStatus: string;
|
|
||||||
createdGameTick: bigint;
|
|
||||||
expiresGameTick: bigint | null;
|
|
||||||
message: unknown;
|
message: unknown;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -98,21 +93,15 @@ const invalidateMessageIds = async (
|
|||||||
db: GamePrisma.TransactionClient,
|
db: GamePrisma.TransactionClient,
|
||||||
world: InMemoryTurnWorld,
|
world: InMemoryTurnWorld,
|
||||||
ids: number[],
|
ids: number[],
|
||||||
now: Date,
|
now: Date
|
||||||
authoritativeGameTick?: bigint
|
|
||||||
): Promise<void> => {
|
): Promise<void> => {
|
||||||
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
|
const uniqueIds = [...new Set(ids.filter((id) => Number.isInteger(id) && id > 0))];
|
||||||
if (uniqueIds.length === 0) return;
|
if (uniqueIds.length === 0) return;
|
||||||
const resolvedGameTick = authoritativeGameTick ?? BigInt(world.dateToGameTick(now));
|
|
||||||
await db.messageAction.updateMany({
|
|
||||||
where: { messageId: { in: uniqueIds }, status: 'PENDING' },
|
|
||||||
data: { status: 'RESOLVED', resolvedGameTick },
|
|
||||||
});
|
|
||||||
await db.message.updateMany({
|
await db.message.updateMany({
|
||||||
where: { id: { in: uniqueIds } },
|
where: { id: { in: uniqueIds } },
|
||||||
data: {
|
data: {
|
||||||
validUntil: now,
|
validUntil: now,
|
||||||
validUntilTick: resolvedGameTick,
|
validUntilTick: BigInt(world.dateToGameTick(now)),
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -123,53 +112,40 @@ const validateActor = async (options: {
|
|||||||
requestId?: string;
|
requestId?: string;
|
||||||
userId: string;
|
userId: string;
|
||||||
generalId: number;
|
generalId: number;
|
||||||
}): Promise<{ processingGameTick: number }> => {
|
}): Promise<Date> => {
|
||||||
const actor = options.world.getGeneralById(options.generalId);
|
const actor = options.world.getGeneralById(options.generalId);
|
||||||
if (!actor || actor.userId !== options.userId) {
|
if (!actor || actor.userId !== options.userId) {
|
||||||
throw new Error('messageRespond general owner does not match command user.');
|
throw new Error('messageRespond general owner does not match command user.');
|
||||||
}
|
}
|
||||||
if (!options.requestId) {
|
if (!options.requestId) return new Date();
|
||||||
throw new Error('messageRespond requires a durable ENGINE input event requestId.');
|
|
||||||
}
|
|
||||||
const event = await options.db.inputEvent.findUnique({
|
const event = await options.db.inputEvent.findUnique({
|
||||||
where: { requestId: options.requestId },
|
where: { requestId: options.requestId },
|
||||||
select: { actorUserId: true, target: true, eventType: true, processingGameTick: true },
|
select: { actorUserId: true, target: true, eventType: true, createdAt: true },
|
||||||
});
|
});
|
||||||
if (!event) throw new Error(`ENGINE input event ${options.requestId} is missing.`);
|
if (!event) throw new Error(`ENGINE input event ${options.requestId} is missing.`);
|
||||||
if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') {
|
if (event.actorUserId !== options.userId || event.target !== 'ENGINE' || event.eventType !== 'messageRespond') {
|
||||||
throw new Error('ENGINE input event actor or type does not match messageRespond.');
|
throw new Error('ENGINE input event actor or type does not match messageRespond.');
|
||||||
}
|
}
|
||||||
const processingGameTick = event.processingGameTick;
|
return event.createdAt;
|
||||||
if (processingGameTick === null || !Number.isSafeInteger(Number(processingGameTick))) {
|
|
||||||
throw new Error('messageRespond requires an authoritative processing game tick.');
|
|
||||||
}
|
|
||||||
return { processingGameTick: Number(processingGameTick) };
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const fetchMessageForUpdate = async (
|
const fetchMessageForUpdate = async (
|
||||||
db: GamePrisma.TransactionClient,
|
db: GamePrisma.TransactionClient,
|
||||||
|
world: InMemoryTurnWorld,
|
||||||
messageId: number,
|
messageId: number,
|
||||||
currentGameTick: number
|
now: Date
|
||||||
): Promise<MessageRow | null> => {
|
): Promise<MessageRow | null> => {
|
||||||
|
const currentTick = BigInt(world.dateToGameTick(now));
|
||||||
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
|
const rows = await db.$queryRaw<MessageRow[]>(GamePrisma.sql`
|
||||||
SELECT
|
SELECT id, mailbox, type, time, valid_until AS "validUntil", message
|
||||||
envelope.id,
|
FROM message
|
||||||
envelope.mailbox,
|
WHERE id = ${messageId}
|
||||||
envelope.type,
|
AND (
|
||||||
envelope.time,
|
(valid_until_tick IS NOT NULL AND valid_until_tick > ${currentTick})
|
||||||
envelope.valid_until AS "validUntil",
|
OR (valid_until_tick IS NULL AND valid_until > ${now})
|
||||||
action.action_type AS "actionType",
|
)
|
||||||
action.status AS "actionStatus",
|
|
||||||
action.created_game_tick AS "createdGameTick",
|
|
||||||
action.expires_game_tick AS "expiresGameTick",
|
|
||||||
envelope.message
|
|
||||||
FROM message AS envelope
|
|
||||||
JOIN message_action AS action ON action.message_id = envelope.id
|
|
||||||
WHERE envelope.id = ${messageId}
|
|
||||||
AND action.status = 'PENDING'
|
|
||||||
AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(currentGameTick)})
|
|
||||||
LIMIT 1
|
LIMIT 1
|
||||||
FOR UPDATE OF envelope, action
|
FOR UPDATE
|
||||||
`);
|
`);
|
||||||
return rows[0] ?? null;
|
return rows[0] ?? null;
|
||||||
};
|
};
|
||||||
@@ -188,17 +164,10 @@ const respondToScout = async (options: {
|
|||||||
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
|
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
|
||||||
return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' };
|
return { ok: false, action: 'scout', reason: '올바른 수신자가 아닙니다.' };
|
||||||
}
|
}
|
||||||
if (row.actionStatus !== 'PENDING' || row.actionType !== 'scout' || isLegacyTruthy(asRecord(payload.option).used)) {
|
if (row.validUntil.getTime() <= row.time.getTime() || isLegacyTruthy(asRecord(payload.option).used)) {
|
||||||
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
|
return { ok: false, action: 'scout', reason: '유효하지 않은 등용장입니다.' };
|
||||||
}
|
}
|
||||||
|
|
||||||
// 등용장은 발신 장수의 현재 소속이 아니라 발송 당시 국가에 귀속된다.
|
|
||||||
// 멸망 처리 도입 전에 남은 편지도 수락/거절 전에 영구 만료한다.
|
|
||||||
if (!world.getNationById(payload.src.nationId)) {
|
|
||||||
await invalidateMessageIds(db, world, [row.id], now);
|
|
||||||
return { ok: false, action: 'scout', reason: '등용장을 보낸 국가가 멸망했습니다.' };
|
|
||||||
}
|
|
||||||
|
|
||||||
const sourceNationName = payload.src.nationName;
|
const sourceNationName = payload.src.nationName;
|
||||||
const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로');
|
const sourceNationJosaRo = JosaUtil.pick(sourceNationName, '로');
|
||||||
if (response) {
|
if (response) {
|
||||||
@@ -216,17 +185,18 @@ const respondToScout = async (options: {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
const otherRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
||||||
SELECT envelope.id
|
SELECT id
|
||||||
FROM message AS envelope
|
FROM message
|
||||||
JOIN message_action AS action ON action.message_id = envelope.id
|
WHERE mailbox = ${payload.src.generalId}
|
||||||
WHERE envelope.mailbox = ${payload.src.generalId}
|
AND type = 'private'
|
||||||
AND envelope.type = 'private'
|
AND dest = mailbox
|
||||||
AND envelope.dest = envelope.mailbox
|
AND id <> ${row.id}
|
||||||
AND envelope.id <> ${row.id}
|
AND (
|
||||||
AND action.status = 'PENDING'
|
(valid_until_tick IS NOT NULL AND valid_until_tick > ${BigInt(world.dateToGameTick(now))})
|
||||||
AND (action.expires_game_tick IS NULL OR action.expires_game_tick > ${BigInt(world.dateToGameTick(now))})
|
OR (valid_until_tick IS NULL AND valid_until > ${now})
|
||||||
AND action.action_type = 'scout'
|
)
|
||||||
FOR UPDATE OF envelope, action
|
AND message->'option'->>'action' = 'scout'
|
||||||
|
FOR UPDATE
|
||||||
`);
|
`);
|
||||||
await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now);
|
await invalidateMessageIds(db, world, [row.id, ...otherRows.map(({ id }) => id)], now);
|
||||||
world.queueMessage({
|
world.queueMessage({
|
||||||
@@ -281,18 +251,6 @@ const respondToRaiseInvader = async (options: {
|
|||||||
payload: MessagePayload;
|
payload: MessagePayload;
|
||||||
now: Date;
|
now: Date;
|
||||||
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
||||||
clockOperationAuthority?: {
|
|
||||||
kind: 'DAEMON';
|
|
||||||
profileName: string;
|
|
||||||
ownerId: string;
|
|
||||||
fencingEpoch: bigint;
|
|
||||||
};
|
|
||||||
reconcileUnificationWait?: (input: {
|
|
||||||
db: GamePrisma.TransactionClient;
|
|
||||||
suspensionId: string;
|
|
||||||
profileName: string;
|
|
||||||
authority: NonNullable<Parameters<typeof reconcileClockSuspensionInTransaction>[0]['authority']>;
|
|
||||||
}) => Promise<ClockReconciliationResult>;
|
|
||||||
}): Promise<ActionableMessageResponseResult> => {
|
}): Promise<ActionableMessageResponseResult> => {
|
||||||
const { db, world, reservedTurns, actorId, response, row, payload, now } = options;
|
const { db, world, reservedTurns, actorId, response, row, payload, now } = options;
|
||||||
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
|
if (row.type !== 'private' || row.mailbox !== actorId || payload.dest.generalId !== actorId) {
|
||||||
@@ -314,22 +272,6 @@ const respondToRaiseInvader = async (options: {
|
|||||||
if (!reservedTurns) {
|
if (!reservedTurns) {
|
||||||
throw new Error('RaiseInvader message response requires the reserved-turn store.');
|
throw new Error('RaiseInvader message response requires the reserved-turn store.');
|
||||||
}
|
}
|
||||||
const suspensionId =
|
|
||||||
typeof state.meta.unificationClockSuspensionId === 'string' ? state.meta.unificationClockSuspensionId : null;
|
|
||||||
if (!suspensionId || !options.clockOperationAuthority) {
|
|
||||||
throw new Error('RaiseInvader requires a daemon-authorized UNIFICATION_WAIT suspension.');
|
|
||||||
}
|
|
||||||
const reconcile =
|
|
||||||
options.reconcileUnificationWait ??
|
|
||||||
((input) => reconcileClockSuspensionInTransaction({ ...input, allowUnificationWait: true }));
|
|
||||||
const alignment = await reconcile({
|
|
||||||
db,
|
|
||||||
suspensionId,
|
|
||||||
profileName: options.clockOperationAuthority.profileName,
|
|
||||||
authority: options.clockOperationAuthority,
|
|
||||||
});
|
|
||||||
world.applyClockReconciliation(alignment);
|
|
||||||
const alignedState = world.getState();
|
|
||||||
const args = asRecord(payload.option).args;
|
const args = asRecord(payload.option).args;
|
||||||
if (!Array.isArray(args) || args.length !== 4 || args.some((value) => typeof value !== 'number')) {
|
if (!Array.isArray(args) || args.length !== 4 || args.some((value) => typeof value !== 'number')) {
|
||||||
return { ok: false, action: 'raiseInvader', reason: '이민족 소환 인자가 올바르지 않습니다.' };
|
return { ok: false, action: 'raiseInvader', reason: '이민족 소환 인자가 올바르지 않습니다.' };
|
||||||
@@ -339,44 +281,22 @@ const respondToRaiseInvader = async (options: {
|
|||||||
reservedTurns,
|
reservedTurns,
|
||||||
env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
|
env: buildCommandEnv(world.getScenarioConfig(), world.getUnitSet()),
|
||||||
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
|
loadArchivedNationMaxId: options.loadArchivedNationMaxId,
|
||||||
clockWallNow: alignment.resumeAnchor ?? alignment.resumeWallAt,
|
|
||||||
});
|
});
|
||||||
const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} };
|
const event: TurnEvent = { id: 0, targetCode: 'month', priority: 0, condition: true, action: [], meta: {} };
|
||||||
await handler(
|
await handler(
|
||||||
args,
|
args,
|
||||||
{
|
{
|
||||||
year: alignedState.currentYear,
|
year: state.currentYear,
|
||||||
month: alignedState.currentMonth,
|
month: state.currentMonth,
|
||||||
startyear: asNumber(alignedState.meta.startYear, alignedState.currentYear),
|
startyear: asNumber(state.meta.startYear, state.currentYear),
|
||||||
currentEventID: 0,
|
currentEventID: 0,
|
||||||
// The exact reconciliation snapshot is the authority even when the
|
// Ref uses the frozen game_env.turntime while unification is paused.
|
||||||
// turn rate stays unchanged. Using the pre-reconciliation cursor here
|
// `now` is the realtime game projection and can be hours ahead after
|
||||||
// would create immediately overdue invader turns after a long wait.
|
// a long response wait, delaying every newly summoned invader turn.
|
||||||
turnTime: alignedState.lastTurnTime,
|
turnTime: state.lastTurnTime,
|
||||||
},
|
},
|
||||||
event
|
event
|
||||||
);
|
);
|
||||||
const resolvedGameTick = BigInt(alignment.alignedTick);
|
|
||||||
if (resolvedGameTick < row.createdGameTick) {
|
|
||||||
throw new Error(
|
|
||||||
`RaiseInvader resolved tick ${resolvedGameTick} precedes prompt tick ${row.createdGameTick}; clock authority is inconsistent.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const promptRows = await db.$queryRaw<Array<{ id: number }>>(GamePrisma.sql`
|
|
||||||
SELECT message_id AS id
|
|
||||||
FROM message_action
|
|
||||||
WHERE action_type = 'raiseInvader'
|
|
||||||
AND status = 'PENDING'
|
|
||||||
AND created_game_tick = ${row.createdGameTick}
|
|
||||||
FOR UPDATE
|
|
||||||
`);
|
|
||||||
await invalidateMessageIds(
|
|
||||||
db,
|
|
||||||
world,
|
|
||||||
promptRows.map(({ id }) => id),
|
|
||||||
world.gameTickToDate(alignment.alignedTick),
|
|
||||||
resolvedGameTick
|
|
||||||
);
|
|
||||||
return { ok: true, action: 'raiseInvader', reason: 'success' };
|
return { ok: true, action: 'raiseInvader', reason: 'success' };
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -391,27 +311,14 @@ export const respondToActionableMessage = async (options: {
|
|||||||
messageId: number;
|
messageId: number;
|
||||||
response: boolean;
|
response: boolean;
|
||||||
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
loadArchivedNationMaxId?: (serverId: string) => Promise<number>;
|
||||||
clockOperationAuthority?: {
|
|
||||||
kind: 'DAEMON';
|
|
||||||
profileName: string;
|
|
||||||
ownerId: string;
|
|
||||||
fencingEpoch: bigint;
|
|
||||||
};
|
|
||||||
reconcileUnificationWait?: (input: {
|
|
||||||
db: GamePrisma.TransactionClient;
|
|
||||||
suspensionId: string;
|
|
||||||
profileName: string;
|
|
||||||
authority: NonNullable<Parameters<typeof reconcileClockSuspensionInTransaction>[0]['authority']>;
|
|
||||||
}) => Promise<ClockReconciliationResult>;
|
|
||||||
}): Promise<ActionableMessageResponseResult> => {
|
}): Promise<ActionableMessageResponseResult> => {
|
||||||
const accepted = await validateActor(options);
|
const acceptedAt = await validateActor(options);
|
||||||
const now = options.world.gameTickToDate(accepted.processingGameTick);
|
const now = options.world.getGameNow(acceptedAt);
|
||||||
const row = await fetchMessageForUpdate(options.db, options.messageId, accepted.processingGameTick);
|
const row = await fetchMessageForUpdate(options.db, options.world, options.messageId, now);
|
||||||
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
|
if (!row) return { ok: false, reason: '존재하지 않는 메시지입니다.' };
|
||||||
const payload = parsePayload(row.message);
|
const payload = parsePayload(row.message);
|
||||||
if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
|
if (!payload) return { ok: false, reason: '응답할 수 없는 메시지입니다.' };
|
||||||
const action = asRecord(payload.option).action;
|
const action = asRecord(payload.option).action;
|
||||||
if (action !== row.actionType) return { ok: false, reason: '메시지 행동 상태가 일치하지 않습니다.' };
|
|
||||||
if (action === 'scout') {
|
if (action === 'scout') {
|
||||||
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });
|
return await respondToScout({ ...options, actorId: options.generalId, row, payload, now });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1149,8 +1149,6 @@ export class GeneralAI {
|
|||||||
const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel]));
|
const effectiveOfficerLevel = new Map(generals.map((candidate) => [candidate.id, candidate.officerLevel]));
|
||||||
|
|
||||||
let userChiefCount = 0;
|
let userChiefCount = 0;
|
||||||
let ambassadorCount = 0;
|
|
||||||
const assignedAmbassadorIds = new Set<number>();
|
|
||||||
const worldKillturn = readMetaNumber(asRecord(this.world.meta), 'killturn', 0);
|
const worldKillturn = readMetaNumber(asRecord(this.world.meta), 'killturn', 0);
|
||||||
const minUserKillturn = worldKillturn - Math.trunc(240 / this.turnTermMinutes);
|
const minUserKillturn = worldKillturn - Math.trunc(240 / this.turnTermMinutes);
|
||||||
const minNpcKillturn = 36;
|
const minNpcKillturn = 36;
|
||||||
@@ -1164,25 +1162,13 @@ export class GeneralAI {
|
|||||||
const killturn = readRequiredMetaNumber(asRecord(chief.meta), 'killturn', `generalId=${chief.id}`);
|
const killturn = readRequiredMetaNumber(asRecord(chief.meta), 'killturn', `generalId=${chief.id}`);
|
||||||
if (chief.npcState < 2 && killturn >= minUserKillturn && penalty.noAmbassador !== true) {
|
if (chief.npcState < 2 && killturn >= minUserKillturn && penalty.noAmbassador !== true) {
|
||||||
userChiefCount += 1;
|
userChiefCount += 1;
|
||||||
if (ambassadorCount < 2) {
|
chief.meta = { ...chief.meta, permission: 'ambassador' };
|
||||||
ambassadorCount += 1;
|
this.promotionPatches.push({
|
||||||
assignedAmbassadorIds.add(chief.id);
|
generalId: chief.id,
|
||||||
chief.meta = { ...chief.meta, permission: 'ambassador' };
|
officerLevel: chief.officerLevel,
|
||||||
this.promotionPatches.push({
|
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
|
||||||
generalId: chief.id,
|
permission: 'ambassador',
|
||||||
officerLevel: chief.officerLevel,
|
});
|
||||||
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
|
|
||||||
permission: 'ambassador',
|
|
||||||
});
|
|
||||||
} else if (asRecord(chief.meta).permission === 'ambassador') {
|
|
||||||
chief.meta = { ...chief.meta, permission: 'normal' };
|
|
||||||
this.promotionPatches.push({
|
|
||||||
generalId: chief.id,
|
|
||||||
officerLevel: chief.officerLevel,
|
|
||||||
officerCity: readMetaNumber(asRecord(chief.meta), 'officer_city', 0),
|
|
||||||
permission: 'normal',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1236,7 +1222,7 @@ export class GeneralAI {
|
|||||||
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
|
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
|
||||||
effectiveOfficerLevel.set(oldChief.id, 1);
|
effectiveOfficerLevel.set(oldChief.id, 1);
|
||||||
}
|
}
|
||||||
const permission = penalty.noAmbassador !== true && ambassadorCount < 2 ? 'ambassador' : undefined;
|
const permission = penalty.noAmbassador === true ? undefined : 'ambassador';
|
||||||
candidate.officerLevel = 11;
|
candidate.officerLevel = 11;
|
||||||
candidate.meta = {
|
candidate.meta = {
|
||||||
...candidate.meta,
|
...candidate.meta,
|
||||||
@@ -1252,10 +1238,6 @@ export class GeneralAI {
|
|||||||
effectiveOfficerLevel.set(candidate.id, 11);
|
effectiveOfficerLevel.set(candidate.id, 11);
|
||||||
chiefSet |= 1 << 11;
|
chiefSet |= 1 << 11;
|
||||||
userChiefCount += 1;
|
userChiefCount += 1;
|
||||||
if (permission) {
|
|
||||||
ambassadorCount += 1;
|
|
||||||
assignedAmbassadorIds.add(candidate.id);
|
|
||||||
}
|
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1325,16 +1307,10 @@ export class GeneralAI {
|
|||||||
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
|
this.promotionPatches.push({ generalId: oldChief.id, officerLevel: 1, officerCity: 0 });
|
||||||
}
|
}
|
||||||
const permission =
|
const permission =
|
||||||
nextChief.npcState < 2 && asRecord(nextChief.penalty).noAmbassador !== true && ambassadorCount < 2
|
nextChief.npcState < 2 && asRecord(nextChief.penalty).noAmbassador !== true ? 'ambassador' : undefined;
|
||||||
? 'ambassador'
|
|
||||||
: undefined;
|
|
||||||
if (nextChief.npcState < 2) {
|
if (nextChief.npcState < 2) {
|
||||||
userChiefCount += 1;
|
userChiefCount += 1;
|
||||||
}
|
}
|
||||||
if (permission) {
|
|
||||||
ambassadorCount += 1;
|
|
||||||
assignedAmbassadorIds.add(nextChief.id);
|
|
||||||
}
|
|
||||||
this.promotionPatches.push({
|
this.promotionPatches.push({
|
||||||
generalId: nextChief.id,
|
generalId: nextChief.id,
|
||||||
officerLevel: chiefLevel,
|
officerLevel: chiefLevel,
|
||||||
@@ -1355,25 +1331,6 @@ export class GeneralAI {
|
|||||||
chiefSet |= 1 << chiefLevel;
|
chiefSet |= 1 << chiefLevel;
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const candidate of generals) {
|
|
||||||
if (
|
|
||||||
asRecord(candidate.meta).permission !== 'ambassador' ||
|
|
||||||
assignedAmbassadorIds.has(candidate.id) ||
|
|
||||||
this.promotionPatches.some(
|
|
||||||
(patch) => patch.generalId === candidate.id && patch.permission === 'normal'
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
candidate.meta = { ...candidate.meta, permission: 'normal' };
|
|
||||||
this.promotionPatches.push({
|
|
||||||
generalId: candidate.id,
|
|
||||||
officerLevel: effectiveOfficerLevel.get(candidate.id) ?? candidate.officerLevel,
|
|
||||||
officerCity: readMetaNumber(asRecord(candidate.meta), 'officer_city', 0),
|
|
||||||
permission: 'normal',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (chiefSet !== initialChiefSet) {
|
if (chiefSet !== initialChiefSet) {
|
||||||
this.promotionNationMeta = {
|
this.promotionNationMeta = {
|
||||||
...this.nation.meta,
|
...this.nation.meta,
|
||||||
|
|||||||
@@ -5,8 +5,6 @@ import type { TurnRunBudget } from '../lifecycle/types.js';
|
|||||||
import { resolveDatabaseUrl } from '../scenario/databaseUrl.js';
|
import { resolveDatabaseUrl } from '../scenario/databaseUrl.js';
|
||||||
import { createTurnDaemonRuntime } from './turnDaemon.js';
|
import { createTurnDaemonRuntime } from './turnDaemon.js';
|
||||||
import { createTurnDaemonMemoryReporter } from './turnDaemonMemoryReporter.js';
|
import { createTurnDaemonMemoryReporter } from './turnDaemonMemoryReporter.js';
|
||||||
import { createGatewayProfileGate } from './gatewayProfileGate.js';
|
|
||||||
import { TurnDaemonLeaseUnavailableError } from '../lifecycle/databaseTurnDaemonLease.js';
|
|
||||||
|
|
||||||
export interface TurnDaemonCliOptions {
|
export interface TurnDaemonCliOptions {
|
||||||
profile?: string;
|
profile?: string;
|
||||||
@@ -91,27 +89,6 @@ export const runTurnDaemonCli = async (options: TurnDaemonCliOptions = {}): Prom
|
|||||||
pauseGateIntervalMs,
|
pauseGateIntervalMs,
|
||||||
adminActionIntervalMs,
|
adminActionIntervalMs,
|
||||||
gameClockMode,
|
gameClockMode,
|
||||||
}).catch(async (error: unknown) => {
|
|
||||||
// 중복 starter가 정상 owner를 멈추면 안 된다. 그 밖의 초기화 실패는
|
|
||||||
// lifecycle hook이 아직 없으므로 여기서 별도로 관리자에게 기록한다.
|
|
||||||
if (!(error instanceof TurnDaemonLeaseUnavailableError)) {
|
|
||||||
try {
|
|
||||||
const gate = await createGatewayProfileGate({
|
|
||||||
databaseUrl,
|
|
||||||
gatewayDatabaseUrl,
|
|
||||||
profileName,
|
|
||||||
incidentContext: () => ({ stage: 'startup' }),
|
|
||||||
});
|
|
||||||
try {
|
|
||||||
await gate.markPaused(error);
|
|
||||||
} finally {
|
|
||||||
await gate.close();
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
/* 원래 시작 실패를 보존한다. */
|
|
||||||
}
|
|
||||||
}
|
|
||||||
throw error;
|
|
||||||
});
|
});
|
||||||
|
|
||||||
const memoryReporter = createTurnDaemonMemoryReporter({
|
const memoryReporter = createTurnDaemonMemoryReporter({
|
||||||
|
|||||||
@@ -1,391 +0,0 @@
|
|||||||
import { createHash } from 'node:crypto';
|
|
||||||
|
|
||||||
import {
|
|
||||||
ChangeJournal,
|
|
||||||
GameClock,
|
|
||||||
formatServerDateTime,
|
|
||||||
parseGameClockPhase,
|
|
||||||
readTurnRecovery,
|
|
||||||
} from '@sammo-ts/common';
|
|
||||||
import { MESSAGE_MAILBOX_PUBLIC, resolveMessageTargetIcon, sendMessage } from '@sammo-ts/logic';
|
|
||||||
import {
|
|
||||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
|
||||||
GamePrisma,
|
|
||||||
persistMessageEnvelope,
|
|
||||||
writeReadModelChangeJournal,
|
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
|
||||||
type GamePrismaClient,
|
|
||||||
} from '@sammo-ts/infra';
|
|
||||||
|
|
||||||
export interface ClockProjectionRedis {
|
|
||||||
get(key: string): Promise<string | null>;
|
|
||||||
eval(script: string, options: { keys: string[]; arguments: string[] }): Promise<unknown>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ClaimedOutboxRow {
|
|
||||||
id: bigint;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DbWallRow {
|
|
||||||
wallNow: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ProjectionPayload {
|
|
||||||
version: 1;
|
|
||||||
profileName: string;
|
|
||||||
suspensionId: string;
|
|
||||||
sourceRevision: number;
|
|
||||||
targetRevision: number;
|
|
||||||
deadlineGeneration: number;
|
|
||||||
shiftTicks: number;
|
|
||||||
projectionDeltaMilliseconds: number;
|
|
||||||
clockBaseTime: string;
|
|
||||||
ticksPerSecond: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TournamentProjectionState {
|
|
||||||
stage?: number;
|
|
||||||
nextAt?: string;
|
|
||||||
nextTick?: number;
|
|
||||||
bettingCloseAt?: string;
|
|
||||||
bettingCloseTick?: number;
|
|
||||||
clockRevision?: number;
|
|
||||||
deadlineGeneration?: number;
|
|
||||||
[key: string]: unknown;
|
|
||||||
}
|
|
||||||
|
|
||||||
const APPLY_CLOCK_PROJECTION_SCRIPT = `
|
|
||||||
local active = redis.call('GET', KEYS[1])
|
|
||||||
if active == ARGV[2] then
|
|
||||||
if redis.call('GET', KEYS[5]) == ARGV[4] and redis.call('GET', KEYS[2]) == ARGV[3] then
|
|
||||||
return 2
|
|
||||||
end
|
|
||||||
return -3
|
|
||||||
end
|
|
||||||
if active and active ~= ARGV[1] then
|
|
||||||
return -1
|
|
||||||
end
|
|
||||||
if ARGV[5] ~= '__NONE__' and redis.call('GET', KEYS[4]) ~= ARGV[5] then
|
|
||||||
return -2
|
|
||||||
end
|
|
||||||
redis.call('DEL', KEYS[3])
|
|
||||||
local count = tonumber(ARGV[7])
|
|
||||||
local offset = 8
|
|
||||||
for index = 1, count do
|
|
||||||
redis.call('ZADD', KEYS[3], ARGV[offset], ARGV[offset + 1])
|
|
||||||
offset = offset + 2
|
|
||||||
end
|
|
||||||
if ARGV[5] ~= '__NONE__' then
|
|
||||||
redis.call('SET', KEYS[4], ARGV[6])
|
|
||||||
end
|
|
||||||
redis.call('SET', KEYS[1], ARGV[2])
|
|
||||||
redis.call('SET', KEYS[2], ARGV[3])
|
|
||||||
redis.call('SET', KEYS[5], ARGV[4])
|
|
||||||
redis.call('SET', KEYS[6], 'RUNNING')
|
|
||||||
return 1
|
|
||||||
`;
|
|
||||||
|
|
||||||
const safeInteger = (value: unknown, label: string): number => {
|
|
||||||
const result = typeof value === 'bigint' ? Number(value) : value;
|
|
||||||
if (typeof result !== 'number' || !Number.isSafeInteger(result)) {
|
|
||||||
throw new Error(`${label} must be a safe integer.`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const canonicalize = (value: unknown): unknown => {
|
|
||||||
if (typeof value === 'bigint') return value.toString();
|
|
||||||
if (Array.isArray(value)) return value.map(canonicalize);
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(value as Record<string, unknown>)
|
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
|
||||||
.map(([key, item]) => [key, canonicalize(item)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const stableJson = (value: unknown): string => JSON.stringify(canonicalize(value));
|
|
||||||
|
|
||||||
const checksum = (value: unknown): string => createHash('sha256').update(stableJson(value)).digest('hex');
|
|
||||||
|
|
||||||
const readDbWall = async (db: GamePrisma.TransactionClient): Promise<Date> => {
|
|
||||||
const rows = await db.$queryRaw<DbWallRow[]>(GamePrisma.sql`
|
|
||||||
SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS "wallNow"
|
|
||||||
`);
|
|
||||||
if (!rows[0]?.wallNow) throw new Error('Failed to read PostgreSQL wall time for the projection outbox.');
|
|
||||||
return rows[0].wallNow;
|
|
||||||
};
|
|
||||||
|
|
||||||
const parsePayload = (value: GamePrisma.JsonValue): ProjectionPayload => {
|
|
||||||
if (!value || typeof value !== 'object' || Array.isArray(value)) {
|
|
||||||
throw new Error('Clock projection outbox payload must be an object.');
|
|
||||||
}
|
|
||||||
const payload = value as Record<string, unknown>;
|
|
||||||
if (payload.version !== 1 || typeof payload.profileName !== 'string' || typeof payload.suspensionId !== 'string') {
|
|
||||||
throw new Error('Clock projection outbox payload identity is invalid.');
|
|
||||||
}
|
|
||||||
if (typeof payload.clockBaseTime !== 'string') {
|
|
||||||
throw new Error('Clock projection outbox is missing its projection base.');
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
version: 1,
|
|
||||||
profileName: payload.profileName,
|
|
||||||
suspensionId: payload.suspensionId,
|
|
||||||
sourceRevision: safeInteger(payload.sourceRevision, 'sourceRevision'),
|
|
||||||
targetRevision: safeInteger(payload.targetRevision, 'targetRevision'),
|
|
||||||
deadlineGeneration: safeInteger(payload.deadlineGeneration, 'deadlineGeneration'),
|
|
||||||
shiftTicks: safeInteger(payload.shiftTicks, 'shiftTicks'),
|
|
||||||
projectionDeltaMilliseconds: safeInteger(payload.projectionDeltaMilliseconds, 'projectionDeltaMilliseconds'),
|
|
||||||
clockBaseTime: payload.clockBaseTime,
|
|
||||||
ticksPerSecond: safeInteger(payload.ticksPerSecond, 'ticksPerSecond'),
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
const claimNext = async (db: GamePrismaClient, workerId: string) =>
|
|
||||||
db.$transaction(async (transaction) => {
|
|
||||||
const rows = await transaction.$queryRaw<ClaimedOutboxRow[]>(GamePrisma.sql`
|
|
||||||
SELECT id
|
|
||||||
FROM clock_projection_outbox
|
|
||||||
WHERE available_at <= (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')
|
|
||||||
AND (
|
|
||||||
status IN ('PENDING', 'FAILED')
|
|
||||||
OR (status = 'APPLYING' AND locked_at < (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') - INTERVAL '30 seconds')
|
|
||||||
)
|
|
||||||
ORDER BY id
|
|
||||||
LIMIT 1
|
|
||||||
FOR UPDATE SKIP LOCKED
|
|
||||||
`);
|
|
||||||
const id = rows[0]?.id;
|
|
||||||
if (id === undefined) return null;
|
|
||||||
const lockedAt = await readDbWall(transaction);
|
|
||||||
return transaction.clockProjectionOutbox.update({
|
|
||||||
where: { id },
|
|
||||||
data: {
|
|
||||||
status: 'APPLYING',
|
|
||||||
attempts: { increment: 1 },
|
|
||||||
lockedAt,
|
|
||||||
lockedBy: workerId,
|
|
||||||
lastError: null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const projectTournamentState = (
|
|
||||||
raw: string | null,
|
|
||||||
payload: ProjectionPayload,
|
|
||||||
clock: GameClock
|
|
||||||
): { expected: string; next: string } | null => {
|
|
||||||
if (!raw) return null;
|
|
||||||
const parsed = JSON.parse(raw) as TournamentProjectionState;
|
|
||||||
const active = typeof parsed.stage === 'number' && parsed.stage > 0;
|
|
||||||
if (active && parsed.nextAt && !Number.isSafeInteger(parsed.nextTick)) {
|
|
||||||
throw new Error('Active tournament nextAt lacks the authoritative nextTick dual-write.');
|
|
||||||
}
|
|
||||||
if (active && parsed.bettingCloseAt && !Number.isSafeInteger(parsed.bettingCloseTick)) {
|
|
||||||
throw new Error('Active tournament bettingCloseAt lacks the authoritative bettingCloseTick dual-write.');
|
|
||||||
}
|
|
||||||
const next: TournamentProjectionState = {
|
|
||||||
...parsed,
|
|
||||||
clockRevision: payload.targetRevision,
|
|
||||||
deadlineGeneration: payload.deadlineGeneration,
|
|
||||||
};
|
|
||||||
if (Number.isSafeInteger(parsed.nextTick)) {
|
|
||||||
next.nextTick = parsed.nextTick! + payload.shiftTicks;
|
|
||||||
next.nextAt = clock.tickToDate(next.nextTick).toISOString();
|
|
||||||
}
|
|
||||||
if (Number.isSafeInteger(parsed.bettingCloseTick)) {
|
|
||||||
next.bettingCloseTick = parsed.bettingCloseTick! + payload.shiftTicks;
|
|
||||||
next.bettingCloseAt = clock.tickToDate(next.bettingCloseTick).toISOString();
|
|
||||||
}
|
|
||||||
return { expected: raw, next: JSON.stringify(next) };
|
|
||||||
};
|
|
||||||
|
|
||||||
const recordFailure = async (db: GamePrismaClient, outboxId: bigint, error: unknown): Promise<void> => {
|
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
|
||||||
await db.$executeRaw(GamePrisma.sql`
|
|
||||||
UPDATE clock_projection_outbox
|
|
||||||
SET status = 'FAILED',
|
|
||||||
locked_at = NULL,
|
|
||||||
locked_by = NULL,
|
|
||||||
last_error = ${message.slice(0, 4_000)},
|
|
||||||
available_at = (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') + INTERVAL '1 second'
|
|
||||||
WHERE id = ${outboxId} AND status = 'APPLYING'
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
export const applyNextClockProjection = async (options: {
|
|
||||||
db: GamePrismaClient;
|
|
||||||
redis: ClockProjectionRedis;
|
|
||||||
workerId: string;
|
|
||||||
}): Promise<'IDLE' | 'APPLIED' | 'RECOVERED'> => {
|
|
||||||
if (!options.workerId.trim()) throw new Error('Clock projection worker ID is required.');
|
|
||||||
const outbox = await claimNext(options.db, options.workerId);
|
|
||||||
if (!outbox) return 'IDLE';
|
|
||||||
try {
|
|
||||||
const payload = parsePayload(outbox.payload);
|
|
||||||
if (checksum(outbox.payload) !== outbox.checksum) {
|
|
||||||
throw new Error('Clock projection outbox checksum verification failed.');
|
|
||||||
}
|
|
||||||
if (outbox.targetRevision !== BigInt(payload.targetRevision)) {
|
|
||||||
throw new Error('Clock projection payload revision differs from its outbox row.');
|
|
||||||
}
|
|
||||||
const world = await options.db.worldState.findUniqueOrThrow({ where: { id: outbox.worldStateId } });
|
|
||||||
if (
|
|
||||||
parseGameClockPhase(world.clockPhase) !== 'RECONCILING' ||
|
|
||||||
world.clockRevision !== outbox.targetRevision ||
|
|
||||||
world.deadlineGeneration !== BigInt(payload.deadlineGeneration) ||
|
|
||||||
!world.clockBaseTime
|
|
||||||
) {
|
|
||||||
throw new Error('Clock projection DB phase/revision/generation fence failed.');
|
|
||||||
}
|
|
||||||
const clock = new GameClock({
|
|
||||||
baseTime: new Date(payload.clockBaseTime),
|
|
||||||
tick: safeInteger(world.clockTick, 'world clock tick'),
|
|
||||||
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
|
|
||||||
wallAnchor: world.clockWallAnchor ?? new Date(),
|
|
||||||
turnSeconds: world.tickSeconds,
|
|
||||||
phase: 'RECONCILING',
|
|
||||||
revision: payload.targetRevision,
|
|
||||||
});
|
|
||||||
if (clock.ticksPerSecond !== payload.ticksPerSecond) {
|
|
||||||
throw new Error('Clock projection rate differs from the durable outbox payload.');
|
|
||||||
}
|
|
||||||
const auctions = await options.db.auction.findMany({
|
|
||||||
where: { status: { in: ['OPEN', 'FINALIZING'] } },
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
select: { id: true, closeTick: true },
|
|
||||||
});
|
|
||||||
const timers = auctions.map((auction) => {
|
|
||||||
if (auction.closeTick === null) {
|
|
||||||
throw new Error(`Active auction ${auction.id} lacks closeTick during projection rebuild.`);
|
|
||||||
}
|
|
||||||
return { score: safeInteger(auction.closeTick, `auction ${auction.id} closeTick`), id: String(auction.id) };
|
|
||||||
});
|
|
||||||
const prefix = `sammo:${payload.profileName}`;
|
|
||||||
const tournamentKey = `${prefix}:tournament:state`;
|
|
||||||
const tournament = projectTournamentState(await options.redis.get(tournamentKey), payload, clock);
|
|
||||||
const result = await options.redis.eval(APPLY_CLOCK_PROJECTION_SCRIPT, {
|
|
||||||
keys: [
|
|
||||||
`${prefix}:clock:active-revision`,
|
|
||||||
`${prefix}:clock:deadline-generation`,
|
|
||||||
`${prefix}:auction:timer`,
|
|
||||||
tournamentKey,
|
|
||||||
`${prefix}:clock:projection-checksum`,
|
|
||||||
`${prefix}:clock:phase`,
|
|
||||||
],
|
|
||||||
arguments: [
|
|
||||||
String(payload.sourceRevision),
|
|
||||||
String(payload.targetRevision),
|
|
||||||
String(payload.deadlineGeneration),
|
|
||||||
outbox.checksum,
|
|
||||||
tournament?.expected ?? '__NONE__',
|
|
||||||
tournament?.next ?? '__NONE__',
|
|
||||||
String(timers.length),
|
|
||||||
...timers.flatMap(({ score, id }) => [String(score), id]),
|
|
||||||
],
|
|
||||||
});
|
|
||||||
const applied = Number(result);
|
|
||||||
if (applied === -1) throw new Error('Redis active clock revision does not match the outbox source revision.');
|
|
||||||
if (applied === -2) throw new Error('Redis tournament state changed while rebuilding its projection.');
|
|
||||||
if (applied === -3) throw new Error('Redis target revision exists without the expected projection checksum.');
|
|
||||||
if (applied !== 1 && applied !== 2)
|
|
||||||
throw new Error(`Unexpected Redis clock projection result: ${String(result)}`);
|
|
||||||
|
|
||||||
await options.db.$transaction(async (transaction) => {
|
|
||||||
await acquireGameSchemaAdvisoryXactLock(transaction, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
|
||||||
await transaction.$queryRaw<ClaimedOutboxRow[]>(GamePrisma.sql`
|
|
||||||
SELECT id FROM world_state WHERE id = ${outbox.worldStateId} FOR UPDATE
|
|
||||||
`);
|
|
||||||
const finalized = await transaction.worldState.updateMany({
|
|
||||||
where: {
|
|
||||||
id: outbox.worldStateId,
|
|
||||||
clockPhase: 'RECONCILING',
|
|
||||||
clockRevision: outbox.targetRevision,
|
|
||||||
deadlineGeneration: BigInt(payload.deadlineGeneration),
|
|
||||||
},
|
|
||||||
data: { clockPhase: 'RUNNING' },
|
|
||||||
});
|
|
||||||
if (finalized.count !== 1) {
|
|
||||||
throw new Error('Clock projection final RUNNING transition fence failed.');
|
|
||||||
}
|
|
||||||
const appliedAt = await readDbWall(transaction);
|
|
||||||
// RUNNING 전이와 같은 transaction에 남겨 재시도 시 전체 공지를 중복 발송하지 않는다.
|
|
||||||
const recovery = readTurnRecovery(world);
|
|
||||||
const journal = new ChangeJournal();
|
|
||||||
journal.mark('world.content').mark('map.world');
|
|
||||||
if (recovery) {
|
|
||||||
const recoveredClock = new GameClock({
|
|
||||||
baseTime: world.clockBaseTime!,
|
|
||||||
tick: Number(world.clockTick),
|
|
||||||
wallAnchor: world.clockWallAnchor!,
|
|
||||||
mode: 'realtime',
|
|
||||||
turnSeconds: world.tickSeconds,
|
|
||||||
recovery,
|
|
||||||
});
|
|
||||||
const endsAt = recoveredClock.tickToWallDate(recovery.endTick);
|
|
||||||
const system = {
|
|
||||||
generalId: 0,
|
|
||||||
generalName: '시스템',
|
|
||||||
nationId: 0,
|
|
||||||
nationName: '',
|
|
||||||
color: '#000000',
|
|
||||||
icon: resolveMessageTargetIcon(),
|
|
||||||
};
|
|
||||||
await sendMessage(
|
|
||||||
{ insertMessage: (draft) => persistMessageEnvelope(transaction, draft) },
|
|
||||||
{
|
|
||||||
msgType: 'public',
|
|
||||||
src: system,
|
|
||||||
dest: system,
|
|
||||||
text: `서버 재개에 따른 2배속 복구 시간: ${formatServerDateTime(recovery.startWallAt)} ~ ${formatServerDateTime(endsAt)} (한국 시각). 시작 전까지 대기하며, 종료 시 정상 속도로 진행합니다.`,
|
|
||||||
time: appliedAt,
|
|
||||||
validUntil: new Date('9999-12-31T00:00:00Z'),
|
|
||||||
option: {
|
|
||||||
recoveryStartsAt: recovery.startWallAt.toISOString(),
|
|
||||||
recoveryEndsAt: endsAt.toISOString(),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
);
|
|
||||||
journal.mark('messages.mailbox', MESSAGE_MAILBOX_PUBLIC);
|
|
||||||
}
|
|
||||||
await writeReadModelChangeJournal(transaction, journal.snapshot());
|
|
||||||
|
|
||||||
await transaction.clockProjectionOutbox.update({
|
|
||||||
where: { id: outbox.id },
|
|
||||||
data: { status: 'APPLIED', appliedAt, lockedAt: null, lockedBy: null, lastError: null },
|
|
||||||
});
|
|
||||||
if (outbox.suspensionId) {
|
|
||||||
await transaction.clockSuspension.update({
|
|
||||||
where: { id: outbox.suspensionId },
|
|
||||||
data: { status: 'APPLIED' },
|
|
||||||
});
|
|
||||||
}
|
|
||||||
});
|
|
||||||
return applied === 2 ? 'RECOVERED' : 'APPLIED';
|
|
||||||
} catch (error) {
|
|
||||||
await recordFailure(options.db, outbox.id, error);
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
export const loadClockReconciliationReadiness = async (db: GamePrismaClient) => {
|
|
||||||
const world = await db.worldState.findFirst({
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
select: { clockPhase: true, clockRevision: true, deadlineGeneration: true },
|
|
||||||
});
|
|
||||||
const incompleteOutboxCount = await db.clockProjectionOutbox.count({ where: { status: { not: 'APPLIED' } } });
|
|
||||||
if (!world) {
|
|
||||||
return { ready: false, phase: null, revision: null, deadlineGeneration: null, incompleteOutboxCount };
|
|
||||||
}
|
|
||||||
const phase = parseGameClockPhase(world.clockPhase);
|
|
||||||
return {
|
|
||||||
ready: phase !== 'RECONCILING' && incompleteOutboxCount === 0,
|
|
||||||
gameplayEnabled: phase === 'RUNNING' || phase === 'MANUAL',
|
|
||||||
phase,
|
|
||||||
revision: safeInteger(world.clockRevision, 'clock revision'),
|
|
||||||
deadlineGeneration: safeInteger(world.deadlineGeneration, 'deadline generation'),
|
|
||||||
incompleteOutboxCount,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
@@ -1,1141 +0,0 @@
|
|||||||
import { createHash } from 'node:crypto';
|
|
||||||
|
|
||||||
import {
|
|
||||||
GAME_TICKS_PER_TURN,
|
|
||||||
MAX_SAFE_GAME_TICK,
|
|
||||||
GameClock,
|
|
||||||
buildClockAlignmentPlan,
|
|
||||||
parseClockAlignmentPolicy,
|
|
||||||
parseGameClockPhase,
|
|
||||||
readTurnRecovery,
|
|
||||||
readSerializedTurnRecovery,
|
|
||||||
serializeTurnRecovery,
|
|
||||||
type TurnRecoveryWindow,
|
|
||||||
type ClockAlignmentPolicy,
|
|
||||||
} from '@sammo-ts/common';
|
|
||||||
import {
|
|
||||||
CLOCK_OPERATION_PERSISTENCE_LOCK,
|
|
||||||
GENERAL_ACCESS_PERSISTENCE_LOCK,
|
|
||||||
GamePrisma,
|
|
||||||
acquireGameSchemaAdvisoryXactLock,
|
|
||||||
type GamePrismaClient,
|
|
||||||
} from '@sammo-ts/infra';
|
|
||||||
|
|
||||||
export type ClockSuspensionSource = 'MAINTENANCE' | 'OPEN_DELAY' | 'UNIFICATION_WAIT' | 'RECOVERY';
|
|
||||||
|
|
||||||
export type ClockOperationAuthority =
|
|
||||||
| { kind: 'DAEMON'; profileName: string; ownerId: string; fencingEpoch: bigint }
|
|
||||||
| { kind: 'OFFLINE'; profileName: string; reason: string };
|
|
||||||
|
|
||||||
export interface ClockSuspensionResult {
|
|
||||||
suspensionId: string;
|
|
||||||
phase: 'SUSPENDED';
|
|
||||||
sourceRevision: number;
|
|
||||||
targetRevision: number;
|
|
||||||
cutTick: number;
|
|
||||||
cutWallAt: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ClockReconciliationResult {
|
|
||||||
suspensionId: string;
|
|
||||||
phase: 'RECONCILING';
|
|
||||||
sourceRevision: number;
|
|
||||||
targetRevision: number;
|
|
||||||
deadlineGeneration: number;
|
|
||||||
gapTicks: number;
|
|
||||||
catchUpTicks: number;
|
|
||||||
shiftTicks: number;
|
|
||||||
alignedTick: number;
|
|
||||||
resumeWallAt: Date;
|
|
||||||
recovery?: TurnRecoveryWindow | null;
|
|
||||||
resumeAnchor?: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface DbWallRow {
|
|
||||||
wallNow: Date;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface LeaseFenceRow {
|
|
||||||
ownerId: string;
|
|
||||||
fencingEpoch: bigint;
|
|
||||||
valid: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface IdRow {
|
|
||||||
id: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface TextIdRow {
|
|
||||||
id: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface ParticipantSnapshot {
|
|
||||||
key: string;
|
|
||||||
policy: 'SHIFT' | 'KEEP' | 'REBUILD';
|
|
||||||
checksum: string;
|
|
||||||
count: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
const asJson = (value: unknown): GamePrisma.InputJsonValue => value as GamePrisma.InputJsonValue;
|
|
||||||
|
|
||||||
const safeNumber = (value: bigint, label: string): number => {
|
|
||||||
const result = Number(value);
|
|
||||||
if (!Number.isSafeInteger(result)) {
|
|
||||||
throw new Error(`${label} is outside the JavaScript safe integer range: ${value}`);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
};
|
|
||||||
|
|
||||||
const canonicalize = (value: unknown): unknown => {
|
|
||||||
if (typeof value === 'bigint') return value.toString();
|
|
||||||
if (Array.isArray(value)) return value.map(canonicalize);
|
|
||||||
if (value && typeof value === 'object') {
|
|
||||||
return Object.fromEntries(
|
|
||||||
Object.entries(value as Record<string, unknown>)
|
|
||||||
.sort(([left], [right]) => left.localeCompare(right))
|
|
||||||
.map(([key, item]) => [key, canonicalize(item)])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
const stableJson = (value: unknown): string => JSON.stringify(canonicalize(value));
|
|
||||||
|
|
||||||
const checksum = (value: unknown): string => createHash('sha256').update(stableJson(value)).digest('hex');
|
|
||||||
|
|
||||||
const aggregateChecksum = (participants: readonly ParticipantSnapshot[]): string =>
|
|
||||||
checksum(participants.map(({ key, policy, checksum: value, count }) => ({ key, policy, checksum: value, count })));
|
|
||||||
|
|
||||||
const readDbWall = async (db: GamePrisma.TransactionClient): Promise<Date> => {
|
|
||||||
const rows = await db.$queryRaw<DbWallRow[]>(GamePrisma.sql`
|
|
||||||
SELECT (CURRENT_TIMESTAMP AT TIME ZONE 'UTC')::timestamp(3) AS "wallNow"
|
|
||||||
`);
|
|
||||||
const wallNow = rows[0]?.wallNow;
|
|
||||||
if (!wallNow || Number.isNaN(wallNow.getTime())) {
|
|
||||||
throw new Error('Failed to read the PostgreSQL wall clock.');
|
|
||||||
}
|
|
||||||
return wallNow;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const readClockDatabaseWall = readDbWall;
|
|
||||||
|
|
||||||
const isRetryableSerializableClockError = (error: unknown): boolean => {
|
|
||||||
if (!error || typeof error !== 'object') return false;
|
|
||||||
const value = error as { code?: unknown; message?: unknown; meta?: unknown };
|
|
||||||
const meta =
|
|
||||||
value.meta && typeof value.meta === 'object'
|
|
||||||
? (value.meta as { code?: unknown; message?: unknown })
|
|
||||||
: undefined;
|
|
||||||
const code = typeof value.code === 'string' ? value.code : '';
|
|
||||||
const databaseCode = typeof meta?.code === 'string' ? meta.code : '';
|
|
||||||
const message = [value.message, meta?.message]
|
|
||||||
.filter((entry): entry is string => typeof entry === 'string')
|
|
||||||
.join(' ');
|
|
||||||
return (
|
|
||||||
code === 'P2034' ||
|
|
||||||
code === '40001' ||
|
|
||||||
databaseCode === '40001' ||
|
|
||||||
message.includes('40001') ||
|
|
||||||
message.includes('could not serialize access')
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const runSerializableClockOperation = async <T>(operation: () => Promise<T>): Promise<T> => {
|
|
||||||
const maxAttempts = 3;
|
|
||||||
for (let attempt = 1; ; attempt += 1) {
|
|
||||||
try {
|
|
||||||
return await operation();
|
|
||||||
} catch (error) {
|
|
||||||
if (attempt >= maxAttempts || !isRetryableSerializableClockError(error)) {
|
|
||||||
throw error;
|
|
||||||
}
|
|
||||||
await new Promise<void>((resolve) => setTimeout(resolve, attempt * 10));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const verifyAuthority = async (db: GamePrisma.TransactionClient, authority: ClockOperationAuthority): Promise<void> => {
|
|
||||||
const rows = await db.$queryRaw<LeaseFenceRow[]>(GamePrisma.sql`
|
|
||||||
SELECT owner_id AS "ownerId",
|
|
||||||
fencing_epoch AS "fencingEpoch",
|
|
||||||
lease_until > (CURRENT_TIMESTAMP AT TIME ZONE 'UTC') AS valid
|
|
||||||
FROM turn_daemon_lease
|
|
||||||
WHERE profile = ${authority.profileName}
|
|
||||||
FOR UPDATE
|
|
||||||
`);
|
|
||||||
const lease = rows[0];
|
|
||||||
if (authority.kind === 'OFFLINE') {
|
|
||||||
if (!authority.reason.trim()) {
|
|
||||||
throw new Error('Offline clock operations require an audit reason.');
|
|
||||||
}
|
|
||||||
if (lease?.valid) {
|
|
||||||
throw new Error(`Clock operation requires the ${authority.profileName} daemon lease to be offline.`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (!lease?.valid || lease.ownerId !== authority.ownerId || lease.fencingEpoch !== authority.fencingEpoch) {
|
|
||||||
throw new Error(`Stale turn-daemon fencing authority for profile ${authority.profileName}.`);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const lockWorld = async (db: GamePrisma.TransactionClient): Promise<number> => {
|
|
||||||
const rows = await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
|
||||||
SELECT id FROM world_state ORDER BY id LIMIT 2 FOR UPDATE
|
|
||||||
`);
|
|
||||||
if (rows.length !== 1) {
|
|
||||||
throw new Error(`Clock reconciliation requires exactly one world_state row; found ${rows.length}.`);
|
|
||||||
}
|
|
||||||
return rows[0]!.id;
|
|
||||||
};
|
|
||||||
|
|
||||||
const lockParticipants = async (db: GamePrisma.TransactionClient, _cutTick: bigint): Promise<void> => {
|
|
||||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`SELECT id FROM general ORDER BY id FOR UPDATE`);
|
|
||||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
|
||||||
SELECT id FROM auction
|
|
||||||
WHERE status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
|
|
||||||
ORDER BY id FOR UPDATE
|
|
||||||
`);
|
|
||||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
|
||||||
SELECT bid.id
|
|
||||||
FROM auction_bid AS bid
|
|
||||||
JOIN auction ON auction.id = bid.auction_id
|
|
||||||
WHERE auction.status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
|
|
||||||
ORDER BY bid.id FOR UPDATE OF bid
|
|
||||||
`);
|
|
||||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
|
||||||
SELECT message_id AS id FROM message_action
|
|
||||||
WHERE status = 'PENDING'
|
|
||||||
ORDER BY message_id FOR UPDATE
|
|
||||||
`);
|
|
||||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`SELECT id FROM inheritance_ledger ORDER BY id FOR UPDATE`);
|
|
||||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
|
||||||
SELECT id FROM vote_poll WHERE closed_at IS NULL ORDER BY id FOR UPDATE
|
|
||||||
`);
|
|
||||||
await db.$queryRaw<IdRow[]>(GamePrisma.sql`
|
|
||||||
SELECT id FROM select_pool WHERE general_id IS NULL ORDER BY id FOR UPDATE
|
|
||||||
`);
|
|
||||||
await db.$queryRaw<TextIdRow[]>(GamePrisma.sql`
|
|
||||||
SELECT owner_user_id AS id FROM select_npc_token ORDER BY owner_user_id FOR UPDATE
|
|
||||||
`);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Gateway는 아직 배포하지 않은 profile의 Prisma 모델로 기존 시계 프로토콜을 처리한다.
|
|
||||||
const supportsRecoveryColumns = (db: GamePrisma.TransactionClient): boolean =>
|
|
||||||
Boolean(db.worldState.fields?.clockRecoveryStartTick);
|
|
||||||
|
|
||||||
const readParticipantSnapshots = async (
|
|
||||||
db: GamePrisma.TransactionClient,
|
|
||||||
worldStateId: number,
|
|
||||||
cutTick: bigint
|
|
||||||
): Promise<ParticipantSnapshot[]> => {
|
|
||||||
const [world, generals, auctions, auctionBids, messages, inheritanceEffects, votes, pool, npcTokens, commands] =
|
|
||||||
await Promise.all([
|
|
||||||
db.worldState.findUniqueOrThrow({
|
|
||||||
where: { id: worldStateId },
|
|
||||||
select: {
|
|
||||||
clockTick: true,
|
|
||||||
...(supportsRecoveryColumns(db)
|
|
||||||
? {
|
|
||||||
clockRecoveryStartTick: true,
|
|
||||||
clockRecoveryEndTick: true,
|
|
||||||
clockRecoveryStartWallAt: true,
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
clockRevision: true,
|
|
||||||
deadlineGeneration: true,
|
|
||||||
lastTurnTick: true,
|
|
||||||
meta: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
db.general.findMany({
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
select: { id: true, turnTick: true, recentWarTick: true, meta: true },
|
|
||||||
}),
|
|
||||||
db.auction.findMany({
|
|
||||||
where: { status: { in: ['OPEN', 'FINALIZING'] } },
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
select: { id: true, status: true, openTick: true, closeTick: true },
|
|
||||||
}),
|
|
||||||
db.auctionBid.findMany({
|
|
||||||
where: { auction: { status: { in: ['OPEN', 'FINALIZING'] } } },
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
select: { id: true, occurredGameTick: true },
|
|
||||||
}),
|
|
||||||
db.messageAction.findMany({
|
|
||||||
where: { status: 'PENDING' },
|
|
||||||
orderBy: { messageId: 'asc' },
|
|
||||||
select: {
|
|
||||||
messageId: true,
|
|
||||||
createdGameTick: true,
|
|
||||||
expiresGameTick: true,
|
|
||||||
clockRevision: true,
|
|
||||||
deadlineGeneration: true,
|
|
||||||
},
|
|
||||||
}),
|
|
||||||
db.inheritanceLedger.findMany({
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
select: { id: true, appliedClockRevision: true, appliedDeadlineGeneration: true },
|
|
||||||
}),
|
|
||||||
db.votePoll.findMany({
|
|
||||||
where: { closedAt: null },
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
select: { id: true, startTick: true, endTick: true },
|
|
||||||
}),
|
|
||||||
db.selectPoolEntry.findMany({
|
|
||||||
where: { generalId: null },
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
select: { id: true, reservedUntilTick: true },
|
|
||||||
}),
|
|
||||||
db.npcSelectionToken.findMany({
|
|
||||||
orderBy: { ownerUserId: 'asc' },
|
|
||||||
select: { ownerUserId: true, validUntilTick: true, pickMoreFromTick: true },
|
|
||||||
}),
|
|
||||||
db.inputEvent.findMany({
|
|
||||||
where: { status: { in: ['PENDING', 'PROCESSING'] } },
|
|
||||||
orderBy: { sequence: 'asc' },
|
|
||||||
select: { sequence: true, acceptedGameTick: true, acceptedClockRevision: true },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const snapshot = (key: string, policy: ParticipantSnapshot['policy'], rows: unknown[]): ParticipantSnapshot => ({
|
|
||||||
key,
|
|
||||||
policy,
|
|
||||||
checksum: checksum(rows),
|
|
||||||
count: rows.length,
|
|
||||||
});
|
|
||||||
const meta = world.meta && typeof world.meta === 'object' && !Array.isArray(world.meta) ? world.meta : {};
|
|
||||||
return [
|
|
||||||
snapshot('world-clock', 'REBUILD', [
|
|
||||||
{
|
|
||||||
clockTick: world.clockTick,
|
|
||||||
...serializeTurnRecovery(readTurnRecovery(world)),
|
|
||||||
clockRevision: world.clockRevision,
|
|
||||||
deadlineGeneration: world.deadlineGeneration,
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
snapshot('turn-cursor', 'SHIFT', [{ lastTurnTick: world.lastTurnTick }]),
|
|
||||||
snapshot(
|
|
||||||
'general-next-turn',
|
|
||||||
'SHIFT',
|
|
||||||
generals.map(({ id, turnTick }) => ({ id, turnTick }))
|
|
||||||
),
|
|
||||||
snapshot(
|
|
||||||
'general-recent-war-occurrence',
|
|
||||||
'KEEP',
|
|
||||||
generals.map(({ id, recentWarTick }) => ({ id, recentWarTick }))
|
|
||||||
),
|
|
||||||
snapshot(
|
|
||||||
'selection-reselection-deadline',
|
|
||||||
'SHIFT',
|
|
||||||
generals.flatMap(({ id, meta: generalMeta }) => {
|
|
||||||
const raw =
|
|
||||||
generalMeta && typeof generalMeta === 'object' && !Array.isArray(generalMeta)
|
|
||||||
? Reflect.get(generalMeta, 'next_change_tick')
|
|
||||||
: null;
|
|
||||||
const value = typeof raw === 'number' ? raw : typeof raw === 'string' ? Number(raw) : Number.NaN;
|
|
||||||
return Number.isSafeInteger(value) && BigInt(value) >= cutTick ? [{ id, nextChangeTick: value }] : [];
|
|
||||||
})
|
|
||||||
),
|
|
||||||
snapshot(
|
|
||||||
'auction-open-occurrence',
|
|
||||||
'KEEP',
|
|
||||||
auctions.map(({ id, openTick }) => ({ id, openTick }))
|
|
||||||
),
|
|
||||||
snapshot(
|
|
||||||
'auction-deadline',
|
|
||||||
'SHIFT',
|
|
||||||
auctions.map(({ id, status, closeTick }) => ({ id, status, closeTick }))
|
|
||||||
),
|
|
||||||
snapshot('auction-bid-occurrence', 'KEEP', auctionBids),
|
|
||||||
snapshot(
|
|
||||||
'auction-finalizing-recovery',
|
|
||||||
'REBUILD',
|
|
||||||
auctions.map(({ id, status }) => ({ id, status }))
|
|
||||||
),
|
|
||||||
snapshot(
|
|
||||||
'message-action-occurrence',
|
|
||||||
'KEEP',
|
|
||||||
messages.map(({ messageId, createdGameTick }) => ({ messageId, createdGameTick }))
|
|
||||||
),
|
|
||||||
snapshot(
|
|
||||||
'message-action-expiry',
|
|
||||||
'SHIFT',
|
|
||||||
messages
|
|
||||||
.filter(({ expiresGameTick }) => expiresGameTick !== null && expiresGameTick >= cutTick)
|
|
||||||
.map(({ messageId, expiresGameTick }) => ({ messageId, expiresGameTick }))
|
|
||||||
),
|
|
||||||
snapshot(
|
|
||||||
'message-action-clock-coordinate',
|
|
||||||
'REBUILD',
|
|
||||||
messages.map(({ messageId, clockRevision, deadlineGeneration }) => ({
|
|
||||||
messageId,
|
|
||||||
clockRevision,
|
|
||||||
deadlineGeneration,
|
|
||||||
}))
|
|
||||||
),
|
|
||||||
snapshot('inheritance-effect-coordinate', 'KEEP', inheritanceEffects),
|
|
||||||
snapshot(
|
|
||||||
'vote-start-occurrence',
|
|
||||||
'KEEP',
|
|
||||||
votes.map(({ id, startTick }) => ({ id, startTick }))
|
|
||||||
),
|
|
||||||
snapshot(
|
|
||||||
'vote-end-deadline',
|
|
||||||
'SHIFT',
|
|
||||||
votes.map(({ id, endTick }) => ({ id, endTick }))
|
|
||||||
),
|
|
||||||
snapshot('select-pool-reservation', 'SHIFT', pool),
|
|
||||||
snapshot('npc-selection-window', 'SHIFT', npcTokens),
|
|
||||||
snapshot('daemon-command-coordinate', 'KEEP', commands),
|
|
||||||
snapshot('movable-json-rule-anchors', 'SHIFT', [
|
|
||||||
{
|
|
||||||
lastTurnTime: Reflect.get(meta, 'lastTurnTime'),
|
|
||||||
turntime: Reflect.get(meta, 'turntime'),
|
|
||||||
starttime: Reflect.get(meta, 'starttime'),
|
|
||||||
tnmt_time: Reflect.get(meta, 'tnmt_time'),
|
|
||||||
},
|
|
||||||
]),
|
|
||||||
];
|
|
||||||
};
|
|
||||||
|
|
||||||
const persistInitialParticipants = async (
|
|
||||||
db: GamePrisma.TransactionClient,
|
|
||||||
suspensionId: string,
|
|
||||||
participants: readonly ParticipantSnapshot[]
|
|
||||||
): Promise<void> => {
|
|
||||||
for (const participant of participants) {
|
|
||||||
await db.clockReconciliationParticipant.create({
|
|
||||||
data: {
|
|
||||||
suspensionId,
|
|
||||||
participantKey: participant.key,
|
|
||||||
policy: participant.policy,
|
|
||||||
beforeChecksum: participant.checksum,
|
|
||||||
afterChecksum: participant.checksum,
|
|
||||||
affectedCount: 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Locks every registered participant before an enclosing transaction mutates
|
|
||||||
* the world into a suspended state. The caller must already hold the daemon,
|
|
||||||
* clock-operation, general-access, and world-row lock prefix.
|
|
||||||
*/
|
|
||||||
export const prepareClockSuspensionUnderHeldLocks = async (options: {
|
|
||||||
db: GamePrisma.TransactionClient;
|
|
||||||
cutTick: number;
|
|
||||||
cutWallAt?: Date;
|
|
||||||
}): Promise<{ cutWallAt: Date }> => {
|
|
||||||
if (!Number.isSafeInteger(options.cutTick)) {
|
|
||||||
throw new Error(`Clock suspension cut tick is outside the safe integer range: ${options.cutTick}.`);
|
|
||||||
}
|
|
||||||
const cutWallAt = options.cutWallAt ? new Date(options.cutWallAt.getTime()) : await readDbWall(options.db);
|
|
||||||
await lockParticipants(options.db, BigInt(options.cutTick));
|
|
||||||
return { cutWallAt };
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Persists the ledger after all suspension-boundary gameplay writes have been
|
|
||||||
* staged in the same transaction. Lock acquisition belongs to
|
|
||||||
* prepareClockSuspensionUnderHeldLocks and must happen first.
|
|
||||||
*/
|
|
||||||
export const persistClockSuspensionLedgerUnderHeldLocks = async (options: {
|
|
||||||
db: GamePrisma.TransactionClient;
|
|
||||||
suspensionId: string;
|
|
||||||
worldStateId: number;
|
|
||||||
profileName: string;
|
|
||||||
source: ClockSuspensionSource;
|
|
||||||
cutTick: number;
|
|
||||||
cutWallAt: Date;
|
|
||||||
rateTicksPerSecond: number;
|
|
||||||
sourceRevision: number;
|
|
||||||
normalTickAtCutWall?: number;
|
|
||||||
policy?: ClockAlignmentPolicy;
|
|
||||||
catchUpTicks?: number;
|
|
||||||
}): Promise<void> => {
|
|
||||||
if (!options.suspensionId.trim() || options.suspensionId.length > 64) {
|
|
||||||
throw new Error('Clock suspension ID must contain 1-64 characters.');
|
|
||||||
}
|
|
||||||
const policy = options.policy ?? 'EXACT';
|
|
||||||
const catchUpTicks = options.catchUpTicks ?? 0;
|
|
||||||
if (!Number.isSafeInteger(catchUpTicks) || catchUpTicks < 0) {
|
|
||||||
throw new Error('Clock suspension catch-up ticks must be a non-negative safe integer.');
|
|
||||||
}
|
|
||||||
const existing = await options.db.clockSuspension.findUnique({ where: { id: options.suspensionId } });
|
|
||||||
if (existing) {
|
|
||||||
if (
|
|
||||||
existing.worldStateId !== options.worldStateId ||
|
|
||||||
existing.source !== options.source ||
|
|
||||||
existing.sourceRevision !== BigInt(options.sourceRevision)
|
|
||||||
) {
|
|
||||||
throw new Error(`Clock suspension ID ${options.suspensionId} is already bound to another operation.`);
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const world = await options.db.worldState.findUniqueOrThrow({ where: { id: options.worldStateId } });
|
|
||||||
if (
|
|
||||||
parseGameClockPhase(world.clockPhase) !== 'SUSPENDED' ||
|
|
||||||
world.clockRevision !== BigInt(options.sourceRevision)
|
|
||||||
) {
|
|
||||||
throw new Error('Clock suspension ledger requires a matching durable SUSPENDED world revision.');
|
|
||||||
}
|
|
||||||
const participants = await readParticipantSnapshots(options.db, options.worldStateId, BigInt(options.cutTick));
|
|
||||||
await options.db.clockSuspension.create({
|
|
||||||
data: {
|
|
||||||
id: options.suspensionId,
|
|
||||||
worldStateId: options.worldStateId,
|
|
||||||
source: options.source,
|
|
||||||
policy,
|
|
||||||
status: 'SUSPENDED',
|
|
||||||
sourceRevision: BigInt(options.sourceRevision),
|
|
||||||
targetRevision: BigInt(options.sourceRevision + 1),
|
|
||||||
cutTick: BigInt(options.cutTick),
|
|
||||||
cutWallAt: options.cutWallAt,
|
|
||||||
rateTicksPerSecond: options.rateTicksPerSecond,
|
|
||||||
catchUpTicks: BigInt(catchUpTicks),
|
|
||||||
participantChecksumBefore: aggregateChecksum(participants),
|
|
||||||
detail: asJson({
|
|
||||||
authority: 'DAEMON',
|
|
||||||
profileName: options.profileName,
|
|
||||||
normalTickAtCutWall: Math.max(options.cutTick, options.normalTickAtCutWall ?? options.cutTick),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await persistInitialParticipants(options.db, options.suspensionId, participants);
|
|
||||||
};
|
|
||||||
|
|
||||||
const shiftMetaDate = (value: unknown, deltaMilliseconds: number): unknown => {
|
|
||||||
if (typeof value !== 'string' || !value.trim()) return value;
|
|
||||||
const parsed = new Date(value);
|
|
||||||
if (Number.isNaN(parsed.getTime())) return value;
|
|
||||||
return new Date(parsed.getTime() + deltaMilliseconds).toISOString();
|
|
||||||
};
|
|
||||||
|
|
||||||
const shiftedMeta = (value: GamePrisma.JsonValue, deltaMilliseconds: number): GamePrisma.InputJsonValue => {
|
|
||||||
const meta = value && typeof value === 'object' && !Array.isArray(value) ? { ...value } : {};
|
|
||||||
for (const key of ['lastTurnTime', 'turntime', 'starttime', 'tnmt_time'] as const) {
|
|
||||||
if (Object.hasOwn(meta, key)) {
|
|
||||||
Reflect.set(meta, key, shiftMetaDate(Reflect.get(meta, key), deltaMilliseconds));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return asJson(meta);
|
|
||||||
};
|
|
||||||
|
|
||||||
const assertShiftFits = (participants: readonly ParticipantSnapshot[], shiftTicks: number): void => {
|
|
||||||
if (!Number.isSafeInteger(shiftTicks) || shiftTicks < 0) {
|
|
||||||
throw new Error(`Invalid reconciliation shift: ${shiftTicks}`);
|
|
||||||
}
|
|
||||||
// Checksums retain stringified values for audit; actual row ranges are
|
|
||||||
// checked by PostgreSQL BIGINT and the world aligned tick is checked by the
|
|
||||||
// shared GameClock plan. The sentinel expiry is deliberately never shifted.
|
|
||||||
if (participants.some((participant) => !participant.checksum)) {
|
|
||||||
throw new Error('Participant snapshot is incomplete.');
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const assertScheduleRanges = async (db: GamePrisma.TransactionClient, shiftTicks: number): Promise<void> => {
|
|
||||||
const shift = BigInt(shiftTicks);
|
|
||||||
const maximum = BigInt(MAX_SAFE_GAME_TICK) - shift;
|
|
||||||
const [general, reselection, auction, message, vote, pool, npcValid, npcMore] = await Promise.all([
|
|
||||||
db.general.aggregate({ _max: { turnTick: true }, where: { turnTick: { not: null } } }),
|
|
||||||
db.$queryRaw<Array<{ maxTick: bigint | null }>>(GamePrisma.sql`
|
|
||||||
SELECT MAX((meta->>'next_change_tick')::bigint) AS "maxTick"
|
|
||||||
FROM general
|
|
||||||
WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$'
|
|
||||||
`),
|
|
||||||
db.auction.aggregate({
|
|
||||||
_max: { closeTick: true },
|
|
||||||
where: { status: { in: ['OPEN', 'FINALIZING'] }, closeTick: { not: null } },
|
|
||||||
}),
|
|
||||||
db.messageAction.aggregate({
|
|
||||||
_max: { expiresGameTick: true },
|
|
||||||
where: { status: 'PENDING', expiresGameTick: { not: null } },
|
|
||||||
}),
|
|
||||||
db.votePoll.aggregate({ _max: { endTick: true }, where: { closedAt: null, endTick: { not: null } } }),
|
|
||||||
db.selectPoolEntry.aggregate({
|
|
||||||
_max: { reservedUntilTick: true },
|
|
||||||
where: { generalId: null, reservedUntilTick: { not: null } },
|
|
||||||
}),
|
|
||||||
db.npcSelectionToken.aggregate({ _max: { validUntilTick: true }, where: { validUntilTick: { not: null } } }),
|
|
||||||
db.npcSelectionToken.aggregate({
|
|
||||||
_max: { pickMoreFromTick: true },
|
|
||||||
where: { pickMoreFromTick: { not: null } },
|
|
||||||
}),
|
|
||||||
]);
|
|
||||||
const values: Array<[string, bigint | null]> = [
|
|
||||||
['general.turn_tick', general._max.turnTick],
|
|
||||||
['general.meta.next_change_tick', reselection[0]?.maxTick ?? null],
|
|
||||||
['auction.close_tick', auction._max.closeTick],
|
|
||||||
['message_action.expires_game_tick', message._max.expiresGameTick],
|
|
||||||
['vote_poll.end_tick', vote._max.endTick],
|
|
||||||
['select_pool.reserved_until_tick', pool._max.reservedUntilTick],
|
|
||||||
['select_npc_token.valid_until_tick', npcValid._max.validUntilTick],
|
|
||||||
['select_npc_token.pick_more_from_tick', npcMore._max.pickMoreFromTick],
|
|
||||||
];
|
|
||||||
for (const [label, value] of values) {
|
|
||||||
if (value !== null && value > maximum) {
|
|
||||||
throw new Error(`${label} would exceed the safe game tick range after reconciliation.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const applyParticipantShift = async (
|
|
||||||
db: GamePrisma.TransactionClient,
|
|
||||||
worldStateId: number,
|
|
||||||
cutTick: bigint,
|
|
||||||
alignedTick: bigint,
|
|
||||||
targetRevision: bigint,
|
|
||||||
targetGeneration: bigint,
|
|
||||||
shiftTicks: bigint,
|
|
||||||
projectionDeltaMilliseconds: number,
|
|
||||||
resumeWallAt: Date
|
|
||||||
): Promise<Map<string, number>> => {
|
|
||||||
const affected = new Map<string, number>();
|
|
||||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId }, select: { meta: true } });
|
|
||||||
const cursor = await db.worldState.updateMany({
|
|
||||||
where: { id: worldStateId, lastTurnTick: { not: null } },
|
|
||||||
data: { lastTurnTick: { increment: shiftTicks } },
|
|
||||||
});
|
|
||||||
affected.set('turn-cursor', cursor.count);
|
|
||||||
affected.set(
|
|
||||||
'general-next-turn',
|
|
||||||
await db.$executeRaw(GamePrisma.sql`
|
|
||||||
UPDATE general
|
|
||||||
SET turn_tick = turn_tick + ${shiftTicks},
|
|
||||||
turn_time = turn_time + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
|
||||||
WHERE turn_tick IS NOT NULL
|
|
||||||
`)
|
|
||||||
);
|
|
||||||
affected.set(
|
|
||||||
'selection-reselection-deadline',
|
|
||||||
await db.$executeRaw(GamePrisma.sql`
|
|
||||||
UPDATE general
|
|
||||||
SET meta = jsonb_set(
|
|
||||||
jsonb_set(
|
|
||||||
jsonb_set(
|
|
||||||
meta,
|
|
||||||
'{next_change_tick}',
|
|
||||||
to_jsonb((meta->>'next_change_tick')::bigint + ${shiftTicks}),
|
|
||||||
true
|
|
||||||
),
|
|
||||||
'{next_change}',
|
|
||||||
to_jsonb(((meta->>'next_change')::timestamp
|
|
||||||
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text),
|
|
||||||
true
|
|
||||||
),
|
|
||||||
'{nextChangeAt}',
|
|
||||||
to_jsonb(((meta->>'nextChangeAt')::timestamp
|
|
||||||
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond')::text),
|
|
||||||
true
|
|
||||||
)
|
|
||||||
WHERE meta->>'next_change_tick' ~ '^-?[0-9]+$'
|
|
||||||
AND (meta->>'next_change_tick')::bigint >= ${cutTick}
|
|
||||||
`)
|
|
||||||
);
|
|
||||||
affected.set(
|
|
||||||
'auction-deadline',
|
|
||||||
await db.$executeRaw(GamePrisma.sql`
|
|
||||||
UPDATE auction
|
|
||||||
SET close_tick = close_tick + ${shiftTicks},
|
|
||||||
close_at = close_at + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
|
||||||
WHERE status IN ('OPEN'::auction_status, 'FINALIZING'::auction_status)
|
|
||||||
AND close_tick IS NOT NULL
|
|
||||||
`)
|
|
||||||
);
|
|
||||||
affected.set(
|
|
||||||
'message-action-clock-coordinate',
|
|
||||||
(
|
|
||||||
await db.messageAction.updateMany({
|
|
||||||
where: { status: 'PENDING' },
|
|
||||||
data: {
|
|
||||||
clockRevision: targetRevision,
|
|
||||||
deadlineGeneration: targetGeneration,
|
|
||||||
},
|
|
||||||
})
|
|
||||||
).count
|
|
||||||
);
|
|
||||||
affected.set(
|
|
||||||
'message-action-expiry',
|
|
||||||
await db.$executeRaw(GamePrisma.sql`
|
|
||||||
WITH shifted AS (
|
|
||||||
UPDATE message_action
|
|
||||||
SET expires_game_tick = expires_game_tick + ${shiftTicks},
|
|
||||||
clock_revision = ${targetRevision},
|
|
||||||
deadline_generation = ${targetGeneration},
|
|
||||||
updated_at_wall = CURRENT_TIMESTAMP AT TIME ZONE 'UTC'
|
|
||||||
WHERE status = 'PENDING'
|
|
||||||
AND expires_game_tick IS NOT NULL
|
|
||||||
AND expires_game_tick >= ${cutTick}
|
|
||||||
RETURNING message_id, expires_game_tick
|
|
||||||
)
|
|
||||||
UPDATE message AS envelope
|
|
||||||
SET valid_until_tick = shifted.expires_game_tick,
|
|
||||||
valid_until = envelope.valid_until
|
|
||||||
+ ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
|
||||||
FROM shifted
|
|
||||||
WHERE envelope.id = shifted.message_id
|
|
||||||
`)
|
|
||||||
);
|
|
||||||
affected.set(
|
|
||||||
'vote-end-deadline',
|
|
||||||
await db.$executeRaw(GamePrisma.sql`
|
|
||||||
UPDATE vote_poll
|
|
||||||
SET end_tick = end_tick + ${shiftTicks},
|
|
||||||
end_at = end_at + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
|
||||||
WHERE closed_at IS NULL AND end_tick IS NOT NULL
|
|
||||||
`)
|
|
||||||
);
|
|
||||||
affected.set(
|
|
||||||
'select-pool-reservation',
|
|
||||||
await db.$executeRaw(GamePrisma.sql`
|
|
||||||
UPDATE select_pool
|
|
||||||
SET reserved_until_tick = reserved_until_tick + ${shiftTicks},
|
|
||||||
reserved_until = reserved_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond'
|
|
||||||
WHERE general_id IS NULL AND reserved_until_tick IS NOT NULL
|
|
||||||
`)
|
|
||||||
);
|
|
||||||
affected.set(
|
|
||||||
'npc-selection-window',
|
|
||||||
await db.$executeRaw(GamePrisma.sql`
|
|
||||||
UPDATE select_npc_token
|
|
||||||
SET valid_until_tick = CASE
|
|
||||||
WHEN valid_until_tick IS NULL THEN NULL ELSE valid_until_tick + ${shiftTicks} END,
|
|
||||||
valid_until = CASE
|
|
||||||
WHEN valid_until_tick IS NULL THEN valid_until
|
|
||||||
ELSE valid_until + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' END,
|
|
||||||
pick_more_from_tick = CASE
|
|
||||||
WHEN pick_more_from_tick IS NULL THEN NULL ELSE pick_more_from_tick + ${shiftTicks} END,
|
|
||||||
pick_more_from = CASE
|
|
||||||
WHEN pick_more_from_tick IS NULL THEN pick_more_from
|
|
||||||
ELSE pick_more_from + ${projectionDeltaMilliseconds} * INTERVAL '1 millisecond' END
|
|
||||||
WHERE valid_until_tick IS NOT NULL OR pick_more_from_tick IS NOT NULL
|
|
||||||
`)
|
|
||||||
);
|
|
||||||
await db.worldState.update({
|
|
||||||
where: { id: worldStateId },
|
|
||||||
data: {
|
|
||||||
clockTick: alignedTick,
|
|
||||||
clockWallAnchor: resumeWallAt,
|
|
||||||
clockPhase: 'RECONCILING',
|
|
||||||
clockRevision: targetRevision,
|
|
||||||
deadlineGeneration: targetGeneration,
|
|
||||||
meta: shiftedMeta(world.meta, projectionDeltaMilliseconds),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
affected.set('world-clock', 1);
|
|
||||||
affected.set('movable-json-rule-anchors', 1);
|
|
||||||
affected.set('auction-finalizing-recovery', 0);
|
|
||||||
return affected;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const startClockSuspension = async (options: {
|
|
||||||
db: GamePrismaClient;
|
|
||||||
suspensionId: string;
|
|
||||||
source: ClockSuspensionSource;
|
|
||||||
authority: ClockOperationAuthority;
|
|
||||||
policy?: ClockAlignmentPolicy;
|
|
||||||
catchUpTicks?: number;
|
|
||||||
recoverDurableObservation?: boolean;
|
|
||||||
}): Promise<ClockSuspensionResult> => {
|
|
||||||
if (!options.suspensionId.trim() || options.suspensionId.length > 64) {
|
|
||||||
throw new Error('Clock suspension ID must contain 1-64 characters.');
|
|
||||||
}
|
|
||||||
const policy = options.policy ?? 'EXACT';
|
|
||||||
const catchUpTicks = options.catchUpTicks ?? 0;
|
|
||||||
if (!Number.isSafeInteger(catchUpTicks) || catchUpTicks < 0) {
|
|
||||||
throw new Error('Clock suspension catch-up ticks must be a non-negative safe integer.');
|
|
||||||
}
|
|
||||||
return runSerializableClockOperation(() =>
|
|
||||||
options.db.$transaction(
|
|
||||||
async (db) => {
|
|
||||||
await verifyAuthority(db, options.authority);
|
|
||||||
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
|
||||||
await acquireGameSchemaAdvisoryXactLock(db, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
|
||||||
const worldStateId = await lockWorld(db);
|
|
||||||
const existing = await db.clockSuspension.findUnique({ where: { id: options.suspensionId } });
|
|
||||||
if (existing) {
|
|
||||||
if (
|
|
||||||
existing.worldStateId !== worldStateId ||
|
|
||||||
existing.source !== options.source ||
|
|
||||||
existing.policy !== policy
|
|
||||||
) {
|
|
||||||
throw new Error(
|
|
||||||
`Clock suspension ID ${options.suspensionId} is already bound to another operation.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (existing.status !== 'SUSPENDED') {
|
|
||||||
throw new Error(
|
|
||||||
`Clock suspension ${options.suspensionId} already advanced to ${existing.status}.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return {
|
|
||||||
suspensionId: existing.id,
|
|
||||||
phase: 'SUSPENDED' as const,
|
|
||||||
sourceRevision: safeNumber(existing.sourceRevision, 'source revision'),
|
|
||||||
targetRevision: safeNumber(existing.targetRevision, 'target revision'),
|
|
||||||
cutTick: safeNumber(existing.cutTick, 'cut tick'),
|
|
||||||
cutWallAt: existing.cutWallAt,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
|
||||||
const phase = parseGameClockPhase(world.clockPhase);
|
|
||||||
if (phase !== 'RUNNING') {
|
|
||||||
throw new Error(`Clock suspension can start only from RUNNING; current phase is ${phase}.`);
|
|
||||||
}
|
|
||||||
if (!world.clockBaseTime || world.clockTick === null || !world.clockWallAnchor) {
|
|
||||||
throw new Error('Clock suspension requires a fully initialized logical game clock.');
|
|
||||||
}
|
|
||||||
if (
|
|
||||||
options.recoverDurableObservation &&
|
|
||||||
(options.authority.kind !== 'DAEMON' || options.source !== 'RECOVERY' || policy !== 'RECOVER_TURNS')
|
|
||||||
) {
|
|
||||||
throw new Error('Only daemon outage recovery may use the durable observation.');
|
|
||||||
}
|
|
||||||
const cutWallAt = options.recoverDurableObservation ? world.clockWallAnchor : await readDbWall(db);
|
|
||||||
const storedTick = safeNumber(world.clockTick, 'world clock tick');
|
|
||||||
const sourceRevision = safeNumber(world.clockRevision, 'world clock revision');
|
|
||||||
const clock = new GameClock({
|
|
||||||
baseTime: world.clockBaseTime,
|
|
||||||
tick: storedTick,
|
|
||||||
mode: world.clockMode === 'manual' ? 'manual' : 'realtime',
|
|
||||||
wallAnchor: world.clockWallAnchor,
|
|
||||||
recovery: readTurnRecovery(world),
|
|
||||||
turnSeconds: world.tickSeconds,
|
|
||||||
phase,
|
|
||||||
revision: sourceRevision,
|
|
||||||
});
|
|
||||||
const cutTick = options.recoverDurableObservation ? clock.tick : clock.nowTick(cutWallAt);
|
|
||||||
await lockParticipants(db, BigInt(cutTick));
|
|
||||||
await db.worldState.update({
|
|
||||||
where: { id: worldStateId },
|
|
||||||
data: { clockPhase: 'SUSPENDED', clockTick: BigInt(cutTick), clockWallAnchor: cutWallAt },
|
|
||||||
});
|
|
||||||
const participants = await readParticipantSnapshots(db, worldStateId, BigInt(cutTick));
|
|
||||||
await db.clockSuspension.create({
|
|
||||||
data: {
|
|
||||||
id: options.suspensionId,
|
|
||||||
worldStateId,
|
|
||||||
source: options.source,
|
|
||||||
policy,
|
|
||||||
status: 'SUSPENDED',
|
|
||||||
sourceRevision: BigInt(sourceRevision),
|
|
||||||
targetRevision: BigInt(sourceRevision + 1),
|
|
||||||
cutTick: BigInt(cutTick),
|
|
||||||
cutWallAt,
|
|
||||||
rateTicksPerSecond: GAME_TICKS_PER_TURN / world.tickSeconds,
|
|
||||||
catchUpTicks: BigInt(catchUpTicks),
|
|
||||||
participantChecksumBefore: aggregateChecksum(participants),
|
|
||||||
detail: asJson({
|
|
||||||
authority: options.authority.kind,
|
|
||||||
profileName: options.authority.profileName,
|
|
||||||
normalTickAtCutWall: Math.max(cutTick, clock.normalNowTick(cutWallAt)),
|
|
||||||
}),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await persistInitialParticipants(db, options.suspensionId, participants);
|
|
||||||
return {
|
|
||||||
suspensionId: options.suspensionId,
|
|
||||||
phase: 'SUSPENDED',
|
|
||||||
sourceRevision,
|
|
||||||
targetRevision: sourceRevision + 1,
|
|
||||||
cutTick,
|
|
||||||
cutWallAt,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Continues a suspension inside a transaction whose caller already verified
|
|
||||||
* daemon authority and acquired the clock/general-access lock prefix.
|
|
||||||
*/
|
|
||||||
export const reconcileClockSuspensionInTransaction = async (options: {
|
|
||||||
db: GamePrisma.TransactionClient;
|
|
||||||
suspensionId: string;
|
|
||||||
profileName: string;
|
|
||||||
allowUnificationWait?: boolean;
|
|
||||||
authority?: ClockOperationAuthority;
|
|
||||||
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
|
|
||||||
testResumeWallAt?: Date;
|
|
||||||
upgradeMaintenancePolicy?: boolean;
|
|
||||||
}): Promise<ClockReconciliationResult> => {
|
|
||||||
const db = options.db;
|
|
||||||
const worldStateId = await lockWorld(db);
|
|
||||||
const suspension = await db.clockSuspension.findUniqueOrThrow({ where: { id: options.suspensionId } });
|
|
||||||
if (suspension.worldStateId !== worldStateId) {
|
|
||||||
throw new Error('Clock suspension belongs to another world state.');
|
|
||||||
}
|
|
||||||
if (suspension.status === 'RECONCILING' || suspension.status === 'APPLIED') {
|
|
||||||
if (
|
|
||||||
suspension.gapTicks === null ||
|
|
||||||
suspension.shiftTicks === null ||
|
|
||||||
suspension.alignedTick === null ||
|
|
||||||
!suspension.resumeWallAt
|
|
||||||
) {
|
|
||||||
throw new Error('Persisted clock reconciliation result is incomplete.');
|
|
||||||
}
|
|
||||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
|
||||||
return {
|
|
||||||
suspensionId: suspension.id,
|
|
||||||
phase: 'RECONCILING' as const,
|
|
||||||
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
|
|
||||||
targetRevision: safeNumber(suspension.targetRevision, 'target revision'),
|
|
||||||
deadlineGeneration: safeNumber(world.deadlineGeneration, 'deadline generation'),
|
|
||||||
gapTicks: safeNumber(suspension.gapTicks, 'gap ticks'),
|
|
||||||
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
|
|
||||||
shiftTicks: safeNumber(suspension.shiftTicks, 'shift ticks'),
|
|
||||||
alignedTick: safeNumber(suspension.alignedTick, 'aligned tick'),
|
|
||||||
resumeWallAt: suspension.resumeWallAt,
|
|
||||||
recovery: readSerializedTurnRecovery(suspension.detail),
|
|
||||||
resumeAnchor:
|
|
||||||
suspension.detail &&
|
|
||||||
typeof suspension.detail === 'object' &&
|
|
||||||
!Array.isArray(suspension.detail) &&
|
|
||||||
typeof suspension.detail.resumeAnchor === 'string'
|
|
||||||
? new Date(suspension.detail.resumeAnchor)
|
|
||||||
: (world.clockWallAnchor ?? suspension.resumeWallAt),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (suspension.status !== 'SUSPENDED') {
|
|
||||||
throw new Error(`Clock suspension cannot reconcile from status ${suspension.status}.`);
|
|
||||||
}
|
|
||||||
const world = await db.worldState.findUniqueOrThrow({ where: { id: worldStateId } });
|
|
||||||
const phase = parseGameClockPhase(world.clockPhase);
|
|
||||||
if (phase !== 'SUSPENDED' || world.clockRevision !== suspension.sourceRevision) {
|
|
||||||
throw new Error('Clock reconciliation phase or source revision fence failed.');
|
|
||||||
}
|
|
||||||
const worldMeta =
|
|
||||||
world.meta && typeof world.meta === 'object' && !Array.isArray(world.meta)
|
|
||||||
? (world.meta as Record<string, unknown>)
|
|
||||||
: {};
|
|
||||||
const united = Number(worldMeta.isunited ?? worldMeta.isUnited ?? 0);
|
|
||||||
if (suspension.source === 'UNIFICATION_WAIT' || united >= 2) {
|
|
||||||
if (!options.allowUnificationWait || options.authority?.kind !== 'DAEMON') {
|
|
||||||
throw new Error('Unification wait requires the daemon-authorized atomic alignment-and-invader workflow.');
|
|
||||||
}
|
|
||||||
await verifyAuthority(db, options.authority);
|
|
||||||
}
|
|
||||||
const cutTick = safeNumber(suspension.cutTick, 'cut tick');
|
|
||||||
await lockParticipants(db, suspension.cutTick);
|
|
||||||
if (options.testResumeWallAt && process.env.NODE_ENV !== 'test') {
|
|
||||||
throw new Error('A clock reconciliation wall override is allowed only in tests.');
|
|
||||||
}
|
|
||||||
const resumeWallAt = options.testResumeWallAt ? new Date(options.testResumeWallAt.getTime()) : await readDbWall(db);
|
|
||||||
const legacyUnificationWait = suspension.source === 'UNIFICATION_WAIT' && suspension.policy !== 'TURN_BOUNDARY';
|
|
||||||
const upgradeMaintenance =
|
|
||||||
options.upgradeMaintenancePolicy === true &&
|
|
||||||
supportsRecoveryColumns(db) &&
|
|
||||||
suspension.source === 'MAINTENANCE' &&
|
|
||||||
suspension.policy !== 'RECOVER_TURNS';
|
|
||||||
const effectivePolicy = legacyUnificationWait
|
|
||||||
? 'TURN_BOUNDARY'
|
|
||||||
: upgradeMaintenance
|
|
||||||
? 'RECOVER_TURNS'
|
|
||||||
: parseClockAlignmentPolicy(suspension.policy);
|
|
||||||
const alignmentCutTick = legacyUnificationWait
|
|
||||||
? safeNumber(world.lastTurnTick!, 'unification execution boundary')
|
|
||||||
: cutTick;
|
|
||||||
const plan = buildClockAlignmentPlan({
|
|
||||||
policy: effectivePolicy,
|
|
||||||
sourceRevision: safeNumber(suspension.sourceRevision, 'source revision'),
|
|
||||||
cutTick: alignmentCutTick,
|
|
||||||
cutWall: suspension.cutWallAt,
|
|
||||||
resumeWall: resumeWallAt,
|
|
||||||
ticksPerSecond: suspension.rateTicksPerSecond,
|
|
||||||
catchUpTicks: safeNumber(suspension.catchUpTicks, 'catch-up ticks'),
|
|
||||||
normalTick: (() => {
|
|
||||||
const detail =
|
|
||||||
suspension.detail && typeof suspension.detail === 'object' && !Array.isArray(suspension.detail)
|
|
||||||
? suspension.detail
|
|
||||||
: {};
|
|
||||||
const normalAtCut = typeof detail.normalTickAtCutWall === 'number' ? detail.normalTickAtCutWall : cutTick;
|
|
||||||
return (
|
|
||||||
normalAtCut +
|
|
||||||
Math.trunc(
|
|
||||||
((resumeWallAt.getTime() - suspension.cutWallAt.getTime()) * suspension.rateTicksPerSecond) / 1_000
|
|
||||||
)
|
|
||||||
);
|
|
||||||
})(),
|
|
||||||
});
|
|
||||||
const before = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
|
|
||||||
assertShiftFits(before, plan.shiftTicks);
|
|
||||||
await assertScheduleRanges(db, plan.shiftTicks);
|
|
||||||
const projectionDeltaMilliseconds = Math.trunc((plan.shiftTicks * 1_000) / suspension.rateTicksPerSecond);
|
|
||||||
if (!Number.isSafeInteger(projectionDeltaMilliseconds)) {
|
|
||||||
throw new Error('Clock reconciliation projection delta is outside the safe integer range.');
|
|
||||||
}
|
|
||||||
const targetGeneration = world.deadlineGeneration + 1n;
|
|
||||||
const affected = await applyParticipantShift(
|
|
||||||
db,
|
|
||||||
worldStateId,
|
|
||||||
suspension.cutTick,
|
|
||||||
BigInt(plan.alignedTick),
|
|
||||||
BigInt(plan.targetRevision),
|
|
||||||
targetGeneration,
|
|
||||||
BigInt(plan.shiftTicks),
|
|
||||||
projectionDeltaMilliseconds,
|
|
||||||
plan.resumeAnchor ?? resumeWallAt
|
|
||||||
);
|
|
||||||
if (supportsRecoveryColumns(db)) {
|
|
||||||
await db.worldState.update({
|
|
||||||
where: { id: worldStateId },
|
|
||||||
data: {
|
|
||||||
clockRecoveryStartTick: plan.recovery ? BigInt(plan.recovery.startTick) : null,
|
|
||||||
clockRecoveryEndTick: plan.recovery ? BigInt(plan.recovery.endTick) : null,
|
|
||||||
clockRecoveryStartWallAt: plan.recovery?.startWallAt ?? null,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
} else if (plan.recovery) {
|
|
||||||
throw new Error('Turn recovery requires an upgraded profile schema and Prisma client.');
|
|
||||||
}
|
|
||||||
|
|
||||||
const after = await readParticipantSnapshots(db, worldStateId, suspension.cutTick);
|
|
||||||
const afterByKey = new Map(after.map((participant) => [participant.key, participant]));
|
|
||||||
for (const participant of before) {
|
|
||||||
const next = afterByKey.get(participant.key);
|
|
||||||
if (!next) throw new Error(`Missing post-reconciliation participant: ${participant.key}`);
|
|
||||||
if (participant.policy === 'KEEP' && participant.checksum !== next.checksum) {
|
|
||||||
throw new Error(`KEEP participant changed during reconciliation: ${participant.key}`);
|
|
||||||
}
|
|
||||||
await db.clockReconciliationParticipant.upsert({
|
|
||||||
where: {
|
|
||||||
suspensionId_participantKey: {
|
|
||||||
suspensionId: suspension.id,
|
|
||||||
participantKey: participant.key,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
create: {
|
|
||||||
suspensionId: suspension.id,
|
|
||||||
participantKey: participant.key,
|
|
||||||
policy: participant.policy,
|
|
||||||
beforeChecksum: participant.checksum,
|
|
||||||
afterChecksum: next.checksum,
|
|
||||||
affectedCount: affected.get(participant.key) ?? 0,
|
|
||||||
},
|
|
||||||
update: {
|
|
||||||
policy: participant.policy,
|
|
||||||
beforeChecksum: participant.checksum,
|
|
||||||
afterChecksum: next.checksum,
|
|
||||||
affectedCount: affected.get(participant.key) ?? 0,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
const outboxPayload = {
|
|
||||||
version: 1,
|
|
||||||
profileName: options.profileName,
|
|
||||||
suspensionId: suspension.id,
|
|
||||||
sourceRevision: plan.sourceRevision,
|
|
||||||
targetRevision: plan.targetRevision,
|
|
||||||
deadlineGeneration: safeNumber(targetGeneration, 'deadline generation'),
|
|
||||||
shiftTicks: plan.shiftTicks,
|
|
||||||
projectionDeltaMilliseconds,
|
|
||||||
clockBaseTime: world.clockBaseTime!.toISOString(),
|
|
||||||
ticksPerSecond: suspension.rateTicksPerSecond,
|
|
||||||
};
|
|
||||||
await db.clockProjectionOutbox.create({
|
|
||||||
data: {
|
|
||||||
worldStateId,
|
|
||||||
suspensionId: suspension.id,
|
|
||||||
targetRevision: BigInt(plan.targetRevision),
|
|
||||||
status: 'PENDING',
|
|
||||||
payload: asJson(outboxPayload),
|
|
||||||
checksum: checksum(outboxPayload),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
await db.clockSuspension.update({
|
|
||||||
where: { id: suspension.id },
|
|
||||||
data: {
|
|
||||||
status: 'RECONCILING',
|
|
||||||
...(legacyUnificationWait || upgradeMaintenance ? { policy: effectivePolicy } : {}),
|
|
||||||
resumeWallAt,
|
|
||||||
gapTicks: BigInt(plan.gapTicks),
|
|
||||||
catchUpTicks: BigInt(plan.catchUpTicks),
|
|
||||||
detail: asJson({
|
|
||||||
...(suspension.detail && typeof suspension.detail === 'object' && !Array.isArray(suspension.detail)
|
|
||||||
? suspension.detail
|
|
||||||
: {}),
|
|
||||||
...serializeTurnRecovery(plan.recovery ?? null),
|
|
||||||
resumeAnchor: (plan.resumeAnchor ?? resumeWallAt).toISOString(),
|
|
||||||
...(legacyUnificationWait || upgradeMaintenance
|
|
||||||
? { previousPolicy: suspension.policy, executionBoundaryTick: alignmentCutTick }
|
|
||||||
: {}),
|
|
||||||
}),
|
|
||||||
shiftTicks: BigInt(plan.shiftTicks),
|
|
||||||
alignedTick: BigInt(plan.alignedTick),
|
|
||||||
participantChecksumBefore: aggregateChecksum(before),
|
|
||||||
participantChecksumAfter: aggregateChecksum(after),
|
|
||||||
},
|
|
||||||
});
|
|
||||||
return {
|
|
||||||
suspensionId: suspension.id,
|
|
||||||
phase: 'RECONCILING',
|
|
||||||
sourceRevision: plan.sourceRevision,
|
|
||||||
targetRevision: plan.targetRevision,
|
|
||||||
deadlineGeneration: safeNumber(targetGeneration, 'deadline generation'),
|
|
||||||
gapTicks: plan.gapTicks,
|
|
||||||
catchUpTicks: plan.catchUpTicks,
|
|
||||||
shiftTicks: plan.shiftTicks,
|
|
||||||
alignedTick: plan.alignedTick,
|
|
||||||
resumeWallAt,
|
|
||||||
recovery: plan.recovery ?? null,
|
|
||||||
resumeAnchor: plan.resumeAnchor ?? resumeWallAt,
|
|
||||||
};
|
|
||||||
};
|
|
||||||
|
|
||||||
/** Finalizes an atomic unification workflow after an optional rate change. */
|
|
||||||
export const refreshClockProjectionForFinalClockUnderHeldLocks = async (options: {
|
|
||||||
db: GamePrisma.TransactionClient;
|
|
||||||
suspensionId: string;
|
|
||||||
clockBaseTime: Date;
|
|
||||||
tickSeconds: number;
|
|
||||||
}): Promise<void> => {
|
|
||||||
if (GAME_TICKS_PER_TURN % options.tickSeconds !== 0) {
|
|
||||||
throw new Error(`Final clock rate cannot represent an integer tick: ${options.tickSeconds}.`);
|
|
||||||
}
|
|
||||||
const outbox = await options.db.clockProjectionOutbox.findFirstOrThrow({
|
|
||||||
where: { suspensionId: options.suspensionId, status: 'PENDING' },
|
|
||||||
orderBy: { id: 'asc' },
|
|
||||||
});
|
|
||||||
const payload =
|
|
||||||
outbox.payload && typeof outbox.payload === 'object' && !Array.isArray(outbox.payload)
|
|
||||||
? { ...(outbox.payload as Record<string, unknown>) }
|
|
||||||
: null;
|
|
||||||
if (!payload || payload.suspensionId !== options.suspensionId) {
|
|
||||||
throw new Error('Unification clock projection outbox payload is invalid.');
|
|
||||||
}
|
|
||||||
payload.clockBaseTime = options.clockBaseTime.toISOString();
|
|
||||||
payload.ticksPerSecond = GAME_TICKS_PER_TURN / options.tickSeconds;
|
|
||||||
await options.db.clockProjectionOutbox.update({
|
|
||||||
where: { id: outbox.id },
|
|
||||||
data: { payload: asJson(payload), checksum: checksum(payload) },
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
export const reconcileClockSuspension = async (options: {
|
|
||||||
db: GamePrismaClient;
|
|
||||||
suspensionId: string;
|
|
||||||
authority: ClockOperationAuthority;
|
|
||||||
/** Deterministic fixture seam; production must always use PostgreSQL CURRENT_TIMESTAMP. */
|
|
||||||
testResumeWallAt?: Date;
|
|
||||||
upgradeMaintenancePolicy?: boolean;
|
|
||||||
}): Promise<ClockReconciliationResult> =>
|
|
||||||
runSerializableClockOperation(() =>
|
|
||||||
options.db.$transaction(
|
|
||||||
async (db) => {
|
|
||||||
await verifyAuthority(db, options.authority);
|
|
||||||
await acquireGameSchemaAdvisoryXactLock(db, CLOCK_OPERATION_PERSISTENCE_LOCK);
|
|
||||||
await acquireGameSchemaAdvisoryXactLock(db, GENERAL_ACCESS_PERSISTENCE_LOCK);
|
|
||||||
return reconcileClockSuspensionInTransaction({
|
|
||||||
db,
|
|
||||||
suspensionId: options.suspensionId,
|
|
||||||
profileName: options.authority.profileName,
|
|
||||||
authority: options.authority,
|
|
||||||
...(options.testResumeWallAt ? { testResumeWallAt: options.testResumeWallAt } : {}),
|
|
||||||
upgradeMaintenancePolicy: options.upgradeMaintenancePolicy,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
{ isolationLevel: 'Serializable', maxWait: 10_000, timeout: 30_000 }
|
|
||||||
)
|
|
||||||
);
|
|
||||||
@@ -39,7 +39,7 @@ const zAuctionFinalize = z.object({
|
|||||||
type: z.literal('auctionFinalize'),
|
type: z.literal('auctionFinalize'),
|
||||||
auctionId: zFiniteNumber,
|
auctionId: zFiniteNumber,
|
||||||
expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
expectedCloseAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
||||||
expectedCloseTick: zSafeInteger,
|
expectedCloseTick: zSafeInteger.optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const zAuctionOpen = z.object({
|
const zAuctionOpen = z.object({
|
||||||
@@ -60,6 +60,7 @@ const zAuctionBid = z.object({
|
|||||||
auctionId: zFiniteNumber,
|
auctionId: zFiniteNumber,
|
||||||
generalId: zFiniteNumber,
|
generalId: zFiniteNumber,
|
||||||
amount: zFiniteNumber,
|
amount: zFiniteNumber,
|
||||||
|
acceptedGameTick: zSafeInteger.optional(),
|
||||||
tryExtendCloseDate: z.boolean().optional(),
|
tryExtendCloseDate: z.boolean().optional(),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -131,15 +132,6 @@ const zMessageRespond = z.object({
|
|||||||
response: z.boolean(),
|
response: z.boolean(),
|
||||||
});
|
});
|
||||||
|
|
||||||
const zSyncDiplomaticResponse = z.object({
|
|
||||||
type: z.literal('syncDiplomaticResponse'),
|
|
||||||
userId: z.string().min(1),
|
|
||||||
generalId: z.number().int().positive(),
|
|
||||||
messageId: z.number().int().positive(),
|
|
||||||
nationIds: z.array(z.number().int().positive()).max(4),
|
|
||||||
cityIds: z.array(z.number().int().positive()).max(256),
|
|
||||||
});
|
|
||||||
|
|
||||||
const zVacation = z.object({
|
const zVacation = z.object({
|
||||||
type: z.literal('vacation'),
|
type: z.literal('vacation'),
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
@@ -435,7 +427,7 @@ const zSelectPoolReserve = z
|
|||||||
requestId: z.string().optional(),
|
requestId: z.string().optional(),
|
||||||
userId: z.string().min(1),
|
userId: z.string().min(1),
|
||||||
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
|
seedOwnerIdentity: z.union([z.string().min(1), zFiniteNumber]),
|
||||||
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp).optional(),
|
acceptedGameAt: z.string().refine(isCanonicalIsoTimestamp),
|
||||||
acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
|
acceptedGameTick: zFiniteNumber.int().min(Number.MIN_SAFE_INTEGER).max(Number.MAX_SAFE_INTEGER).optional(),
|
||||||
})
|
})
|
||||||
.strict();
|
.strict();
|
||||||
@@ -618,14 +610,6 @@ const normalizeMessageRespond: CommandNormalizer<'messageRespond'> = (envelope)
|
|||||||
return { ...command, requestId: envelope.requestId };
|
return { ...command, requestId: envelope.requestId };
|
||||||
};
|
};
|
||||||
|
|
||||||
const normalizeSyncDiplomaticResponse: CommandNormalizer<'syncDiplomaticResponse'> = (envelope) => {
|
|
||||||
const command = parseWith(zSyncDiplomaticResponse, envelope.command);
|
|
||||||
if (!command) {
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
return { ...command, requestId: envelope.requestId };
|
|
||||||
};
|
|
||||||
|
|
||||||
const normalizeVacation: CommandNormalizer<'vacation'> = (envelope) => {
|
const normalizeVacation: CommandNormalizer<'vacation'> = (envelope) => {
|
||||||
const command = parseWith(zVacation, envelope.command);
|
const command = parseWith(zVacation, envelope.command);
|
||||||
if (!command) {
|
if (!command) {
|
||||||
@@ -876,7 +860,6 @@ const normalizers: CommandNormalizerMap = {
|
|||||||
buildNationCandidate: normalizeBuildNationCandidate,
|
buildNationCandidate: normalizeBuildNationCandidate,
|
||||||
instantRetreat: normalizeInstantRetreat,
|
instantRetreat: normalizeInstantRetreat,
|
||||||
messageRespond: normalizeMessageRespond,
|
messageRespond: normalizeMessageRespond,
|
||||||
syncDiplomaticResponse: normalizeSyncDiplomaticResponse,
|
|
||||||
vacation: normalizeVacation,
|
vacation: normalizeVacation,
|
||||||
setMySetting: normalizeSetMySetting,
|
setMySetting: normalizeSetMySetting,
|
||||||
dropItem: normalizeDropItem,
|
dropItem: normalizeDropItem,
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user