핵심 구조와 판단 경계
교육 엔진 → 공개 관찰 → 규칙 지휘관 → 근거 카드 → 가상 명령·복기. 기관용은 인증 → 역할·기관 검증 → 트랜잭션 → 관찰·자원·사람 승인 → 대상별 보고 → 감사 체인으로 분리합니다.
공식 원문 자료·비공식 해설·기관 검토 근거를 구분합니다. 자동 현장 작전·실장비 인터페이스·외부 LLM 연결을 구현했다고 표시하지 않습니다.
스냅샷에 포함된 코드가 구현의 기준입니다. 실제 기능 추가 시 안내 모델을 수정하고 생성·검사를 수행해야 합니다.
AI 재구현 프롬프트
아래 프롬프트와 소스 스냅샷을 AI에게 함께 전달하세요. 원문 PDF는 자료 메타데이터의 공식 출처와 파일 해시로 검증합니다.
너는 한국어 UI를 구현하는 시니어 엔지니어다. 이 logic.html의 소스 지문·파일 목록·소스 스냅샷을 기준으로 AI Firestation을 같은 기능과 계약으로 재구현하라. 아래 요구를 생략하지 마라.
1. Next.js App Router, React, TypeScript strict, Zod, Node.js SQLite를 사용한다. 라이브러리는 포함된 package.json/package-lock.json을 기준으로 재현하고 Node.js 22.22.3 이상에서 검증한다. UI는 어두운 청록 지휘 헤더·밝은 상황도·주황 명령 강조·한국어 표시를 유지하며 모바일·키보드 접근성을 제공한다.
2. 교육 시연과 기관용 지휘 지원을 분리한다. 교육은 3개 시나리오, 숨겨진 정답/미래 시간표 비노출, 시드 재현, L0~L3, 가상 드론 관찰, 개입·승인·취소·복기이다. 실제 운영에서는 자동 작전 발행과 가상 수신/완료를 금지한다. 기존 교육 테스트를 유지한다.
2-1. 가상 상황도는 소방서 (75,224), 신고 위치 (470,164)를 사용한다. 펌프1 경로는 (75,224)→(355,224)→(355,110)→(470,110)이며 마지막 지점은 도로상의 현장 진입 지점이다. 경로 선과 위치 계산에 동일한 좌표를 쓰고 구간 길이에 비례해 일정한 지도 속도로 이동한다. 드론은 같은 소방서에서 신고 위치까지 최단 직선으로 이동한다. 공개 ETA와 서버·지도 공통 상수(차량 40초/드론 12초)로 위치를 계산하고 승인 전 대기·늦은 승인·일시정지·도착을 반영한다. 정상 자동 시연에서는 드론 20초/차량 40초 도착을 유지하고 숨겨진 미래 정보나 실제 지리 경로를 클라이언트에 추가하지 않는다. 경로 모서리·중간 위치·출발·도착을 단위 및 브라우저 테스트로 검증한다.
2-2. 모든 화면을 PC·스마트폰으로 함께 구현하고 검증한다. PC 1366×768/1920×1080에서는 교육 상황본부의 핵심 정보·조작을 화면 안에 배치하고 긴 기록은 패널 내부에서 스크롤한다. 스마트폰 360×800/390×844에서는 공통 메뉴 열기/닫기, 상황도/AI·명령/관찰·드론/복기 선택, 개입·진행 재개를 제공한다. 기관용 PC는 사건 목록/현장 상황/명령·보고를 병렬 배치하고 스마트폰은 화면 선택을 제공하며 대원 로그인 후 명령·보고를 먼저 연다. 터치 44px·입력 16px 이상, 키보드 접근과 낮은 화면/확대/긴 문서의 스크롤 대안을 유지한다. 마지막 성공 조회와 갱신 실패를 구분하고 연결 복구·화면 복귀 시 재조회한다. 오프라인 보고·실장비 연동을 꾸며내지 않는다. 기본 로컬 실행과 start:lan 시험 실행을 구분하고 같은 HTTPS 주소·기관 로그인·사건 배정으로 기기 간 같은 상태를 조회한다. 기관의 인증·권한 계약과 기존 동작을 유지한다.
2-3. 모바일 높이 740px 이하에서는 화면을 억지로 줄이지 말고 지도가 읽을 수 있는 높이와 세로 스크롤을 유지한다. HTTP LAN에서는 crypto.randomUUID가 없을 수 있으므로 crypto.getRandomValues 기반 UUID v4 대안을 사용하고 Math.random을 사용하지 않는다. 비보안 LAN 원점의 브라우저에서 수신/수행/완료 POST와 UUID 계약을 회귀 검사한다. HTTPS를 위한 기존 서버 Origin·쿠키·기관 권한 검증은 약화하지 않는다.
2-4. 식당 restaurant-gas/DEMO 한 개에 UE5.8 C++ 프로젝트 unreal/AIFirestation을 구현한다. 명세 docs/UE5_RESTAURANT_SPEC.md의 protocol ai-firestation.restaurant.v1 DTO를 GET /api/sessions/{id}/scene으로 읽고 사건 시간을 변경하지 않는다. 지도 좌표를 (x-360)*20,(y-180)*20 센티미터로 변환하고 동일 경로/공개 ETA를 사용한다. 공개 전 fireCells를 노출하지 않는다. UE Python 부트스트랩으로 맵/재질을 생성하고 C++가 도로·식당 주방·펌프차·드론·불꽃·연기를 구성한다. 폴링 중복·수명 종료·session/protocol/version 역전·정지/종료를 처리한다. 네이티브 입력은 같은 sessionId의 firestation.camera 메시지와 overview/drone/vehicle/crew 카메라만 허용한다.
2-5. 공식 @epicgames-ps/lib-pixelstreamingfrontend-ue5.8 0.1.2로 /simulation?session=UUID 영상을 연결한다. 신호 URL은 FIRESTATION_UE_SIGNAL_URL로 서버에서 제공한다. 스마트폰에 localhost를 전달하지 않고 HTTPS에는 WSS를 요구한다. 사건 UUID StreamerId 고정, 다른 스트리머 선구독/자동 fallback 차단, 마이크/카메라/키보드/터치/게임패드 입력 해제, 영상 playing 이벤트로 실제 재생 판정, 명시 재시도와 unmount 정리를 구현한다. 같은 인스턴스의 카메라는 공유됨을 표시한다. 구성 없음·엔진 미실행은 연결 대기로 표시하고 UE 영상을 모방해 완료로 표시하지 않는다. UE 미설치 상태의 native 빌드/영상 미검증을 기록하고 설치 후 실제 PC/휴대폰 재생까지 검증한다.
2-6. 교육 시연에 기본 켜짐의 한국어 합성 음원18개와 전화벨/무전 연결음/이동 사이렌3개를 구현한다. 공개 상태 기반 cue와 세션별 중복 방지 cursor를 사용하며 초안/취소/미공개 보고를 방송하지 않는다. 새 시연은 약25초 신고 접수 음성 동안 가상 시간 진행을 기다리고 완료/음소거/오류 시 진행한다. WebAudio context는 클릭에서 unlock하고 비동기 로드/세션 교체/종료/취소의 이전 재생을 폐기한다. 사이렌은 공개 차량 출동 중/ETA>0 기준이며 음성 중 낮춘다. 정지/개입/도착/취소/백그라운드/새 시연/화면 이동 때 소리를 정리한다. 음소거 후 늦은 합성 오류 콜백은 세션 세대/현재 cue 검사로 폐기하고 사용자 작성 음성의20초 제한에서 일시정지 시간을 제외한다. PC·모바일 음향 버튼/44px 볼륨/44px 전체 자막 펼치기/역할 자막, 개입/종료 음소거를 유지하고 새로고침/차량 창에서는 과거 대사를 재생하지 않는다. 고배속 대기 방송20가상초 제한과 종료 안내를 반영한다. 정형 대사는 배포 WAV로 모든 기기에서 재생하며 사용자 작성 명령은 브라우저 한국어 음성을 사용한다. scripts/export-demo-audio.ts와 generate-demo-voices.ps1로 Windows 한국어 음성/PowerShell7에서 재생성하고 audio-recordings.json의 해시/길이와 audio:check 공개 대사 대응을 유지한다. 합성 교육 음성임과 실기/OS 검증 제한을 표시하고 실제 기관용 무전으로 연결하지 않는다. 음원 오류로 시연이 갇히지 않도록 검증하고 기존 승인·이동·기관 경계 계약을 유지한다.
3. 기관용은 로컬 CLI 일회용 설정 키(15분·서버 해시·성공 시 소진), 관리자 최초 설정·로그인·로그아웃·계정 비활성화/복구·역할 변경·암호 재설정·전체 세션 폐기·마지막 활성 관리자 보호, 관리자/지휘관/상황요원/대원/감사자 역할을 구현한다. scrypt 개별 salt, HttpOnly/SameSite 세션 쿠키, 세션 토큰 해시만 저장, 만료·속도 제한·서버 역할/기관 경계 검증을 구현한다. 기본 비밀번호·자동 로그인·기관 ID 주입을 금지한다.
4. SQLite 트랜잭션 안에서 사건 버전·자원 중복 배정·감사·요청 중복 키를 함께 저장한다. 사건 등록·관찰·자원 배정·명령 초안·새 관찰 재검토·담당 지휘관 승인·대상별 수신/수행/완료·취소 확인·지휘권 인계·종료 검사를 구현한다. 대원은 자신의 배정 사건/명령만 조작한다.
5. 기관용 AI는 공개 관찰에서 위험 후보·추가 확인 질문·자료 후보를 제안하는 규칙 기반 지원이다. 인명/가스/물질/통전 상태를 냄새·색·외부 영상만으로 확정하지 않는다. 명령의 현장 판단과 승인은 사람에게 남긴다. 교육 카드 초안은 기관용 승인 근거로 사용하지 않는다. 외부 LLM이나 실제 장비가 연결됐다고 표시하지 않는다.
6. 공식 중앙소방학교 2026 자료의 문서명·게시일·공식 URL·공공누리 조건·SHA-256·페이지 수를 보존한다. 이용 허용된 원문은 변경 없는 PDF로 제공하고 페이지별 검색·원문/텍스트·출처 포함 복사·북마크·인쇄를 제공한다. 독립 해설에는 자체 작성·검수 전을 표시한다. 허용 확인 전 재배포하지 않는다. 첨부는 포함된 수입 스크립트와 메타데이터를 사용하고 해시를 검증한다.
6-1. 자료실은 HTML/PDF 두 탭과 HTML 기본값이다. 공식14권의 인덱스로 이스케이프된 전체 HTML을 생성하고 페이지별 본문을 React 텍스트/mark로 읽는다. 이전/다음/독립 페이지 입력·이동, 동일 페이지 PDF iframe, 본문 검색·강조·출처 복사·북마크·전체 HTML/PDF 다운로드를 유지한다. 그림/표/스캔은 PDF를 기준으로 하고 OCR을 구현했다고 표시하지 않는다. 목록은 드래그와44px 위·아래 이동을 제공한다. 방문자/일반 사용자 순서는 브라우저별, 관리자 순서는 기관 DB의 버전 검증으로 저장한다. PC 읽기 영역은 내부 스크롤과 자연스러운 패널 높이를 사용하여 grid 축소/overflow로 본문을 숨기지 않는다. 1366×768에서 본문 실제 노출 높이180px 이상·문서 전체가 화면 안에 들어옴을 검증한다. 스마트폰은 목록 접기·간결한 검색/출처 안내·44px 페이지 조작을 제공하고 긴 본문에 세로 스크롤을 허용한다. 목록 접기 버튼은 PC에서 숨긴다.
6-2. 기관 관리자 PDF 업로드는 기존 세션·서버 역할·동일 Origin으로 검증한다. UUID 이름으로 /data/library에 원문 PDF와 이스케이프 HTML·JSON을 저장하고 기관별 메타데이터·순서를 SQLite에 원자적으로 반영하여 맨 뒤에 추가한다. 같은 기관의 로그인 사용자만 업로드의 목록·페이지·검색·HTML/PDF 파일에 접근한다. 권한은 변환 후에도 재검증하고 파일/검색 API는 no-store를 사용한다.64MiB/1500페이지/1200만자·기관30개/512MiB,60초·384MiB Node 자식 프로세스에서 PDF.js 추출 및 동시 변환 제한을 적용한다. 실패 시 부분 파일을 지우고 목록/버전을 변경하지 않는다. PDF 파일 조회는 byte range를 지원한다. Nginx는 /api/library/upload만65m를 허용하고 Compose는 FIRESTATION_LIBRARY_DIR=/data/library로 영구 마운트한다. 늦은 페이지/검색/목록 응답과 로그아웃 후 기관 자료 재노출을 검증한다. scripts/extract-library-pdf.mjs도 logic.html 소스 스냅샷에 포함한다.
7. 교육 시연 오른쪽 사용설명서 메뉴, 1. 쉽게 따라하기/2. 상세 사용설명서, HTML 보기·다운로드를 구현한다. 루트 및 공개 manual.html·logic.html은 동일 생성기로 생성한다. 코드가 바뀔 때 안내 모델과 두 HTML을 반드시 함께 갱신한다. 실제 소스/API 목록·지문·버전·생성 시각·재구현 프롬프트·소스 스냅샷을 갱신하고 오프라인에서도 검색·복사·인쇄가 동작하게 한다. dev 감시·build 전 생성·docs:check·스테이지 pre-commit 검사로 오래된 문서를 차단한다. unreal/ C++·헤더·C# 빌드 규칙·설정·uproject·Python·PowerShell와 deploy/Dockerfile·Compose·Nginx 템플릿·.dockerignore도 수집/감시하며 환경 파일·Binaries/Intermediate/Saved/DerivedDataCache 및 생성된 이진 자산은 제외한다.
8. 수용 검증: 기존 단위/브라우저 테스트, 인증·권한·기관 경계·자원 충돌·새 관찰·지연 수신·취소·중복 요청·감사 해시·문서 지문 및 손상 검사를 수행한다. 브라우저에서 설명서 다운로드·자료 검색/복사·관리자 설정→사용자/근거/자원→사건→초안→승인→대원 완료를 검증한다. typecheck/docs:check/build/e2e를 통과시키고 결과와 운영 검증 한계를 기록한다.
9. GitHub minhokil/ai_firestation main에서 버전 소스를 보관하고 Hostinger /root/projects/ai_firestation에서 Docker Node22 웹 서비스를 실행한다. 현재 HTTPS 접속 주소는 https://fs.afr.co.kr이며 도메인을 변경하면 사용 안내·서버 Origin·TLS·Nginx를 함께 갱신한다. nginx-redirect.conf.template으로 이전 주소의 인증서/ACME 경로와 path/query를 보존하며 새 HTTPS로301 이동하고 새 도메인 재로그인을 안내한다. 배포 검증의 실제 성공·실패·재시험과 휴대폰 에뮬레이션/실기 검증 범위를 docs/DEPLOYMENT_RESULTS.md에 기록한다. releases/SHA 코드와 영구 .data를 분리하고 image tag/compose/.env를 보존한다. 비root Node 프로세스·loopback3118·전용 HTTPS Nginx·실제 FIRESTATION_ORIGIN·Secure 쿠키·기존 기관 권한을 유지한다. 로컬 DB/계정/키를 배포하지 않고 기본 관리자를 만들지 않는다. bootstrap 키는 Docker CLI에서 일회용으로 발급한다. Docker health/HTTP리다이렉트/HTTPS/PC·스마트폰/Origin/재시작 데이터 유지 검수를 하고 업데이트 및 이전 이미지 복구·SQLite/WAL 백업 방법을 문서화한다. VPS 웹 배포를 UE 렌더링 또는 실전 지휘 검증 완료라고 표시하지 않는다.
소스 스냅샷의 파일별 경로와 내용을 복원하는 것이 가장 정확하다. 스냅샷에 없는 비밀번호·사건 데이터·기관 승인·외부 API 키를 만들어 넣지 마라. 운영 인증을 주장하지 말고 실제 구현과 외부 검증이 필요한 조건을 사용설명서에 구분해라.현재 소스·API 목록
/api/library/files/:id/api/library/order/api/library/api/library/upload/api/sessions/:id/api/sessions/:id/scene/api/sessions/api/station/audit/api/station/auth/api/station/incidents/:id/api/station
소스 162개 보기
.dockerignore.gitattributes.githooks/pre-commit.gitignoreAGENTS.mdREADME.mdapps/web/AGENTS.mdapps/web/app/api/library/files/[id]/route.tsapps/web/app/api/library/order/route.tsapps/web/app/api/library/route.tsapps/web/app/api/library/upload/route.tsapps/web/app/api/sessions/[id]/route.tsapps/web/app/api/sessions/[id]/scene/route.tsapps/web/app/api/sessions/route.tsapps/web/app/api/station/audit/route.tsapps/web/app/api/station/auth/route.tsapps/web/app/api/station/incidents/[id]/route.tsapps/web/app/api/station/route.tsapps/web/app/demo/page.tsxapps/web/app/globals.cssapps/web/app/help/page.tsxapps/web/app/layout.tsxapps/web/app/library/page.tsxapps/web/app/manuals/page.tsxapps/web/app/page.tsxapps/web/app/simulation/page.tsxapps/web/app/simulation/simulation.cssapps/web/app/station/page.tsxapps/web/components/command-room.tsxapps/web/components/copy-text.tsapps/web/components/demo-audio-player.tsapps/web/components/library-highlight.tsxapps/web/components/library-room.tsxapps/web/components/library-upload.tsxapps/web/components/nav.tsxapps/web/components/request-id.tsapps/web/components/station-room.tsxapps/web/components/unreal-viewer-config.tsapps/web/components/unreal-viewer-lifecycle.tsapps/web/components/unreal-viewer-playback.tsapps/web/components/unreal-viewer.tsxapps/web/lib/http.tsapps/web/lib/library.tsapps/web/lib/service.tsapps/web/lib/station-http.tsapps/web/lib/station.tsapps/web/next-env.d.tsapps/web/next.config.tsapps/web/tsconfig.jsondeploy/Dockerfiledeploy/README.mddeploy/compose.yamldeploy/nginx-http.conf.templatedeploy/nginx-https.conf.templatedeploy/nginx-redirect.conf.templatedeploy/renew-certificate.shdocs/ACCEPTANCE_RESULTS.mddocs/CORE_CONTRACT.mddocs/DEMO_AUDIO_VALIDATION.mddocs/DEPLOYMENT_RESULTS.mddocs/DEPLOYMENT_SPEC.mddocs/IMPLEMENTATION_PROGRESS.mddocs/KB0_교육카드_40장.mddocs/LIBRARY_HTML_SPEC.mddocs/LIBRARY_HTML_VALIDATION.mddocs/RESPONSIVE_WORKSPACE_SPEC.mddocs/UE5_RESTAURANT_SPEC.mddocs/UE5_VALIDATION.mddocs/UPGRADE_V0_3_PLAN.mddocs/UPGRADE_V0_3_PROGRESS.mddocs/UPGRADE_V0_3_SPEC.mddocs/V0_2_REVIEW.mddocs/V0_3_ACCEPTANCE.mddocs/superpowers/plans/2026-10-09-demo-audio.mddocs/superpowers/plans/2026-10-09-firestation-domain.mddocs/superpowers/plans/2026-10-09-hostinger-deploy.mddocs/superpowers/plans/2026-10-09-library-html.mddocs/superpowers/plans/2026-10-09-ue5-restaurant.mdpackage-lock.jsonpackage.jsonpackages/core/commander/index.tspackages/core/commander/narration.tspackages/core/engine/index.tspackages/core/map-motion.tspackages/core/scenario/index.tspackages/core/types.tspackages/docs/build.tspackages/docs/content.tspackages/docs/render.tspackages/docs/watch.tspackages/kb/definitions.tspackages/kb/index.tspackages/library/catalog.jsonpackages/library/html.tspackages/library/index.tspackages/library/store.tspackages/simulation/audio-recordings.jsonpackages/simulation/demo-audio.tspackages/simulation/scene.tspackages/station/assistant.tspackages/station/auth.tspackages/station/database.tspackages/station/service.tspackages/station/types.tsplaywright.config.tsscripts/check-demo-audio.tsscripts/check-library.tsscripts/dev.tsscripts/docs.tsscripts/e2e.tsscripts/export-cards.tsscripts/export-demo-audio.tsscripts/export-library-html.tsscripts/extract-library-pdf.mjsscripts/generate-demo-voices.ps1scripts/import-library.pyscripts/setup-key.tsscripts/simulate.tsscripts/unreal-stream.ps1scripts/unreal.ps1tests/auth.test.tstests/cards.test.tstests/commander.test.tstests/demo-audio-player.test.tstests/demo-audio.test.tstests/docs.test.tstests/e2e/audio.e2e.tstests/e2e/demo.e2e.tstests/e2e/lan.e2e.tstests/e2e/library-html.e2e.tstests/e2e/responsive.e2e.tstests/e2e/simulation.e2e.tstests/e2e/upgrade.e2e.tstests/e2e/zz-library-upload.e2e.tstests/engine.test.tstests/fixtures/pdf.tstests/http.test.tstests/library-html.test.tstests/library.test.tstests/map-motion.test.tstests/request-id.test.tstests/scenario.test.tstests/scene.test.tstests/server.test.tstests/station.test.tstests/unreal-runtime.test.tstests/unreal-viewer.test.tstsconfig.jsonunreal/AIFirestation/AIFirestation.uprojectunreal/AIFirestation/Config/DefaultEngine.iniunreal/AIFirestation/Config/DefaultGame.iniunreal/AIFirestation/Scripts/bootstrap.pyunreal/AIFirestation/Source/AIFirestation.Target.csunreal/AIFirestation/Source/AIFirestation/AIFirestation.Build.csunreal/AIFirestation/Source/AIFirestation/AIFirestation.cppunreal/AIFirestation/Source/AIFirestation/RestaurantGameMode.cppunreal/AIFirestation/Source/AIFirestation/RestaurantGameMode.hunreal/AIFirestation/Source/AIFirestation/RestaurantScene.cppunreal/AIFirestation/Source/AIFirestation/RestaurantScene.hunreal/AIFirestation/Source/AIFirestationEditor.Target.csunreal/README.mdvitest.config.ts
검증 대상 소스 스냅샷
비밀번호·사건 DB·환경 변수·빌드 결과·원문 PDF는 포함하지 않습니다. 코드와 설정은 아래 경로대로 복원합니다.
.dockerignore
.git node_modules **/node_modules **/.next .data **/.data .env* **/.env* .superpowers .unreal-runtime .deploy .gitnexus .venv artifacts test-results playwright-report tmp **/Binaries **/Intermediate **/Saved **/DerivedDataCache **/*.tsbuildinfo
.gitattributes
* text=auto eol=lf *.pdf binary *.wav binary # 교재 HTML의 추출 본문 공백은 원문 인덱스를 그대로 보존한다. apps/web/public/library/*.html whitespace=-blank-at-eol *.png binary *.zip binary *.uasset binary *.umap binary
.githooks/pre-commit
#!/bin/sh # 코드와 같은 스테이지의 사용설명서·재구현 문서를 검증한다. npm run docs:check -- --staged
.gitignore
node_modules/ .next/ .data/ .gitnexus/ .venv/ tmp/ .env* !.env.example test-results/ playwright-report/ artifacts/ *.tsbuildinfo .superpowers/ .unreal-runtime/ .deploy/ # UE 에디터가 생성하는 빌드·캐시·맵/재질은 부트스트랩으로 재생성한다. unreal/**/Binaries/ unreal/**/Intermediate/ unreal/**/Saved/ unreal/**/DerivedDataCache/ unreal/**/.vs/ unreal/**/*.sln unreal/**/*.uasset unreal/**/*.umap
AGENTS.md
# AI Firestation 작업 규칙 - 한국어로 응답하고 코드 주석도 한국어로 작성한다. - 모든 화면 기능 추가·수정은 PC와 스마트폰 UI를 함께 구현하고 검증한다. PC 상황본부는 가능한 한 화면 안에 핵심 정보를 배치하고 긴 기록은 패널 내부에서 스크롤한다. 스마트폰은 화면 전환·44px 이상 터치 조작·읽기 쉬운 입력을 제공하며 대원 로그인·배정 사건 조회·수신/수행/완료 보고를 검증한다. 작은 화면·화면 확대에서는 내용을 자르지 않고 필요한 스크롤을 허용한다. - 사용자는 코드 변경마다 `manual.html`과 `logic.html`의 갱신을 명시적으로 요구했다. 이 규칙은 이후 모든 코드 업그레이드에 적용된다. - 기능·화면·API·권한·설치 방법이 바뀌면 `packages/docs/content.ts`의 사용 안내와 재구현 프롬프트도 수정한다. 구현하지 않은 기능을 완료로 표시하지 않는다. - 코드 변경 후 `npm run docs:sync`를 실행하여 루트·공개 HTML과 `docs/document-build.json`을 갱신하고 `npm run docs:check`를 통과시킨다. 문서도 코드와 함께 커밋한다. - 개발 서버는 문서 생성 감시를 사용한다. build 전에도 자동 생성하며 pre-commit은 스테이지 문서 지문과 내용을 검사한다. - 기관용 사람 지휘 지원과 교육용 가상 엔진을 분리한다. 기관용 자동 작전 발행·가상 수신/완료·실장비 제어를 도입하지 않는다. - 공식 원문, 자체 해설, 기관 검토 근거를 구분하며 발행기관·판본·출처·이용 조건·검수 상태를 보존한다. - 권한·기관·명령·자원 상태는 서버에서 검증하고 비밀번호·실제 사건 데이터·토큰을 소스나 HTML 스냅샷에 넣지 않는다. - 기존 테스트와 의미 있는 신규 회귀 테스트, 타입·문서 검사·빌드·브라우저 검증 후 결과를 보고한다.
README.md
# AI Firestation v0.6.0 — HTML 교재·PDF 추가·교육 시연·기관용 지휘 지원 v0.2 교육 코어를 유지하고 v0.3 기관용 지휘 지원, 공식 교재 자료실, 문서 자동 갱신을 추가했다. 식당·창고·주택 3개 가상 시나리오, 시드 기반 사건 엔진, L0~L3 규칙 기반 지휘관, 교육 카드 40장, 상황본부·차량 보기·복기 기록을 제공한다. 실제 119·소방차·드론·영상·외부 LLM은 연결되지 않았다. 교육 카드는 자체 작성한 비공식 초안이며 전문가 검수 전이다. 서버 API는 DEMO만 생성한다. 식당 화재 교육 시연에 UE5.8 C++ 장면과 Pixel Streaming 웹·스마트폰 화면을 추가한다. 현재 작업 환경에 UE 엔진이 없어 네이티브 빌드·맵 생성·실제 영상은 검증 대기다. [UE 설치·실행 안내](unreal/README.md)와 [연결 명세](docs/UE5_RESTAURANT_SPEC.md)를 참조한다. `/simulation?session=UUID`는 해당 식당 사건의 읽기 전용 현장이다. 사건 시간과 지휘는 기존 `/demo`가 관리한다. GitHub 저장소는 비공개 [minhokil/ai_firestation](https://github.com/minhokil/ai_firestation)이다. [배포된 웹 서비스](https://fs.afr.co.kr)는 PC·스마트폰에서 접속할 수 있다. [VPS 배포·업데이트·복구](deploy/README.md) 설정을 제공하며 서버 프로젝트는 `/root/projects/ai_firestation`이다. 코드 릴리스와 영구 기관 데이터를 분리한다. 실제 검증과 한계는 [배포 기록](docs/DEPLOYMENT_RESULTS.md)에 있다. ## 실행 Node.js 22 이상과 npm이 필요하다. 확인 환경은 Node.js 22.22.3이다. ```powershell npm ci npm run dev ``` 브라우저에서 <http://127.0.0.1:3100>을 연다. 2026 교재 자료실은 HTML을 기본으로 페이지 이동·검색·PDF 원문 탭을 제공한다. 전체14권의 HTML/PDF를 다운로드하며 목록을 마우스로 끌거나 위·아래 버튼으로 이동한다. 관리자는 기관 로그인 후 PDF 교재·매뉴얼을 올려 HTML·검색 인덱스를 자동 생성하고 기관 목록 끝에 추가한다. 파일64MiB·1500페이지, 기관30개/512MiB. 업로드는 같은 기관 사용자에게만 공유하고 원문을 보존한다. 그림·표·스캔은 PDF에서 확인하며 OCR은 제공하지 않는다. 1. `가상 시연 시작` → 시나리오·시드·지휘 수준 선택 → `시연 실행`. 기본 음향은 켜짐이며 신고전화·접수 대화가 먼저 재생되고 출동 지시·사이렌으로 이어진다. `음향 켜짐` 버튼으로 끄면 도입 대기를 생략한다. PC·스마트폰 볼륨/자막을 제공한다. 2. L3 기본 시연은 가상 시간96초에 종료된다. 사건 진행은 기본2배속 약48초,8배속 약12초이며 음향 켜짐의 신고 도입 대화 약25초는 별도다. 고배속에서는 오래된 대기 방송을 줄인다. 3. `개입 · 일시정지`는 서버 시간·자동 판단을 멈춘다. `내가 지휘`로 L0 전환 후 `진행 재개`하면 수동 명령을 발행할 수 있다. 4. L1은 조언만 제공한다. L2는 초안을 만들고 사람이 승인해야 발행한다. 새 관찰을 받은 과거 초안은 재검토 후 승인한다. 5. `차량 화면 열기`는 같은 사건의 읽기 전용 창을 연다. 서버 저장 상태를 1초마다 조회한다. 6. 근거 ID를 누르면 카드의 확인 질문·준비·주의·참고 출처를 볼 수 있다. 7. 무전은 기본 꺼짐이다. 켜면 이후 발행된 가상 명령을 브라우저 음성으로 읽는다. 한국어 음성 지원 여부는 브라우저·OS에 따른다. 상황본부 창이 열려 있고 재생 중일 때만 가상 시간이 진행된다. 재생 정지는 로컬 재생 제어이며, 서버에서 사건을 멈추려면 `개입`을 사용한다. 초기 골격은 **사건당 한 개의 상황본부 창 + 여러 읽기 전용 차량 창**을 기준으로 한다. 상황본부를 여러 개 열어 변경하면 버전 충돌로 재생을 중단하고 최신 상태를 조회한다. ## 검증 ```powershell npm test npm run typecheck npm run simulate npm run cards:export npm run build ``` 브라우저 검증은 `npm run build` 이후 실행한다. 3110 포트의 별도 서버와 임시 DB를 자동 생성하고 종료·삭제하므로 기관 DB를 변경하지 않는다. ```powershell npx playwright install chromium npm run test:e2e ``` `npm run simulate`는 웹 화면 없이 3개 시나리오의 종료·명령·공개 정보 경계를 검사한다. 브라우저 테스트는 소개→시연→개입→수동 전환→카드 검색, API 경계, 모바일 가로 넘침, 자동 종료, 차량 동기화, 새로고침 복구를 확인한다. 배포형 빌드 확인 후 로컬 실행: ```powershell npm run build npm start ``` 같은 `.next`를 사용하는 개발 서버와 빌드는 동시에 실행하지 않는다. ## 구조 | 경로 | 역할 | | --------------------------------- | ------------------------------------------------------- | | `packages/core/scenario` | 시나리오 스키마·대표 3개·공개 메타데이터 투영 | | `packages/core/engine` | 시드·시간·이벤트·명령·화재 셀·공개 상태 | | `packages/core/commander` | 관찰 기반 규칙 판단·근거·모드 경계·설명 대체 어댑터 | | `packages/kb` | 교육 카드 40장·출처 메타데이터·검색·검증 | | `apps/web/app` | 소개·상황본부·카드 페이지·서버 API | | `apps/web/lib/service.ts` | 단일 로컬 프로세스용 원자적 JSON 저장·버전 검사 | | `packages/simulation/scene.ts` | 공개 상태를 UE 센티미터 좌표·차량·드론·공개 화재로 투영 | | `unreal/AIFirestation` | UE5.8 C++ 식당 현장·맵/재질 생성 스크립트 | | `tests` | 엔진·시나리오·카드·서버·HTTP·설명 대체·브라우저 검증 | | `docs/KB0_교육카드_40장.md` | 사람이 검토할 전체 카드 내보내기 | | `docs/V0_2_REVIEW.md` | 승인 범위·계획서 검토·법령 판본 확인 한계 | | `docs/CORE_CONTRACT.md` | 공통 코어 상태·API·시뮬레이션 계약 | | `docs/IMPLEMENTATION_PROGRESS.md` | 구현 판단·작업·검증·최종 리뷰 기록 | ## 로컬 데이터 실행 작업 디렉터리의 `.data/sessions.json`에 사건이 저장된다. 안내한 루트 npm 명령으로 실행하면 프로젝트 루트 아래에 생성된다. Git에는 포함하지 않는다. 기록은 최대 200건이다. 개발 서버를 정지한 후 이 **파일 하나**를 다른 이름으로 옮기면 다음 실행에서 빈 사건 보관함으로 시작한다. 기존 파일을 보존해 검증·복구에 사용할 수 있다. ## v0.3 사용 - `/help`: 쉽게 따라하기·상세 설명서, `manual.html`과 `logic.html` 보기·HTML 다운로드. - `/library`: 중앙소방학교 2026 공통교재 PDF 14권, 6,799페이지. 문서별 공식 출처·공공누리 1유형·SHA-256·페이지 인덱스를 보존한다. 전체 본문 검색(최대 50건), 원문 PDF·페이지 텍스트·출처 포함 복사·북마크·독립 해설을 제공한다. - `/station`: 로컬 터미널 `npm run setup:key` → 15분 안에 설정 키 입력·최초 관리자 설정 → 사용자·대원 자원·기관 검토 근거 등록 → 사건 등록 → 관찰·배정 → 초안·지휘관 승인 → 대상별 수신·수행·완료 → 사건 종료. - 역할은 관리자·지휘관·상황요원·대원·감사자다. 기본 계정·암호는 없으며 사용자가 직접 설정한다. 관리자는 계정 비활성화·복구·역할 변경·암호 재설정과 대상 전체 세션 폐기를 수행하며 마지막 활성 관리자를 해제할 수 없다. 대원은 배정된 사건과 자신의 임무만 조작한다. - 새 관찰 이후 초안은 지휘관의 재검토가 필요하다. 취소 확인·미완료 임무가 남으면 종료할 수 없다. 자원 중복 배정·버전 충돌·요청 재전송·감사 기록은 SQLite 트랜잭션으로 처리한다. - AI는 규칙 기반 위험 후보·확인 질문만 제시한다. 실제 기관 사건에는 자동 명령·자동 수신·자동 완료가 없다. ## 문서 상시 갱신 ```powershell npm run docs:sync npm run docs:check npm run hooks:install ``` `packages/docs/content.ts`에 사용 안내와 재구현 프롬프트를 유지한다. 개발 서버는 소스 변경을 감시해 루트/공개 `manual.html`·`logic.html`을 동일하게 갱신한다. `logic.html`에는 실제 소스 스냅샷과 API 목록도 포함된다. UE C++·헤더·C# 빌드 규칙·설정·Python·PowerShell도 감시·수집하며 빌드/캐시와 생성된 이진 자산은 제외한다. 빌드 전 생성·검사와 Git 스테이지 검사로 오래된 문서를 거부한다. 변경 시 4개 HTML과 `docs/document-build.json`을 함께 저장한다. 문서는 오프라인 검색·복사·인쇄가 가능하다. 공식 자료 갱신은 `scripts/import-library.py`를 사용한다(pypdf 필요). 개별 게시글의 이용 조건을 확인하고 원문을 다운로드해 인덱스를 생성한다. `npm run library:check`가 PDF 해시·크기와 페이지 인덱스를 검증한다. 원문 게시글: https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/ ## 기관 설치·보관 기관 DB는 루트 `.data/station.db`(SQLite WAL)에 저장된다. `FIRESTATION_DB`로 경로를 지정할 수 있다. `FIRESTATION_ORIGIN`에는 내부 HTTPS의 정확한 공개 Origin을 지정한다. 기본 서버는 127.0.0.1에만 바인딩한다. 변경 요청은 같은 Origin·JSON·128KiB 상한을 요구하고, 세션은 HttpOnly/SameSite=Strict, HTTPS에서는 Secure 쿠키를 사용한다. 암호는 개별 salt/scrypt, 토큰은 해시로 보관한다. 세션은 8시간, 로그인 5회 실패 후 15분 제한이다. 최초 설정 키는 로컬 CLI로 발급하고 서버에는 해시만 보관한다. 15분 만료·성공 시 소진으로 Host 헤더 위조만으로 설정할 수 없다. 기관 데이터·암호 해시·세션은 Git과 공개 HTML에 포함하지 않는다. 감사/사건 JSON 내보내기는 관리자·지휘관·감사자 권한으로 제공한다. 해시 체인은 변조 검출 보조이며 DB 소유자에 대한 독립 서명이나 공인 무결성 인증은 아니다. 백업/복구는 서버를 정지한 뒤 `.data/station.db`, `station.db-wal`, `station.db-shm`이 남아 있으면 함께 보관하고 같은 파일 세트로 복구한다. 기존 DB를 먼저 별도로 보존한다. 운영 전 기관 담당자와 복구 훈련·접근 계정·보존 기간·HTTPS/내부망·현장 검수·공식 절차 적용성을 확인한다. Node.js 22.22.3의 `node:sqlite`는 실험적 경고를 표시하며 현재 단일 호스트 설치를 전제로 한다. 교육 JSON 저장은 단일 프로세스용으로 유지한다. 실제 119·무전·드론/차량 영상·LLM·기관 공인 운영 인증·다중 서버 DB·WebSocket·Phaser 게임은 구현되지 않았다. 이 업그레이드의 범위는 기관용 지휘 **지원·기록**이며 실제 화재 자동 지휘 검증 완료를 주장하지 않는다.
apps/web/AGENTS.md
<!-- BEGIN:nextjs-agent-rules --> ## This is NOT the Next.js you know This version has breaking changes — APIs, conventions, and file structure may all differ from your training data. Read the relevant guide in `node_modules/next/dist/docs/` (resolved from this file's directory; in monorepos the `next` package may not be visible from the repo root) before writing any code. Heed deprecation notices. This block is written and re-added by `next dev` — verify at `node_modules/next/dist/server/lib/generate-agent-files.js`. Removing it from a diff only re-creates the uncommitted change; committing it with your work keeps the tree clean. <!-- END:nextjs-agent-rules -->
apps/web/app/api/library/files/[id]/route.ts
import { library, libraryUser } from "../../../../../lib/library";
import { privateHeaders } from "../../../../../lib/station-http";
import { failure } from "../../../../../lib/http";
import { DomainError } from "../../../../../../../packages/core/types";
export const runtime = "nodejs";
export async function GET(
request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
try {
const { id } = await params,
format = new URL(request.url).searchParams.get("format"),
store = library(),
user = libraryUser(request);
if (format !== "pdf" && format !== "html")
throw new DomainError("HTML 또는 PDF 형식을 선택하세요.", 400);
const doc = store.document(id, user),
bytes = store.file(id, format, user);
const headers: Record<string, string> = {
...privateHeaders,
"Content-Type":
format === "html" ? "text/html; charset=utf-8" : "application/pdf",
"Content-Disposition": `inline; filename="${id}.${format}"; filename*=UTF-8''${encodeURIComponent(doc.title + "." + format)}`,
};
if (format === "html")
headers["Content-Security-Policy"] =
"sandbox; default-src 'none'; style-src 'unsafe-inline'; frame-ancestors 'self'";
const range = request.headers.get("range");
if (format === "pdf") {
headers["Accept-Ranges"] = "bytes";
if (range) {
const match = /^bytes=(\d*)-(\d*)$/.exec(range);
let start = 0,
end = bytes.length - 1;
if (match && (match[1] || match[2])) {
if (match[1]) {
start = Number(match[1]);
end = match[2] ? Number(match[2]) : end;
} else start = Math.max(0, bytes.length - Number(match[2]));
} else start = bytes.length;
if (
!Number.isSafeInteger(start) ||
!Number.isSafeInteger(end) ||
start < 0 ||
start >= bytes.length ||
end < start
)
return new Response(null, {
status: 416,
headers: { ...headers, "Content-Range": `bytes */${bytes.length}` },
});
end = Math.min(end, bytes.length - 1);
return new Response(new Uint8Array(bytes.subarray(start, end + 1)), {
status: 206,
headers: {
...headers,
"Content-Range": `bytes ${start}-${end}/${bytes.length}`,
"Content-Length": String(end - start + 1),
},
});
}
}
return new Response(new Uint8Array(bytes), {
headers: { ...headers, "Content-Length": String(bytes.length) },
});
} catch (e) {
return failure(e);
}
}
apps/web/app/api/library/order/route.ts
import { z } from "zod";
import { library } from "../../../../lib/library";
import {
actor,
stationJson,
privateHeaders,
} from "../../../../lib/station-http";
import { failure } from "../../../../lib/http";
export const runtime = "nodejs";
export async function POST(request: Request) {
try {
const user = actor(request);
const data = z
.object({
ids: z.array(z.string().max(100)).max(100),
version: z.number().int().nonnegative(),
})
.strict()
.parse(await stationJson(request));
return Response.json(library().reorder(user, data.ids, data.version), {
headers: privateHeaders,
});
} catch (e) {
return failure(e);
}
}
apps/web/app/api/library/route.ts
import { library, libraryUser } from "../../../lib/library";
import { privateHeaders } from "../../../lib/station-http";
import { failure } from "../../../lib/http";
export const runtime = "nodejs";
export async function GET(request: Request) {
try {
const url = new URL(request.url),
id = url.searchParams.get("document") ?? undefined,
page = url.searchParams.get("page");
const store = library(),
user = libraryUser(request);
if (url.searchParams.has("catalog"))
return Response.json(
{ ...store.list(user), user },
{ headers: privateHeaders },
);
if (id && page) {
return Response.json(
{
document: store.document(id, user),
page: Number(page),
text: store.page(id, Number(page), user),
},
{ headers: privateHeaders },
);
}
return Response.json(
{ results: store.search(url.searchParams.get("q") ?? "", id, user) },
{ headers: privateHeaders },
);
} catch (e) {
return failure(e);
}
}
apps/web/app/api/library/upload/route.ts
import { library, libraryOrigin } from "../../../../lib/library";
import { actor, privateHeaders } from "../../../../lib/station-http";
import { station } from "../../../../lib/station";
import { failure } from "../../../../lib/http";
import { MAX_PDF_BYTES } from "../../../../../../packages/library/store";
import { DomainError } from "../../../../../../packages/core/types";
export const runtime = "nodejs";
let receiving = false;
export async function POST(request: Request) {
let owned = false;
try {
libraryOrigin(request);
const user = actor(request);
station().auth.require(user, ["admin"]);
if (receiving)
throw new DomainError(
"다른 PDF를 처리 중입니다. 잠시 후 다시 올리세요.",
503,
);
if (
!request.headers.get("content-type")?.startsWith("multipart/form-data;")
)
throw new DomainError("PDF 파일 업로드 형식이 필요합니다.", 415);
const maximum = MAX_PDF_BYTES + 1024 * 1024;
if (Number(request.headers.get("content-length")) > maximum)
throw new DomainError("업로드 크기 상한을 초과했습니다.", 413);
receiving = true;
owned = true;
const reader = request.body?.getReader();
if (!reader) throw new DomainError("PDF 파일을 선택하세요.", 400);
const chunks: Uint8Array[] = [];
let size = 0;
while (true) {
const item = await reader.read();
if (item.done) break;
size += item.value.length;
if (size > maximum) {
await reader.cancel();
throw new DomainError("업로드 크기 상한을 초과했습니다.", 413);
}
chunks.push(item.value);
}
let form: FormData;
try {
form = await new Response(Buffer.concat(chunks), {
headers: { "Content-Type": request.headers.get("content-type")! },
}).formData();
} catch {
throw new DomainError("업로드 입력을 확인하세요.", 400);
}
const file = form.get("file");
if (!file || typeof file === "string")
throw new DomainError("PDF 파일을 선택하세요.", 400);
const store = library();
const doc = await store.upload(
user,
{
title: form.get("title"),
publisher: form.get("publisher"),
licenseStatement: form.get("licenseStatement"),
filename: file.name,
},
Buffer.from(await file.arrayBuffer()),
);
return Response.json(
{ document: doc, ...store.list(user) },
{ status: 201, headers: privateHeaders },
);
} catch (e) {
return failure(e);
} finally {
if (owned) receiving = false;
}
}
apps/web/app/api/sessions/[id]/route.ts
import { demoService } from "../../../../lib/service";
import { failure, guard, readJson } from "../../../../lib/http";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
export async function GET(_request: Request, context: Context) {
try {
const { id } = await context.params;
return Response.json(demoService.get(id), {
headers: { "Cache-Control": "no-store" },
});
} catch (error) {
return failure(error);
}
}
export async function POST(request: Request, context: Context) {
try {
guard(request);
const { id } = await context.params;
const body = await readJson(request);
if (
!body ||
typeof body !== "object" ||
Object.keys(body).some((k) => !["action", "version"].includes(k))
)
return Response.json(
{ error: "잘못된 변경 요청입니다." },
{ status: 400 },
);
const data = body as Record<string, unknown>;
return Response.json(
demoService.change(id, data.action, data.version as number),
);
} catch (error) {
return failure(error);
}
}
apps/web/app/api/sessions/[id]/scene/route.ts
import { demoService } from "../../../../../lib/service";
import { failure } from "../../../../../lib/http";
import { restaurantScene } from "../../../../../../../packages/simulation/scene";
export const runtime = "nodejs";
export const dynamic = "force-dynamic";
type Context = { params: Promise<{ id: string }> };
export async function GET(_request: Request, context: Context) {
try {
const { id } = await context.params;
return Response.json(restaurantScene(demoService.get(id)), {
headers: { "Cache-Control": "no-store" },
});
} catch (error) {
const response = failure(error);
response.headers.set("Cache-Control", "no-store");
return response;
}
}
apps/web/app/api/sessions/route.ts
import { demoService } from "../../../lib/service";
import { failure, guard, readJson } from "../../../lib/http";
export const runtime = "nodejs";
export async function POST(request: Request) {
try {
guard(request);
return Response.json(demoService.create(await readJson(request)), {
status: 201,
});
} catch (error) {
return failure(error);
}
}
apps/web/app/api/station/audit/route.ts
import { station } from "../../../../lib/station";
import { actor, privateHeaders } from "../../../../lib/station-http";
import { failure } from "../../../../lib/http";
export const runtime = "nodejs";
export async function GET(request: Request) {
try {
const user = actor(request),
{ service } = station();
return Response.json(
{
exportedAt: new Date().toISOString(),
audit: service.audit(user),
incidents: service.list(user),
},
{
headers: {
...privateHeaders,
"Content-Disposition":
'attachment; filename="firestation-audit.json"',
},
},
);
} catch (e) {
return failure(e);
}
}
apps/web/app/api/station/auth/route.ts
import { z } from "zod";
import { station } from "../../../../lib/station";
import {
actor,
authCookie,
sessionToken,
stationJson,
privateHeaders,
} from "../../../../lib/station-http";
import { failure } from "../../../../lib/http";
export const runtime = "nodejs";
export async function GET(request: Request) {
try {
const { auth } = station();
let user;
try {
user = actor(request);
} catch {
user = null;
}
return Response.json(
{ configured: auth.configured(), user },
{ headers: privateHeaders },
);
} catch (e) {
return failure(e);
}
}
export async function POST(request: Request) {
try {
const d = z
.object({
type: z.enum(["setup", "login", "logout"]),
input: z.unknown().optional(),
})
.strict()
.parse(await stationJson(request));
const { auth } = station();
if (d.type === "logout") {
auth.logout(sessionToken(request));
return Response.json(
{ ok: true },
{
headers: { ...privateHeaders, "Set-Cookie": authCookie("", request) },
},
);
}
const result =
d.type === "setup" ? auth.bootstrap(d.input) : auth.login(d.input);
return Response.json(
{ user: result.user },
{
headers: {
...privateHeaders,
"Set-Cookie": authCookie(result.token, request),
},
},
);
} catch (e) {
return failure(e);
}
}
apps/web/app/api/station/incidents/[id]/route.ts
import { station } from "../../../../../lib/station";
import {
actor,
stationJson,
privateHeaders,
} from "../../../../../lib/station-http";
import { failure } from "../../../../../lib/http";
export const runtime = "nodejs";
type Context = { params: Promise<{ id: string }> };
export async function GET(request: Request, context: Context) {
try {
return Response.json(
station().service.get(actor(request), (await context.params).id),
{ headers: privateHeaders },
);
} catch (e) {
return failure(e);
}
}
export async function POST(request: Request, context: Context) {
try {
return Response.json(
station().service.act(
actor(request),
(await context.params).id,
await stationJson(request),
),
{ headers: privateHeaders },
);
} catch (e) {
return failure(e);
}
}
apps/web/app/api/station/route.ts
import { z } from "zod";
import { station } from "../../../lib/station";
import { actor, stationJson, privateHeaders } from "../../../lib/station-http";
import { failure } from "../../../lib/http";
import { suggestions } from "../../../../../packages/station/assistant";
export const runtime = "nodejs";
export async function GET(request: Request) {
try {
const user = actor(request),
{ auth, service } = station();
const incidents = service.list(user);
return Response.json(
{
user,
users:
user.role === "crew" ? [] : auth.users(user, user.role === "admin"),
resources: service.resources(user),
evidence: service.evidence(user),
incidents,
suggestions: Object.fromEntries(
incidents.map((i) => [i.id, suggestions(i)]),
),
},
{ headers: privateHeaders },
);
} catch (e) {
return failure(e);
}
}
export async function POST(request: Request) {
try {
const user = actor(request),
{ auth, service } = station();
const d = z
.object({
type: z.enum([
"user",
"updateUser",
"resource",
"evidence",
"incident",
]),
input: z.unknown(),
})
.strict()
.parse(await stationJson(request));
const value =
d.type === "user"
? auth.createUser(user, d.input)
: d.type === "updateUser"
? auth.updateUser(user, d.input)
: d.type === "resource"
? service.addResource(user, d.input)
: d.type === "evidence"
? service.addEvidence(user, d.input)
: service.create(user, d.input);
return Response.json({ value }, { status: 201, headers: privateHeaders });
} catch (e) {
return failure(e);
}
}
apps/web/app/demo/page.tsx
import { scenarios, publicScenario } from "../../../../packages/core/scenario";
import { CommandRoom } from "../../components/command-room";
export default function Page() {
// 숨겨진 정답·타임라인을 클라이언트 번들에 보내지 않는다.
return <CommandRoom scenarios={scenarios.map(publicScenario)} />;
}
apps/web/app/globals.css
:root {
color-scheme: light;
--paper: #f1f4f6;
--ink: #19303c;
--muted: #5d707b;
--line: #d8e0e5;
--orange: #d95332;
--teal: #237d72;
--navy: #203b49;
}
.portal {
max-width: 1640px;
margin: 0 auto;
padding: 32px 36px 60px;
}
.service-links {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
margin: 30px 0;
}
.service-links .panel {
border: 1px solid var(--line);
background: white;
padding: 24px;
}
.service-links h2 {
margin-top: 12px;
font-size: 20px;
}
@media (max-width: 760px) {
.service-links {
grid-template-columns: 1fr;
}
}
.portal-heading {
display: flex;
justify-content: space-between;
align-items: flex-start;
gap: 24px;
margin-bottom: 20px;
}
.portal h1 {
font-size: 30px;
letter-spacing: -1px;
margin-bottom: 8px;
}
.portal h2 {
font-size: 19px;
margin-bottom: 14px;
}
.portal h3 {
font-size: 16px;
}
.portal .panel {
background: #fff;
border: 1px solid var(--line);
border-radius: 6px;
padding: 22px;
min-width: 0;
}
.portal .actions {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 10px;
}
.notice {
border-left: 4px solid var(--teal);
background: #e3efed;
padding: 13px 18px;
margin-bottom: 20px;
}
.portal .alert {
border-left: 3px solid #c64b2e;
color: #893420;
background: #fff1e9;
padding: 12px 16px;
}
.portal .badge {
display: inline-block;
font-size: 12px;
font-weight: 600;
background: #e5eeec;
color: #246158;
padding: 5px 10px;
border-radius: 4px;
}
.guide-layout {
display: grid;
grid-template-columns: 280px 1fr;
align-items: start;
gap: 24px;
}
.guide-nav {
position: sticky;
top: 16px;
}
.guide-nav a {
display: block;
padding: 10px 0;
border-top: 1px solid var(--line);
}
.guide-content section {
scroll-margin-top: 20px;
padding: 16px 0 22px;
border-bottom: 1px solid var(--line);
}
.guide-content li {
margin-bottom: 12px;
}
.library-search {
display: grid;
grid-template-columns: minmax(160px, 1fr) minmax(160px, 280px) auto;
gap: 14px;
align-items: end;
}
.library-layout {
display: grid;
grid-template-columns: 280px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.library-main,
.station-main {
display: grid;
gap: 20px;
min-width: 0;
}
.library-portal {
display: flex;
flex-direction: column;
height: calc(100dvh - 84px);
min-height: 640px;
padding: 18px 24px 14px;
}
.library-portal .portal-heading {
margin-bottom: 10px;
}
.library-portal .portal-heading p {
margin: 0;
font-size: 13px;
}
.library-portal h1 {
font-size: 25px;
margin: 6px 0;
}
.library-portal .notice {
font-size: 12px;
padding: 8px 12px;
margin-bottom: 10px;
}
.library-portal .library-search {
padding: 12px 16px;
flex-shrink: 0;
}
.library-portal > .muted {
min-height: 18px;
font-size: 12px;
margin: 5px 0;
}
.library-portal > .library-layout {
flex: 1;
min-height: 0;
align-items: stretch;
gap: 16px;
}
.library-portal .book-list,
.library-portal .library-main {
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
}
.library-portal .library-main {
display: block;
}
.library-portal .library-main > .panel {
margin-bottom: 16px;
}
.library-portal .library-reader h2 {
margin: 0 0 10px;
}
.library-portal .library-source-note {
margin: 0 0 10px;
flex-shrink: 0;
}
.library-portal .library-source-note summary {
cursor: pointer;
padding: 0;
}
.library-source-note p {
margin: 8px 0 0;
}
.library-portal .book-list {
padding: 14px 10px;
}
.library-portal .book-list p {
font-size: 11px;
}
.book-row {
display: flex;
border-bottom: 1px solid var(--line);
cursor: grab;
}
.book-row.dragging {
opacity: 0.5;
}
.book-row .book-item {
min-width: 0;
border: 0;
flex: 1;
}
.book-row .book-item b {
overflow-wrap: anywhere;
}
.book-order {
display: flex;
flex-direction: column;
justify-content: center;
}
.book-order button {
min-width: 44px;
min-height: 44px;
padding: 0;
background: transparent;
}
.library-portal .catalog-toggle {
display: none;
}
.library-tabs {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.library-tabs [aria-selected="true"] {
background: var(--navy);
color: white;
}
.library-tabs .button {
min-width: 90px;
}
.page-navigation {
margin-bottom: 14px;
}
.library-portal .page-navigation {
margin: 0 0 12px;
align-items: end;
}
.page-navigation input {
width: 84px;
}
.page-navigation label {
font-size: 12px;
}
.html-page {
background: #fafbf9;
border: 1px solid var(--line);
padding: 24px 30px;
min-height: 260px;
}
.html-page header {
display: flex;
justify-content: space-between;
gap: 12px;
font-size: 12px;
color: var(--muted);
border-bottom: 1px solid var(--line);
padding-bottom: 10px;
}
.html-page pre {
font: inherit;
font-size: 16px;
line-height: 1.9;
white-space: pre-wrap;
overflow-wrap: anywhere;
margin: 20px 0 0;
}
.html-page mark {
background: #ffe3a1;
color: var(--ink);
}
.reader-actions {
margin-top: 14px;
}
.library-reader details {
margin-top: 12px;
}
.library-upload {
margin-top: 16px;
border-top: 1px solid var(--line);
padding-top: 12px;
}
.library-upload summary {
min-height: 44px;
cursor: pointer;
}
.upload-fields {
display: grid;
gap: 12px;
}
.upload-fields input[type="file"] {
width: 100%;
min-width: 0;
font-size: 12px;
}
.upload-login {
margin-top: 14px;
width: 100%;
font-size: 12px;
}
@media (max-width: 760px) {
.library-portal {
height: auto;
min-height: 0;
padding: 16px 12px 24px;
}
.library-portal > .library-layout {
display: flex;
flex-direction: column;
}
.library-portal .book-list,
.library-portal .library-main {
overflow: visible;
}
.library-portal .library-search {
grid-template-columns: minmax(0, 1fr) auto;
padding: 12px;
gap: 8px;
}
.library-portal .library-search > label:first-child {
grid-column: 1 / -1;
}
.library-portal .portal-heading {
position: relative;
display: block;
}
.library-portal .portal-heading .eyebrow {
padding-right: 112px;
font-size: 10px;
min-height: 36px;
}
.library-portal .portal-heading > .button {
position: absolute;
top: 0;
right: 0;
min-height: 36px;
padding: 8px;
font-size: 11px;
}
.library-portal h1 {
font-size: 23px;
}
.library-portal .book-list h2 {
margin-bottom: 8px;
}
.library-portal .catalog-toggle {
display: block;
width: 100%;
min-height: 44px;
}
.book-list[data-catalog-open="false"] .book-list-content {
display: none;
}
.book-list[data-catalog-open="true"] .book-items {
max-height: 350px;
overflow: auto;
}
.library-portal .library-reader {
padding: 14px;
}
.page-navigation {
display: grid !important;
grid-template-columns: 1fr 1fr 1fr;
gap: 8px !important;
}
.library-portal .page-navigation > button[aria-label="다음 페이지"] {
grid-column: 3;
grid-row: 1;
}
.library-portal .page-navigation > button[type="submit"] {
grid-column: 1 / 3;
}
.page-navigation input {
width: 100%;
}
.library-portal .button {
min-height: 44px;
}
.html-page {
padding: 16px 14px;
}
.html-page header {
flex-wrap: wrap;
}
.reader-actions .button {
flex: 1;
min-width: 130px;
}
}
.book-item {
display: block;
width: 100%;
padding: 14px 12px;
text-align: left;
background: transparent;
border: 0;
border-bottom: 1px solid var(--line);
}
.book-item span,
.search-hit span {
display: block;
font-size: 12px;
color: var(--muted);
margin-top: 6px;
}
.book-item.selected {
background: #e5efed;
border-left: 3px solid var(--teal);
}
.book-list h3 {
margin-top: 24px;
}
.pdf-viewer {
width: 100%;
height: 650px;
border: 1px solid var(--line);
margin-top: 20px;
}
.page-text {
width: 100%;
min-height: 300px;
font-size: 14px;
line-height: 1.8;
margin-bottom: 12px;
}
.search-results {
max-height: 430px;
overflow-y: auto;
}
.search-hit {
display: block;
width: 100%;
text-align: left;
border: 0;
border-bottom: 1px solid var(--line);
padding: 14px 8px;
background: #fff;
}
.search-hit:hover,
.book-item:hover {
background: #f0f5f4;
}
.hash {
font:
12px Consolas,
monospace;
overflow-wrap: anywhere;
}
.bookmark {
display: flex;
border-bottom: 1px solid var(--line);
}
.bookmark button {
padding: 9px 0;
text-align: left;
border: 0;
background: none;
flex: 1;
}
.bookmark button:last-child {
flex: 0 0 25px;
text-align: right;
}
.station-tabs {
margin: 20px 0;
}
.admin-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 20px;
align-items: start;
}
.station-layout {
display: grid;
grid-template-columns: 290px minmax(0, 1fr);
gap: 24px;
align-items: start;
}
.portal fieldset {
border: 0;
margin: 0;
padding: 0;
display: grid;
gap: 14px;
}
.portal form {
margin-top: 14px;
margin-bottom: 18px;
}
.portal details summary {
font-weight: 600;
cursor: pointer;
padding: 10px 0;
}
.portal .auth-panel {
max-width: 520px;
margin: 24px auto;
}
.command-title {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.command-title time,
.observation-row time {
font-size: 12px;
color: var(--muted);
}
.station-command {
border: 1px solid var(--line);
border-left: 4px solid var(--orange);
padding: 18px;
margin-bottom: 18px;
}
.station-command h3 {
margin: 12px 0;
}
.recipient-list > div {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 16px;
padding: 10px 0;
border-top: 1px solid var(--line);
}
.recipient-list {
margin-bottom: 12px;
}
.suggestion,
.observation-row {
padding: 14px 0;
border-top: 1px solid var(--line);
}
.suggestion p,
.observation-row p {
margin: 6px 0;
}
.portal .check-label {
display: flex;
flex-direction: row;
align-items: center;
font-size: 13px;
}
.check-label input {
width: 18px;
height: 18px;
}
@media (max-width: 1150px) {
.site-nav {
flex-wrap: wrap;
gap: 14px;
}
.site-nav nav {
flex-wrap: wrap;
gap: 16px;
}
.admin-grid {
grid-template-columns: 1fr 1fr;
}
}
@media (max-width: 760px) {
.portal {
padding: 22px 16px;
}
.portal-heading {
flex-direction: column;
}
.guide-layout,
.library-layout,
.station-layout,
.admin-grid {
grid-template-columns: 1fr;
}
.guide-nav {
position: static;
}
.library-search {
grid-template-columns: 1fr;
}
.pdf-viewer {
height: 460px;
}
.portal .panel {
padding: 16px;
}
.portal .actions {
gap: 8px;
}
.book-list {
max-height: 380px;
overflow: auto;
}
}
* {
box-sizing: border-box;
}
body {
margin: 0;
background: var(--paper);
color: var(--ink);
font-family: "Segoe UI", "Malgun Gothic", Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
}
a {
color: inherit;
text-decoration: none;
}
button,
input,
select,
textarea {
font: inherit;
color: inherit;
}
button,
a,
input,
select,
textarea {
-webkit-tap-highlight-color: transparent;
}
button {
cursor: pointer;
}
button:disabled {
opacity: 0.5;
cursor: not-allowed;
}
button:focus-visible,
a:focus-visible,
input:focus-visible,
select:focus-visible,
textarea:focus-visible {
outline: 3px solid #49998e;
outline-offset: 3px;
}
h1,
h2,
h3,
p {
margin-top: 0;
}
svg {
flex-shrink: 0;
}
input,
select,
textarea {
min-width: 0;
max-width: 100%;
background: white;
border: 1px solid var(--line);
border-radius: 4px;
padding: 9px 11px;
}
select {
cursor: pointer;
}
textarea {
resize: vertical;
min-height: 75px;
}
label {
font-size: 12px;
font-weight: 600;
display: flex;
flex-direction: column;
gap: 5px;
}
.mono {
font-family: Consolas, "Courier New", monospace;
font-size: 11px;
letter-spacing: 0.04em;
}
.muted {
color: var(--muted);
font-size: 12px;
}
.eyebrow {
font-family: Consolas, monospace;
font-size: 11px;
letter-spacing: 0.12em;
color: var(--muted);
display: flex;
align-items: center;
gap: 9px;
}
.status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
background: var(--teal);
flex-shrink: 0;
}
.button {
border: 1px solid var(--line);
background: white;
border-radius: 4px;
padding: 10px 16px;
display: inline-flex;
align-items: center;
justify-content: center;
gap: 8px;
font-weight: 600;
line-height: 1.3;
min-height: 40px;
transition: background 0.15s;
}
.button:hover:not(:disabled) {
background: #e9eff0;
}
.button.primary {
background: var(--orange);
color: white;
border-color: var(--orange);
}
.button.primary:hover:not(:disabled) {
background: #b94023;
}
.text-link {
display: inline-flex;
gap: 7px;
align-items: center;
font-weight: 600;
font-size: 12px;
}
.text-link:hover {
text-decoration: underline;
}
.icon-button {
border: 0;
background: transparent;
padding: 6px;
display: inline-flex;
border-radius: 3px;
}
.icon-button:hover {
background: #e9eff0;
}
.site-nav {
height: 84px;
padding: 0 5%;
display: flex;
align-items: center;
justify-content: space-between;
background: #fff;
border-bottom: 1px solid var(--line);
gap: 16px;
}
.brand {
display: flex;
gap: 10px;
align-items: center;
font-family: Georgia, serif;
font-size: 23px;
line-height: 1.2;
white-space: nowrap;
}
.brand b {
font-weight: 500;
}
.brand small {
display: block;
font-family: Consolas, monospace;
font-size: 8px;
letter-spacing: 0.16em;
margin-top: 5px;
color: var(--muted);
}
.brand-mark {
width: 38px;
height: 42px;
background: var(--orange);
color: white;
display: grid;
place-items: center;
border-radius: 3px;
}
.site-nav nav {
display: flex;
gap: 28px;
align-items: center;
font-size: 13px;
font-weight: 600;
}
.site-nav nav a[aria-current="page"] {
color: var(--orange);
}
.nav-note {
display: flex;
gap: 7px;
align-items: center;
border-left: 1px solid var(--line);
padding-left: 25px;
color: var(--muted);
}
.site-footer {
padding: 25px 5%;
display: flex;
justify-content: space-between;
gap: 20px;
color: var(--muted);
font-size: 10px;
letter-spacing: 0.05em;
border-top: 1px solid var(--line);
}
.home {
max-width: 1440px;
margin: auto;
padding: 44px 5% 30px;
}
.hero {
display: grid;
grid-template-columns: 1.1fr 1fr;
gap: 6%;
padding: 47px 0 58px;
align-items: center;
}
.section-number {
font-family: Consolas, monospace;
letter-spacing: 0.1em;
color: #88979f;
font-size: 10px;
}
.hero h1 {
font-size: clamp(33px, 3.45vw, 52px);
letter-spacing: -0.065em;
line-height: 1.3;
margin: 20px 0 24px;
font-weight: 700;
}
.hero-copy > p {
color: var(--muted);
max-width: 485px;
line-height: 1.95;
font-size: 15px;
word-break: keep-all;
}
.hero-actions {
display: flex;
gap: 24px;
align-items: center;
flex-wrap: wrap;
margin-top: 31px;
}
.hero-actions .primary {
padding: 15px 21px;
}
.hero-footnote {
font-size: 11px;
color: var(--muted);
margin-top: 19px;
}
.hero-board {
border: 1px solid #c3d0d6;
border-radius: 6px;
overflow: hidden;
background: white;
box-shadow: 0 20px 45px #19303c0a;
transform: rotate(-1deg);
}
.board-head {
display: flex;
gap: 9px;
padding: 15px 19px;
background: var(--navy);
color: white;
font-size: 10px;
font-family: Consolas, monospace;
letter-spacing: 0.06em;
}
.board-head .mono {
margin-left: auto;
color: #b5c7cc;
}
.board-head .status-dot {
background: #79b8a2;
}
.board-scene {
position: relative;
height: 230px;
background: #e7ede8;
overflow: hidden;
}
.scene-grid {
position: absolute;
inset: 0;
background-image:
linear-gradient(#bbc9c32e 1px, transparent 1px),
linear-gradient(90deg, #bbc9c32e 1px, transparent 1px);
background-size: 22px 22px;
}
.scene-road {
position: absolute;
background: #fff;
}
.road-one {
width: 100%;
height: 27px;
top: 140px;
}
.road-two {
width: 27px;
height: 100%;
left: 48%;
}
.scene-building {
position: absolute;
background: #cfdbd3;
border: 1px solid #b3c5ba;
border-radius: 2px;
}
.b-one {
left: 8%;
top: 33px;
width: 31%;
height: 66px;
}
.b-two {
left: 58%;
top: 28px;
width: 33%;
height: 73px;
}
.b-three {
left: 62%;
top: 185px;
width: 30%;
height: 60px;
}
.scene-incident,
.scene-drone,
.scene-unit {
position: absolute;
display: flex;
gap: 8px;
align-items: center;
font-size: 11px;
font-weight: 600;
}
.scene-incident {
top: 112px;
right: 14%;
color: var(--orange);
background: #fff7ef;
border: 1px solid #e0a393;
padding: 8px 12px;
border-radius: 4px;
box-shadow: 0 0 0 20px #d9533209;
}
.scene-drone {
top: 41px;
left: 23%;
color: var(--teal);
}
.scene-unit {
left: 7%;
top: 171px;
background: var(--navy);
padding: 7px 11px;
color: white;
border-radius: 3px;
}
.board-decision {
padding: 21px 22px;
}
.small-tag {
font-size: 9px;
letter-spacing: 0.09em;
font-family: Consolas, monospace;
color: var(--muted);
}
.board-decision > strong {
display: block;
margin: 8px 0;
font-size: 15px;
}
.board-decision p {
font-size: 11px;
color: var(--muted);
margin-bottom: 14px;
}
.source-chip {
display: inline-flex;
padding: 3px 7px;
background: #edf5f2;
color: #236454;
border: 1px solid #d4e5df;
font-size: 10px;
border-radius: 3px;
}
.feature-strip {
display: grid;
grid-template-columns: repeat(4, 1fr);
border-top: 1px solid var(--line);
border-bottom: 1px solid var(--line);
}
.feature-strip article {
padding: 26px 25px 20px;
border-right: 1px solid var(--line);
}
.feature-strip article:first-child {
padding-left: 0;
}
.feature-strip article:last-child {
border: 0;
}
.feature-top {
display: flex;
justify-content: space-between;
color: var(--muted);
margin-bottom: 18px;
}
.feature-strip h2 {
font-size: 16px;
margin-bottom: 9px;
}
.feature-strip p {
font-size: 12px;
line-height: 1.8;
color: var(--muted);
margin: 0;
word-break: keep-all;
}
.home-bottom {
display: flex;
justify-content: space-between;
gap: 20px;
margin-top: 25px;
color: var(--muted);
font-size: 11px;
}
.home-bottom strong {
color: var(--ink);
margin-right: 12px;
}
.room {
max-width: 1720px;
margin: auto;
padding: 27px 3.5% 25px;
}
.room-top {
display: flex;
align-items: center;
justify-content: space-between;
gap: 15px;
margin-bottom: 21px;
}
.room-top h1 {
font-size: 25px;
letter-spacing: -0.04em;
margin: 6px 0 0;
}
.virtual-badge {
display: flex;
align-items: center;
gap: 7px;
border: 1px solid #b5cec5;
color: #276650;
background: #e9f2ed;
padding: 6px 10px;
font-size: 11px;
font-weight: 600;
border-radius: 3px;
}
.setup-bar {
background: white;
border: 1px solid var(--line);
border-radius: 5px;
display: flex;
align-items: flex-end;
gap: 16px;
padding: 15px 18px;
margin-bottom: 21px;
flex-wrap: wrap;
}
.setup-bar > label:first-child {
min-width: 255px;
}
.setup-bar select {
min-height: 40px;
}
.seed-field {
width: 85px;
}
.vehicle-link {
margin-left: auto;
align-self: center;
margin-top: 16px;
}
.room-grid {
display: grid;
grid-template-columns: minmax(0, 1.85fr) minmax(320px, 1fr);
gap: 20px;
}
.situation-column,
.command-column {
min-width: 0;
display: flex;
flex-direction: column;
gap: 16px;
}
.panel {
background: white;
border: 1px solid var(--line);
border-radius: 5px;
overflow: hidden;
}
.panel-title {
padding: 12px 16px;
border-bottom: 1px solid var(--line);
display: flex;
gap: 12px;
align-items: center;
justify-content: space-between;
min-height: 47px;
}
.panel-title h2 {
font-size: 13px;
margin: 0;
display: flex;
align-items: center;
gap: 7px;
font-weight: 650;
}
.panel-title > .mono {
color: var(--muted);
}
.incident-header {
background: var(--navy);
color: white;
border-radius: 5px;
padding: 18px 23px;
position: relative;
}
.incident-header > .mono {
color: #abc0c8;
font-size: 10px;
}
.incident-header h2 {
font-size: 21px;
margin: 10px 0 8px;
letter-spacing: -0.04em;
}
.incident-header p {
font-size: 12px;
color: #d0dde2;
line-height: 1.8;
margin-bottom: 13px;
}
.incident-meta {
display: flex;
gap: 8px 20px;
flex-wrap: wrap;
font-size: 10px;
color: #adbec5;
border-top: 1px solid #ffffff20;
padding-top: 12px;
}
.status-pill {
float: right;
font-size: 10px;
color: #b1d6c8;
border: 1px solid #658c84;
border-radius: 3px;
padding: 2px 8px;
}
.incident-map svg {
display: block;
width: 100%;
max-height: 350px;
}
.map-legend {
display: flex;
gap: 18px;
align-items: center;
padding: 8px 15px;
font-size: 10px;
background: #f5f8f7;
color: var(--muted);
border-top: 1px solid var(--line);
flex-wrap: wrap;
}
.map-legend span {
display: flex;
align-items: center;
gap: 5px;
}
.map-legend span:last-child {
margin-left: auto;
}
.legend-dot {
width: 7px;
height: 7px;
border-radius: 50%;
display: inline-block;
}
.orange {
background: var(--orange);
}
.teal {
background: var(--teal);
}
.resource-row {
display: grid;
grid-template-columns: repeat(3, 1fr);
border-top: 1px solid var(--line);
font-size: 11px;
}
.resource-row > div {
padding: 12px;
display: grid;
grid-template-columns: 20px auto;
gap: 2px 6px;
border-right: 1px solid var(--line);
}
.resource-row > div:last-child {
border: 0;
}
.resource-row svg {
grid-row: span 2;
color: var(--teal);
margin-top: 3px;
}
.resource-row span {
color: var(--muted);
}
.resource-row small {
grid-column: span 2;
font-size: 9px;
margin-top: 4px;
color: var(--muted);
}
.observation-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 16px;
}
.thermal-view {
height: 160px;
position: relative;
background: #203331;
color: #bdd3c9;
padding: 15px;
display: grid;
place-items: center;
overflow: hidden;
}
.thermal-view:before {
content: "";
position: absolute;
inset: 0;
background: repeating-linear-gradient(
0deg,
transparent,
transparent 3px,
#0000000a 4px
);
pointer-events: none;
}
.camera-corner {
position: absolute;
width: 16px;
height: 16px;
border-color: #83a69a;
border-style: solid;
}
.top-left {
left: 12px;
top: 12px;
border-width: 1px 0 0 1px;
}
.bottom-right {
right: 12px;
bottom: 12px;
border-width: 0 1px 1px 0;
}
.drone-wait {
display: flex;
flex-direction: column;
align-items: center;
gap: 6px;
font-size: 11px;
}
.drone-wait span {
font-size: 10px;
color: #9db6ab;
}
.heat-grid {
display: grid;
width: 82%;
height: 110px;
grid-template-columns: repeat(8, 1fr);
gap: 2px;
transform: perspective(300px) rotateX(12deg);
}
.heat-grid span {
border-radius: 2px;
}
.camera-caption {
font-size: 8px;
letter-spacing: 0.04em;
position: absolute;
bottom: 5px;
left: 15px;
font-family: Consolas, monospace;
}
.panel-note {
font-size: 10px;
color: var(--muted);
line-height: 1.7;
padding: 10px 14px;
margin: 0;
}
.observation-list {
max-height: 213px;
overflow: auto;
padding: 0 14px;
}
.observation-list article {
border-bottom: 1px solid var(--line);
padding: 11px 0;
}
.observation-list article:last-child {
border: 0;
}
.observation-list article > div {
display: flex;
gap: 10px;
justify-content: space-between;
align-items: center;
}
.observation-list .mono {
font-size: 9px;
color: var(--muted);
}
.observation-list p {
font-size: 11px;
line-height: 1.7;
margin: 7px 0 0;
}
.observation-status {
font-size: 9px;
color: #9a6d22;
background: #f8efd9;
padding: 1px 5px;
border-radius: 2px;
}
.observation-status.confirmed {
color: #28705a;
background: #e7f2eb;
}
.empty-state {
padding: 23px 16px;
color: var(--muted);
font-size: 12px;
line-height: 1.8;
margin: 0;
}
.commander-panel {
border-top: 3px solid var(--orange);
}
.commander-mode {
display: flex;
gap: 8px;
align-items: center;
padding: 10px 16px;
background: #f5f8f7;
font-size: 12px;
border-bottom: 1px solid var(--line);
}
.commander-mode > span:last-child {
margin-left: auto;
font-size: 9px;
color: var(--muted);
}
.decision-content {
padding: 18px;
}
.decision-content h3 {
font-size: 17px;
line-height: 1.6;
letter-spacing: -0.03em;
margin: 9px 0;
}
.decision-content p {
font-size: 12px;
color: var(--muted);
line-height: 1.8;
margin-bottom: 14px;
}
.decision-action {
background: #f5f7f8;
border-left: 2px solid #869ba6;
padding: 11px 12px;
font-size: 11px;
line-height: 1.8;
}
.decision-action > span {
display: block;
color: var(--muted);
font-size: 10px;
margin-bottom: 4px;
}
.decision-action > strong {
font-weight: 500;
}
.evidence-links {
display: flex;
gap: 6px;
flex-wrap: wrap;
margin: 12px 0 4px;
}
.decision-content small {
font-size: 9px;
color: var(--muted);
}
.commander-controls {
padding: 0 16px 16px;
}
.button.intervention {
width: 100%;
border-color: #dfad9f;
background: #fff7f3;
color: #ac3e24;
}
.button.intervention:hover:not(:disabled) {
background: #ffeade;
}
.playback-row {
display: flex;
gap: 8px;
align-items: center;
margin-top: 9px;
}
.playback-row > .button {
padding: 8px 10px;
font-size: 10px;
min-height: 35px;
}
.playback-row label {
display: flex;
flex-direction: row;
align-items: center;
font-size: 10px;
gap: 7px;
}
.playback-row select {
padding: 5px;
font-size: 10px;
min-height: 35px;
}
.voice-button {
margin-left: auto;
}
.sound-volume {
display: flex;
align-items: center;
gap: 5px;
font-size: 11px;
min-width: 60px;
}
.sound-volume input[type="range"] {
width: 85px;
min-width: 0;
min-height: 40px;
padding: 0;
accent-color: var(--teal);
}
.sound-caption {
display: flex;
gap: 8px;
align-items: center;
min-height: 24px;
flex-shrink: 0;
font-size: 11px;
padding: 3px 8px;
border-left: 2px solid var(--teal);
background: #e3efed;
color: var(--navy);
}
.sound-caption strong {
flex-shrink: 0;
}
.sound-caption span {
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.sound-caption[data-audio-phase="error"] {
background: #fff1e9;
border-color: var(--orange);
}
.incident-header .sound-caption {
position: relative;
background: transparent;
border: 0;
color: #d5e5eb;
min-height: 0;
padding: 0 48px 0 0;
margin: 4px 0;
}
.sound-caption .caption-expander {
position: absolute;
right: 0;
top: 50%;
transform: translateY(-50%);
min-height: 44px;
min-width: 44px;
padding: 0;
background: transparent;
color: inherit;
font-size: 11px;
}
.sound-caption[data-expanded="true"] {
align-items: flex-start;
}
.sound-caption[data-expanded="true"] span {
white-space: normal;
overflow-wrap: anywhere;
}
.incident-header .sound-caption[data-audio-phase="error"] {
color: #ffd1bd;
}
.command-scroll {
max-height: 290px;
overflow: auto;
}
.command-item {
padding: 14px 16px;
border-bottom: 1px solid var(--line);
font-size: 11px;
}
.command-item:last-child {
border: 0;
}
.command-item.cancelled {
opacity: 0.65;
}
.command-meta {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 6px;
}
.command-meta .mono {
font-size: 9px;
color: var(--muted);
}
.command-item > strong {
font-size: 11px;
}
.command-item p {
line-height: 1.8;
margin: 5px 0 9px;
}
.command-status {
font-size: 9px;
border-radius: 3px;
background: #edf1f4;
padding: 2px 6px;
color: var(--muted);
}
.command-status.complete {
background: #e4f1eb;
color: #23664f;
}
.command-status.draft {
background: #fff1d4;
color: #926421;
}
.command-status.executing {
background: #dfeef3;
color: #236175;
}
.command-card-row {
display: flex;
gap: 6px;
align-items: center;
flex-wrap: wrap;
}
.command-card-row > a {
font-size: 9px;
color: var(--teal);
background: #eff6f3;
padding: 2px 5px;
border-radius: 2px;
}
.command-card-row button {
font-size: 10px;
border: 1px solid var(--line);
background: white;
display: flex;
align-items: center;
gap: 3px;
border-radius: 3px;
padding: 3px 6px;
margin-left: auto;
}
.command-card-row button + button {
margin-left: 0;
}
.manual-command {
padding: 0 15px 15px;
display: flex;
flex-direction: column;
gap: 12px;
}
.manual-command .panel-title {
margin: 0 -15px;
padding: 12px 15px;
}
.manual-command .button {
font-size: 12px;
}
.event-log {
margin-top: 20px;
}
.event-scroll {
max-height: 250px;
overflow: auto;
}
.event-row {
display: grid;
grid-template-columns: 65px 90px 1fr 60px;
gap: 10px;
border-bottom: 1px solid #e9edf0;
padding: 10px 16px;
font-size: 11px;
align-items: start;
}
.event-row p {
margin: 0;
line-height: 1.7;
}
.event-type {
font-size: 9px;
color: var(--teal);
padding-top: 2px;
}
.event-row .mono {
color: var(--muted);
font-size: 10px;
}
.room-footnote {
margin-top: 15px;
color: var(--muted);
font-size: 10px;
}
.intervention-banner,
.error-banner,
.review-banner {
padding: 14px 18px;
border: 1px solid #d7b67b;
background: #fff6df;
color: #715119;
display: flex;
align-items: center;
gap: 14px;
border-radius: 4px;
margin-bottom: 18px;
}
.intervention-banner > div {
flex: 1;
}
.intervention-banner span {
display: block;
font-size: 11px;
margin-top: 3px;
}
.intervention-banner .button {
font-size: 11px;
}
.error-banner {
background: #fff0ed;
border-color: #e9c1b8;
color: #a33824;
font-size: 12px;
}
.error-banner button {
margin-left: auto;
}
.review-banner {
margin: 20px 0 0;
background: #e7f3ed;
border-color: #b5d0c3;
color: #256450;
}
.review-banner h2 {
font-size: 17px;
margin: 0 0 5px;
}
.review-banner p {
font-size: 12px;
margin: 0;
}
.manual-page {
max-width: 1440px;
margin: auto;
padding: 38px 5%;
}
.manual-page h1 {
font-size: 33px;
letter-spacing: -0.045em;
margin: 10px 0 15px;
}
.page-intro {
color: var(--muted);
font-size: 13px;
max-width: 770px;
line-height: 1.9;
}
.notice {
display: flex;
gap: 10px;
align-items: center;
background: #e8efef;
border-left: 3px solid #6d9990;
padding: 13px 16px;
margin: 22px 0;
color: #375e59;
font-size: 12px;
}
.manual-toolbar {
display: flex;
align-items: center;
gap: 20px;
justify-content: space-between;
}
.search-field {
display: flex;
flex-direction: row;
align-items: center;
gap: 8px;
background: white;
border: 1px solid var(--line);
padding: 0 12px;
border-radius: 4px;
max-width: 550px;
flex: 1;
}
.search-field input {
border: 0;
padding: 13px 0;
flex: 1;
font-size: 13px;
width: 100%;
}
.category-tabs {
display: flex;
gap: 7px;
flex-wrap: wrap;
margin: 18px 0 23px;
}
.category-tabs button {
border: 1px solid var(--line);
background: white;
border-radius: 3px;
padding: 5px 13px;
font-size: 11px;
}
.category-tabs button.active {
background: var(--navy);
border-color: var(--navy);
color: white;
}
.manual-layout {
display: grid;
grid-template-columns: 1.7fr 1fr;
gap: 24px;
align-items: start;
}
.card-grid {
display: grid;
grid-template-columns: repeat(2, minmax(0, 1fr));
gap: 12px;
}
.manual-tile {
text-align: left;
padding: 17px;
border: 1px solid var(--line);
border-radius: 4px;
background: white;
display: flex;
flex-direction: column;
transition: border-color 0.15s;
}
.manual-tile:hover,
.manual-tile.selected {
border-color: var(--teal);
}
.manual-tile.selected {
box-shadow: inset 3px 0 var(--teal);
}
.tile-meta {
display: flex;
justify-content: space-between;
font-size: 10px;
color: var(--muted);
margin-bottom: 12px;
}
.tile-meta .mono {
color: var(--teal);
}
.manual-tile > strong {
font-size: 14px;
line-height: 1.6;
margin-bottom: 8px;
}
.manual-tile p {
font-size: 11px;
color: var(--muted);
line-height: 1.8;
margin-bottom: 16px;
}
.tile-bottom {
display: flex;
justify-content: space-between;
font-size: 9px;
color: var(--muted);
margin-top: auto;
}
.manual-detail {
background: white;
border: 1px solid var(--line);
border-radius: 5px;
position: sticky;
top: 20px;
padding: 0 22px 22px;
max-height: calc(100vh - 40px);
overflow: auto;
}
.manual-detail .panel-title {
margin: 0 -22px;
padding: 12px 22px;
}
.manual-detail h2 {
font-size: 22px;
margin: 18px 0 12px;
line-height: 1.5;
letter-spacing: -0.03em;
}
.draft-label {
font-size: 9px;
color: #966c26;
background: #fcf2dd;
display: inline-block;
padding: 4px 7px;
border-radius: 2px;
}
.manual-detail > p {
font-size: 12px;
line-height: 1.9;
margin: 15px 0;
color: var(--muted);
}
.manual-detail h3 {
font-size: 12px;
margin: 23px 0 9px;
border-bottom: 1px solid var(--line);
padding-bottom: 7px;
}
.manual-detail ul {
padding-left: 17px;
font-size: 12px;
line-height: 1.9;
margin: 0;
}
.manual-detail li {
margin-bottom: 5px;
}
.source-detail a {
font-size: 11px;
display: inline;
line-height: 1.8;
color: var(--teal);
}
.source-detail svg {
vertical-align: middle;
}
.source-detail small {
display: block;
font-size: 9px;
line-height: 1.8;
color: var(--muted);
margin-top: 5px;
}
.detail-placeholder {
padding: 60px 0;
text-align: center;
color: var(--muted);
}
.detail-placeholder h2 {
font-size: 17px;
}
.detail-placeholder p {
font-size: 12px;
line-height: 1.9;
}
@media (min-width: 1700px) {
.hero h1 {
font-size: 52px;
}
.room-grid {
grid-template-columns: minmax(0, 2fr) minmax(350px, 1fr);
}
}
@media (max-width: 1100px) {
.site-nav {
padding: 0 3.5%;
}
.room {
padding: 24px 3%;
}
.room-grid {
grid-template-columns: minmax(0, 1.4fr) minmax(300px, 1fr);
}
.observation-grid {
grid-template-columns: 1fr;
}
.thermal-view {
height: 170px;
}
.observation-list {
max-height: 180px;
}
.hero {
gap: 4%;
}
.hero h1 {
font-size: 37px;
}
.hero-actions {
gap: 16px;
}
.manual-layout {
grid-template-columns: 1.2fr 1fr;
}
.card-grid {
grid-template-columns: 1fr;
}
.hero-copy > p {
font-size: 13px;
}
}
@media (max-width: 760px) {
.site-nav {
height: auto;
min-height: 76px;
padding: 14px 5%;
flex-wrap: wrap;
gap: 14px;
}
.brand {
font-size: 21px;
}
.brand-mark {
width: 32px;
height: 35px;
}
.site-nav nav {
gap: 22px;
font-size: 12px;
}
.nav-note {
display: none;
}
.home {
padding-top: 28px;
}
.hero {
grid-template-columns: 1fr;
padding: 30px 0 35px;
gap: 30px;
}
.hero h1 {
font-size: 39px;
}
.hero-board {
transform: none;
}
.feature-strip {
grid-template-columns: 1fr 1fr;
}
.feature-strip article {
padding: 20px 16px !important;
border-bottom: 1px solid var(--line);
}
.feature-strip article:nth-child(2) {
border-right: 0;
}
.feature-strip article:nth-child(3) {
border-bottom: 0;
}
.home-bottom {
flex-direction: column;
gap: 0;
}
.site-footer {
flex-direction: column;
gap: 7px;
}
.room {
padding: 23px 4%;
}
.room-top {
align-items: flex-start;
flex-direction: column;
gap: 10px;
}
.room-top h1 {
font-size: 25px;
}
.setup-bar {
gap: 12px;
padding: 14px;
}
.setup-bar > label:first-child {
min-width: 0;
width: 100%;
}
.setup-bar > label:nth-child(3) {
flex: 1;
}
.vehicle-link {
width: 100%;
margin: 0;
}
.room-grid {
grid-template-columns: 1fr;
gap: 16px;
}
.observation-grid {
grid-template-columns: 1fr 1fr;
}
.thermal-view {
height: 160px;
}
.incident-header {
padding: 16px;
}
.incident-header h2 {
font-size: 20px;
}
.event-row {
grid-template-columns: 55px 65px 1fr;
gap: 8px;
padding: 10px 12px;
}
.event-row > span:last-child:not(.event-type) {
grid-column: 3;
font-size: 9px;
}
.intervention-banner {
flex-wrap: wrap;
gap: 10px;
padding: 13px;
}
.intervention-banner > div {
flex-basis: calc(100% - 35px);
}
.intervention-banner .button {
flex: 1;
}
.manual-page {
padding: 28px 5%;
}
.manual-page h1 {
font-size: 27px;
}
.manual-layout {
grid-template-columns: 1fr;
}
.card-grid {
grid-template-columns: 1fr 1fr;
}
.manual-detail {
position: static;
max-height: none;
grid-row: 1;
}
.detail-placeholder {
padding: 18px 0;
}
.detail-placeholder svg {
display: none;
}
.detail-placeholder h2 {
margin: 0 0 6px;
font-size: 14px;
}
.detail-placeholder p {
margin: 0;
}
.manual-toolbar > .mono {
font-size: 9px;
}
.notice {
font-size: 11px;
align-items: flex-start;
}
}
@media (max-width: 450px) {
.site-nav {
gap: 12px;
}
.site-nav nav {
width: 100%;
padding-left: 42px;
}
.home {
padding-top: 24px;
}
.hero h1 {
font-size: 32px;
}
.hero-copy > p {
font-size: 13px;
}
.hero-actions {
flex-direction: column;
align-items: flex-start;
}
.hero-board .board-decision {
padding: 16px;
}
.board-decision > strong {
font-size: 14px;
}
.board-decision p {
font-size: 10px;
}
.feature-strip h2 {
font-size: 14px;
}
.feature-strip p {
font-size: 11px;
}
.virtual-badge {
font-size: 10px;
}
.room-top .eyebrow {
font-size: 10px;
}
.observation-grid {
grid-template-columns: 1fr;
}
.incident-header h2 {
font-size: 18px;
}
.incident-header .status-pill {
font-size: 9px;
}
.incident-map svg {
min-height: 175px;
}
.resource-row > div {
padding: 9px 7px;
}
.resource-row strong,
.resource-row span {
font-size: 10px;
}
.resource-row small {
font-size: 8px;
}
.map-legend {
gap: 10px;
font-size: 9px;
}
.map-legend span:last-child {
margin-left: 0;
}
.card-grid {
grid-template-columns: 1fr;
}
.manual-toolbar {
flex-wrap: wrap;
gap: 8px;
}
.manual-toolbar > .mono {
width: 100%;
}
.search-field {
max-width: 100%;
}
.manual-page h1 {
font-size: 25px;
}
}
@media (prefers-reduced-motion: reduce) {
* {
transition: none !important;
animation: none !important;
scroll-behavior: auto !important;
}
}
@media (max-width: 760px) {
.nav-note {
display: inline-flex;
}
}
/* 상황본부: 화면 높이를 배분하고 긴 기록만 패널 안에서 읽는다. */
:root {
--site-nav-height: 84px;
}
.nav-toggle,
.workspace-switcher,
.workspace-settings {
display: none;
}
.site-nav {
padding-inline: clamp(16px, 3vw, 48px);
}
.workspace-room,
.station-workspace {
height: calc(100dvh - var(--site-nav-height));
min-height: 0;
display: flex;
flex-direction: column;
padding: 12px 20px;
gap: 8px;
}
.workspace-room > *,
.station-workspace > * {
flex-shrink: 0;
}
.workspace-room .room-top,
.workspace-room .setup-bar {
margin: 0;
}
.workspace-room .room-top h1 {
font-size: 22px;
margin-top: 0;
}
.workspace-room .room-top .eyebrow {
font-size: 9px;
}
.workspace-room .setup-bar {
padding: 8px 12px;
gap: 12px;
}
.workspace-room .setup-bar label {
font-size: 11px;
gap: 3px;
margin: 0;
}
.workspace-room .setup-bar select,
.workspace-room .setup-bar input {
min-height: 36px;
padding: 6px 9px;
}
.workspace-room > .commander-controls {
padding: 0;
display: flex;
gap: 12px;
align-items: center;
}
.workspace-room > .commander-controls .intervention {
width: auto;
min-width: 160px;
}
.workspace-room > .commander-controls .playback-row {
margin: 0;
}
.workspace-room:has(.intervention-banner) > .commander-controls {
display: none;
}
.workspace-room .intervention-banner {
margin: 0;
padding: 8px 12px;
}
.workspace-room .room-grid {
flex: 1;
min-height: 0;
gap: 12px;
grid-template-rows: minmax(0, 1fr);
}
.workspace-room .situation-column {
display: grid;
min-height: 0;
grid-template-columns: minmax(0, 1.7fr) minmax(190px, 1fr);
grid-template-rows: auto minmax(0, 1fr);
gap: 8px;
}
.workspace-room .incident-header {
grid-column: 1 / -1;
padding: 8px 12px;
}
.workspace-room .incident-header h2 {
font-size: 17px;
margin: 3px 0;
}
.workspace-room .incident-header p {
font-size: 11px;
line-height: 1.5;
margin-bottom: 4px;
}
.workspace-room .incident-meta {
padding-top: 4px;
font-size: 9px;
gap: 4px 12px;
}
.workspace-room .situation-map-panel,
.workspace-room .observation-grid > .panel,
.workspace-room .commander-panel,
.workspace-room .command-list,
.workspace-room .event-log {
display: flex;
flex-direction: column;
min-height: 0;
}
.workspace-room .panel-title {
min-height: 32px;
padding: 6px 12px;
flex-shrink: 0;
}
.workspace-room .incident-map {
flex: 1;
min-height: 0;
display: flex;
flex-direction: column;
}
.workspace-room .incident-map svg {
flex: 1;
min-height: 0;
height: 100%;
max-height: none;
}
.workspace-room .map-legend {
flex-shrink: 0;
padding: 4px 8px;
gap: 4px 8px;
font-size: 9px;
}
.workspace-room .resource-row {
flex-shrink: 0;
}
.workspace-room .resource-row > div {
padding: 6px;
gap: 1px 4px;
}
.workspace-room .resource-row svg {
width: 14px;
}
.workspace-room .observation-grid {
display: flex;
flex-direction: column;
min-height: 0;
gap: 8px;
}
.workspace-room .observation-grid > .panel {
flex: 1;
}
.workspace-room .thermal-view {
flex: 1;
min-height: 55px;
height: auto;
padding: 8px;
}
.workspace-room .heat-grid {
height: 100%;
max-height: 110px;
}
.workspace-room .panel-note {
padding: 5px 9px;
line-height: 1.4;
flex-shrink: 0;
}
.workspace-room .observation-list,
.workspace-room .command-scroll,
.workspace-room .decision-content {
flex: 1;
min-height: 0;
max-height: none;
overflow: auto;
overscroll-behavior: contain;
}
.workspace-room .observation-list article {
padding: 7px 0;
}
.workspace-room .command-column {
min-height: 0;
overflow: auto;
gap: 8px;
}
.workspace-room .commander-panel {
flex: 1;
min-height: 145px;
}
.workspace-room .command-list {
flex: 1;
min-height: 100px;
}
.workspace-room .commander-mode {
padding: 6px 12px;
flex-shrink: 0;
}
.workspace-room .decision-content {
padding: 10px 12px;
}
.workspace-room .decision-content h3 {
margin: 4px 0;
font-size: 15px;
}
.workspace-room .decision-content p {
margin-bottom: 6px;
}
.workspace-room .decision-action {
padding: 6px 9px;
}
.workspace-room .manual-command {
flex-shrink: 0;
}
.workspace-room .event-log {
margin: 0;
height: 78px;
}
.workspace-room .event-scroll {
min-height: 0;
max-height: none;
overflow: auto;
}
.workspace-room .event-row {
padding: 5px 12px;
}
.workspace-room .room-footnote {
margin: 0;
font-size: 9px;
line-height: 1.4;
}
.workspace-room .review-banner {
margin: 0;
padding: 6px 12px;
}
.workspace-room .review-banner h2 {
font-size: 14px;
margin: 0;
}
.workspace-room .review-banner p {
margin: 0;
font-size: 10px;
}
.station-workspace .portal-heading {
margin: 0;
align-items: center;
gap: 12px;
}
.station-workspace .portal-heading h1 {
font-size: 23px;
margin: 0;
}
.station-workspace .portal-heading p {
margin: 0;
font-size: 11px;
}
.station-workspace .portal-heading .eyebrow {
font-size: 9px;
}
.station-workspace .notice {
padding: 7px 12px;
margin: 0;
font-size: 11px;
}
.station-sync {
margin: 0;
font-size: 12px;
overflow-wrap: anywhere;
}
.sync-stale {
color: #9f3823;
background: #fff1e9;
padding: 6px 10px;
border-left: 3px solid var(--orange);
}
.station-workspace .station-tabs {
margin: 0;
}
.station-workspace .station-layout {
flex: 1;
min-height: 0;
align-items: stretch;
gap: 12px;
grid-template-columns: 220px minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
}
.station-workspace .station-incidents {
overflow: auto;
min-height: 0;
}
.station-workspace .station-main {
min-height: 0;
grid-template-columns: minmax(0, 1fr) minmax(0, 1.2fr);
gap: 12px;
}
.station-workspace .station-context {
min-height: 0;
overflow: auto;
display: flex;
flex-direction: column;
gap: 8px;
}
.station-workspace .station-context > .panel {
flex-shrink: 0;
}
.station-workspace .station-orders {
overflow: auto;
min-height: 0;
}
.station-workspace .panel {
padding: 12px;
}
.station-workspace h2 {
font-size: 17px;
margin-bottom: 8px;
}
.station-workspace .admin-grid {
flex: 1;
min-height: 0;
overflow: auto;
}
.station-workspace .admin-grid > .panel {
overflow: visible;
}
.station-portal p,
.station-command h3 {
overflow-wrap: anywhere;
}
.workspace-room [tabindex="0"]:focus-visible,
.station-workspace [tabindex="0"]:focus-visible {
outline: 3px solid #49998e;
outline-offset: -3px;
}
/* 스마트폰은 같은 사건을 화면별로 전환하며 큰 터치 조작을 유지한다. */
@media (max-width: 760px) {
:root {
--site-nav-height: 64px;
}
.site-nav {
height: 64px;
min-height: 64px;
padding: 8px 16px;
gap: 8px;
flex-wrap: nowrap;
position: relative;
z-index: 20;
}
.brand {
font-size: 19px;
}
.brand-mark {
width: 30px;
height: 34px;
}
.brand small {
font-size: 7px;
}
.nav-toggle {
display: inline-flex;
align-items: center;
gap: 6px;
min-height: 44px;
padding: 8px 10px;
border: 1px solid var(--line);
border-radius: 4px;
background: white;
}
.site-nav nav {
display: none;
}
.site-nav nav.menu-open {
display: grid;
position: absolute;
top: 100%;
left: 0;
right: 0;
width: auto;
padding: 10px 16px;
gap: 0;
background: white;
border-bottom: 1px solid var(--line);
box-shadow: 0 10px 20px #19303c18;
}
.site-nav nav a {
min-height: 44px;
padding: 10px;
display: flex;
align-items: center;
border: 0;
}
.workspace-room,
.station-workspace {
padding: 8px 12px max(8px, env(safe-area-inset-bottom));
gap: 6px;
}
.workspace-room .room-top {
flex-direction: row;
align-items: center;
gap: 6px;
}
.workspace-room .room-top h1 {
font-size: 19px;
}
.workspace-room .room-top .eyebrow {
display: none;
}
.workspace-room .virtual-badge {
padding: 3px 5px;
font-size: 9px;
max-width: 135px;
}
.workspace-room .setup-bar {
display: grid;
grid-template-columns: minmax(0, 1fr) 80px;
padding: 8px;
gap: 6px;
}
.workspace-room .setup-bar > label:first-child {
min-width: 0;
width: auto;
}
.workspace-room .setup-bar label {
font-size: 11px;
}
.workspace-room .setup-bar .seed-field {
width: auto;
}
.workspace-room .setup-bar select,
.workspace-room .setup-bar input {
min-height: 44px;
width: 100%;
font-size: 16px;
padding: 6px;
}
.workspace-room .vehicle-link {
grid-column: 1 / -1;
margin: 0;
min-height: 44px;
}
.workspace-room[data-active="true"][data-settings-open="false"] .setup-bar {
display: none;
}
.workspace-settings {
display: block;
min-height: 44px;
align-self: flex-end;
padding: 6px 12px;
}
.workspace-room[data-active="false"] .workspace-settings {
display: none;
}
.workspace-switcher {
display: grid;
grid-auto-flow: column;
grid-auto-columns: minmax(0, 1fr);
gap: 4px;
}
.workspace-switcher button {
min-height: 44px;
border: 1px solid var(--line);
border-radius: 4px;
background: white;
padding: 6px 3px;
font-size: 12px;
font-weight: 600;
}
.workspace-switcher button[aria-pressed="true"] {
background: var(--navy);
color: white;
border-color: var(--navy);
}
.workspace-room > .commander-controls {
display: grid;
grid-template-columns: 1fr;
gap: 4px;
}
.workspace-room > .commander-controls .intervention {
width: 100%;
}
.workspace-room .playback-row {
justify-content: space-between;
min-width: 0;
gap: 5px;
}
.workspace-room .sound-volume {
flex-direction: column;
gap: 0;
font-size: 10px;
}
.workspace-room .sound-volume input[type="range"] {
width: 64px;
min-height: 44px;
}
.workspace-room .playback-row .voice-button {
padding: 8px;
margin-left: auto;
}
.workspace-room .sound-caption {
font-size: 10px;
min-height: 24px;
}
.workspace-room .playback-row > .button,
.workspace-room .playback-row select {
min-height: 44px;
font-size: 12px;
}
.workspace-room .playback-row > .button:first-child {
min-width: 44px;
}
.workspace-room:has(.intervention-banner) > .commander-controls {
display: none;
}
.workspace-room:has(.intervention-banner) .commander-controls .intervention,
.workspace-room:has(.intervention-banner)
.playback-row
> .button:first-child:not(.voice-button),
.workspace-room:has(.intervention-banner)
.playback-row
> label:not(.sound-volume) {
display: none;
}
.workspace-room .intervention-banner {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.workspace-room .intervention-banner > svg {
display: none;
}
.workspace-room .intervention-banner > div {
grid-column: 1 / -1;
}
.workspace-room .intervention-banner .button {
padding: 6px;
font-size: 11px;
margin: 0;
}
.workspace-room .intervention-banner span {
font-size: 10px;
}
.workspace-room .room-grid {
grid-template-columns: minmax(0, 1fr);
}
.workspace-room .situation-column {
display: flex;
min-height: 0;
gap: 6px;
}
.workspace-room .incident-header {
flex-shrink: 0;
padding: 6px 9px;
}
.workspace-room .incident-header h2 {
font-size: 15px;
}
.workspace-room .incident-header p {
line-height: 1.4;
font-size: 10px;
}
.workspace-room .incident-meta {
font-size: 9px;
}
.workspace-room .situation-map-panel,
.workspace-room .observation-grid {
flex: 1;
min-height: 0;
}
.workspace-room .resource-row {
font-size: 10px;
}
.workspace-room .resource-row > div {
grid-template-columns: 16px minmax(0, 1fr);
}
.workspace-room .resource-row small {
font-size: 9px;
}
.workspace-room .commander-panel {
flex: 1;
min-height: 120px;
}
.workspace-room .command-column {
gap: 6px;
}
.workspace-room .command-item {
font-size: 13px;
}
.workspace-room .command-item > strong {
font-size: 13px;
}
.workspace-room .command-card-row button {
min-height: 44px;
min-width: 44px;
font-size: 12px;
}
.workspace-room .event-log {
flex: 1;
height: auto;
min-height: 0;
}
.workspace-room .observation-list p {
font-size: 13px;
}
.workspace-room .room-footnote {
font-size: 9px;
}
.workspace-room[data-mobile-panel="map"] .observation-grid,
.workspace-room[data-mobile-panel="map"] .command-column,
.workspace-room[data-mobile-panel="observations"] .situation-map-panel,
.workspace-room[data-mobile-panel="observations"] .command-column,
.workspace-room[data-mobile-panel="commands"] .situation-column,
.workspace-room:not([data-mobile-panel="history"]) .event-log,
.workspace-room[data-mobile-panel="history"] .room-grid {
display: none;
}
.workspace-room[data-mobile-panel="commands"] .room-grid {
overflow: auto;
}
.station-workspace .portal-heading {
flex-direction: column;
align-items: stretch;
gap: 4px;
}
.station-workspace .portal-heading .eyebrow,
.station-workspace .portal-heading > div > p:not(.eyebrow) {
display: none;
}
.station-workspace .portal-heading h1 {
font-size: 20px;
}
.station-workspace .portal-heading .actions {
gap: 5px;
font-size: 11px;
}
.station-workspace .portal-heading .badge {
font-size: 10px;
padding: 3px 5px;
}
.station-workspace .portal-heading .actions .button {
padding: 6px 8px;
}
.station-workspace .notice {
padding: 5px 8px;
font-size: 10px;
line-height: 1.5;
}
.station-workspace .station-sync {
font-size: 11px;
}
.station-workspace[data-role="crew"] .station-tabs {
display: none;
}
.station-workspace .station-layout {
grid-template-columns: minmax(0, 1fr);
gap: 0;
}
.station-workspace .station-main {
display: flex;
flex-direction: column;
}
.station-workspace .station-orders,
.station-workspace .station-context {
flex: 1;
min-height: 0;
}
.station-workspace .station-orders > h2 {
position: sticky;
top: -12px;
padding: 8px 0;
background: white;
z-index: 1;
}
.station-workspace[data-mobile-panel="orders"] .station-incidents,
.station-workspace[data-mobile-panel="orders"] .station-context,
.station-workspace[data-mobile-panel="context"] .station-incidents,
.station-workspace[data-mobile-panel="context"] .station-orders,
.station-workspace[data-mobile-panel="incidents"] .station-main {
display: none;
}
.station-workspace .station-command {
padding: 10px;
margin-bottom: 10px;
}
.station-workspace .station-command h3 {
font-size: 16px;
}
.station-workspace .command-title {
flex-wrap: wrap;
gap: 4px;
}
.station-workspace .recipient-list > div {
gap: 8px;
}
.station-workspace .recipient-list .actions {
width: 100%;
}
.station-workspace .recipient-list .button {
width: 100%;
}
.station-workspace .admin-grid {
grid-template-columns: 1fr;
}
.portal input,
.portal select,
.portal textarea,
.manual-page input,
.manual-command select,
.manual-command textarea {
font-size: 16px;
}
.button,
.portal select,
.portal input:not([type="checkbox"]),
.book-item,
.search-hit,
.manual-filters button {
min-height: 44px;
}
.check-label {
min-height: 44px;
}
.hash,
.page-text,
.portal-heading h1 {
overflow-wrap: anywhere;
}
}
/* 낮은 화면·확대·설정 편집에서는 잘라내지 않고 필요한 스크롤을 허용한다. */
@media (max-height: 620px) {
.workspace-room,
.station-workspace {
height: auto;
min-height: calc(100dvh - var(--site-nav-height));
}
.workspace-room .room-grid {
min-height: 540px;
height: 540px;
flex: none;
}
.workspace-room .event-log {
flex: none;
height: 180px;
}
.station-workspace .station-layout {
min-height: 540px;
height: 540px;
flex: none;
}
}
@media (max-width: 760px) and (max-height: 740px) {
.workspace-room,
.station-workspace {
height: auto;
min-height: calc(100dvh - var(--site-nav-height));
}
.workspace-room .room-grid {
height: 460px;
min-height: 460px;
flex: none;
}
.workspace-room .event-log {
height: 240px;
flex: none;
}
.station-workspace .station-layout {
height: 460px;
min-height: 460px;
flex: none;
}
}
@media (max-width: 760px) {
.workspace-room[data-settings-open="true"] {
height: auto;
}
.workspace-room[data-settings-open="true"] .room-grid {
min-height: 420px;
}
}
/* 시연 중에는 설정을 접어 상황도 공간을 확보한다. */
.room-header-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 10px;
}
.workspace-room[data-active="true"] .workspace-settings {
display: inline-flex;
min-height: 44px;
}
.workspace-room[data-active="true"][data-settings-open="false"] .setup-bar {
display: none;
}
.workspace-room .room-header-actions .vehicle-link {
margin: 0;
width: auto;
}
.station-mobile-summary {
display: none;
}
@media (max-width: 760px) {
.workspace-room .room-top {
align-items: flex-start;
}
.workspace-room .room-header-actions {
gap: 4px;
flex-wrap: wrap;
max-width: 170px;
}
.workspace-room .room-header-actions .virtual-badge {
display: none;
}
.workspace-room .room-header-actions .vehicle-link {
font-size: 10px;
min-height: 44px;
}
.station-mobile-summary {
display: grid;
max-height: 88px;
overflow: auto;
padding: 6px 10px;
border-left: 3px solid var(--teal);
background: #e3efed;
overflow-wrap: anywhere;
}
.station-mobile-summary strong {
font-size: 14px;
}
.station-mobile-summary span {
font-size: 12px;
}
}
apps/web/app/help/page.tsx
import { Nav } from "../../components/nav";
import { guideSections } from "../../../../packages/docs/content";
export default function Help() {
return (
<>
<Nav active="help" />
<main className="portal">
<div className="portal-heading">
<div>
<p className="eyebrow">USER GUIDE / ALWAYS IN SYNC</p>
<h1>사용설명서</h1>
<p>
따라하기부터 기관용 지휘 지원까지. HTML은 내려받은 뒤
오프라인에서도 볼 수 있습니다.
</p>
</div>
<div className="actions">
<a
className="button primary"
download="manual.html"
href="/manual.html"
>
설명서 HTML 다운로드
</a>
<a className="button" download="logic.html" href="/logic.html">
구현 로직 HTML 다운로드
</a>
<a
className="text-link"
href="/manual.html"
target="_blank"
rel="noreferrer"
>
전체 HTML 보기 ↗
</a>
</div>
</div>
<div className="guide-layout">
<aside className="panel guide-nav">
<h2>목차</h2>
{guideSections.map((s) => (
<a key={s.id} href={`#${s.id}`}>
{s.title}
</a>
))}
<a href="/logic.html" target="_blank" rel="noreferrer">
핵심 로직·AI 재구현 프롬프트 ↗
</a>
</aside>
<article className="panel guide-content">
{guideSections.map((s) => (
<section id={s.id} key={s.id}>
<h2>{s.title}</h2>
{s.paragraphs.map((p, i) => (
<p key={i}>{p}</p>
))}
{s.steps && (
<ol>
{s.steps.map((p) => (
<li key={p}>{p}</li>
))}
</ol>
)}
</section>
))}
</article>
</div>
</main>
</>
);
}
apps/web/app/layout.tsx
import type { Metadata } from "next";
import "./globals.css";
export const metadata: Metadata = {
title: "AI Firestation · 교육 시연과 기관용 지휘 지원",
description:
"가상 소방 지휘 훈련, 사람 승인과 대원 보고를 기록하는 기관용 지휘 지원, 중앙소방학교 2026 교재 자료실.",
};
export default function Layout({ children }: { children: React.ReactNode }) {
return (
<html lang="ko">
<body>{children}</body>
</html>
);
}
apps/web/app/library/page.tsx
import { Nav } from "../../components/nav";
import { LibraryRoom } from "../../components/library-room";
import { catalog } from "../../../../packages/library";
export default function Library() {
return (
<>
<Nav active="library" />
<LibraryRoom documents={catalog.documents} />
</>
);
}
apps/web/app/manuals/page.tsx
"use client";
import { useEffect, useState } from "react";
import { Search, ArrowUpRight, X, BookOpen } from "lucide-react";
import { cards, searchCards, type ManualCard } from "../../../../packages/kb";
import { Nav } from "../../components/nav";
export default function Manuals() {
const [query, setQuery] = useState("");
const [category, setCategory] = useState("");
const [selected, setSelected] = useState<ManualCard | null>(null);
useEffect(() => {
const id = new URLSearchParams(window.location.search).get("card");
if (id) {
setSelected(cards.find((c) => c.id === id) ?? null);
setQuery(id);
}
}, []);
const filtered = searchCards(query, category);
return (
<>
<Nav active="manuals" />
<main className="manual-page">
<div className="eyebrow">KNOWLEDGE BASE / KB-0</div>
<h1>판단에는 근거가 필요합니다.</h1>
<p className="page-intro">
신고 질문과 준비 확인을 위한 교육 카드 40장. 자체 작성 초안이며, 출처
링크는 용어·제도를 이해하기 위한 참고 자료입니다.
</p>
<div className="notice">
<BookOpen size={18} />
<span>
교육용 · 비공식 요약 · 검수 전 — 실제 현장 전술이나 기관 승인
매뉴얼을 대신하지 않습니다.
</span>
</div>
<div className="manual-toolbar">
<label className="search-field">
<Search size={18} />
<input
placeholder="상황·위험·카드 ID 검색"
value={query}
onChange={(e) => setQuery(e.target.value)}
aria-label="카드 검색"
/>
</label>
<span className="mono">{filtered.length} / 40 CARDS</span>
</div>
<div className="category-tabs" aria-label="카드 분류">
<button
className={!category ? "active" : ""}
onClick={() => setCategory("")}
>
전체
</button>
{[...new Set(cards.map((c) => c.category))].map((c) => (
<button
key={c}
className={category === c ? "active" : ""}
onClick={() => setCategory(c)}
>
{c}
</button>
))}
</div>
<div className="manual-layout">
<section className="card-grid" aria-label="교육 카드 목록">
{filtered.length === 0 ? (
<p className="empty-state">
검색 결과가 없습니다. 다른 위험이나 카드 ID를 입력하세요.
</p>
) : (
filtered.map((c) => (
<button
key={c.id}
className={`manual-tile ${selected?.id === c.id ? "selected" : ""}`}
onClick={() => setSelected(c)}
aria-label={`${c.id} ${c.title}`}
>
<span className="tile-meta">
<span className="mono">{c.id}</span>
<span>{c.category}</span>
</span>
<strong>{c.title}</strong>
<p>{c.summary}</p>
<span className="tile-bottom">
초안 · 검수 대기 <ArrowUpRight size={16} />
</span>
</button>
))
)}
</section>
<aside className="manual-detail" aria-live="polite">
{selected ? (
<>
<div className="panel-title">
<span className="mono">{selected.id} / CARD DETAIL</span>
<button
className="icon-button"
aria-label="카드 상세 닫기"
onClick={() => setSelected(null)}
>
<X size={18} />
</button>
</div>
<h2>{selected.title}</h2>
<span className="draft-label">{selected.label}</span>
<p>{selected.summary}</p>
<h3>추가 확인 질문</h3>
<ul>
{selected.questions.map((q) => (
<li key={q}>{q}</li>
))}
</ul>
<h3>준비·공유할 사항</h3>
<ul>
{selected.preparation.map((q) => (
<li key={q}>{q}</li>
))}
</ul>
<h3>판단 시 주의</h3>
<ul>
{selected.cautions.map((q) => (
<li key={q}>{q}</li>
))}
</ul>
<h3>참고 출처</h3>
{selected.sources.map((s) => (
<div className="source-detail" key={s.url}>
<a target="_blank" rel="noreferrer" href={s.url}>
{s.title} <ArrowUpRight size={14} />
</a>
<small>
{s.location}
<br />
{s.relation}
</small>
</div>
))}
<p className="muted">
검수자·검수일: 미지정
<br />
{selected.authoring}
</p>
</>
) : (
<div className="detail-placeholder">
<BookOpen size={32} />
<h2>카드를 선택하세요.</h2>
<p>
확인 질문, 준비 사항, 주의점과 출처를 함께 볼 수 있습니다.
</p>
</div>
)}
</aside>
</div>
</main>
</>
);
}
apps/web/app/page.tsx
import Link from "next/link";
import {
ArrowRight,
Radio,
ScanLine,
BookOpen,
CirclePause,
Flame,
} from "lucide-react";
import { Nav } from "../components/nav";
export default function Home() {
return (
<>
<Nav />
<main className="home">
<div className="eyebrow">
<span className="status-dot" /> AI FIRESTATION · v0.3 / COMMAND
SUPPORT
</div>
<section className="hero">
<div className="hero-copy">
<span className="section-number">01 / THE COMMAND ROOM</span>
<h1>
신고부터 복기까지,
<br />
AI와 함께 지휘하다.
</h1>
<p>
신고를 정리하고, 드론 관찰을 공유하고, 근거 카드를 확인합니다.
가상의 화재에서 AI 지휘와 사람의 판단이 어떻게 이어지는지 직접
경험하세요.
</p>
<div className="hero-actions">
<Link className="button primary" href="/demo">
가상 시연 시작 <ArrowRight size={18} />
</Link>
<Link className="text-link" href="/manuals">
교육 카드 40장 보기 <ArrowRight size={16} />
</Link>
</div>
<div className="hero-footnote">
규칙 기반 지휘 · 가상 데이터 · 교육용 비공식 초안
</div>
</div>
<div className="hero-board" aria-label="가상 상황본부 흐름 미리보기">
<div className="board-head">
<span className="status-dot" /> SCENARIO 01
<span className="mono">SIMULATION</span>
</div>
<div className="board-scene">
<div className="scene-grid" />
<div className="scene-road road-one" />
<div className="scene-road road-two" />
<div className="scene-building b-one" />
<div className="scene-building b-two" />
<div className="scene-building b-three" />
<div className="scene-incident">
<Flame size={29} />
<span>가상 현장</span>
</div>
<div className="scene-drone">
<ScanLine size={23} />
<span>외부 관찰</span>
</div>
<div className="scene-unit">
<Radio size={19} />
<span>출동 차량</span>
</div>
</div>
<div className="board-decision">
<span className="small-tag">AI COMMANDER / SCRIPT</span>
<strong>확인된 정보와 미확인 위험을 구분합니다.</strong>
<p>가스 냄새 신고 → 공급 상태 추가 확인 → 준비 목록 공유</p>
<span className="source-chip">근거 G-01 · 교육용 초안</span>
</div>
</div>
</section>
<section className="feature-strip" aria-label="서비스 흐름">
{[
{
icon: Radio,
n: "01",
title: "신고와 출동",
text: "세 가지 시나리오에서 인명·연료·장비 정보를 확인합니다.",
},
{
icon: ScanLine,
n: "02",
title: "관찰과 판단",
text: "드론 도착 후 공개된 관찰을 바탕으로 AI가 다음 임무를 제시합니다.",
},
{
icon: CirclePause,
n: "03",
title: "사람의 개입",
text: "언제든 진행을 멈추고, 명령을 취소하거나 직접 지휘합니다.",
},
{
icon: BookOpen,
n: "04",
title: "근거와 복기",
text: "판단 이유·카드·수신·완료 기록을 한 흐름으로 살펴봅니다.",
},
].map((f) => (
<article key={f.n}>
<div className="feature-top">
<f.icon size={21} />
<span className="mono">{f.n}</span>
</div>
<h2>{f.title}</h2>
<p>{f.text}</p>
</article>
))}
</section>
<section className="service-links" aria-label="기관 지휘와 자료 이용">
<Link className="panel" href="/station">
<span className="eyebrow">STATION</span>
<h2>기관용 지휘 지원</h2>
<p>사건·자원·지휘관 승인·대원 보고를 기록합니다.</p>
<span className="text-link">기관 연결 →</span>
</Link>
<Link className="panel" href="/library">
<span className="eyebrow">NFSA 2026</span>
<h2>공식 교재 14권</h2>
<p>중앙소방학교 원문, 본문 검색, 해설과 출처 복사.</p>
<span className="text-link">교재 열기 →</span>
</Link>
<Link className="panel" href="/help">
<span className="eyebrow">GUIDE</span>
<h2>사용설명서</h2>
<p>쉽게 따라하기, 상세 안내와 AI 재구현 프롬프트.</p>
<span className="text-link">설명서 열기 →</span>
</Link>
</section>
<div className="home-bottom">
<p>
<strong>현재 범위</strong> 공통 사건 엔진 · 자동 지휘 데모 · 매뉴얼
카드 · 기관용 지휘 지원 · 공식 교재 자료실
</p>
<p>실제 119 연동·드론 영상·실운영 지휘·게임은 후속 단계입니다.</p>
</div>
</main>
<footer className="site-footer">
<span>AI FIRESTATION / LOCAL PREVIEW</span>
<span>교육용 비공식 자료 · 전문가 검수 전</span>
</footer>
</>
);
}
apps/web/app/simulation/page.tsx
import { UnrealViewer } from "../../components/unreal-viewer";
import { sceneSignalUrl } from "../../components/unreal-viewer-config";
import "./simulation.css";
export default async function SimulationPage({
searchParams,
}: {
searchParams: Promise<{ session?: string | string[] }>;
}) {
const { session } = await searchParams;
return (
<UnrealViewer
sessionId={typeof session === "string" ? session : ""}
signalUrl={sceneSignalUrl(process.env.FIRESTATION_UE_SIGNAL_URL)}
/>
);
}
apps/web/app/simulation/simulation.css
.ue-viewer {
width: min(1400px, 100%);
margin: 0 auto;
padding: 24px;
display: flex;
flex-direction: column;
gap: 16px;
min-height: 100dvh;
}
.ue-viewer header {
display: flex;
justify-content: space-between;
gap: 16px;
align-items: center;
flex-wrap: wrap;
}
.ue-viewer h1 {
font-size: clamp(24px, 3vw, 36px);
margin: 4px 0;
}
.ue-viewer .button {
min-height: 44px;
min-width: 44px;
padding: 10px 16px;
white-space: normal;
}
.ue-media {
position: relative;
background: #101820;
color: #edf2f6;
height: clamp(260px, 56dvh, 680px);
flex-shrink: 0;
overflow: auto;
border-radius: 12px;
}
.ue-video {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
}
.ue-video video {
width: 100% !important;
height: 100% !important;
object-fit: contain;
}
.ue-placeholder {
position: relative;
display: flex;
align-items: center;
justify-content: center;
flex-direction: column;
min-height: 100%;
padding: 28px;
text-align: center;
pointer-events: none;
}
.ue-placeholder p {
max-width: 650px;
overflow-wrap: anywhere;
}
.ue-placeholder h2 {
font-size: 20px;
}
.ue-controls {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.ue-controls [aria-pressed="true"] {
border-color: #cc5628;
}
.ue-viewer footer {
font-size: 14px;
line-height: 1.6;
}
.ue-viewer footer p {
margin: 3px 0;
}
.ue-error {
padding: 12px;
background: #fee9e4;
color: #8e2b18;
border-radius: 8px;
}
@media (max-width: 600px) {
.ue-viewer {
padding: 16px;
gap: 12px;
}
.ue-media {
height: 42dvh;
min-height: 290px;
}
.ue-controls .button {
flex: 1 1 44%;
}
.ue-placeholder {
padding: 20px;
}
}
apps/web/app/station/page.tsx
import { Nav } from "../../components/nav";
import { StationRoom } from "../../components/station-room";
import { catalog } from "../../../../packages/library";
export default function Station() {
return (
<>
<Nav active="station" />
<StationRoom
documents={catalog.documents.map((d) => ({
id: d.id,
title: d.title,
pageCount: d.pageCount,
}))}
/>
</>
);
}
apps/web/components/command-room.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import Link from "next/link";
import {
Play,
Pause,
Radio,
ScanLine,
Flame,
ArrowUpRight,
Volume2,
VolumeX,
RotateCcw,
ShieldCheck,
Hand,
Check,
X,
} from "lucide-react";
import type { PublicScenario } from "../../../packages/core/scenario";
import type {
PublicState,
Action,
ActionKey,
Level,
} from "../../../packages/core/types";
import { Nav } from "./nav";
import {
audioEvents,
DemoAudioCursor,
introCues,
sirenShouldPlay,
} from "../../../packages/simulation/demo-audio";
import { DemoAudioPlayer, type AudioStatus } from "./demo-audio-player";
import {
STATION_POSITION,
INCIDENT_POSITION,
PUMP_ROUTE,
mapPositions,
} from "../../../packages/core/map-motion";
const levels = [
"L0 · 사람 지휘",
"L1 · AI 조언",
"L2 · 사람 승인",
"L3 · AI 자동 지휘",
];
const statuses = {
draft: "승인 대기",
issued: "발행",
acknowledged: "수신 확인",
executing: "수행 중",
complete: "완료",
cancelled: "취소",
};
const manualActions: {
key: ActionKey;
name: string;
target: string;
card: string;
}[] = [
{
key: "DISPATCH",
name: "가상 출동·준비 확인",
target: "펌프1 · 구조1",
card: "R-03",
},
{ key: "RECON", name: "외부 정찰 임무", target: "드론팀", card: "D-01" },
{
key: "VERIFY",
name: "현장 위험 추가 확인",
target: "선착대",
card: "R-02",
},
{
key: "CONTROL",
name: "가상 대응 임무 확인",
target: "선착대",
card: "R-05",
},
{ key: "SUPPORT", name: "가상 지원 요청", target: "상황실", card: "R-04" },
{ key: "MONITOR", name: "잔여 위험 확인", target: "선착대", card: "E-04" },
{ key: "CLOSE", name: "종료·복기 준비", target: "지휘관", card: "E-05" },
];
const clock = (time: number) =>
`${String(Math.floor(time / 60)).padStart(2, "0")}:${String(time % 60).padStart(2, "0")}`;
function IncidentMap({ state }: { state: PublicState | null }) {
const positions = mapPositions(state);
const [accessX, accessY] = PUMP_ROUTE[PUMP_ROUTE.length - 1];
const roadPath = PUMP_ROUTE.map(
([x, y], i) => `${i ? "L" : "M"}${x} ${y}`,
).join(" ");
return (
<div className="incident-map">
<svg
viewBox="0 0 720 360"
role="img"
aria-label="가상 도시 상황도. 드론과 출동 차량의 위치를 보여줍니다."
>
<defs>
<pattern
id="city-grid"
width="24"
height="24"
patternUnits="userSpaceOnUse"
>
<path
d="M 24 0 L 0 0 0 24"
fill="none"
stroke="#dae2e5"
strokeWidth=".5"
/>
</pattern>
</defs>
<rect width="720" height="360" fill="#edf1ed" />
<rect width="720" height="360" fill="url(#city-grid)" />
<path
d="M0 315 Q120 260 285 290 T720 265"
fill="none"
stroke="#c1dce0"
strokeWidth="32"
/>
<g stroke="#fff" strokeWidth="24" fill="none">
<path d="M0 110H720" />
<path d="M0 224H720" />
<path d="M150 0V360" />
<path d="M355 0V360" />
<path d="M595 0V360" />
</g>
<g fill="#d7dfd8" stroke="#bccac2">
{[
[38, 28, 65, 47],
[205, 28, 108, 42],
[405, 26, 143, 48],
[620, 36, 67, 45],
[27, 151, 85, 38],
[202, 146, 103, 42],
[408, 143, 120, 43],
[626, 152, 70, 37],
[210, 257, 98, 51],
[405, 268, 109, 58],
].map(([x, y, w, h], i) => (
<rect key={i} x={x} y={y} width={w} height={h} rx="3" />
))}
</g>
<g fill="#899b91" fontFamily="sans-serif" fontSize="10">
<text x="207" y="58">
가상 상업구역
</text>
<text x="414" y="170">
가상 신고 현장
</text>
<text x="210" y="288">
가상 주거구역
</text>
</g>
<path
d={roadPath}
stroke="#758d99"
strokeWidth="3"
strokeDasharray="6 5"
fill="none"
/>
<path
d={`M${STATION_POSITION.join(" ")}L${INCIDENT_POSITION.join(" ")}`}
stroke="#237d72"
strokeWidth="2"
strokeDasharray="2 6"
fill="none"
/>
<circle cx={accessX} cy={accessY} r="5" fill="#758d99" />
<text x={accessX + 14} y={accessY - 16} fill="#526c7a" fontSize="10">
현장 진입 지점
</text>
<g transform={`translate(${STATION_POSITION.join(" ")})`}>
<rect x="-23" y="-16" width="46" height="32" rx="4" fill="#203b49" />
<text y="4" textAnchor="middle" fill="white" fontSize="10">
소방서
</text>
</g>
<g transform={`translate(${INCIDENT_POSITION.join(" ")})`}>
<circle
r="38"
stroke="#d95332"
strokeWidth="1"
strokeDasharray="4 4"
fill="#d9533212"
/>
<circle r="16" fill="#d95332" />
<text y="5" textAnchor="middle" fill="white" fontSize="17">
!
</text>
<rect x="-48" y="42" width="96" height="23" rx="3" fill="white" />
<text y="57" textAnchor="middle" fill="#9f3823" fontSize="10">
신고 위치 · 가상
</text>
</g>
{state && (
<g transform={`translate(${positions.pump.join(" ")})`}>
<rect
x="-20"
y="-11"
width="40"
height="22"
rx="4"
fill="#237d72"
/>
<text y="4" textAnchor="middle" fill="white" fontSize="10">
펌프1
</text>
</g>
)}
{state?.drone.status !== "대기" && state && (
<g transform={`translate(${positions.drone.join(" ")})`}>
<circle r="17" fill="white" stroke="#237d72" />
<path
d="M-10 -6L10 6M-10 6L10 -6"
stroke="#237d72"
strokeWidth="2"
/>
<text x="23" y="4" fill="#237d72" fontSize="11">
드론
</text>
</g>
)}
<text
x="18"
y="342"
fill="#718681"
fontSize="10"
fontFamily="monospace"
>
FICTIONAL MAP / POSITIONS FOR DEMO ONLY
</text>
</svg>
<div className="map-legend">
<span>
<i className="legend-dot orange" />
신고 위치
</span>
<span>
<i className="legend-dot teal" />
가상 자원
</span>
<span>실제 지도·비행 경로 아님</span>
<span>차량: 도로 이동 · 드론: 직선 비행</span>
</div>
</div>
);
}
export function CommandRoom({ scenarios }: { scenarios: PublicScenario[] }) {
const [scenarioId, setScenarioId] = useState(scenarios[0].id);
const [seed, setSeed] = useState("42");
const [initialLevel, setInitialLevel] = useState<Level>(3);
const [state, setState] = useState<PublicState | null>(null);
const [playing, setPlaying] = useState(false);
const [speed, setSpeed] = useState(2);
const [busy, setBusy] = useState(false);
const [error, setError] = useState("");
const [vehicle, setVehicle] = useState(false);
const [voice, setVoice] = useState(true);
const [volume, setVolume] = useState(60);
const [introPlaying, setIntroPlaying] = useState(false);
const [visible, setVisible] = useState(true);
const [captionExpanded, setCaptionExpanded] = useState(false);
const [audioStatus, setAudioStatus] = useState<AudioStatus>({
phase: "off",
cue: null,
message: "시연 실행을 누르면 교육 음향이 시작됩니다.",
siren: false,
});
const [mobilePanel, setMobilePanel] = useState("map");
const [settingsOpen, setSettingsOpen] = useState(false);
const [manualKey, setManualKey] = useState<ActionKey>("DISPATCH");
const [manualText, setManualText] = useState("");
const current = useRef<PublicState | null>(null);
const pending = useRef(false);
const audio = useRef<DemoAudioPlayer | null>(null);
const audioCursor = useRef(new DemoAudioCursor());
const scenario =
state?.scenario ?? scenarios.find((s) => s.id === scenarioId)!;
function accept(next: PublicState) {
if (current.current?.id !== next.id) {
audio.current?.clear();
audioCursor.current.attach(next);
}
current.current = next;
setState(next);
setScenarioId(next.scenario.id);
setSeed(String(next.seed));
if (next.status === "closed") setPlaying(false);
}
async function fetchState(id: string) {
const response = await fetch(`/api/sessions/${encodeURIComponent(id)}`, {
cache: "no-store",
});
const data = await response.json();
if (!response.ok) throw new Error(data.error);
return data as PublicState;
}
useEffect(() => {
const player = new DemoAudioPlayer(setAudioStatus);
audio.current = player;
const visibility = () => setVisible(document.visibilityState !== "hidden");
document.addEventListener("visibilitychange", visibility);
return () => {
document.removeEventListener("visibilitychange", visibility);
player.dispose();
audio.current = null;
};
}, []);
useEffect(() => {
const params = new URLSearchParams(window.location.search);
const id = params.get("session");
setVehicle(params.get("view") === "vehicle");
if (id) {
setVoice(false);
audio.current?.mute();
fetchState(id)
.then(accept)
.catch((e) => setError(e.message));
}
}, []);
async function create() {
if (pending.current) return;
// 모바일 재생 권한은 클릭 안에서 먼저 연다. POST 응답을 기다린 뒤 열지 않는다.
if (voice) audio.current?.unlock();
pending.current = true;
setBusy(true);
setError("");
try {
const response = await fetch("/api/sessions", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
scenarioId,
seed: Number(seed),
level: initialLevel,
}),
});
const next = await response.json();
if (!response.ok) throw new Error(next.error);
accept(next);
audioCursor.current.attach(next, voice);
if (voice) {
setIntroPlaying(true);
audio.current?.startIntro(introCues(next.scenario), () =>
setIntroPlaying(false),
);
}
setPlaying(true);
window.history.replaceState(null, "", `/demo?session=${next.id}`);
} catch (e) {
setError(e instanceof Error ? e.message : "사건을 생성하지 못했습니다.");
} finally {
pending.current = false;
setBusy(false);
}
}
async function send(action: Action | { kind: "tick"; seconds: number }) {
const snapshot = current.current;
if (!snapshot || pending.current) return;
pending.current = true;
setBusy(true);
setError("");
try {
const response = await fetch(`/api/sessions/${snapshot.id}`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ version: snapshot.version, action }),
});
const next = await response.json();
if (response.status === 409) {
accept(await fetchState(snapshot.id));
setPlaying(false);
throw new Error(next.error);
}
if (!response.ok) throw new Error(next.error);
accept(next);
} catch (e) {
setPlaying(false);
setError(e instanceof Error ? e.message : "상태를 변경하지 못했습니다.");
} finally {
pending.current = false;
setBusy(false);
}
}
useEffect(() => {
if (
!playing ||
introPlaying ||
!visible ||
vehicle ||
state?.paused ||
state?.status === "closed"
)
return;
const timer = setInterval(() => {
void send({ kind: "tick", seconds: speed });
}, 1000);
return () => clearInterval(timer);
}, [
playing,
introPlaying,
visible,
speed,
vehicle,
state?.paused,
state?.status,
]);
useEffect(() => {
if (!vehicle || !state?.id) return;
const timer = setInterval(() => {
fetchState(state.id)
.then(accept)
.catch((e) => setError(e.message));
}, 1000);
return () => clearInterval(timer);
}, [vehicle, state?.id]);
useEffect(() => {
const player = audio.current;
if (!voice || !state || !player) return;
const paused =
state.paused ||
!visible ||
(!vehicle && !playing && state.status !== "closed");
player.setPaused(paused);
player.reconcile(
new Set(audioEvents(state).map((c) => c.id)),
state.elapsed,
state.status === "closed",
);
if (!introPlaying && !paused)
player.enqueue(audioCursor.current.collect(state));
player.setSiren(!introPlaying && !paused && sirenShouldPlay(state));
}, [voice, state, playing, vehicle, introPlaying, visible]);
function stopVoice() {
audio.current?.clear();
if (current.current) audioCursor.current.attach(current.current);
setIntroPlaying(false);
}
function toggleVoice() {
if (voice && audioStatus.phase !== "error") audio.current?.mute();
else {
if (current.current) audioCursor.current.attach(current.current);
audio.current?.unlock();
}
setVoice(!voice || audioStatus.phase === "error");
}
const soundButton = (
<button
className="button voice-button"
onClick={toggleVoice}
aria-pressed={voice}
>
{voice ? <Volume2 size={15} /> : <VolumeX size={15} />} 음향{" "}
{voice ? "켜짐" : "꺼짐"}
</button>
);
function reset() {
setPlaying(false);
stopVoice();
current.current = null;
setState(null);
setMobilePanel("map");
setSettingsOpen(false);
setError("");
window.history.replaceState(null, "", "/demo");
}
const chosen = manualActions.find((a) => a.key === manualKey)!;
const lastDecision = state?.decisions.at(-1);
return (
<>
<Nav active="demo" />
<main
className="room workspace-room"
data-mobile-panel={mobilePanel}
data-active={!!state}
data-settings-open={settingsOpen}
>
<div className="room-top">
<div>
<div className="eyebrow">
{vehicle
? "VEHICLE VIEW / READ ONLY"
: "INCIDENT COMMAND / SIMULATION"}
</div>
<h1>{vehicle ? "출동 차량 보기" : "가상 화재 상황본부"}</h1>
</div>
<div className="room-header-actions">
{state?.scenario.id === "restaurant-gas" &&
state.mode === "DEMO" && (
<a className="button" href={`/simulation?session=${state.id}`}>
UE5 현장 보기
</a>
)}
<button
className="workspace-settings button"
aria-expanded={settingsOpen}
aria-controls="demo-setup"
onClick={() => setSettingsOpen(!settingsOpen)}
>
시연 설정
</button>
{state && !vehicle && (
<a
className="text-link vehicle-link"
href={`/demo?session=${state.id}&view=vehicle`}
target="_blank"
rel="noreferrer"
>
차량 화면 열기 <ArrowUpRight size={15} />
</a>
)}
<span className="virtual-badge">
<ShieldCheck size={15} />
가상 훈련 · 실제 출동 아님
</span>
</div>
</div>
<div className="setup-bar" id="demo-setup">
<label>
시나리오
<select
value={scenarioId}
onChange={(e) => setScenarioId(e.target.value)}
disabled={!!state || vehicle}
>
{scenarios.map((s) => (
<option key={s.id} value={s.id}>
{s.title}
</option>
))}
</select>
</label>
<label className="seed-field">
시드
<input
type="number"
min="0"
max="4294967295"
value={seed}
disabled={!!state || vehicle}
onChange={(e) => setSeed(e.target.value)}
/>
</label>
<label>
지휘 수준
<select
value={state?.level ?? initialLevel}
disabled={busy || vehicle || state?.status === "closed"}
onChange={(e) => {
const level = Number(e.target.value) as Level;
if (state) void send({ kind: "level", level });
else setInitialLevel(level);
}}
>
{levels.map((label, i) => (
<option key={i} value={i}>
{label}
</option>
))}
</select>
</label>
{!state && !vehicle ? (
<button
className="button primary"
disabled={
busy ||
!seed ||
!Number.isInteger(Number(seed)) ||
Number(seed) < 0 ||
Number(seed) > 0xffffffff
}
onClick={create}
>
<Play size={16} />
시연 실행
</button>
) : (
!vehicle && (
<button className="button" onClick={reset} disabled={busy}>
<RotateCcw size={15} />새 시연
</button>
)
)}
</div>
{error && (
<div className="error-banner" role="alert">
{error}
<button
className="icon-button"
onClick={() => setError("")}
aria-label="오류 닫기"
>
<X size={16} />
</button>
</div>
)}
{state?.paused && (
<div className="intervention-banner">
<Hand size={20} />
<div>
<strong>사람이 개입했습니다</strong>
<span>
가상 시간과 자동 지휘가 멈췄습니다. 재개 전에 판단과 명령을
확인하세요.
</span>
</div>
{!vehicle && (
<>
<button
className="button"
disabled={busy}
onClick={() => send({ kind: "level", level: 0 })}
>
내가 지휘
</button>
<button
className="button primary"
disabled={busy}
onClick={() => {
void send({ kind: "resume" });
setPlaying(true);
}}
>
진행 재개
</button>
{soundButton}
</>
)}
</div>
)}
{state?.status !== "closed" && (
<div className="commander-controls">
{!vehicle && (
<button
className="button intervention"
disabled={!state || busy || state.paused}
onClick={() => {
setPlaying(false);
stopVoice();
void send({ kind: "intervene" });
}}
>
<Pause size={17} />
개입 · 일시정지
</button>
)}
<div className="playback-row">
{!vehicle && (
<>
<button
className="button"
disabled={!state || busy || state.paused}
aria-label={playing ? "재생 정지" : "재생 시작"}
onClick={() => setPlaying(!playing)}
>
{playing ? <Pause size={15} /> : <Play size={15} />}
</button>
<label>
속도
<select
aria-label="가상 시간 배속"
value={speed}
onChange={(e) => setSpeed(Number(e.target.value))}
>
<option value={1}>1×</option>
<option value={2}>2×</option>
<option value={8}>8×</option>
</select>
</label>
</>
)}
{soundButton}
<label className="sound-volume">
볼륨
<input
aria-label="교육 음향 볼륨"
type="range"
min="0"
max="100"
value={volume}
onChange={(e) => {
const value = Number(e.target.value);
setVolume(value);
audio.current?.setVolume(value / 100);
}}
/>
</label>
</div>
</div>
)}
<nav className="workspace-switcher" aria-label="상황본부 화면 선택">
{[
["map", "상황도"],
["commands", "AI·명령"],
["observations", "관찰·드론"],
["history", "복기"],
].map(([key, label]) => (
<button
key={key}
type="button"
aria-pressed={mobilePanel === key}
onClick={() => setMobilePanel(key)}
>
{label}
</button>
))}
</nav>
<div className="room-grid">
<section className="situation-column">
<div className="incident-header">
<span className="mono">
{state
? `INC-${state.id.slice(0, 8).toUpperCase()}`
: "READY / SELECT SCENARIO"}
</span>
<span className="status-pill">
{state?.status === "closed"
? "훈련 종료"
: state?.paused
? "개입 중"
: playing
? "가상 진행 중"
: "준비 / 정지"}
</span>
<h2>{scenario.title}</h2>
<p
className="sound-caption"
role="status"
aria-live="polite"
data-audio-phase={audioStatus.phase}
data-audio-cue={audioStatus.cue?.id ?? ""}
data-siren={audioStatus.siren}
data-expanded={captionExpanded}
>
<strong>
{audioStatus.phase === "paused"
? "음향 일시정지"
: (audioStatus.cue?.role ??
(voice ? "교육 음향" : "음향 꺼짐"))}
</strong>
<span title={audioStatus.cue?.text ?? scenario.briefing}>
{audioStatus.message ||
audioStatus.cue?.text ||
(introPlaying
? "신고 접수 재생 중 · 가상 시간 대기"
: audioStatus.siren
? "출동 사이렌 재생 중"
: scenario.briefing)}
</span>
<button
className="caption-expander"
aria-expanded={captionExpanded}
aria-label={
captionExpanded ? "음향 자막 접기" : "음향 자막 전체 보기"
}
onClick={() => setCaptionExpanded(!captionExpanded)}
>
{captionExpanded ? "접기" : "자막"}
</button>
</p>
<div className="incident-meta">
<span>{scenario.address}</span>
<span>분류 후보 {scenario.classes.join(" · ")}</span>
<span>{scenario.risks.join(" · ")}</span>
</div>
</div>
<div className="panel situation-map-panel">
<div className="panel-title">
<h2>상황도</h2>
<span className="mono">T+{clock(state?.elapsed ?? 0)}</span>
</div>
<IncidentMap state={state} />
<div className="resource-row">
{(
state?.units ?? [
{ id: "pump-1", name: "펌프1", status: "준비", eta: 40 },
{ id: "rescue-1", name: "구조1", status: "준비", eta: 40 },
]
).map((u) => (
<div key={u.id}>
<Radio size={17} />
<strong>{u.name}</strong>
<span>{u.status}</span>
<small className="mono">가상 ETA {u.eta}s</small>
</div>
))}
<div>
<ScanLine size={17} />
<strong>드론1</strong>
<span>{state?.drone.status ?? "대기"}</span>
<small className="mono">
가상 ETA {state?.drone.eta ?? 20}s
</small>
</div>
</div>
</div>
<div className="observation-grid">
<div className="panel drone-panel">
<div className="panel-title">
<h2>
<ScanLine size={16} /> 드론 정찰
</h2>
<span className="small-tag">가상 데이터</span>
</div>
<div
className={`thermal-view ${state?.fireCells.length ? "arrived" : ""}`}
>
<div className="camera-corner top-left" />
<div className="camera-corner bottom-right" />
{state?.fireCells.length ? (
<>
<div
className="heat-grid"
aria-label="교육용 가상 열 분포"
>
{state.fireCells.map((heat, i) => (
<span
key={i}
style={{
background:
heat > 0
? `rgba(237,110,47,${0.25 + heat / 140})`
: "#203e3c",
}}
/>
))}
</div>
<span className="camera-caption">
SIMULATED HEAT / 물리 모델 아님
</span>
</>
) : (
<div className="drone-wait">
<ScanLine size={28} />
<strong>관찰 수신 대기</strong>
<span>드론 도착 후 공개됩니다.</span>
</div>
)}
</div>
<p className="panel-note">
실제 영상·열화상 아님. 외부 관찰로 내부 인명이나 안전을
확정하지 않습니다.
</p>
</div>
<div className="panel observations">
<div className="panel-title">
<h2>신고·관찰 기록</h2>
<span className="mono">
{state?.observations.length ?? 0} RECORDS
</span>
</div>
<div
className="observation-list"
tabIndex={0}
aria-label="신고·관찰 기록 목록"
>
{state ? (
state.observations
.slice()
.reverse()
.map((o) => (
<article key={o.key}>
<div>
<span
className={`observation-status ${o.status === "확인됨" ? "confirmed" : ""}`}
>
{o.status}
</span>
<span className="mono">
{o.source} / {clock(o.at)}
</span>
</div>
<p>{o.text}</p>
</article>
))
) : (
<p className="empty-state">
시연을 시작하면 출처와 확인 상태가 함께 기록됩니다.
</p>
)}
</div>
</div>
</div>
</section>
<aside className="command-column">
<div className="panel commander-panel">
<div className="panel-title">
<h2>
<Flame size={17} /> AI 지휘관
</h2>
<span className="small-tag">SCRIPT</span>
</div>
<div className="commander-mode">
<span className="status-dot" />
<strong>{levels[state?.level ?? initialLevel]}</strong>
<span>교육 각본</span>
</div>
<div
className="decision-content"
tabIndex={0}
aria-label="AI 판단 내용"
>
{lastDecision ? (
<>
<div className="eyebrow">
LATEST DECISION / {clock(lastDecision.at)}
</div>
<h3>{lastDecision.title}</h3>
<p>{lastDecision.reason}</p>
<div className="decision-action">
<span>제안 임무 → {lastDecision.target}</span>
<strong>{lastDecision.text}</strong>
</div>
<div className="evidence-links">
{lastDecision.cardIds.map((id) => (
<Link
key={id}
href={`/manuals?card=${id}`}
className="source-chip"
>
{id} ↗
</Link>
))}
</div>
<small>교육용 초안 · 원문 전술 검수 전</small>
</>
) : (
<>
<h3>
근거를 확인하고,
<br />
다음 임무를 연결합니다.
</h3>
<p>
규칙 기반 지휘관은 현재 공개된 관찰만 사용합니다. 실제
생성형 AI는 연결되지 않았습니다.
</p>
<div className="decision-action">
신고 → 판단 → 근거 → 명령 → 수신 → 완료
</div>
</>
)}
</div>
{vehicle && (
<p className="panel-note">
읽기 전용 · 상황본부 변경을 1초마다 확인합니다.
</p>
)}
</div>
{state?.level === 0 && !vehicle && state.status !== "closed" && (
<form
className="panel manual-command"
onSubmit={(e) => {
e.preventDefault();
void send({
kind: "manual",
actionKey: chosen.key,
target: chosen.target,
text: manualText.trim() || chosen.name,
cardIds: [chosen.card],
});
}}
>
<div className="panel-title">
<h2>사람의 가상 명령</h2>
<Hand size={17} />
</div>
<label>
임무
<select
value={manualKey}
onChange={(e) => setManualKey(e.target.value as ActionKey)}
>
{manualActions.map((a) => (
<option key={a.key} value={a.key}>
{a.name}
</option>
))}
</select>
</label>
<label>
교육용 지시문
<textarea
value={manualText}
maxLength={500}
placeholder={chosen.name}
onChange={(e) => setManualText(e.target.value)}
/>
</label>
<Link
className="text-link"
href={`/manuals?card=${chosen.card}`}
>
근거 {chosen.card} 확인 ↗
</Link>
<button
className="button primary"
type="submit"
disabled={busy || state.paused}
>
가상 명령 발행
</button>
</form>
)}
<div className="panel command-list">
<div className="panel-title">
<h2>임무·명령</h2>
<span className="mono">{state?.commands.length ?? 0}</span>
</div>
<div
className="command-scroll"
tabIndex={0}
aria-label="임무·명령 목록"
>
{state?.commands.length ? (
state.commands
.slice()
.reverse()
.map((c) => {
const needsReview =
c.status === "draft" &&
state.observations.some(
(o) =>
!(c.basisObservationKeys ?? []).includes(o.key),
);
return (
<article
key={c.id}
className={`command-item ${c.status}`}
>
<div className="command-meta">
<span className="mono">
{c.id} · {c.actor === "ai" ? "AI" : "사람"}
</span>
<span className={`command-status ${c.status}`}>
{needsReview
? "새 관찰 · 재검토 필요"
: statuses[c.status]}
</span>
</div>
<strong>{c.target}</strong>
<p>{c.text}</p>
<div className="command-card-row">
{c.cardIds.map((id) => (
<Link href={`/manuals?card=${id}`} key={id}>
{id}
</Link>
))}
{!vehicle && c.status === "draft" && (
<button
disabled={
busy || (!needsReview && state.paused)
}
onClick={() =>
send({
kind: needsReview ? "review" : "approve",
commandId: c.id,
})
}
>
<Check size={14} />
{needsReview ? "재검토" : "승인"}
</button>
)}
{!vehicle &&
!["complete", "cancelled"].includes(c.status) && (
<button
disabled={busy}
onClick={() =>
send({ kind: "cancel", commandId: c.id })
}
>
<X size={13} />
취소
</button>
)}
</div>
{needsReview && (
<p className="muted">
새 관찰을 반영한 제안을 확인한 뒤 승인하세요.
</p>
)}
</article>
);
})
) : (
<p className="empty-state">
발행한 명령과 수신·수행 상태가 여기에 표시됩니다.
</p>
)}
</div>
</div>
</aside>
</div>
{state?.status === "closed" && (
<section className="review-banner">
<ShieldCheck size={26} />
<div>
<h2>가상 훈련이 종료됐습니다.</h2>
<p>
임무 완료 {state.summary.completed}건 · 취소{" "}
{state.summary.cancelled}건 · 사람 개입{" "}
{state.summary.interventions}회. 아래 기록에서 판단과 진행을
복기하세요.
</p>
</div>
{soundButton}
</section>
)}
<section className="panel event-log">
<div className="panel-title">
<h2>사건 타임라인 · 복기</h2>
<span className="mono">VERSION {state?.version ?? "—"}</span>
</div>
<div
className="event-scroll"
tabIndex={0}
aria-label="사건 복기 기록"
>
{state?.log.length ? (
state.log
.slice()
.reverse()
.map((e) => (
<div className="event-row" key={e.sequence}>
<span className="mono">T+{clock(e.at)}</span>
<span className="event-type">{e.type}</span>
<p>{e.text}</p>
{e.commandId && <span className="mono">{e.commandId}</span>}
</div>
))
) : (
<p className="empty-state">
신고·판단·명령·수신·완료·개입·취소 기록이 순서대로 쌓입니다.
</p>
)}
</div>
</section>
<p className="room-footnote">
가상 데이터 · 규칙 기반 AI · 교육 카드 검수 전 · 가상 임무는 일정
시간이 지나면 완료됩니다. 실제 소방 전술·장비·비행과 연결되지
않습니다.
</p>
</main>
</>
);
}
apps/web/components/copy-text.ts
'use client';
export async function copyText(text:string){if(navigator.clipboard){try{await navigator.clipboard.writeText(text);return;}catch{}}const area=document.createElement('textarea');area.value=text;area.style.position='fixed';area.style.opacity='0';document.body.appendChild(area);area.select();const ok=document.execCommand('copy');area.remove();if(!ok)throw Error('복사 실패');}
apps/web/components/demo-audio-player.ts
import type { AudioCue } from "../../../packages/simulation/demo-audio";
import manifest from "../../../packages/simulation/audio-recordings.json";
export type AudioStatus = {
phase: "off" | "ready" | "playing" | "paused" | "error";
cue: AudioCue | null;
message: string;
siren: boolean;
};
type Queued = { cue: AudioCue; done?: () => void };
// 브라우저당 한 재생기를 사용하고 세션/음소거/수명 변경 때 이전 비동기 재생을 폐기한다.
export class DemoAudioPlayer {
private context: AudioContext | null = null;
private master: GainNode | null = null;
private sirenGain: GainNode | null = null;
private siren: AudioBufferSourceNode | null = null;
private sirenWanted = false;
private buffers = new Map<string, Promise<AudioBuffer>>();
private queue: Queued[] = [];
private current: Queued | null = null;
private source: AudioBufferSourceNode | null = null;
private generation = 0;
private enabled = false;
private paused = false;
private disposed = false;
private starting = false;
private introDone: (() => void) | null = null;
private fallback: SpeechSynthesisUtterance | null = null;
private fallbackTimer: ReturnType<typeof setTimeout> | null = null;
private fallbackRemaining = 0;
private fallbackStartedAt = 0;
private fallbackExpire: (() => void) | null = null;
private volume = 0.6;
private status: AudioStatus = {
phase: "off",
cue: null,
message: "교육 음향 꺼짐",
siren: false,
};
constructor(private notify: (status: AudioStatus) => void) {}
private publish(
phase: AudioStatus["phase"],
cue: AudioCue | null = this.current?.cue ?? null,
message = "",
) {
if (this.disposed) return;
this.status = { phase, cue, message, siren: !!this.siren };
this.notify(this.status);
}
get snapshot() {
return this.status;
}
// 반드시 클릭 처리 안에서 호출한다. 네트워크 응답을 기다린 뒤 resume하지 않는다.
unlock() {
if (this.disposed) return;
try {
this.context ??= new AudioContext();
if (!this.master) {
this.master = this.context.createGain();
this.master.gain.value = this.volume;
this.master.connect(this.context.destination);
this.sirenGain = this.context.createGain();
this.sirenGain.gain.value = 0.3;
this.sirenGain.connect(this.master);
}
this.enabled = true;
const generation = this.generation;
void this.context
.resume()
.then(() => {
if (generation !== this.generation || !this.enabled || this.disposed)
return;
if (this.paused) {
void this.context?.suspend().catch(() => {});
this.publish("paused");
} else {
this.publish("ready");
this.pump();
this.updateSiren();
}
})
.catch(() =>
this.failed(
"이 브라우저에서 음향 재생을 허용하지 않았습니다. 음향 버튼을 다시 누르세요.",
),
);
} catch {
this.failed(
"이 브라우저는 Web Audio를 지원하지 않습니다. 자막으로 시연을 계속합니다.",
);
}
}
setVolume(value: number) {
this.volume = Math.max(0, Math.min(1, value));
if (this.master && this.context)
this.master.gain.setTargetAtTime(
this.volume,
this.context.currentTime,
0.04,
);
}
private async buffer(file: string) {
let promise = this.buffers.get(file);
if (!promise) {
promise = fetch(file, { signal: AbortSignal.timeout(7000) }).then(
async (r) => {
if (!r.ok) throw Error("음원 응답 오류");
return this.context!.decodeAudioData(await r.arrayBuffer());
},
);
this.buffers.set(file, promise);
promise.catch(() => this.buffers.delete(file));
}
return promise;
}
startIntro(cues: AudioCue[], done: () => void) {
this.introDone = done;
if (!this.enabled || !this.context || this.status.phase === "error") {
this.finishIntro();
return;
}
this.queue.push(
...cues.map((cue, index) => ({
cue,
...(index === cues.length - 1
? { done: () => this.finishIntro() }
: {}),
})),
);
this.pump();
}
private finishIntro() {
const done = this.introDone;
this.introDone = null;
done?.();
}
enqueue(cues: AudioCue[]) {
if (!this.enabled) return;
this.queue.push(...cues.map((cue) => ({ cue })));
this.pump();
}
reconcile(valid: Set<string>, elapsed: number, closed: boolean) {
const allowed = (cue: AudioCue) =>
cue.id.startsWith("intro:") ||
(valid.has(cue.id) &&
(!closed || cue.id === "training-end") &&
(closed || elapsed - cue.at <= 20));
this.queue = this.queue.filter((item) => allowed(item.cue));
if (this.current && !allowed(this.current.cue)) this.stopCurrent();
this.pump();
}
private stopCurrent() {
this.generation++;
this.starting = false;
if (this.source) {
this.source.onended = null;
this.source.stop();
this.source.disconnect();
this.source = null;
}
if (this.fallback) {
this.fallback.onend = null;
this.fallback.onerror = null;
window.speechSynthesis?.cancel();
this.fallback = null;
}
this.resetFallbackTimer();
this.current = null;
this.duck(false);
}
// 정지·백그라운드 시간은 합성 음성의 재생 제한에서 제외한다.
private pauseFallbackTimer() {
if (this.fallbackTimer !== null) {
clearTimeout(this.fallbackTimer);
this.fallbackTimer = null;
this.fallbackRemaining = Math.max(
0,
this.fallbackRemaining - (Date.now() - this.fallbackStartedAt),
);
}
}
private armFallbackTimer() {
if (!this.fallbackExpire || this.paused || this.fallbackTimer !== null)
return;
this.fallbackStartedAt = Date.now();
this.fallbackTimer = setTimeout(() => {
this.fallbackTimer = null;
this.fallbackExpire?.();
}, this.fallbackRemaining);
}
private resetFallbackTimer() {
if (this.fallbackTimer !== null) clearTimeout(this.fallbackTimer);
this.fallbackTimer = null;
this.fallbackExpire = null;
this.fallbackRemaining = 0;
}
private duck(talking: boolean) {
if (this.sirenGain && this.context)
this.sirenGain.gain.setTargetAtTime(
talking ? 0.08 : 0.3,
this.context.currentTime,
0.08,
);
}
private async pump() {
if (
!this.enabled ||
this.disposed ||
this.paused ||
this.context?.state !== "running" ||
this.current ||
this.starting ||
!this.queue.length
)
return;
this.current = this.queue.shift()!;
const item = this.current,
generation = this.generation;
this.starting = true;
const finished = () => {
if (
this.disposed ||
generation !== this.generation ||
this.current !== item
)
return;
this.source?.disconnect();
this.source = null;
this.current = null;
this.starting = false;
if (this.fallback) {
this.fallback.onend = null;
this.fallback.onerror = null;
}
this.fallback = null;
this.resetFallbackTimer();
this.duck(false);
item.done?.();
this.publish("ready", null);
this.pump();
};
try {
const recording = manifest.recordings.find(
(r) => r.role === item.cue.role && r.text === item.cue.text,
);
const file = item.cue.effect
? manifest.effects[item.cue.effect].file
: recording?.file;
if (!file) {
// 사용자 작성 명령은 녹음된 정형 대사로 바꾸지 않고 실제 문장을 읽는다.
const synth = window.speechSynthesis;
if (!synth) throw Error("한국어 음성 합성 지원 없음");
const utterance = new SpeechSynthesisUtterance(item.cue.text);
utterance.lang = "ko-KR";
utterance.volume = this.volume;
utterance.rate = 1.05;
utterance.voice =
synth.getVoices().find((v) => v.lang.startsWith("ko")) ?? null;
utterance.onend = finished;
utterance.onerror = () => {
if (
this.disposed ||
generation !== this.generation ||
this.current !== item
)
return;
finished();
// 다음 대사가 시작됐으면 이전 오류가 새 재생 상태를 덮지 않는다.
if (!this.current && this.enabled)
this.publish(
"error",
item.cue,
"사용자 작성 명령의 음성 재생 실패. 자막을 확인하세요.",
);
};
this.fallback = utterance;
this.starting = false;
this.duck(true);
this.publish("playing", item.cue);
this.fallbackRemaining = 20000;
this.fallbackExpire = () => {
utterance.onend = null;
utterance.onerror = null;
synth.cancel();
finished();
};
synth.speak(utterance);
this.armFallbackTimer();
return;
}
let buffer = await this.buffer(file);
if (["지휘본부", "대원", "드론팀"].includes(item.cue.role)) {
const radio = await this.buffer(manifest.effects.radio.file);
const combined = this.context!.createBuffer(
1,
radio.length + buffer.length,
buffer.sampleRate,
);
combined.getChannelData(0).set(radio.getChannelData(0));
combined.getChannelData(0).set(buffer.getChannelData(0), radio.length);
buffer = combined;
}
if (
generation !== this.generation ||
!this.enabled ||
this.disposed ||
this.current !== item
)
return;
const source = this.context!.createBufferSource();
source.buffer = buffer;
source.connect(this.master!);
this.source = source;
this.starting = false;
source.onended = finished;
this.duck(!item.cue.effect);
source.start();
this.publish(this.paused ? "paused" : "playing", item.cue);
} catch {
if (generation === this.generation)
this.failed(
"음원 로드 또는 재생에 실패했습니다. 음향을 다시 켜거나 자막으로 계속하세요.",
);
}
}
setPaused(paused: boolean) {
this.paused = paused;
if (paused) {
this.pauseFallbackTimer();
this.stopSiren();
void this.context?.suspend().catch(() => {});
window.speechSynthesis?.pause();
if (this.enabled) this.publish("paused");
} else if (this.enabled && this.context) {
void this.context
.resume()
.then(() => {
if (!this.paused && this.enabled) {
window.speechSynthesis?.resume();
this.armFallbackTimer();
this.publish(this.current ? "playing" : "ready");
this.pump();
this.updateSiren();
}
})
.catch(() => this.failed("음향 버튼을 눌러 재생을 허용하세요."));
}
}
setSiren(wanted: boolean) {
this.sirenWanted = wanted;
this.updateSiren();
}
private stopSiren() {
if (this.siren) {
this.siren.stop();
this.siren.disconnect();
this.siren = null;
}
}
private sirenLoading = false;
private async updateSiren() {
if (!this.enabled || this.paused || !this.sirenWanted || this.disposed) {
this.stopSiren();
return;
}
if (this.siren || this.sirenLoading || this.context?.state !== "running")
return;
this.sirenLoading = true;
const generation = this.generation;
try {
const buffer = await this.buffer(manifest.effects.siren.file);
if (
generation !== this.generation ||
!this.enabled ||
this.paused ||
!this.sirenWanted ||
this.disposed
)
return;
const source = this.context!.createBufferSource();
source.buffer = buffer;
source.loop = true;
source.connect(this.sirenGain!);
source.start();
this.siren = source;
this.publish(this.current ? "playing" : "ready");
} catch {
if (generation === this.generation)
this.publish("error", null, "사이렌 음원을 불러오지 못했습니다.");
} finally {
this.sirenLoading = false;
}
}
clear() {
this.queue = [];
this.stopCurrent();
this.stopSiren();
this.sirenWanted = false;
this.finishIntro();
this.publish(this.enabled ? "ready" : "off", null);
}
mute() {
this.enabled = false;
this.clear();
void this.context?.suspend().catch(() => {});
this.publish("off", null, "교육 음향 꺼짐");
}
private failed(message: string) {
this.enabled = false;
this.clear();
this.publish("error", null, message);
}
dispose() {
this.introDone = null;
this.disposed = true;
this.mute();
this.buffers.clear();
void this.context?.close().catch(() => {});
}
}
apps/web/components/library-highlight.tsx
// 문서를 HTML 문자열로 삽입하지 않고 React 텍스트로 읽고 강조한다.
export function libraryHighlight(text: string, query: string) {
const terms = query
.trim()
.split(/\s+/)
.filter(Boolean)
.map((t) => t.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"));
if (!terms.length) return text;
return text
.split(new RegExp(`(${terms.join("|")})`, "gi"))
.map((part, index) => (index % 2 ? <mark key={index}>{part}</mark> : part));
}
apps/web/components/library-room.tsx
"use client";
import { useState, useEffect, useRef } from "react";
import type { LibraryDocument, SearchResult } from "../../../packages/library";
import { copyText } from "./copy-text";
import type { User } from "../../../packages/station/types";
import { LibraryUpload } from "./library-upload";
import { libraryHighlight } from "./library-highlight";
type Bookmark = { documentId: string; page: number; title: string };
export function LibraryRoom({
documents: initialDocuments,
}: {
documents: LibraryDocument[];
}) {
const [documents, setDocuments] = useState(initialDocuments),
[user, setUser] = useState<User | null>(null),
[version, setVersion] = useState(0),
[ordering, setOrdering] = useState(false);
const [tab, setTab] = useState<"html" | "pdf">("html"),
[pageInput, setPageInput] = useState("1"),
[pageBusy, setPageBusy] = useState(true),
[searched, setSearched] = useState("");
const [catalogOpen, setCatalogOpen] = useState(false),
[dragged, setDragged] = useState("");
const [filter, setFilter] = useState(""),
[query, setQuery] = useState(""),
[scope, setScope] = useState(""),
[selected, setSelected] = useState(documents[0]),
[page, setPage] = useState(1),
[text, setText] = useState(""),
[results, setResults] = useState<SearchResult[]>([]),
[message, setMessage] = useState(""),
[error, setError] = useState(""),
[busy, setBusy] = useState(false),
[bookmarks, setBookmarks] = useState<Bookmark[]>([]);
const textRef = useRef<HTMLTextAreaElement>(null),
htmlRef = useRef<HTMLElement>(null);
const catalogEpoch = useRef(0),
searchEpoch = useRef(0),
pageEpoch = useRef(0),
selectedId = useRef(selected.id);
const mounted = useRef(true),
uploadEpoch = useRef(0);
const orderKey = (u: User | null) =>
`firestation-library-order:${u?.stationId ?? "public"}:${u?.id ?? "guest"}`;
const bookmarkKey = user
? `firestation-bookmarks:${user.stationId}:${user.id}`
: "firestation-bookmarks";
function applyCatalog(
data: { documents: LibraryDocument[]; version: number },
actor = user,
) {
let next = data.documents;
if (actor?.role !== "admin")
try {
const saved = JSON.parse(localStorage.getItem(orderKey(actor)) ?? "[]");
if (Array.isArray(saved)) {
const ids = [...new Set(saved.filter((v) => typeof v === "string"))];
next = [
...ids
.map((id) => next.find((d) => d.id === id))
.filter((d): d is LibraryDocument => !!d),
...next.filter((d) => !ids.includes(d.id)),
];
}
} catch {}
setDocuments(next);
setVersion(data.version);
if (!next.some((d) => d.id === selectedId.current)) {
pageEpoch.current++;
searchEpoch.current++;
setText("");
setResults([]);
setSelected(next[0]);
selectedId.current = next[0].id;
setPage(1);
setPageInput("1");
}
setScope((s) => (next.some((d) => d.id === s) ? s : ""));
}
async function loadCatalog() {
const epoch = ++catalogEpoch.current;
try {
const response = await fetch("/api/library?catalog=1", {
cache: "no-store",
}),
data = await response.json();
if (epoch !== catalogEpoch.current) return;
if (!response.ok) throw Error(data.error);
setUser(data.user);
applyCatalog(data, data.user);
} catch (e) {
if (epoch === catalogEpoch.current) {
setUser(null);
applyCatalog({ documents: initialDocuments, version: 0 }, null);
setError((e as Error).message);
}
}
}
useEffect(() => {
mounted.current = true;
void loadCatalog();
const refresh = () => void loadCatalog();
window.addEventListener("focus", refresh);
return () => {
mounted.current = false;
catalogEpoch.current++;
searchEpoch.current++;
pageEpoch.current++;
window.removeEventListener("focus", refresh);
};
}, []);
useEffect(() => {
const q = new URLSearchParams(window.location.search).get("query");
if (q) setQuery(q.slice(0, 80));
try {
const b = JSON.parse(localStorage.getItem(bookmarkKey) ?? "[]");
if (Array.isArray(b))
setBookmarks(
b
.filter(
(v: Bookmark) =>
documents.some(
(d) =>
d.id === v.documentId &&
v.page >= 1 &&
v.page <= d.pageCount,
) && Number.isInteger(v.page),
)
.slice(0, 100),
);
} catch {
setBookmarks([]);
}
}, [documents, bookmarkKey]);
useEffect(() => {
const controller = new AbortController();
const epoch = ++pageEpoch.current;
setText("");
setPageBusy(true);
fetch(`/api/library?document=${selected.id}&page=${page}`, {
signal: controller.signal,
})
.then(async (r) => {
const d = await r.json();
if (!r.ok) throw Error(d.error);
if (!controller.signal.aborted && epoch === pageEpoch.current)
setText(d.text);
})
.catch((e) => {
if (!controller.signal.aborted && epoch === pageEpoch.current)
setError(e.message);
})
.finally(() => {
if (!controller.signal.aborted && epoch === pageEpoch.current)
setPageBusy(false);
});
return () => controller.abort();
}, [selected.id, page, user?.id]);
useEffect(() => {
searchEpoch.current++;
setResults([]);
setSearched("");
setBusy(false);
}, [query, scope, user?.id]);
async function search() {
const epoch = ++searchEpoch.current;
setBusy(true);
setError("");
setMessage("");
try {
const r = await fetch(
`/api/library?q=${encodeURIComponent(query)}${scope ? "&document=" + scope : ""}`,
),
d = await r.json();
if (epoch !== searchEpoch.current) return;
if (!r.ok) throw Error(d.error);
setResults(d.results);
setSearched(query);
setMessage(
`${d.results.length}개 결과${d.results.length === 50 ? " · 최대 50개, 검색어를 좁혀주세요" : ""}`,
);
} catch (e) {
if (epoch === searchEpoch.current) setError((e as Error).message);
} finally {
if (epoch === searchEpoch.current) setBusy(false);
}
}
function open(id: string, p: number) {
const d = documents.find((d) => d.id === id);
if (d) {
if (selected.id !== id || page !== p) {
pageEpoch.current++;
setText("");
setPageBusy(true);
}
selectedId.current = id;
setSelected(d);
setPage(p);
setPageInput(String(p));
setCatalogOpen(false);
setError("");
}
}
function go(p: number) {
if (!Number.isInteger(p) || p < 1 || p > selected.pageCount) {
setError(`1~${selected.pageCount}페이지의 정수를 입력하세요.`);
return;
}
open(selected.id, p);
}
async function move(id: string, target: string) {
if (ordering || id === target) return;
const from = documents.findIndex((d) => d.id === id),
to = documents.findIndex((d) => d.id === target);
if (from < 0 || to < 0) return;
const next = [...documents];
next.splice(to, 0, next.splice(from, 1)[0]);
setError("");
if (user?.role !== "admin") {
try {
localStorage.setItem(
orderKey(user),
JSON.stringify(next.map((d) => d.id)),
);
setDocuments(next);
setMessage("교재 순서를 이 브라우저에 저장했습니다.");
} catch {
setError("브라우저 저장소를 사용할 수 없습니다.");
}
return;
}
setOrdering(true);
const epoch = ++catalogEpoch.current;
try {
const response = await fetch("/api/library/order", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ ids: next.map((d) => d.id), version }),
}),
data = await response.json();
if (!mounted.current) return;
if (epoch !== catalogEpoch.current) {
void loadCatalog();
return;
}
if (!response.ok) {
if (response.status === 409) {
await loadCatalog();
if (mounted.current) setError(data.error);
return;
}
throw Error(data.error);
}
applyCatalog(data);
setMessage("기관 공통 교재 순서를 저장했습니다.");
} catch (e) {
if (mounted.current && epoch === catalogEpoch.current)
setError((e as Error).message);
} finally {
if (mounted.current) {
setOrdering(false);
setDragged("");
}
}
}
async function copy(selection = false) {
const area = textRef.current,
content =
selection &&
tab === "html" &&
htmlRef.current?.contains(window.getSelection()?.anchorNode ?? null) &&
htmlRef.current?.contains(window.getSelection()?.focusNode ?? null) &&
window.getSelection()?.toString()
? window.getSelection()!.toString()
: selection && area && area.selectionStart !== area.selectionEnd
? text.slice(area.selectionStart, area.selectionEnd)
: text;
try {
await copyText(
`${content}\n\n출처: ${selected.publisher}, ${selected.title}, ${selected.year}년, PDF ${page}페이지\n${selected.postUrl}\n${selected.licenseStatement}`,
);
setMessage(
selection
? "선택 내용·출처를 복사했습니다."
: "페이지·출처를 복사했습니다.",
);
} catch {
setError("복사하지 못했습니다. 텍스트를 선택해 복사하세요.");
}
}
function bookmark() {
const b = [
{ documentId: selected.id, page, title: selected.title },
...bookmarks.filter(
(b) => b.documentId !== selected.id || b.page !== page,
),
].slice(0, 100);
setBookmarks(b);
try {
localStorage.setItem(bookmarkKey, JSON.stringify(b));
setMessage("이 브라우저에 북마크를 저장했습니다.");
} catch {
setError("브라우저 저장소를 사용할 수 없습니다.");
}
}
const explanation = selected.title.includes("법령")
? "교재 발행 이후 개정될 수 있으므로 현행 조문·시행일을 국가법령정보센터에서 대조하세요."
: selected.title.includes("차량")
? "장비 구성과 점검 항목을 학습하고, 실제 장비의 제작사 지침과 기관 점검 기준을 함께 확인하세요."
: "원문에서 개념·절차·예시를 확인하고 현장 적용은 담당 지휘관과 기관 승인 절차·현장 보고에 따라 검토하세요.";
return (
<main className="portal library-portal">
<div className="portal-heading">
<div>
<p className="eyebrow">OFFICIAL SOURCE / NFSA 2026</p>
<h1>2026 중앙소방학교 교재</h1>
<p>공식14권 · HTML 페이지 읽기 / 원문 PDF / 검색 / 해설 / 복사</p>
</div>
<a
className="button"
href="https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/"
target="_blank"
rel="noreferrer"
>
공식 자료실 ↗
</a>
</div>
<details className="notice library-source-note">
<summary>자료 출처·HTML 이용 안내</summary>
<p>
소방청 · 중앙소방학교 발행, 2026.03.17 게시. 원문은 변경 없이
제공합니다. HTML은 추출 본문이며 그림·표 배치는 PDF에서 확인합니다.
서비스 해설은 자체 작성이며 기관의 공식 해설·승인 지시가 아닙니다.
</p>
</details>
<div className="library-search panel">
<label>
본문 검색어
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") void search();
}}
placeholder="예: 가스, 공기호흡기, 인명"
maxLength={80}
/>
</label>
<label>
검색 범위
<select value={scope} onChange={(e) => setScope(e.target.value)}>
<option value="">전체 교재</option>
{documents.map((d) => (
<option key={d.id} value={d.id}>
{d.title}
</option>
))}
</select>
</label>
<button
className="button primary"
disabled={busy || query.trim().length < 2}
onClick={search}
>
{busy ? "검색 중…" : "본문 검색"}
</button>
</div>
{error && (
<p className="alert" role="alert">
{error}
</p>
)}
<p role="status" className="muted">
{message}
</p>
<div className="library-layout">
<aside className="panel book-list" data-catalog-open={catalogOpen}>
<h2>교재 목록 · {documents.length}</h2>
<button
className="button catalog-toggle"
aria-expanded={catalogOpen}
onClick={() => setCatalogOpen(!catalogOpen)}
>
{catalogOpen ? "교재 목록 접기" : "교재 선택·순서 변경"}
</button>
<div className="book-list-content">
<label>
제목 찾기
<input
value={filter}
onChange={(e) => setFilter(e.target.value)}
/>
</label>
<p className="muted">
마우스로 끌거나 ↑↓로 이동합니다.{" "}
{user?.role === "admin" ? "기관 공통 순서" : "이 브라우저의 순서"}
</p>
<div className="book-items">
{documents
.filter((d) => d.title.includes(filter))
.map((d) => (
<div
className={`book-row ${dragged === d.id ? "dragging" : ""}`}
key={d.id}
data-document-id={d.id}
draggable={!ordering}
onDragStart={(e) => {
setDragged(d.id);
e.dataTransfer.setData("text/plain", d.id);
e.dataTransfer.effectAllowed = "move";
}}
onDragEnd={() => setDragged("")}
onDragOver={(e) => {
e.preventDefault();
e.dataTransfer.dropEffect = "move";
}}
onDrop={(e) => {
e.preventDefault();
void move(e.dataTransfer.getData("text/plain"), d.id);
}}
>
<button
className={`book-item ${d.id === selected.id ? "selected" : ""}`}
key={d.id}
onClick={() => open(d.id, 1)}
>
<b>{d.title}</b>
<span>
{d.pageCount}페이지 ·{" "}
{d.source === "uploaded"
? "기관 업로드"
: "공공누리 1유형"}
</span>
</button>
<div className="book-order">
<button
aria-label={`${d.title} 위로 이동`}
disabled={ordering || documents[0].id === d.id}
onClick={() =>
move(
d.id,
documents[
documents.findIndex((x) => x.id === d.id) - 1
].id,
)
}
>
↑
</button>
<button
aria-label={`${d.title} 아래로 이동`}
disabled={ordering || documents.at(-1)?.id === d.id}
onClick={() =>
move(
d.id,
documents[
documents.findIndex((x) => x.id === d.id) + 1
].id,
)
}
>
↓
</button>
</div>
</div>
))}
</div>
{user?.role === "admin" ? (
<LibraryUpload
onStarted={() => {
uploadEpoch.current = ++catalogEpoch.current;
}}
onError={(value) => {
if (
mounted.current &&
uploadEpoch.current === catalogEpoch.current
)
setError(value);
}}
onUploaded={(data) => {
if (!mounted.current) return;
if (uploadEpoch.current !== catalogEpoch.current) {
void loadCatalog();
return;
}
catalogEpoch.current++;
applyCatalog(data);
selectedId.current = data.document.id;
pageEpoch.current++;
setSelected(data.document);
setPage(1);
setPageInput("1");
setTab("html");
setText("");
setPageBusy(true);
setCatalogOpen(false);
setResults([]);
setMessage(
"PDF와 HTML을 저장하고 목록 맨 뒤에 추가했습니다.",
);
}}
/>
) : (
<a className="button upload-login" href="/station">
PDF 추가 · 관리자 로그인
</a>
)}
<h3>북마크</h3>
{bookmarks.map((b) => (
<div className="bookmark" key={b.documentId + b.page}>
<button onClick={() => open(b.documentId, b.page)}>
{b.title} · p.{b.page}
</button>
<button
aria-label={`${b.title} ${b.page}페이지 북마크 삭제`}
onClick={() => {
const next = bookmarks.filter((x) => x !== b);
setBookmarks(next);
localStorage.setItem(bookmarkKey, JSON.stringify(next));
}}
>
×
</button>
</div>
))}
</div>
</aside>
<section className="library-main">
{results.length > 0 && (
<div className="panel search-results">
<h2>본문 검색 결과</h2>
{results.map((r) => (
<button
className="search-hit"
key={r.documentId + r.page}
onClick={() => open(r.documentId, r.page)}
>
<b>
{r.title} · PDF {r.page}페이지
</b>
<span>{libraryHighlight(r.snippet, searched)}</span>
</button>
))}
</div>
)}
<div className="panel library-reader">
<h2>{selected.title}</h2>
<div
className="library-tabs"
role="tablist"
aria-label="교재 보기 형식"
>
<button
id="library-tab-html"
className="button"
role="tab"
aria-selected={tab === "html"}
aria-controls="library-view"
onClick={() => setTab("html")}
>
HTML
</button>
<button
id="library-tab-pdf"
className="button"
role="tab"
aria-selected={tab === "pdf"}
aria-controls="library-view"
onClick={() => setTab("pdf")}
>
PDF
</button>
</div>
<form
className="actions page-navigation"
onSubmit={(e) => {
e.preventDefault();
go(Number(pageInput));
}}
>
<button
type="button"
className="button"
aria-label="이전 페이지"
disabled={page === 1}
onClick={() => go(page - 1)}
>
← 이전
</button>
<label>
PDF 페이지
<input
type="number"
min={1}
max={selected.pageCount}
value={pageInput}
onChange={(e) => setPageInput(e.target.value)}
/>
</label>
<button type="submit" className="button">
페이지 이동
</button>
<span>
{page} / {selected.pageCount}
</span>
<button
type="button"
className="button"
aria-label="다음 페이지"
disabled={page === selected.pageCount}
onClick={() => go(page + 1)}
>
다음 →
</button>
</form>
<div
id="library-view"
role="tabpanel"
aria-labelledby={`library-tab-${tab}`}
>
{tab === "html" ? (
<article
ref={htmlRef}
className="html-page"
aria-label="HTML 페이지 본문"
aria-busy={pageBusy}
data-page={page}
>
<header>
<span>{selected.publisher}</span>
<b>
{page}페이지 / {selected.pageCount}
</b>
</header>
{pageBusy ? (
<p>페이지를 불러오는 중…</p>
) : (
<pre>
{text
? libraryHighlight(text, searched)
: "추출 가능한 텍스트가 없습니다. PDF 탭에서 원문을 확인하세요."}
</pre>
)}
</article>
) : (
<>
<iframe
key={selected.id + page}
className="pdf-viewer"
title="공식 교재 PDF 원문"
src={`${selected.pdfUrl}#page=${page}`}
/>
<p className="muted">
PDF가 표시되지 않으면 전문·선택 페이지 보기를 이용하세요.
</p>
</>
)}
</div>
<div className="actions reader-actions">
<a
className="button"
href={selected.htmlUrl ?? `/library/${selected.id}.html`}
download={`${selected.title}.html`}
>
전체 HTML 다운로드
</a>
<a
className="button"
href={`${selected.pdfUrl}#page=${page}`}
target="_blank"
rel="noreferrer"
>
전문·선택 페이지 보기 ↗
</a>
<a
className="button"
href={selected.pdfUrl}
download={selected.filename}
>
원문 PDF 다운로드
</a>
<button className="button" onClick={bookmark}>
북마크
</button>
</div>
<p className="muted">
HTML은 추출 본문입니다. 표·그림·스캔·수치의 기준은 PDF 원문입니다.
</p>
<details>
<summary>원문 출처·이용 조건·무결성 정보</summary>
<p>
{selected.publisher} · 게시일 {selected.publishedAt} ·{" "}
{selected.bytes.toLocaleString()}바이트
</p>
{selected.source !== "uploaded" && (
<p>
<a
className="text-link"
href={selected.postUrl}
target="_blank"
rel="noreferrer"
>
공식 게시글 ↗
</a>{" "}
·{" "}
<a
className="text-link"
href={selected.licenseUrl}
target="_blank"
rel="noreferrer"
>
공공누리 1유형 조건 ↗
</a>
</p>
)}
<p>{selected.licenseStatement}</p>
<p className="hash">SHA-256: {selected.sha256}</p>
{selected.source !== "uploaded" ? (
<p>
출처 표시, 상업적 이용·변경 가능. 기관의 후원이나 공식
승인으로 표시하지 않습니다.
</p>
) : (
<p>기관 내부 업로드 · 공식 검증 교재가 아닙니다.</p>
)}
</details>
</div>
<div className="panel">
<h2>페이지 텍스트·복사</h2>
<p className="muted">
추출 과정에서 표·그림이나 글자 순서가 달라질 수 있습니다. 선택하지
않으면 페이지 전체를 복사합니다.
</p>
<details>
<summary>페이지 텍스트 펼치기</summary>
<textarea
ref={textRef}
aria-label="페이지 텍스트"
readOnly
value={text}
className="page-text"
/>
</details>
<div className="actions">
<button
className="button"
disabled={!text}
onClick={() => copy()}
>
페이지·출처 복사
</button>
<button
className="button"
disabled={!text}
onClick={() => copy(true)}
>
선택 내용·출처 복사
</button>
</div>
</div>
<div className="panel">
<h2>해설·활용 안내</h2>
<p className="badge">자체 작성 해설 · 소방 전문가 검수 전</p>
<p>{explanation}</p>
<p>
PDF 페이지와 인쇄된 쪽수는 다를 수 있습니다. 지휘 근거로 사용할
때는 원문을 읽고 문서명·판본·페이지·검토자·검토 메모를 기관 관리
화면에 등록하세요.
</p>
<a className="text-link" href="/station">
기관 검토 근거 등록 →
</a>
</div>
</section>
</div>
</main>
);
}
apps/web/components/library-upload.tsx
"use client";
import { useState, useEffect, useRef } from "react";
import type { LibraryDocument } from "../../../packages/library";
export function LibraryUpload({
onUploaded,
onError,
onStarted,
}: {
onUploaded: (data: {
document: LibraryDocument;
documents: LibraryDocument[];
version: number;
}) => void;
onError: (error: string) => void;
onStarted: () => void;
}) {
const [busy, setBusy] = useState(false),
[title, setTitle] = useState("");
const active = useRef(true),
abort = useRef<AbortController | null>(null);
useEffect(() => {
active.current = true;
return () => {
active.current = false;
abort.current?.abort();
};
}, []);
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
const form = event.currentTarget,
data = new FormData(form);
onStarted();
const file = data.get("file");
if (!(file instanceof File) || !file.size || file.size > 64 * 1024 * 1024) {
onError("64MiB 이하 PDF를 선택하세요.");
return;
}
setBusy(true);
onError("");
const controller = new AbortController();
abort.current = controller;
try {
const response = await fetch("/api/library/upload", {
method: "POST",
body: data,
signal: controller.signal,
}),
value = await response.json();
if (!active.current || controller.signal.aborted) return;
if (!response.ok) throw Error(value.error);
onUploaded(value);
form.reset();
setTitle("");
} catch (e) {
if (active.current && !controller.signal.aborted)
onError((e as Error).message);
} finally {
if (active.current) setBusy(false);
}
}
return (
<details className="library-upload">
<summary>PDF 교재·매뉴얼 추가</summary>
<form onSubmit={submit} className="upload-fields">
<label>
추가할 PDF
<input
name="file"
type="file"
accept="application/pdf,.pdf"
required
disabled={busy}
onChange={(e) =>
setTitle(e.target.files?.[0]?.name.replace(/\.pdf$/i, "") ?? "")
}
/>
</label>
<label>
교재 제목
<input
name="title"
required
maxLength={120}
value={title}
onChange={(e) => setTitle(e.target.value)}
disabled={busy}
/>
</label>
<label>
발행기관
<input name="publisher" required maxLength={100} disabled={busy} />
</label>
<label>
이용 조건
<input
name="licenseStatement"
required
maxLength={500}
defaultValue="기관 내부 교육용"
disabled={busy}
/>
</label>
<p className="muted">
같은 기관의 로그인 사용자에게 공유합니다. PDF64MiB·1500페이지 이내.
HTML은 추출 본문이며 스캔의 OCR은 제공하지 않습니다.
</p>
<button className="button primary" disabled={busy}>
{busy ? "PDF 업로드·HTML 변환 중…" : "PDF 추가 · HTML 자동 생성"}
</button>
</form>
</details>
);
}
apps/web/components/nav.tsx
"use client";
import { useState } from "react";
import Link from "next/link";
import { Flame, ArrowUpRight, Menu, X } from "lucide-react";
export function Nav({ active = "" }: { active?: string }) {
const [open, setOpen] = useState(false);
return (
<header className="site-nav">
<Link className="brand" href="/" aria-label="AI Firestation 홈">
<span className="brand-mark">
<Flame size={21} />
</span>
<span>
AI <b>Firestation</b>
<small>COMMAND / TRAIN / REVIEW</small>
</span>
</Link>
<button
className="nav-toggle"
aria-label={open ? "메뉴 닫기" : "메뉴 열기"}
aria-expanded={open}
aria-controls="site-menu"
onClick={() => setOpen(!open)}
>
{open ? <X size={20} /> : <Menu size={20} />} 메뉴
</button>
<nav
id="site-menu"
className={open ? "menu-open" : ""}
aria-label="주 메뉴"
onClick={() => setOpen(false)}
>
<Link
aria-current={active === "demo" ? "page" : undefined}
href="/demo"
>
가상 상황본부
</Link>
<Link
aria-current={active === "manuals" ? "page" : undefined}
href="/manuals"
>
매뉴얼 카드
</Link>
<Link
aria-current={active === "station" ? "page" : undefined}
href="/station"
>
기관용 지휘 지원
</Link>
<Link
aria-current={active === "library" ? "page" : undefined}
href="/library"
>
2026 소방 교재
</Link>
<a className="nav-note" href="/demo">
교육 시연 <ArrowUpRight size={14} />
</a>
<Link
aria-current={active === "help" ? "page" : undefined}
href="/help"
>
사용설명서
</Link>
</nav>
</header>
);
}
apps/web/components/request-id.ts
// HTTP LAN에서도 제공되는 암호학적 난수로 서버의 UUID v4 계약을 유지한다.
export function newRequestId(): string {
if (typeof crypto.randomUUID === "function") return crypto.randomUUID();
const bytes = crypto.getRandomValues(new Uint8Array(16));
bytes[6] = (bytes[6] & 0x0f) | 0x40;
bytes[8] = (bytes[8] & 0x3f) | 0x80;
const hex = Array.from(bytes, (b) => b.toString(16).padStart(2, "0")).join(
"",
);
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-${hex.slice(12, 16)}-${hex.slice(16, 20)}-${hex.slice(20)}`;
}
apps/web/components/station-room.tsx
"use client";
import {
useState,
useEffect,
useRef,
type FormEvent,
type ReactNode,
} from "react";
import { roles, roleNames, type User } from "../../../packages/station/types";
import type {
Incident,
Resource,
Evidence,
Command,
} from "../../../packages/station/service";
import { newRequestId } from "./request-id";
type Doc = { id: string; title: string; pageCount: number };
type Snapshot = {
user: User;
users: User[];
incidents: Incident[];
resources: Resource[];
evidence: Evidence[];
suggestions: Record<
string,
{ risk: string; question: string; topic: string; label: string }[]
>;
};
const statusNames = {
draft: "초안",
issued: "발행",
complete: "완료",
cancelled: "취소",
pending: "수신 대기",
acknowledged: "수신 확인",
executing: "수행 중",
cancelConfirmed: "취소 확인",
};
function Field({
label,
name,
type = "text",
options,
required = true,
}: {
label: string;
name: string;
type?: string;
options?: { id: string; name: string }[];
required?: boolean;
}) {
return (
<label>
{label}
{options ? (
<select name={name} required={required}>
<option value="">선택하세요</option>
{options.map((o) => (
<option value={o.id} key={o.id}>
{o.name}
</option>
))}
</select>
) : type === "textarea" ? (
<textarea name={name} required={required} maxLength={4000} />
) : (
<input
name={name}
required={required}
type={type}
min={type === "number" ? 1 : undefined}
minLength={type === "password" ? 12 : undefined}
maxLength={type === "password" ? 200 : 500}
autoComplete={type === "password" ? "new-password" : "off"}
/>
)}
</label>
);
}
function InputForm({
children,
submit,
busy,
onSubmit,
}: {
children: ReactNode;
submit: string;
busy: boolean;
onSubmit: (data: FormData) => Promise<boolean>;
}) {
return (
<form
onSubmit={async (e: FormEvent<HTMLFormElement>) => {
e.preventDefault();
const form = e.currentTarget;
if (await onSubmit(new FormData(form))) form.reset();
}}
>
<fieldset disabled={busy}>
{children}
<button className="button primary" type="submit">
{submit}
</button>
</fieldset>
</form>
);
}
const value = (data: FormData, name: string) => String(data.get(name) ?? "");
export function StationRoom({ documents }: { documents: Doc[] }) {
const [configured, setConfigured] = useState<boolean | null>(null),
[user, setUser] = useState<User | null>(null),
[snapshot, setSnapshot] = useState<Snapshot | null>(null),
[selected, setSelected] = useState(""),
[tab, setTab] = useState<"command" | "admin">("command"),
[error, setError] = useState(""),
[message, setMessage] = useState(""),
[busy, setBusy] = useState(false),
[updated, setUpdated] = useState("");
const [mobilePanel, setMobilePanel] = useState<string | null>(null);
const [connectionError, setConnectionError] = useState("");
const loadSeq = useRef(0),
authEpoch = useRef(0),
loadController = useRef<AbortController | null>(null),
busyRef = useRef(false);
async function refresh() {
const seq = ++loadSeq.current;
loadController.current?.abort();
const controller = new AbortController();
loadController.current = controller;
try {
const r = await fetch("/api/station", {
cache: "no-store",
signal: controller.signal,
}),
data = await r.json();
if (seq !== loadSeq.current) return;
if (!r.ok) {
if (r.status === 401) {
setUser(null);
setSnapshot(null);
}
throw Error(data.error);
}
setSnapshot(data);
setUser(data.user);
setSelected((current) =>
current && data.incidents.some((i: Incident) => i.id === current)
? current
: (data.incidents[0]?.id ?? ""),
);
setUpdated(new Date().toLocaleTimeString("ko-KR"));
setConnectionError("");
} catch (e) {
if (seq !== loadSeq.current || (e as Error).name === "AbortError") return;
setConnectionError((e as Error).message);
}
}
useEffect(() => {
let live = true;
const epoch = authEpoch.current;
fetch("/api/station/auth", { cache: "no-store" })
.then(async (r) => {
const d = await r.json();
if (!live || epoch !== authEpoch.current) return;
if (!r.ok) throw Error(d.error);
setConfigured(d.configured);
setUser(d.user);
if (d.user) void refresh();
})
.catch((e) => {
if (live && epoch === authEpoch.current) setError(e.message);
});
return () => {
live = false;
loadController.current?.abort();
};
}, []);
useEffect(() => {
if (!user) return;
const timer = setInterval(() => {
if (!busyRef.current) void refresh();
}, 5000);
const resume = () => {
if (!busyRef.current && document.visibilityState === "visible")
void refresh();
};
window.addEventListener("online", resume);
document.addEventListener("visibilitychange", resume);
return () => {
clearInterval(timer);
window.removeEventListener("online", resume);
document.removeEventListener("visibilitychange", resume);
};
}, [user?.id]);
async function request(url: string, body: unknown): Promise<boolean> {
if (busyRef.current) return false;
busyRef.current = true;
if (url.endsWith("/auth")) {
authEpoch.current++;
loadSeq.current++;
loadController.current?.abort();
}
setBusy(true);
setError("");
setMessage("");
try {
const r = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(body),
}),
d = await r.json();
if (!r.ok) throw Error(d.error);
if (url.endsWith("/auth")) {
if ((body as { type: string }).type === "logout") {
setUser(null);
setSnapshot(null);
setSelected("");
setMobilePanel(null);
setConnectionError("");
setUpdated("");
return true;
}
setUser(d.user);
setConfigured(true);
}
if (d.value?.title) setSelected(d.value.id);
await refresh();
setMessage("요청을 반영했습니다.");
return true;
} catch (e) {
setError((e as Error).message);
if ((e as Error).message.includes("충돌")) await refresh();
return false;
} finally {
setBusy(false);
busyRef.current = false;
}
}
function create(type: string, input: unknown) {
return request("/api/station", { type, input });
}
const incident = snapshot?.incidents.find((i) => i.id === selected),
prepare =
!!user && ["admin", "commander", "dispatcher"].includes(user.role),
leader =
!!user &&
!!incident &&
(user.role === "admin" ||
(user.role === "commander" && incident.commanderId === user.id)),
open = incident?.status === "open";
function act(input: Record<string, unknown>) {
return incident
? request(`/api/station/incidents/${incident.id}`, {
...input,
version: incident.version,
requestId: newRequestId(),
})
: Promise.resolve(false);
}
const people = snapshot?.users ?? [],
commanders = people
.filter(
(u) => u.active !== false && ["admin", "commander"].includes(u.role),
)
.map((u) => ({ id: u.id, name: u.displayName }));
function commandView(c: Command) {
return (
<article key={c.id} className="station-command">
<div className="command-title">
<b>{statusNames[c.status]}</b>
<time>{new Date(c.createdAt).toLocaleString("ko-KR")}</time>
</div>
<h3>{c.text}</h3>
<p className="muted">
근거{" "}
{c.evidenceIds
.map((id) => {
const e = snapshot?.evidence.find((e) => e.id === id);
return e ? `${e.documentTitle} p.${e.page} (${e.reviewer})` : id;
})
.join(" / ")}
</p>
{c.basis !== incident!.observationRevision && c.status === "draft" && (
<p className="alert">
새 관찰이 있습니다. 현재 관찰을 읽고 재검토하세요.
</p>
)}
<div className="recipient-list">
{c.recipients.map((r) => (
<div key={r.resourceId}>
<b>
{snapshot?.resources.find((x) => x.id === r.resourceId)?.name ??
r.resourceId}
</b>
<span>{statusNames[r.status]}</span>
{user?.id === r.userId && user.role === "crew" && open && (
<div className="actions">
{c.status === "issued" && r.status === "pending" && (
<button
disabled={busy}
className="button"
onClick={() =>
act({
type: "ack",
commandId: c.id,
resourceId: r.resourceId,
})
}
>
수신 확인
</button>
)}
{c.status === "issued" && r.status === "acknowledged" && (
<button
disabled={busy}
className="button"
onClick={() =>
act({
type: "execute",
commandId: c.id,
resourceId: r.resourceId,
})
}
>
수행 시작
</button>
)}
{c.status === "issued" && r.status === "executing" && (
<button
disabled={busy}
className="button primary"
onClick={() =>
act({
type: "complete",
commandId: c.id,
resourceId: r.resourceId,
})
}
>
완료 보고
</button>
)}
{c.status === "cancelled" &&
r.status !== "cancelConfirmed" && (
<button
disabled={busy}
className="button"
onClick={() =>
act({
type: "cancelAck",
commandId: c.id,
resourceId: r.resourceId,
})
}
>
취소 확인
</button>
)}
</div>
)}
</div>
))}
</div>
{leader && open && (
<div className="actions">
{c.status === "draft" && (
<>
{c.basis !== incident!.observationRevision && (
<button
className="button"
disabled={busy}
onClick={() => act({ type: "review", commandId: c.id })}
>
관찰 확인·재검토
</button>
)}
<button
className="button primary"
disabled={busy || c.basis !== incident!.observationRevision}
onClick={() => act({ type: "approve", commandId: c.id })}
>
승인·발행
</button>
</>
)}
{["draft", "issued"].includes(c.status) && (
<button
className="button"
disabled={busy}
onClick={() => act({ type: "cancel", commandId: c.id })}
>
명령 취소
</button>
)}
</div>
)}
</article>
);
}
return (
<main
className={`portal station-portal ${user ? "station-workspace" : ""}`}
data-role={user?.role}
data-mobile-panel={
mobilePanel ?? (user?.role === "crew" ? "orders" : "context")
}
>
<div className="portal-heading">
<div>
<p className="eyebrow">STATION / HUMAN COMMAND</p>
<h1>기관용 지휘 지원</h1>
<p>사람이 승인하고, 대상 대원이 보고하며, 변경 이력을 보존합니다.</p>
</div>
{user && (
<div className="actions">
<span className="badge">{roleNames[user.role]} · 기관 연결됨</span>
<span>{user.displayName}</span>
<button className="button" disabled={busy} onClick={refresh}>
새로고침
</button>
<button
className="button"
disabled={busy}
onClick={() => request("/api/station/auth", { type: "logout" })}
>
로그아웃
</button>
</div>
)}
</div>
<div className="notice">
교육 시연과 별도 데이터입니다. 실제 119·무전망·드론·차량 제어는 연결되지
않았습니다. AI는 확인 제안만 하며 현장 판단과 명령 승인은 사람이
수행합니다.
</div>
{error && (
<p className="alert" role="alert">
{error}
</p>
)}
<p
role="status"
className={`station-sync ${connectionError ? "sync-stale" : "muted"}`}
>
{message}
{user
? connectionError
? `갱신 실패 · ${updated ? `마지막 성공 ${updated}` : "아직 조회하지 못했습니다"} · ${connectionError}`
: updated
? ` · 마지막 조회 ${updated} · 5초마다 갱신`
: "연결 확인 중"
: ""}
</p>
{configured === null ? (
<p>기관 설정을 확인하고 있습니다.</p>
) : !user ? (
<section className="panel auth-panel">
<h2>{configured ? "로그인" : "최초 관리자 설정"}</h2>
<p>
{configured
? "기관에 등록된 계정으로 로그인하세요."
: "로컬 설치에서 한 번만 설정합니다. 기본 계정이나 기본 암호는 없습니다."}
</p>
<InputForm
busy={busy}
submit={configured ? "로그인" : "최초 관리자 설정"}
onSubmit={(d) =>
request("/api/station/auth", {
type: configured ? "login" : "setup",
input: {
...(configured
? {}
: {
stationName: value(d, "stationName"),
setupKey: value(d, "setupKey"),
displayName: value(d, "displayName"),
}),
username: value(d, "username"),
password: value(d, "password"),
},
})
}
>
{!configured && (
<>
<Field label="기관명" name="stationName" />
<p className="muted">
로컬 터미널에서 npm run setup:key를 실행한 뒤 15분 안에
입력하세요.
</p>
<Field label="설정 키" name="setupKey" type="password" />
<Field label="표시 이름" name="displayName" />
</>
)}
<Field label="사용자명" name="username" />
<Field label="암호" name="password" type="password" />
</InputForm>
<p className="muted">
암호 12~200자. 사용자명은 영문·숫자·밑줄·하이픈 3~64자입니다.
</p>
</section>
) : snapshot ? (
<>
<div className="actions station-tabs">
<button
className={`button ${tab === "command" ? "primary" : ""}`}
onClick={() => setTab("command")}
>
사건 지휘
</button>
{user.role === "admin" && (
<button
className={`button ${tab === "admin" ? "primary" : ""}`}
onClick={() => setTab("admin")}
>
기관 관리
</button>
)}
{["admin", "commander", "auditor"].includes(user.role) && (
<a className="button" href="/api/station/audit" download>
감사·사건 내보내기
</a>
)}
</div>
{tab === "admin" && user.role === "admin" ? (
<div className="admin-grid">
<section className="panel">
<h2>계정·역할</h2>
<InputForm
busy={busy}
submit="사용자 등록"
onSubmit={(d) =>
create("user", {
username: value(d, "username"),
displayName: value(d, "displayName"),
password: value(d, "password"),
role: value(d, "role"),
})
}
>
<Field label="새 사용자명" name="username" />
<Field label="새 표시 이름" name="displayName" />
<Field label="새 암호" name="password" type="password" />
<Field
label="새 역할"
name="role"
options={roles.map((r) => ({ id: r, name: roleNames[r] }))}
/>
</InputForm>
<p className="muted">
계정을 변경하면 해당 사용자의 모든 세션을 폐기합니다. 마지막
활성 관리자는 해제할 수 없습니다. 배정 임무와 기록은
보존됩니다.
</p>
{people.map((u) => (
<details key={`${u.id}-${u.role}-${u.active}`}>
<summary>
{u.displayName} · {roleNames[u.role]} ·{" "}
{u.active === false ? "비활성" : "활성"}
</summary>
<InputForm
busy={busy}
submit={`${u.displayName} 계정 변경`}
onSubmit={(d) =>
create("updateUser", {
userId: u.id,
role: value(d, "role"),
active: value(d, "active") === "true",
...(value(d, "password")
? { password: value(d, "password") }
: {}),
})
}
>
<label>
계정 역할 {u.displayName}
<select name="role" defaultValue={u.role}>
{roles.map((r) => (
<option key={r} value={r}>
{roleNames[r]}
</option>
))}
</select>
</label>
<label>
계정 상태 {u.displayName}
<select
name="active"
defaultValue={u.active === false ? "false" : "true"}
>
<option value="true">활성</option>
<option value="false">비활성</option>
</select>
</label>
<Field
label={`재설정 암호 ${u.displayName}`}
name="password"
type="password"
required={false}
/>
<p className="muted">
암호를 비워 두면 기존 암호를 유지합니다.
</p>
</InputForm>
</details>
))}
</section>
<section className="panel">
<h2>기관 자원</h2>
<InputForm
busy={busy}
submit="자원 등록"
onSubmit={(d) =>
create("resource", {
name: value(d, "name"),
kind: value(d, "kind"),
crewUserId: value(d, "crewUserId"),
})
}
>
<Field label="자원 이름" name="name" />
<Field label="자원 종류" name="kind" />
<Field
label="담당 대원"
name="crewUserId"
options={people
.filter((u) => u.active !== false && u.role === "crew")
.map((u) => ({ id: u.id, name: u.displayName }))}
/>
</InputForm>
<ul>
{snapshot.resources.map((r) => (
<li key={r.id}>
{r.name} · {r.kind} · {r.incidentId ? "배정됨" : "대기"}
</li>
))}
</ul>
</section>
<section className="panel">
<h2>기관 검토 근거</h2>
<p className="muted">
원문 확인 후 기관 적용성을 검토해 등록하세요. 교육 카드 초안은
사용할 수 없습니다.
</p>
<InputForm
busy={busy}
submit="검토 근거 등록"
onSubmit={(d) =>
create("evidence", {
documentId: value(d, "documentId"),
page: Number(value(d, "page")),
reviewer: value(d, "reviewer"),
notes: value(d, "notes"),
})
}
>
<Field
label="근거 교재"
name="documentId"
options={documents.map((d) => ({
id: d.id,
name: d.title,
}))}
/>
<Field label="PDF 페이지" name="page" type="number" />
<Field label="검토자" name="reviewer" />
<Field label="검토 메모" name="notes" type="textarea" />
</InputForm>
{snapshot.evidence.map((e) => (
<p key={e.id}>
<a
className="text-link"
href={`/library/${e.documentId}.pdf#page=${e.page}`}
target="_blank"
rel="noreferrer"
>
{e.documentTitle} p.{e.page}
</a>{" "}
· {e.reviewer}
<br />
{e.notes}
</p>
))}
</section>
</div>
) : (
<>
<nav className="workspace-switcher" aria-label="기관 화면 선택">
{[
["orders", "명령·보고"],
["context", "현장 상황"],
["incidents", "사건 목록"],
].map(([key, label]) => (
<button
key={key}
type="button"
aria-pressed={
(mobilePanel ??
(user.role === "crew" ? "orders" : "context")) === key
}
onClick={() => setMobilePanel(key)}
>
{label}
</button>
))}
</nav>
{incident && (
<div
className="station-mobile-summary"
tabIndex={0}
aria-label="선택한 사건 위치"
>
<strong>{incident.title}</strong>
<span>
{incident.address} ·{" "}
{incident.status === "open" ? "진행" : "종료"}
</span>
</div>
)}
<div className="station-layout">
<aside
className="panel station-incidents"
tabIndex={0}
aria-label="사건 목록 패널"
>
<h2>사건 목록</h2>
{snapshot.incidents.map((i) => (
<button
className={`book-item ${i.id === selected ? "selected" : ""}`}
key={i.id}
onClick={() => {
setSelected(i.id);
setMobilePanel(null);
}}
>
<b>{i.title}</b>
<span>
{i.status === "open" ? "진행" : "종료"} · v{i.version}
</span>
</button>
))}
{prepare && (
<details open={!snapshot.incidents.length}>
<summary>새 사건 등록</summary>
<InputForm
busy={busy}
submit="사건 등록"
onSubmit={(d) =>
create("incident", {
title: value(d, "title"),
address: value(d, "address"),
report: value(d, "report"),
commanderId: value(d, "commanderId"),
})
}
>
<Field label="사건 제목" name="title" />
<Field label="위치" name="address" />
<Field
label="신고 내용"
name="report"
type="textarea"
/>
<label>
담당 지휘관
<select
name="commanderId"
required
defaultValue={
commanders.some((u) => u.id === user.id)
? user.id
: ""
}
>
<option value="">선택하세요</option>
{commanders.map((u) => (
<option value={u.id} key={u.id}>
{u.name}
</option>
))}
</select>
</label>
</InputForm>
</details>
)}
</aside>
<div className="station-main" key={incident?.id ?? "empty"}>
{incident ? (
<>
<div
className="station-context"
tabIndex={0}
aria-label="현장 상황 패널"
>
<section className="panel">
<div className="command-title">
<h2>{incident.title}</h2>
<span className="badge">
{incident.status === "open" ? "진행" : "종료"}
</span>
</div>
<p>{incident.address}</p>
<p>{incident.report}</p>
<p className="muted">
담당 지휘관:{" "}
{people.find((u) => u.id === incident.commanderId)
?.displayName ?? "기관 담당 지휘관"}{" "}
· 사건 v{incident.version} · 관찰 v
{incident.observationRevision}
</p>
{leader && open && (
<div className="actions">
<InputForm
busy={busy}
submit="지휘권 인계"
onSubmit={(d) =>
act({
type: "handover",
commanderId: value(d, "commanderId"),
})
}
>
<Field
label="인계 지휘관"
name="commanderId"
options={commanders.filter(
(u) => u.id !== incident.commanderId,
)}
/>
</InputForm>
<button
className="button"
disabled={busy}
onClick={() => act({ type: "close" })}
>
사건 종료
</button>
</div>
)}
</section>
<section className="panel">
<h2>확인 지원</h2>
<p className="muted">
규칙 기반 확인 제안 · 물질·인명·통전 상태를 확정하지
않습니다.
</p>
{(snapshot.suggestions[incident.id] ?? []).map(
(s) => (
<div className="suggestion" key={s.risk}>
<b>{s.risk}</b>
<p>{s.question}</p>
<a
className="text-link"
href={`/library?query=${encodeURIComponent(s.topic)}`}
>
관련 교재 찾기 →
</a>
</div>
),
)}
{!(snapshot.suggestions[incident.id] ?? [])
.length && (
<p>
특정 위험 후보를 찾지 못했습니다. 현장 보고와 승인
근거를 확인하세요.
</p>
)}
</section>
<section className="panel">
<h2>관찰·현장 보고</h2>
{incident.observations.map((o) => (
<div className="observation-row" key={o.id}>
<b>
{o.certainty === "confirmed"
? "확인 보고"
: o.certainty === "suspected"
? "의심"
: "미확인"}{" "}
· {o.source}
</b>
<p>{o.text}</p>
<time>
{new Date(o.at).toLocaleString("ko-KR")}
</time>
</div>
))}
{prepare && open && (
<InputForm
busy={busy}
submit="관찰 추가"
onSubmit={(d) =>
act({
type: "observe",
text: value(d, "text"),
source: value(d, "source"),
certainty: value(d, "certainty"),
})
}
>
<Field
label="관찰 내용"
name="text"
type="textarea"
/>
<Field label="보고 출처" name="source" />
<Field
label="확인 상태"
name="certainty"
options={[
{ id: "unconfirmed", name: "미확인" },
{ id: "suspected", name: "의심" },
{ id: "confirmed", name: "현장 확인 보고" },
]}
/>
</InputForm>
)}
</section>
<section className="panel">
<h2>배정 자원</h2>
{snapshot.resources
.filter((r) => r.incidentId === incident.id)
.map((r) => (
<p key={r.id}>
{r.name} · {r.kind}
</p>
))}
{prepare && open && (
<InputForm
busy={busy}
submit="자원 배정"
onSubmit={(d) =>
act({
type: "assign",
resourceId: value(d, "resourceId"),
})
}
>
<Field
label="배정할 자원"
name="resourceId"
options={snapshot.resources
.filter((r) => !r.incidentId)
.map((r) => ({ id: r.id, name: r.name }))}
/>
</InputForm>
)}
</section>
</div>
<section
className="panel station-orders"
tabIndex={0}
aria-label="명령·보고 패널"
>
<h2>명령·대상별 보고</h2>
{incident.commands.slice().reverse().map(commandView)}
{prepare && open && (
<details open={!incident.commands.length}>
<summary>명령 초안 작성</summary>
<InputForm
busy={busy}
submit="명령 초안 저장"
onSubmit={(d) =>
act({
type: "draft",
text: value(d, "text"),
resourceIds: d
.getAll("resourceIds")
.map(String),
evidenceIds: d
.getAll("evidenceIds")
.map(String),
})
}
>
<Field
label="명령 내용"
name="text"
type="textarea"
/>
<p>대상 자원</p>
{snapshot.resources
.filter((r) => r.incidentId === incident.id)
.map((r) => (
<label className="check-label" key={r.id}>
<input
type="checkbox"
name="resourceIds"
value={r.id}
/>
대상 {r.name}
</label>
))}
<p>기관 검토 근거</p>
{snapshot.evidence.map((e) => (
<label className="check-label" key={e.id}>
<input
type="checkbox"
name="evidenceIds"
value={e.id}
/>
{e.documentTitle} p.{e.page} · {e.reviewer}
</label>
))}
{!snapshot.evidence.length && (
<p className="alert">
관리자가 기관 검토 근거를 먼저 등록해야
합니다.
</p>
)}
</InputForm>
</details>
)}
</section>
</>
) : (
<section className="panel">
<h2>조회할 사건이 없습니다.</h2>
<p>
{user.role === "crew"
? "배정된 자원의 사건이 표시됩니다. 상황요원에게 배정을 확인하세요."
: "새 사건을 등록하거나 기관 관리에서 계정·자원·근거를 준비하세요."}
</p>
</section>
)}
</div>
</div>
</>
)}
</>
) : (
<p>기관 데이터를 불러오고 있습니다.</p>
)}
</main>
);
}
apps/web/components/unreal-viewer-config.ts
export function validSceneSession(value: string): boolean {
return /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i.test(
value,
);
}
export function sceneSignalUrl(
value: string | undefined,
browserUrl?: string,
): string | null {
if (!value) return null;
try {
const url = new URL(value);
if (
!["ws:", "wss:"].includes(url.protocol) ||
url.username ||
url.password ||
url.hash
)
return null;
const local = (host: string) =>
/^(localhost|127(?:\.\d+){3}|\[::1\]|0\.0\.0\.0)$/i.test(host);
// 서버 설정 단계에서 루프백 주소를 배제해 휴대폰에 전달하지 않는다.
if (local(url.hostname)) return null;
if (
browserUrl &&
new URL(browserUrl).protocol === "https:" &&
url.protocol !== "wss:"
)
return null;
// 신호 서버가 첫 번째 스트리머를 임의로 구독하지 못하게 한다.
url.searchParams.set("autoSubscribe", "false");
return url.toString();
} catch {
return null;
}
}
import type {
Config,
AllSettings,
} from "@epicgames-ps/lib-pixelstreamingfrontend-ue5.8";
export function lockSceneConfig(
config: Config,
sessionId: string,
url: string,
): void {
const settings: Partial<AllSettings> = {
ss: url,
StreamerId: sessionId,
WaitForStreamer: true,
MaxReconnectAttempts: 0,
AutoConnect: false,
AutoPlayVideo: true,
StartVideoMuted: true,
UseMic: false,
UseCamera: false,
KeyboardInput: false,
MouseInput: false,
TouchInput: false,
GamepadInput: false,
XRControllerInput: false,
FakeMouseWithTouches: false,
MatchViewportRes: false,
TimeoutIfIdle: false,
};
config.setOptionSettingOptions("StreamerId", [sessionId]);
config.setSettings(settings);
}
apps/web/components/unreal-viewer-lifecycle.ts
// Epic frontend 0.1.2 VideoPlayer는 resize/orientationchange 리스너를 익명 등록하고
// destroy()에서 제거하지 않는다. 동기 생성 동안 등록된 리스너의 소유권을 추적한다.
export function ownWindowListeners<T>(
target: Window,
construct: () => T,
cleanup: (() => void)[],
): T {
const original = target.addEventListener;
target.addEventListener = function (
type: string,
listener: EventListenerOrEventListenerObject | null,
options?: boolean | AddEventListenerOptions,
) {
if (!listener) return;
cleanup.push(() => target.removeEventListener(type, listener, options));
return original.call(target, type, listener, options);
} as Window["addEventListener"];
try {
return construct();
} finally {
target.addEventListener = original;
}
}
apps/web/components/unreal-viewer-playback.ts
export type PlaybackState =
| "playing"
| "request"
| "rejected"
| "error"
| "disconnected"
| "pause"
| "waiting"
| "ended";
// SDK playStream은 video.play() 호출 전 발생한다. 실제 미디어 playing만 재생을 확정한다.
export function monitorScenePlayback(
video: EventTarget,
update: (state: PlaybackState) => void,
) {
let active = true;
const notify = (state: PlaybackState) => {
if (active) update(state);
};
const events = ["playing", "pause", "waiting", "ended", "error"] as const;
const listeners = events.map((state) => {
const listener = () => notify(state);
video.addEventListener(state, listener);
return () => video.removeEventListener(state, listener);
});
return {
suspend: notify,
dispose: () => {
active = false;
listeners.forEach((remove) => remove());
},
};
}
apps/web/components/unreal-viewer.tsx
"use client";
import { useEffect, useRef, useState } from "react";
import type { PixelStreaming } from "@epicgames-ps/lib-pixelstreamingfrontend-ue5.8";
import type { RestaurantScene } from "../../../packages/simulation/scene";
import {
lockSceneConfig,
sceneSignalUrl,
validSceneSession,
} from "./unreal-viewer-config";
import { ownWindowListeners } from "./unreal-viewer-lifecycle";
import { monitorScenePlayback } from "./unreal-viewer-playback";
const cameras = [
["overview", "전체 현장"],
["drone", "드론 시점"],
["vehicle", "차량 시점"],
["crew", "대원 시점"],
] as const;
export function UnrealViewer({
sessionId,
signalUrl,
}: {
sessionId: string;
signalUrl: string | null;
}) {
const parent = useRef<HTMLDivElement>(null);
const stream = useRef<PixelStreaming | null>(null);
const [scene, setScene] = useState<RestaurantScene | null>(null);
const [error, setError] = useState("");
const [connection, setConnection] = useState("UE 엔진 연결 대기");
const [playing, setPlaying] = useState(false);
const [channel, setChannel] = useState(false);
const [attempt, setAttempt] = useState(0);
const [camera, setCamera] = useState("overview");
const [gesture, setGesture] = useState(false);
useEffect(() => {
setScene(null);
setError("");
if (!validSceneSession(sessionId)) {
setError(
"올바른 사건 UUID가 필요합니다. 2D 상황본부에서 식당 사건을 선택하세요.",
);
return;
}
const abort = new AbortController();
let timer: ReturnType<typeof setTimeout>;
async function poll() {
try {
const response = await fetch(`/api/sessions/${sessionId}/scene`, {
cache: "no-store",
signal: abort.signal,
});
if (!response.ok)
throw Error(
response.status === 404
? "사건을 찾을 수 없습니다."
: response.status === 409
? "식당 화재 DEMO 사건만 UE5로 볼 수 있습니다."
: "사건 상태를 읽을 수 없습니다.",
);
const next: RestaurantScene = await response.json();
if (
next.sessionId !== sessionId ||
next.protocol !== "ai-firestation.restaurant.v1" ||
next.scenario.id !== "restaurant-gas"
)
throw Error("사건 장면 계약이 일치하지 않습니다.");
if (!abort.signal.aborted) {
setError("");
setScene(next);
timer = setTimeout(poll, 1000);
}
} catch (e) {
if (!abort.signal.aborted) {
setScene(null);
setError(e instanceof Error ? e.message : "사건 상태 연결 오류");
}
}
}
void poll();
return () => {
abort.abort();
clearTimeout(timer);
};
}, [sessionId, attempt]);
const ready = !!scene && !error;
useEffect(() => {
setPlaying(false);
setChannel(false);
setGesture(false);
setConnection("UE 엔진 연결 대기");
if (!ready || !signalUrl || !parent.current) return;
const url = sceneSignalUrl(signalUrl, window.location.href);
if (!url) {
setConnection(
"신호 서버 주소 오류: HTTPS에서는 WSS와 휴대폰에서 접근할 서버 주소가 필요합니다.",
);
return;
}
let disposed = false;
const cleanup: (() => void)[] = [];
const host = parent.current;
void (async () => {
try {
const { Config, PixelStreaming: Player } =
await import("@epicgames-ps/lib-pixelstreamingfrontend-ue5.8");
if (disposed) return;
const config = new Config({
useUrlParams: false,
initialSettings: {
ss: url,
StreamerId: sessionId,
},
});
lockSceneConfig(config, sessionId, url);
// Epic 0.1.2는 익명 창 리스너를 해제하지 않으므로 동기 생성 중 등록분을 보관한다.
const player = ownWindowListeners(
window,
() => new Player(config, { videoElementParent: host }),
cleanup,
);
stream.current = player;
cleanup.push(() => {
player.disconnect();
clearTimeout(player.webRtcController.videoPlayer.resizeTimeoutHandle);
player.webRtcController.destroyVideoPlayer();
host.replaceChildren();
});
const listen = <
K extends Parameters<PixelStreaming["addEventListener"]>[0],
>(
type: K,
listener: () => void,
) => {
player.addEventListener(type, listener);
cleanup.push(() => player.removeEventListener(type, listener));
};
const playback = monitorScenePlayback(
player.webRtcController.videoPlayer.getVideoElement(),
(state) => {
setPlaying(false);
if (state === "playing") {
if (player.webRtcController.subscribedStream === sessionId) {
setPlaying(true);
setGesture(false);
setConnection("UE5 실제 영상 재생 중");
} else {
player.disconnect();
setConnection("다른 사건 영상 연결을 차단했습니다.");
}
} else if (
state === "pause" ||
state === "waiting" ||
state === "ended"
) {
setConnection("영상 재생이 멈췄습니다. 사건 종료와 별개입니다.");
} else if (state === "error") {
setConnection("영상 재생 오류. 연결 재시도를 눌러 주세요.");
}
},
);
cleanup.push(playback.dispose);
listen("playStream", () => playback.suspend("request"));
listen("playStreamRejected", () => {
playback.suspend("rejected");
setGesture(true);
setConnection("영상 재생 버튼을 눌러 시작하세요.");
});
listen("playStreamError", () => {
playback.suspend("error");
setConnection("영상 재생 오류. 연결 재시도를 눌러 주세요.");
});
listen("dataChannelOpen", () => setChannel(true));
listen("dataChannelClose", () => setChannel(false));
listen("webRtcDisconnected", () => {
playback.suspend("disconnected");
setChannel(false);
setConnection("영상 연결이 끊겼습니다. 사건 종료와 별개입니다.");
});
listen("webRtcFailed", () => {
playback.suspend("error");
setChannel(false);
setConnection("영상 연결 오류. 신호 서버와 네트워크를 확인하세요.");
});
listen("subscribeFailed", () => {
setPlaying(false);
setChannel(false);
setConnection("해당 사건의 UE 스트리머 연결 대기");
});
const changed = (event: { data: { newID: string } }) => {
if (event.data.newID !== sessionId) {
player.disconnect();
setPlaying(false);
setChannel(false);
setConnection("스트리머 사건 변경을 차단했습니다.");
}
};
player.addEventListener("streamerIDChangedMessage", changed);
cleanup.push(() =>
player.removeEventListener("streamerIDChangedMessage", changed),
);
setConnection("해당 사건의 UE 스트리머 연결 대기");
player.connect();
} catch {
if (!disposed)
setConnection(
"UE 영상 연결을 시작하지 못했습니다. 연결 재시도를 눌러 주세요.",
);
}
})();
return () => {
disposed = true;
stream.current = null;
cleanup.reverse().forEach((fn) => fn());
};
}, [ready, signalUrl, sessionId, attempt]);
return (
<main className="ue-viewer">
<header>
<div>
<p className="eyebrow">교육용 가상 훈련 · 읽기 전용</p>
<h1>UE5 식당 화재 현장</h1>
</div>
<a
className="button"
href={
validSceneSession(sessionId)
? `/demo?session=${sessionId}`
: "/demo"
}
>
2D 상황본부로 복귀
</a>
</header>
{error && (
<p role="alert" className="ue-error">
{error}
</p>
)}
<section className="ue-media" aria-label="UE5 스트리밍 영상">
<div ref={parent} className="ue-video" />
{!playing && (
<div className="ue-placeholder">
<strong>{connection}</strong>
<p>실제 UE5 영상은 엔진과 Pixel Streaming 연결 후 표시됩니다.</p>
<div>
<h2>스트리밍 실행 안내</h2>
<p>
관리자가 UE5.8 설치·빌드·맵 생성 후 이 사건 UUID로 엔진을
실행하고 FIRESTATION_UE_SIGNAL_URL에 휴대폰에서도 접근할 ws/wss
주소를 설정해야 합니다.
</p>
</div>
</div>
)}
</section>
<section className="ue-controls" aria-label="카메라 선택">
{cameras.map(([id, label]) => (
<button
key={id}
className="button"
disabled={!playing || !channel}
aria-pressed={camera === id}
onClick={() => {
if (
stream.current?.emitUIInteraction({
type: "firestation.camera",
sessionId,
camera: id,
})
)
setCamera(id);
}}
>
{label}
</button>
))}
{gesture && (
<button
className="button primary"
onClick={() => stream.current?.play()}
>
영상 재생
</button>
)}
<button className="button" onClick={() => setAttempt((n) => n + 1)}>
연결 재시도
</button>
</section>
<footer>
<p role="status">{connection}</p>
<p>
{scene
? `사건 ${scene.status === "closed" ? "종료" : scene.paused ? "일시정지" : "진행 중"} · 경과 ${scene.elapsed}초 · 상태 버전 ${scene.version}`
: "사건 상태 확인 중"}
</p>
<p>
같은 UE 인스턴스의 시청자는 카메라를 공유합니다. 사건 진행·지휘는 2D
상황본부에서 조작하세요.
</p>
</footer>
</main>
);
}
apps/web/lib/http.ts
import { ZodError } from "zod";
import { DomainError } from "../../../packages/core/types";
export function failure(error: unknown) {
if (error instanceof DomainError)
return Response.json({ error: error.message }, { status: error.code });
if (error instanceof ZodError)
return Response.json(
{
error: "입력 형식을 확인하십시오.",
details: error.issues.map((i) => ({
field: i.path.join("."),
message: i.message,
})),
},
{ status: 400 },
);
return Response.json(
{ error: "요청 처리에 실패했습니다. 서버 기록을 확인하십시오." },
{ status: 500 },
);
}
export function guard(request: Request) {
const origin = request.headers.get("origin");
const internal = new URL(request.url);
const host = request.headers.get("host") ?? internal.host;
if (origin) {
let browser: URL;
try {
browser = new URL(origin);
} catch {
throw new DomainError(
"다른 사이트의 변경 요청은 허용하지 않습니다.",
403,
);
}
if (browser.host !== host || browser.protocol !== internal.protocol)
throw new DomainError(
"다른 사이트의 변경 요청은 허용하지 않습니다.",
403,
);
}
if (!request.headers.get("content-type")?.includes("application/json"))
throw new DomainError("JSON 요청이 필요합니다.", 415);
}
export async function readJson(request: Request): Promise<unknown> {
try {
return await request.json();
} catch {
throw new DomainError("JSON 입력을 확인하십시오.", 400);
}
}
apps/web/lib/library.ts
import { dirname, join } from "node:path";
import { LibraryStore } from "../../../packages/library/store";
import { DomainError } from "../../../packages/core/types";
import { station } from "./station";
import { actor } from "./station-http";
export function library() {
const root = process.cwd().replace(/[\\/]apps[\\/]web$/, "");
return new LibraryStore(
station().db,
process.env.FIRESTATION_LIBRARY_DIR ??
join(
dirname(process.env.FIRESTATION_DB ?? join(root, ".data/station.db")),
"library",
),
);
}
export function libraryUser(request: Request) {
try {
return actor(request);
} catch (e) {
if (e instanceof DomainError && e.code === 401) return null;
throw e;
}
}
export function libraryOrigin(request: Request) {
const site =
process.env.FIRESTATION_ORIGIN ??
`${new URL(request.url).protocol}//${request.headers.get("host") ?? new URL(request.url).host}`;
try {
if (new URL(request.headers.get("origin") ?? "").origin === site) return;
} catch {}
throw new DomainError("같은 사이트의 변경 요청이 필요합니다.", 403);
}
apps/web/lib/service.ts
import {
existsSync,
mkdirSync,
readFileSync,
writeFileSync,
renameSync,
} from "node:fs";
import { dirname, resolve } from "node:path";
import { randomUUID } from "node:crypto";
import { z } from "zod";
import {
createSession,
publicState,
applyAction,
tick,
} from "../../../packages/core/engine";
import { scenarios } from "../../../packages/core/scenario";
import {
DomainError,
type Session,
type PublicState,
} from "../../../packages/core/types";
const createSchema = z.strictObject({
scenarioId: z.string(),
seed: z.number().int().min(0).max(0xffffffff),
level: z
.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)])
.optional(),
});
const actionSchema = z.discriminatedUnion("kind", [
z.strictObject({
kind: z.literal("intervene"),
reason: z.string().max(300).optional(),
}),
z.strictObject({ kind: z.literal("resume") }),
z.strictObject({
kind: z.literal("level"),
level: z.union([z.literal(0), z.literal(1), z.literal(2), z.literal(3)]),
}),
z.strictObject({
kind: z.literal("tick"),
seconds: z.number().int().min(1).max(30),
}),
z.strictObject({ kind: z.literal("cancel"), commandId: z.string() }),
z.strictObject({ kind: z.literal("approve"), commandId: z.string() }),
z.strictObject({ kind: z.literal("review"), commandId: z.string() }),
z.strictObject({
kind: z.literal("manual"),
actionKey: z.enum([
"DISPATCH",
"RECON",
"VERIFY",
"CONTROL",
"SUPPORT",
"MONITOR",
"CLOSE",
]),
target: z.string().min(1).max(100),
text: z.string().min(1).max(500),
cardIds: z.array(z.string()).min(1).max(5),
}),
]);
export class DemoService {
constructor(
public file = process.env.FIRESTATION_DEMO_FILE ??
resolve(process.cwd(), ".data", "sessions.json"),
) {}
private read(): Record<string, Session> {
if (!existsSync(this.file)) return {};
try {
return JSON.parse(readFileSync(this.file, "utf8"));
} catch {
throw new DomainError(
"가상 사건 저장 파일을 읽을 수 없습니다. 복구가 필요합니다.",
503,
);
}
}
private save(records: Record<string, Session>) {
mkdirSync(dirname(this.file), { recursive: true });
// 단일 로컬 프로세스용 저장이다. 기관 운영 전에는 DB 트랜잭션으로 교체한다.
const tmp = `${this.file}.tmp`;
writeFileSync(tmp, JSON.stringify(records), "utf8");
renameSync(tmp, this.file);
}
create(input: unknown): PublicState {
const data = createSchema.parse(input);
const scenario = scenarios.find((s) => s.id === data.scenarioId);
if (!scenario) throw new DomainError("시나리오가 없습니다.", 404);
const records = this.read();
if (Object.keys(records).length >= 200)
throw new DomainError("로컬 사건 보관 한도에 도달했습니다.", 429);
// 데모 API는 실제 모드를 받지 않으며 서버에서 DEMO를 고정한다.
const s = createSession(scenario, {
id: randomUUID(),
seed: data.seed,
mode: "DEMO",
level: data.level ?? 3,
});
records[s.id] = s;
this.save(records);
return publicState(s);
}
get(id: string): PublicState {
const records = this.read();
const s = Object.hasOwn(records, id) ? records[id] : undefined;
if (!s) throw new DomainError("사건이 없습니다.", 404);
return publicState(s);
}
change(id: string, input: unknown, version: number): PublicState {
const action = actionSchema.parse(input);
const records = this.read();
const current = Object.hasOwn(records, id) ? records[id] : undefined;
if (!current) throw new DomainError("사건이 없습니다.", 404);
if (!Number.isSafeInteger(version) || version !== current.version)
throw new DomainError(
"사건 버전이 변경됐습니다. 최신 상태를 다시 확인하십시오.",
409,
);
const next =
action.kind === "tick"
? tick(current, action.seconds)
: applyAction(current, action);
records[id] = next;
this.save(records);
return publicState(next);
}
}
export const demoService = new DemoService();
apps/web/lib/station-http.ts
import { DomainError } from "../../../packages/core/types";
import { station } from "./station";
export const cookieName = "firestation_session";
export function sessionToken(request: Request) {
return (
request.headers
.get("cookie")
?.split(";")
.map((s) => s.trim())
.find((s) => s.startsWith(cookieName + "="))
?.slice(cookieName.length + 1) ?? ""
);
}
export function actor(request: Request) {
return station().auth.authenticate(sessionToken(request));
}
export function authCookie(token: string, request: Request) {
const secure =
new URL(process.env.FIRESTATION_ORIGIN ?? request.url).protocol ===
"https:";
return `${cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=${token ? 28800 : 0}${secure ? "; Secure" : ""}`;
}
export async function stationJson(request: Request) {
const origin = request.headers.get("origin"),
site =
process.env.FIRESTATION_ORIGIN ??
`${new URL(request.url).protocol}//${request.headers.get("host") ?? new URL(request.url).host}`;
if (!origin || new URL(origin).origin !== site)
throw new DomainError("같은 사이트의 변경 요청이 필요합니다.", 403);
if (!request.headers.get("content-type")?.startsWith("application/json"))
throw new DomainError("JSON 요청이 필요합니다.", 415);
const reader = request.body?.getReader();
if (!reader) throw new DomainError("입력 본문이 필요합니다.", 400);
let size = 0;
const chunks: Uint8Array[] = [];
while (true) {
const item = await reader.read();
if (item.done) break;
size += item.value.length;
if (size > 128 * 1024) {
await reader.cancel();
throw new DomainError("입력 크기가 너무 큽니다.", 413);
}
chunks.push(item.value);
}
try {
return JSON.parse(Buffer.concat(chunks).toString("utf8")) as unknown;
} catch {
throw new DomainError("JSON 입력을 확인하세요.", 400);
}
}
export const privateHeaders = {
"Cache-Control": "no-store",
"X-Content-Type-Options": "nosniff",
};
apps/web/lib/station.ts
import { join } from "node:path";
import { StationDatabase } from "../../../packages/station/database";
import { StationAuth } from "../../../packages/station/auth";
import { StationService } from "../../../packages/station/service";
import { documentById } from "../../../packages/library";
const state = globalThis as typeof globalThis & {
firestationDatabase?: StationDatabase;
};
export function station() {
const root = process.cwd().replace(/[\\/]apps[\\/]web$/, "");
const db = (state.firestationDatabase ??= new StationDatabase(
process.env.FIRESTATION_DB ?? join(root, ".data", "station.db"),
));
const auth = new StationAuth(db);
return { db, auth, service: new StationService(db, auth, documentById) };
}
apps/web/next-env.d.ts
/// <reference types="next" /> /// <reference types="next/image-types/global" /> import "./.next/types/routes.d.ts"; import "./.next/types/root-params.d.ts"; // NOTE: This file should not be edited // see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
apps/web/next.config.ts
import type { NextConfig } from "next";
const config: NextConfig = {
reactStrictMode: true,
devIndicators: false,
turbopack: { root: process.cwd() },
};
export default config;
apps/web/tsconfig.json
{
"extends": "../../tsconfig.json",
"compilerOptions": {"plugins": [{"name": "next"}], "incremental": true},
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "../../packages/**/*.ts"],
"exclude": ["node_modules"]
}
deploy/Dockerfile
FROM node:22.23.2-bookworm-slim
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && mkdir -p /data && chown -R node:node /app /data
ENV NODE_ENV=production
USER node
EXPOSE 3100
HEALTHCHECK --interval=15s --timeout=5s --start-period=45s --retries=4 CMD node -e "fetch('http://127.0.0.1:3100/api/station/auth').then(r=>process.exit(r.ok?0:1)).catch(()=>process.exit(1))"
CMD ["node", "node_modules/next/dist/bin/next", "start", "apps/web", "--hostname", "0.0.0.0", "--port", "3100"]
deploy/README.md
# Hostinger 배포·업데이트·복구 Node22 Docker 이미지로 웹 서비스를 실행한다. UE5 네이티브 렌더링은 별도 GPU 호스트/엔진 준비 후 연결한다. 현재 접속 주소: https://fs.afr.co.kr (A레코드72.62.71.171). 실제 배포 검증 결과는 `docs/DEPLOYMENT_RESULTS.md`를 참조한다. 서버 프로젝트: `/root/projects/ai_firestation`. `releases/<Git SHA>`에는 소스, 루트 `compose.yaml`/`.env`에는 런타임 설정, 루트 `.data`에는 SQLite/교육 JSON과 `library/`의 기관 업로드 PDF·HTML·인덱스를 보관한다. `.data` 전체를 함께 백업/복구한다. 비밀·로컬 DB·계정을 GitHub 또는 배포 아카이브에 넣지 않는다. ## 배포 및 업데이트 저장소 전용 읽기 전용 deploy key는 서버 `.deploy`에 권한600으로 보관하고 GitHub 공식 호스트 공개키로 known_hosts를 고정한다. GitHub 사용자 토큰을 VPS로 복사하지 않는다. 새 releases/SHA에 검증한 커밋을 clone하고 다음 명령을 실행한다. 기존 데이터 디렉터리는 초기화하지 않는다. ```bash cd /root/projects/ai_firestation export GIT_SSH_COMMAND='ssh -i /root/projects/ai_firestation/.deploy/id_ed25519 -o IdentitiesOnly=yes -o StrictHostKeyChecking=yes -o UserKnownHostsFile=/root/projects/ai_firestation/.deploy/known_hosts' git clone --depth 1 --branch main git@github.com:minhokil/ai_firestation.git releases/SHA test "$(git -C releases/SHA rev-parse HEAD)" = '대상-전체-SHA' # 새 설치 때만, Node 이미지의 UID/GID 1000에 맞춘다. install -d -m 700 -o 1000 -g 1000 .data docker build -f releases/SHA/deploy/Dockerfile -t ai-firestation:SHA releases/SHA cp releases/SHA/deploy/compose.yaml compose.yaml ``` 루트 `.env`를 권한600으로 유지한다. 실제 주소와 이미지 태그를 넣고 업데이트 때 원래 설정을 보존한다. ```text FIRESTATION_IMAGE=ai-firestation:SHA FIRESTATION_ORIGIN=https://fs.afr.co.kr FIRESTATION_PORT=3118 ``` ```bash docker compose up -d docker compose ps docker compose logs --tail 50 web ``` Nginx 템플릿의 `__FIRESTATION_HOST__`를 실제 호스트로 바꾼다. HTTP 전용 파일을 새 사이트에 등록하고 `nginx -t` 성공 후 reload한다. ACME root `/var/www/ai-firestation-acme`로 인증서를 발급한 다음 HTTPS 템플릿을 적용한다. 기존 사이트 설정은 수정하지 않는다. custom 포트를 쓰면 Compose와 Nginx의 3118을 함께 변경한다. 인증서 발급 시 runtime에 복사한 deploy/renew-certificate.sh를 `--deploy-hook`으로 지정해 이 인증서 갱신 후 Nginx 문법 검사와 reload를 수행한다. Certbot 갱신 타이머를 확인하고 `certbot renew --cert-name 실제-호스트 --dry-run`으로 갱신 경로를 시험한다. 후속 업데이트에도 hook 파일을 유지한다. ## 최초 관리자 교재 업로드는 기존 기관 관리자가 로그인한 뒤 `/library`에서 사용한다. Compose의 `FIRESTATION_LIBRARY_DIR=/data/library`는 `.data/library`를 유지한다. HTTPS Nginx 템플릿의 `/api/library/upload` 전용 location(65m/90초)을 업데이트 때에도 적용하고 `nginx -t` 후 reload한다. 다른 API의256k 상한은 유지한다. Node 자식 프로세스가 PDF.js를 사용하므로 Python/별도 PDF 도구 설치는 필요 없다. ```bash cd /root/projects/ai_firestation docker compose exec web npm run setup:key ``` 이 명령은 15분 유효한 일회용 초기화 키를 화면에 표시한다. 서비스 `/station`에서 사용자가 기관명·계정·암호를 직접 설정한다. 키를 Git·문서·공개 로그에 넣지 않는다. 배포 과정은 관리자 계정을 만들지 않는다. ## 복구와 백업 업데이트 전 기존 `.env`, compose.yaml, 현재 이미지 태그를 별도 보관한다. 문제가 있으면 이전 이미지 태그로 바꾸고 `docker compose up -d`를 실행한다. 영구 `.data`는 동일하게 유지한다. 이 버전은 DB 마이그레이션을 추가하지 않았다. DB 백업은 `docker compose stop web` 후 `.data` 전체(SQLite/WAL/SHM/교육 JSON 포함)를 보호된 위치에 복사하고 `docker compose start web`으로 재개한다. 복구 전 현재 데이터도 보존한다. `docker compose down -v`, 데이터 삭제, Docker 전체 정리는 배포 절차에 포함하지 않는다. 서비스 도메인은 fs.afr.co.kr이다. 기존 ai-firestation.72.62.71.171.sslip.io는 nginx-redirect.conf.template을 적용해 새 HTTPS 주소로 이동한다. 기존 호스트 인증서의 ACME 경로를 유지하며 path/query도 보존한다. 도메인 변경은 새 인증서·vhost를 먼저 준비한 다음 FIRESTATION_ORIGIN·설명서·기존 주소 이동을 함께 전환한다. 이전 도메인 쿠키는 새 도메인에 공유되지 않으므로 로그인 사용자는 새 주소에서 다시 로그인한다. 공개 접속 검수는 웹 UI/인증 경계 확인이며 기관 실전 작전 또는 UE 영상 검증을 의미하지 않는다. 리다이렉트 템플릿은 `__FIRESTATION_HOST__`를 `fs.afr.co.kr`, `__FIRESTATION_LEGACY_HOST__`를 `ai-firestation.72.62.71.171.sslip.io`로 모두 치환한다. 기존 호스트 인증서가 존재하는지 확인하고 생성된 전용 vhost에 미치환 표기가 없는지 확인한 뒤 `nginx -t` 성공 후 reload한다. 새 호스트 인증서 대신 기존 호스트 인증서 경로를 유지한다.
deploy/compose.yaml
name: ai_firestation
services:
web:
image: ${FIRESTATION_IMAGE:?배포 이미지 태그를 지정하세요}
restart: unless-stopped
init: true
stop_grace_period: 30s
ports:
- "127.0.0.1:${FIRESTATION_PORT:-3118}:3100"
environment:
NODE_ENV: production
TZ: Asia/Seoul
NEXT_TELEMETRY_DISABLED: "1"
FIRESTATION_ORIGIN: ${FIRESTATION_ORIGIN:?HTTPS 주소를 지정하세요}
FIRESTATION_DB: /data/station.db
FIRESTATION_DEMO_FILE: /data/sessions.json
FIRESTATION_LIBRARY_DIR: /data/library
FIRESTATION_UE_SIGNAL_URL: ${FIRESTATION_UE_SIGNAL_URL:-}
volumes:
- ./.data:/data
cap_drop:
- ALL
security_opt:
- no-new-privileges:true
mem_limit: 1g
cpus: 2
logging:
driver: json-file
options:
max-size: "10m"
max-file: "3"
deploy/nginx-http.conf.template
server {
listen 80;
listen [::]:80;
server_name __FIRESTATION_HOST__;
location /.well-known/acme-challenge/ {
root /var/www/ai-firestation-acme;
}
location / {
return 301 https://$host$request_uri;
}
}
deploy/nginx-https.conf.template
server {
listen 80;
listen [::]:80;
server_name __FIRESTATION_HOST__;
location /.well-known/acme-challenge/ {
root /var/www/ai-firestation-acme;
}
location / {
return 301 https://$host$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name __FIRESTATION_HOST__;
ssl_certificate /etc/letsencrypt/live/__FIRESTATION_HOST__/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/__FIRESTATION_HOST__/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 256k;
location = /api/library/upload {
client_max_body_size 65m;
client_body_timeout 90s;
proxy_pass http://127.0.0.1:3118;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_read_timeout 90s;
}
add_header X-Content-Type-Options nosniff always;
add_header Referrer-Policy strict-origin-when-cross-origin always;
location / {
proxy_pass http://127.0.0.1:3118;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Connection "";
proxy_read_timeout 90s;
}
}
deploy/nginx-redirect.conf.template
# 기존 접속 주소를 신규 HTTPS로 이동한다. 인증서 갱신용 HTTP 경로를 유지한다.
server {
listen 80;
listen [::]:80;
server_name __FIRESTATION_LEGACY_HOST__;
location ^~ /.well-known/acme-challenge/ {
root /var/www/ai-firestation-acme;
}
location / {
return 301 https://__FIRESTATION_HOST__$request_uri;
}
}
server {
listen 443 ssl;
listen [::]:443 ssl;
server_name __FIRESTATION_LEGACY_HOST__;
ssl_certificate /etc/letsencrypt/live/__FIRESTATION_LEGACY_HOST__/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/__FIRESTATION_LEGACY_HOST__/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
return 301 https://__FIRESTATION_HOST__$request_uri;
}
deploy/renew-certificate.sh
#!/bin/sh # 이 인증서가 갱신되면 새 인증서를 Nginx에 반영한다. set -eu /usr/sbin/nginx -t /usr/bin/systemctl reload nginx
docs/ACCEPTANCE_RESULTS.md
# 1~2주차 검증 범위 계획서 21.1의 27개 수용 사례 전체가 완료됐다는 뜻이 아니다. 승인된 C01~C04·D01에 해당하는 공통 계약을 검증했다. 최종 실행 결과는 `IMPLEMENTATION_PROGRESS.md`에 기록한다. | 범위 | 증거 | 한계 | |---|---|---| | C01 | 시나리오 3개 Zod 검증·키 중복·시간순서·공개 투영 | 강사 편집기는 후속 | | C02 / AC-17 일부 | 동일 시드·동일 입력 재현, 원본 불변, 명령 상태·취소 | 게임 점수·리플레이 파일은 후속 | | C03 / AC-21 | 세 시나리오 모두 96초 종료·7개 임무 완료·복기 로그 | 교육 각본의 정상 진행 조건 | | AC-22 | 개입 후 tick 상태 불변·개입 기록, 브라우저 수동 전환 | 실제 자원은 연결되지 않음 | | AC-23 / AC-15 일부 | core 실제 모드 L3 거부, 웹 임의 mode/field 요청 거부 | 실장비 어댑터 자체가 없음; 기관 감사 로그는 후속 | | AC-06 | 새 관찰 이후 초안 승인 거부·재검토·승인 | 동적인 센서 위험 평가 모델은 후속 | | AC-07 | 동일 승인 재시도 명령 중복 없음 | 차량별 수신 통신은 가상 상태 | | AC-09 일부 | 취소된 명령이 tick으로 부활하지 않음 | 실제 단말 재연결·취소 전파는 후속 | | AC-04/25 일부 | 카드 존재 검증, 미존재 근거 발행 거부 | 승인 원문 조항 검색·평가 점수는 후속 | | AC-24 일부 | 설명 어댑터 타임아웃·실패·빈 응답은 규칙 문장 대체 | 웹 외부 LLM 미연결, 어댑터 계약 시험만 시행 | | AC-27 일부 | 소개·상황본부·차량·카드 교육/가상 표식 | 게임 화면은 후속 | | C04 | 40장·분류 검색·draft/검수자 미지정·참고 출처·Markdown 내보내기 | 전문가 검수 통과 아님 | | D01 | 소개→시연→개입→수동→카드, 자동 종료, 차량 동기화·새로고침, 모바일 390px | 두 화면은 로컬 브라우저 탭. 실제 차량·별도 태블릿 연동 아님 | 미구현 수용 사례: 문서 승인·폐기 관리(AC-05), 대상별 미수신(AC-08), 지휘권 인계(AC-10), 드론 불가·지연·영상 정지(AC-11~13), 기관 접근 분리(AC-14), 게임 안전·점수·대결 리플레이(AC-16/26), 운영 AI/DB 장애 정책(AC-18), 기관 자원 중복 배정(AC-19), 문서 주입 평가(AC-20). 영상·RAG·게임·기관 연동 단계에 맞춰 구현·검증한다.
docs/CORE_CONTRACT.md
# 공통 코어 계약 v0.2
## 사건과 관찰
시나리오는 `id/title/description/briefing/address/classes/risks/primaryCard/initial/hidden/timeline`으로 정의한다. Zod로 필드·시간순서·관찰 키 중복을 검증한다. `hidden`과 `timeline`은 서버 엔진 전용이며 `publicScenario()`에 포함하지 않는다.
관찰은 `key/text/source/status/at`을 갖는다. 출처는 신고·드론·현장·시스템, 상태는 의심·미확인·확인됨이다. `확인됨`은 해당 **보고의 접수 상태**다. 예를 들어 “내부 인명 미확인”이라는 보고 접수가 확인됐다고 해서 내부 인명 상태가 확인된 것은 아니다.
기본 각본 시간표: 준비 8초 → 드론 20초 → 선착대 40초 → 현장 보고 52초 → 감소 보고 66초 → 잔여 위험 검토 80초 → 종료 검토 96초. 이 시간표는 가장 이른 공개 시각이며 각 이벤트는 해당 선행 임무가 완료된 뒤에만 나타난다. 늦은 승인에서는 드론 발행 후 12초·차량 발행 후 40초의 가상 이동 시간이 반영된다. 현장 확인 보고는 선착대 도착 후 공개된다. 숫자는 가상 진행 시각이며 실제 출동 성능·전술 기준이 아니다.
## 엔진
가상 지도는 공개 ETA에 맞춰 위치를 계산한다. 펌프1은 소방서 (75,224)에서 도로 (355,224) → (355,110)를 따라 현장 진입 지점 (470,110)으로 이동한다. 드론은 같은 소방서에서 신고 위치 (470,164)까지 직선으로 이동한다. 차량 경로 선과 위치 계산은 동일한 좌표를 사용하며 구간 길이에 비례해 진행한다. 서버와 지도는 차량 40초·드론 12초 상수를 공유한다. 승인 전에는 출발하지 않고 늦은 승인도 출발 지점부터 이동한다. 실제 지도·비행 제한·실제 도착 시간 계산은 이 계약에 포함하지 않는다.
- 순수 함수 `createSession / tick / applyAction / publicState`로 상태를 만든다.
- 매 tick은 0~30초 정수이며 원본 입력을 변경하지 않는다.
- 시드 기반 가상 화재 셀 8×6은 확산·성장·감소를 재현한다. 물리적 화재 예측이나 진압 모델이 아니다.
- 지휘관에는 공개 스냅샷만 입력한다. 드론 도착 전에는 화점 셀도 공개하지 않는다.
- 임무는 발행 후 가상 2초 수신, 4초 수행, 8초 완료로 진행한다. 실제 장비 수신 확인이 아니다.
- 개입은 사건을 서버에서 일시정지하며 tick·자동 발행을 막는다.
- 취소된 명령은 뒤늦은 자동 수신·완료로 되살아나지 않는다.
- 재현 계약은 같은 시드·시나리오·동일 action/tick 입력에서 같다. 서버 UUID는 사건 식별용으로 재현 비교에서 제외한다.
## 지휘 수준·근거
| 수준 | 현재 계약 |
|---|---|
| L0 | AI 새 판단·자동 발행 없음, 사람이 카드 기반 명령 발행 |
| L1 | 공개 관찰 기반 조언, 자동 명령 없음 |
| L2 | 근거가 있으면 draft 생성, 사람 승인 후 issued |
| L3 | 가상 모드에서만 자동 발행 |
OPERATIONS에서 L3는 거부하고, SHADOW는 L1만 허용한다. KB-0 교육 초안은 실제 모드 명령 근거로 사용할 수 없다. 웹 API는 OPERATIONS·SHADOW 생성·모드 변경을 받지 않는다.
판단·명령에는 액션 키, 대상, 지시문, 이유, 카드 ID, 생성 시각, 사건 버전, 근거 관찰 키, 교육용 근거 상태가 들어간다. 미존재 카드가 있으면 발행을 보류한다. L1→L2·L3 전환 시 기존 조언을 연결하고, L2→L3에서는 남은 초안을 발행하되 취소된 명령을 다시 발행하지 않는다. 새 관찰 이후의 과거 초안은 재검토해야 사람이 승인할 수 있다.
명령 상태: `draft → issued → acknowledged → executing → complete`; 완료 전 취소는 `cancelled`. 사람 승인 재시도는 중복 명령을 만들지 않는다.
외부 문장 생성 어댑터는 지시 의사결정을 대체하지 않는다. 실패·타임아웃·빈 응답·전술 수치 등은 규칙 문장으로 대체한다. 현재 웹 UI는 규칙 문장을 그대로 사용한다.
## 로컬 API
| 요청 | 입력/출력 |
|---|---|
| `POST /api/sessions` | `{scenarioId,seed,level?}` → 201 공개 상태, 서버 DEMO 고정 |
| `GET /api/sessions/:id` | 현재 공개 상태, no-store |
| `POST /api/sessions/:id` | `{version,action}` → 새 공개 상태 |
action: `tick(seconds)` · `intervene(reason?)` · `resume` · `level(level)` · `approve(commandId)` · `review(commandId)` · `cancel(commandId)` · `manual(actionKey,target,text,cardIds)`.
오류: 형식/JSON 400, 외부 Origin·모드 경계 403, 없는 사건/명령 404, 과거 버전·종료·개입 중 발행·재검토 필요 409, 근거 없음 422, 보관 한도 429, 저장 손상 503. 상세 내부 오류·비공개 시나리오는 응답하지 않는다.
현재 저장은 단일 로컬 프로세스의 JSON 원자 교체 방식이다. 인증·다중 서버·DB 트랜잭션·서버 주도 시계는 구현하지 않았다. 이 경계는 공개 배포·기관 운영 계약이 아니다.
docs/DEMO_AUDIO_VALIDATION.md
# v0.5.0 가상 출동 음향 검증 검증일: 2026-10-09. 대상: 기존 교육 시연의 식당·창고·주택 시나리오. ## 구현 범위 - 공개 사건 상태를 바탕으로 전화벨·접수요원·신고자·접수 확인, 지휘본부의 발행 명령, 대원 수신·현장 보고, 드론 선착 보고와 종료 안내를 재생한다. - 자체 작성한 한국어 합성 대사18개와 전화벨·무전 연결음·사이렌3개를 배포 WAV로 제공한다. 실제 신고·무전 녹음이나 전문 성우 녹음은 아니다. 음원 전체 크기5,905,426바이트. - 새 시연은 신고 접수 대화 약25초를 먼저 재생하고 가상 시간을 진행한다. 음소거/재생 실패 때는 대기를 해제한다. 기본2배속96가상초 진행 약48초는 도입 대화 시간과 별도다. - 차량 출동 상태와 ETA가 남은 동안 사이렌을 재생하고 음성 중 음량을 낮춘다. 초안·취소 명령·미공개 관찰은 방송하지 않는다. 고배속에서는20가상초보다 오래된 대기 방송을 줄인다. - PC·모바일의 음향·볼륨·역할 자막, 44px 전체 자막 펼치기, 개입/종료 화면 음소거를 제공한다. 정지·개입·백그라운드·세션 교체·화면 이동에 대응하며 새로고침/차량 창에서는 과거 대사를 다시 읽지 않는다. - 사용자 작성 명령은 실제 입력 문장을 브라우저 한국어 음성으로 읽는다. 해당 음성의20초 제한에서 일시정지 시간을 제외한다. OS 음성 지원에 따라 재생이 제한될 수 있다. ## 검증 증거 - 단위 시험101건/17개 파일 통과. 초안·취소 제외, 중복 방지, ETA 기준 사이렌, 공개 시점 보고, 과거 상태 재방송 방지, 늦은 음원 응답 폐기, 정지/음소거/오류 시 도입 대기 해제를 검증했다. - 독립 음향 코드 리뷰의 중요 지적2건(음소거 후 늦은 합성 오류, 일시정지 중 합성 재생 제한 소진)을 각각 실패하는 시험으로 재현하고 수정 후 통과했다. 긴 모바일 자막 접근성 지적도 전체 보기로 보완했다. - TypeScript, 음원21개 해시/길이/공개 대사 대응, 공식 교재14권 해시, HTML 문서 지문 검사 및 로컬 production build 통과. - 격리 데이터에서 전체 Playwright27건 통과. 실제 WebAudio의 신고→접수→출동 지시→사이렌과 일시정지/개입, 음원503 실패 시 계속 진행, 모바일 볼륨/전체 자막/새로고침 음소거를 검증했다. 기존 도로 이동·드론 선착·L2 승인·기관 인증/승인/대원 보고·UE 연결 대기 검증도 통과했다. - PC1366×768·1920×1080, 모바일360×800·390×844의 기존 화면 배치 검증을 유지했다. 휴대폰은 브라우저 크기 에뮬레이션이며 실제 iOS/Android 하드웨어 재생 검증은 별도다. 음성의 자연스러움에 대한 전문 성우/소방관 청취 평가는 수행하지 않았다. ## 배포 확인 절차 GitHub main에 커밋을 푸시한 후 해당 전체 SHA의 Linux Docker 이미지를 `/root/projects/ai_firestation`에서 빌드·전환한다. 기존 영구 데이터를 유지하고 healthy, 외부 HTTPS 음향/반응형 시험,21개 음원 및 manual.html/logic.html 해시, 기존 교육 사건 상태와 기관 관리자 미설정 상태를 확인한다. 실제 실행 SHA는 GitHub main과 컨테이너 이미지 태그를 비교한다. 기관 계정 생성 시험은 운영 데이터에서 실행하지 않는다. 이번 변경은 가상 교육 음향이다. 실제119·소방 무전·드론 장비와 연결하거나 기관용 사건에 가상 음성을 방송하지 않는다.
docs/DEPLOYMENT_RESULTS.md
# Hostinger 배포 검증 기록 검증일: 2026-10-09. 현재 서비스: https://fs.afr.co.kr 저장소: https://github.com/minhokil/ai_firestation (비공개), main. 최초 운영 검증 소스는 `ad8d9af8f5bfdfcdcc67ed86346917466222b027` / v0.4.1이다. 이 기록과 접속 주소를 추가한 문서 릴리스도 동일 Docker 빌드·전환 절차를 적용한다. 현재 소스 SHA는 GitHub main과 실행 중인 `ai-firestation:<SHA>` 이미지 태그를 비교해 확인한다. ## 실행 환경 - Hostinger VPS `72.62.71.171`, Ubuntu24.04, 프로젝트 `/root/projects/ai_firestation`. - 소스 `releases/<SHA>`, 실행 설정 `compose.yaml` / `.env`, 영구 데이터 `.data`. - Node22.23.2 Docker, UID1000 프로세스, Compose `ai_firestation`, 서비스 `web`. - 컨테이너3100 → `127.0.0.1:3118` → 이 호스트 전용 Nginx443. 기존 서비스와 포트를 공유하지 않는다. - 저장소 전용 읽기 전용 deploy key로 clone하고 전체 SHA를 확인했다. 개인키·GitHub 사용자 토큰·로컬 기관 DB·계정은 저장소 및 공개 문서에 포함하지 않았다. - HTTPS 인증서 발급, HTTP301 리다이렉트, Certbot 타이머 active. `certbot renew --cert-name ai-firestation.72.62.71.171.sslip.io --dry-run --run-deploy-hooks`가 성공했고 갱신 hook의 Nginx 문법 검사·reload도 성공했다. 최초 인증서 만료일은 2027-01-07이다. ## 검증 결과 - 단위 시험88건, TypeScript 검사, 공식 교재14권 해시·인덱스 검사, 문서 지문 검사, 로컬 및 Linux Docker production build 통과. - 외부 HTTPS Playwright9건: 차량 도로 이동, 드론 직선 선착, 소개→시연→개입·수동→카드 검색, 실제 모드/과거 버전 요청 거부, PC/모바일 배치, UE 대기/2D 복귀, 설명서 HTML 다운로드, 교재 검색·원문·해설·복사. - 최초9건 실행 중 드론 시험1건에서 전송 ECONNRESET 발생. 서버 restart/오류는 없었고 해당 시험 재실행은 통과했다. 다른8건은 최초 실행에서 통과했다. - PC1366×768·1920×1080, 스마트폰360×800·390×844의 상황본부가 문서 가로/세로 넘침 없이 동작했다. PC/390 화면을 추가 캡처·검토했고 브라우저 pageerror는 없었다. 스마트폰 크기 에뮬레이션 검증이며 실제 휴대폰 하드웨어 검증은 별도다. - 같은 HTTPS Origin의 교육 생성201, 잘못된 Origin의 교육/기관 변경403, 미인증 기관 조회401. HTTPS 로그아웃 응답의 쿠키 HttpOnly / SameSite=Strict / Secure 확인. - Docker `web`만 재시작한 뒤 교육 사건 전체 상태(version21 / elapsed20)가 재시작 전과 일치했고 health가 정상으로 복구됐다. 기관 DB의 조회도 정상이며 관리자 미설정 상태를 유지했다. - `/help`, `/manuals`, `/library`, `/manual.html`, `/logic.html`200. PDF 원문과 검색 결과는 브라우저에서 확인했다. 독립 배포 리뷰에서 수정이 필요한 P1/P2 지적은 없었다. ## 접속과 최초 설정 PC와 스마트폰에서 같은 HTTPS 주소를 연다. `/demo`는 가상 교육, `/library`는 교재, `/help`는 사용설명서다. `/station`의 기관 사용은 관리자 최초 설정과 사용자 계정·사건 배정 후 가능하다. 기본 계정이나 암호는 없다. 서버에서 `cd /root/projects/ai_firestation` 후 `docker compose exec web npm run setup:key`로 15분 유효한 일회용 키를 발급하고 `/station`에서 기관·관리자를 직접 설정한다. 키를 문서나 Git에 기록하지 않는다. 업데이트·백업·이전 이미지 복구 절차는 `deploy/README.md`를 따른다. 사용자 지정 도메인 fs.afr.co.kr의 A레코드는72.62.71.171이다. 최초 임시 주소 ai-firestation.72.62.71.171.sslip.io는 새 HTTPS 주소로301 이동한다. 도메인을 변경할 때는 A레코드·TLS·Nginx·FIRESTATION_ORIGIN을 함께 변경한다. 기존 도메인의 로그인 쿠키는 새 주소에 공유되지 않으므로 다시 로그인한다. UE5 엔진/별도 GPU 영상 호스트가 준비되지 않아 네이티브 영상은 연결 대기 상태다. 이번 검증은 웹 배포 검증이며 실제 119·무전·장비 통합이나 기관 실전 지휘 검증은 아니다. ## 사용자 도메인 전환 — 2026-10-09 - 로컬 및 VPS에서 fs.afr.co.kr A레코드72.62.71.171 확인. 새 호스트의 TLS 인증서를 발급하고 전용 Nginx vhost 문법 검사 후 적용했다. 새 인증서 최초 만료일2027-01-07. - FIRESTATION_ORIGIN=https://fs.afr.co.kr로 변경하고 동일 영구 .data를 유지했다. 이전 runtime/vhost는 서버 .deploy에 백업했다. - 새 HTTP 및 기존 HTTP/HTTPS 주소에서 path/query를 유지해 https://fs.afr.co.kr로301 이동 확인. 이전 인증서 갱신의 HTTP ACME 경로도 유지한다. - 새 주소 /demo·/library·/help·/station200, 올바른 Origin의 교육201·기관 로그아웃200, 이전/잘못된 Origin의 변경403, HTTPS 보안 쿠키 확인. - 컨테이너 Origin 전환 후 기존 교육 사건 전체 상태(version21 / elapsed20) 일치, healthy 및 기관 관리자 미설정 유지 확인. - fs.afr.co.kr에서 외부 Playwright4건(PC/스마트폰 상황본부·HTML 다운로드·교재 검색/복사)이 통과했다. 스마트폰은 크기 에뮬레이션이며 실기 검증은 별도다. 도메인 전환의 단위 시험88건과 문서 검사도 통과했다. - 새 인증서 `certbot renew --cert-name fs.afr.co.kr --dry-run --run-deploy-hooks` 성공. Certbot의 정상 무작위 대기약303초 후 갱신 시험과 Nginx 문법 검사·reload가 완료됐다. 타이머 active 및 기존 주소 ACME200을 확인했다. - 독립 도메인 리뷰에서 기능/설정 차단 문제는 없었다. 재배포 시 필수인 기존 호스트 치환 표기 안내를 보완하고 실제 두 vhost에 미치환 표기가 없으며 Nginx 문법이 정상임을 확인했다.
docs/DEPLOYMENT_SPEC.md
# GitHub 및 Hostinger VPS 배포 명세 사용자 승인: GitHub minhokil/ai_firestation에 전체 작업을 푸시하고 Hostinger VPS projects/ai_firestation에 배포한다. 확인 환경: 비공개 GitHub 저장소, main 최초 푸시 완료. 기존 SSH 키와 알려진 호스트로 root@72.62.71.171 접속 확인. Ubuntu24.04, Node22.23.2, Docker Compose5.5.1, Nginx1.24, Certbot2.9가 설치되어 있다. /root/projects/ai_firestation은 새 경로이며 host3100은 다른 서비스가 사용한다. 웹·공식 교재·설명서·기관용 기록 지원을 배포한다. UE 프로젝트 소스는 보관하지만 VPS에서 UE 네이티브 렌더링을 실행했다고 표시하지 않는다. 사용자 지정 서비스 도메인은 fs.afr.co.kr이며 A레코드72.62.71.171을 확인했다. 기존 임시 ai-firestation.72.62.71.171.sslip.io는 새 HTTPS 주소로301 이동하며 인증서 갱신용 HTTP ACME 경로를 유지한다. Docker 내부3100 → 호스트127.0.0.1:3118 → 전용 Nginx HTTPS vhost. Node22 이미지, 비root 사용자, restart unless-stopped, 상태 점검, 제한된 권한을 사용한다. 기관 SQLite와 교육 JSON은 /root/projects/ai_firestation/.data에 영구 보관하고 컨테이너 /data로 연결한다. 로컬 DB·계정·키·환경 파일을 복사하지 않는다. 최초 관리자 계정은 자동으로 만들지 않는다. 배포 소스는 저장소 전용 읽기 전용 SSH deploy key로 GitHub main을 releases/<SHA>에 clone한다. GitHub 공식 meta API의 호스트 공개키로 known_hosts를 고정한다. 태그별 이미지를 빌드하고 runtime Compose 설정은 프로젝트 루트에서 유지한다. 코드·런타임 데이터·SSH/TLS 개인키를 구분하며 코드 업데이트는 데이터를 덮어쓰지 않는다. 서버용 환경변수는 .env 파일600 권한으로 보관한다. FIRESTATION_ORIGIN은 실제 HTTPS 주소로 설정하고 Nginx는 Host 및 X-Forwarded-Proto를 지정한다. 배포 후 컨테이너 health, HTTPS/HTTP 리다이렉트, 교재/HTML, PC·스마트폰 시연 및 Origin 거부를 확인한다. 실제 기관 DB 초기화는 하지 않는다. 후속 업데이트와 복구는 기존 릴리스·이미지를 보존하고 동일 .data를 사용한다. 초기화 키는 서버 CLI로 발급한다. 배포 파일 및 서버 실행·복구 안내는 manual.html과 logic.html에 포함한다. 모든 코드·문서 변경을 다시 커밋하고 GitHub main에 푸시한다.
docs/IMPLEMENTATION_PROGRESS.md
# 실행 기록 — AI_FIRESTATION_상세계획서_v0.2.md ## 승인 범위 2026-10-09 사용자가 v0.2 검토 후 1~2주차 공통 기반·데모 카드 개발을 지시했다. C01~C04와 D01 골격을 이번 구현 범위로 한다. 실제 장비·기관 연결·실제 화재 운용·공개 배포는 포함하지 않는다. ## 검토·구현 결정 - Ruling: 근거 없는 명령 발행은 4.3.6과 공통 근거 검증 원칙의 충돌이다. 근거 부족 판단은 표시하지만 명령은 보류한다. AC-25도 같은 해석으로 검증한다. 비용: 오류 명령 체험 기능은 후속 강사 전용 기능으로 분리해야 한다. - Ruling: 전문가가 아직 검수하지 않은 KB-0는 교육용 비공식 초안으로 공개한다. 규칙 기반 가상 시나리오에서만 사용하며 실운영·전술 수치에 적용하지 않는다. 비용: 전문가 승인 전에는 공인 훈련 자료라고 부를 수 없다. - Ruling: 현재 폴더는 Git 저장소가 아닌 새 프로젝트다. 원본 계획서를 보존하고 같은 폴더에 모듈을 구축한다. 비용: 기존 구현과의 충돌은 없으며 이력은 새 저장소부터 관리한다. - Ruling: 1~2주차 D01은 로컬 Next.js 화면과 서버 상태·버전 계약을 제공한다. WebSocket·MP4·Phaser 게임·외부 LLM은 3주차 이후 작업이며 이번에 구현했다고 주장하지 않는다. 비용: 외부 LLM 대신 규칙 기반 자동 지휘, 드론은 가상 데이터로 작동한다. - Ruling: 최초 로컬 저장은 단일 프로세스 JSON 원자 교체로 구현한다. C01~C04 검증에 집중하고 DB·다중 서버 트랜잭션은 후속이다. 비용: 사건당 상황본부 한 창, 최대 200건 보관, 기관 배포 전에 저장 계층 교체 필요. - Ruling: 사람 개입 시 진행 중인 가상 임무도 함께 멈추는 교육 시계로 정의한다. 비용: 실제 현장처럼 계속 움직이는 자원 재현은 후속 훈련 엔진에서 분리해야 한다. ## 인터페이스 사전 대조 | 연결 | 계약 | |---|---| | C01 → C02 | 시나리오 공개 메타데이터·비공개 타임라인을 분리한다. 공개 상태는 허용 필드로 투영한다. | | C02 → C03 | 관찰·가상 시간·사건 버전으로 판단하며 숨겨진 실제 상태는 지휘관 입력에서 제외한다. | | C04 → C03 | 명령의 모든 cardId는 존재하는 교육 카드와 연결하며 비공식·검수 전 라벨을 유지한다. | | C02/C03 → D01 | 서버에서 모드·자율 수준·일시정지·명령 상태를 검증한 공개 스냅샷만 전달한다. | ## 작업 - [x] C01 시나리오 스키마·3개 시나리오·정보 비노출 - [x] C04 교육 카드 40장·검수·출처 상태·Markdown 내보내기 - [x] C02 시드 재현·사건 상태·시간·자원·취소·공개 투영 - [x] C03 L0~L3·서버 경계·근거·개입·규칙 기반 판단·설명 대체 - [x] D01 소개·상황본부·차량·카드 화면 골격 - [x] 검증: 전체 테스트·타입·빌드·브라우저·최종 리뷰 ## 시각 방향 항공 관제 화면의 기록성과 소방 상황도의 구역 표현을 사용한다. 바탕 #F1F4F6, 글자 #19303C, 구조선 #D8E0E5, 상황 헤더 #203B49, 명령 강조 #D95332, 확인 #237D72. 한글 본문은 로컬 시스템 고딕, 영문 브랜드는 Georgia, 숫자는 monospace. 화면의 대표 요소는 차량·드론·화재의 시간별 위치를 보여주는 가상 도시 상황도다. 가상 표식과 검수 상태를 모든 화면에 유지한다. ## 작업별 검증 기록 - C01/C04: 스키마·순서·키 중복·공개 투영·40장·검색·초안 라벨·출처 필드 검증. 구현 전 실패 확인 후 통과. - C02/C03: 자동 종료 3개, 시드 재현, 개입·취소·사람 승인·근거·실제 모드 경계. L1/L2→L3 전환과 새 관찰 후 승인 충돌은 회귀 테스트 RED→GREEN으로 수정. - 서버: 임시 저장 파일의 재시작 복구, 임의 모드/필드 거부, 사건 버전 충돌, 오류 입력 검증. 브라우저 Origin과 Next 내부 호스트 차이를 재현한 HTTP 테스트 RED→GREEN. - 설명 대체: 어댑터 타임아웃·실패·빈 문장 검증. 외부 LLM이 연결됐다고 표시하지 않음. - D01: 미구현 화면에서 E2E 실패 확인 후 소개→시연→개입→수동→카드 검색 통과. 모바일 390px 가로 넘침 없음. 자동 종료·차량 조회 동기화·새로고침 복구 통과. - 화면: artifacts/home.png, command-room.png, mobile.png를 직접 열어 확인. 생성 사진 대신 가상 도시 도식·드론 관찰 상태를 사용. 최종 테스트·빌드·리뷰 결과는 후속 기록에 추가한다. ## 검증 실행 결과 — 리뷰 전 - `npm test`: 6파일·35개 통과. - `npm run typecheck`: 통과. - `npm run simulate`: 세 시나리오 모두 closed / 96초 / 7명령 완료 / 8관찰 / hidden 없음. - `npm run cards:export`: 교육 카드 40장 내보내기 완료. - `npm run test:e2e`: 개발 서버에서 5개 통과. - `npm run build`: Next.js 최적화 빌드·TypeScript·6정적 페이지 생성 통과. - `rg`로 배포형 static 번들에서 강사 전용 정답 문자열이 없는 것을 확인. - `npm start`: 127.0.0.1:3100 로컬 배포형 서버 기동 확인. - 최종 리뷰: 별도 fresh-context 리뷰어 읽기 전용 점검 진행 중. ## 최종 리뷰 및 단일 수정 묶음 - Important: L2 지연 승인에서 ETA 0초·드론 지도 이탈. 엔진의 ETA 0 vs 기대 40, 브라우저 x=794 vs 지도 범위 검사로 RED 재현. 발행 시각 기준 이동 시간·도착 이벤트·지도 이동률을 함께 수정. - 리뷰 Minor였던 초안 무전 누락은 Important로 재평가했다. README가 ‘켜면 이후 발행된 명령을 읽는다’고 약속한 실제 UI 동작을 깨기 때문이다. L2 초안→무전 켜기→승인에서 음성 0 vs 기대 1로 RED 재현. 이미 발행한 명령만 읽은 목록으로 등록하도록 수정. - 두 항목을 한 번의 수정 묶음으로 처리했다. 미룬 리뷰 항목 없음. 수정 후 전체 검증 결과를 아래에 기록한다. ## 최종 결과 - `npm test`: 6파일·36개 통과. 늦은 승인→이동→도착 상태·ETA 회귀 포함. - `npm run typecheck`: 통과. - `npm run simulate`: 세 시나리오 모두 기존 정상 흐름 96초·7명령 완료 유지. - `npm run build`: 수정 후 최적화 빌드·TypeScript·정적 페이지 생성 통과. - `npm run test:e2e`: **배포형 서버에서 7개 통과**. L2 초안→무전→승인 음성 1회, 지연 승인 드론 지도 범위 회귀 포함. - 최종 리뷰의 완료 방해 항목을 모두 수정·검증했다. 미룬 리뷰 항목 없음. - 실행 환경: Windows / Node.js 22.22.3 / Next.js 16.4.0 / TypeScript 7.0.2. 로컬 서버 http://127.0.0.1:3100. - 원본 계획서를 보존한 새 Git 저장소의 `feat/core-demo` 브랜치에 저장한다. 기존 기반 브랜치·원격 저장소가 없으므로 병합·푸시는 진행하지 않는다. - 승인 범위 **C01~C04 + D01 완료**. 전체 16주 계획·21.1의 27개 수용 사례 완료를 주장하지 않는다. 상세 수용 범위는 `ACCEPTANCE_RESULTS.md` 참고. ## 환경 도구 제한 구현 완료 후 환경 지시에 따라 `npx gitnexus analyze`와 `npx gitnexus analyze --verbose --skip-agents-md`를 실행했다. 두 번 모두 Analyzer 시작 문구 후 상세 오류 없이 exit 1로 종료됐다. `npx gitnexus status`는 Repository not indexed를 표시했다. 부분 생성된 `.gitnexus` 캐시는 Git에서 제외하고 보존했다. 코드 색인은 갱신되지 않았으며 서비스 실행·테스트·빌드 완료와 별개인 환경 도구 제한이다.
docs/KB0_교육카드_40장.md
# KB-0 교육용 카드 40장 교육용 · 비공식 요약 · 검수 전. 자체 작성한 질문·준비 초안입니다. 실제 현장 전술이 아니며 소방 자문 검수가 필요합니다. 참고 출처는 용어·지휘 제도의 맥락이며 각 행동을 승인한 근거가 아닙니다. ## A-01 주택 화재·인원 미확인 교육용 · 비공식 요약 · 검수 전 외부 관찰과 내부 인원 확인을 구분합니다. 확인 질문: - 어느 층에서 연기를 관찰했나요? - 내부 인원은 직접 확인했나요? - 출입구 상태를 누가 확인했나요? 준비 검토: - 인명 확인 담당·보고 경로 확인 - 보호·통신 장비 준비 확인 주의: 사람이 보이지 않아도 내부 인원이 없다고 단정하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## A-02 공동주택·상층 연기 교육용 · 비공식 요약 · 검수 전 발생 층과 영향을 받는 층을 따로 기록합니다. 확인 질문: - 발생 층이 확인됐나요? - 다른 층에도 연기가 있나요? - 대피 경로를 확인했나요? 준비 검토: - 층별 확인·보고 구역 정리 - 구조 자원 가용 상태 확인 주의: 발화층과 연기 관찰층을 같은 것으로 기록하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## A-03 사무실·야간 화재 교육용 · 비공식 요약 · 검수 전 시설 용도와 출입 정보를 확인합니다. 확인 질문: - 야간 근무자가 있나요? - 출입 담당자에게 연락했나요? - 전기·가스 시설 정보가 있나요? 준비 검토: - 시설 관계인 연락 확인 - 인명·출입 확인 목록 준비 주의: 시설이 닫혔다고 무인 상태를 확정하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## A-04 다중이용시설·대피 교육용 · 비공식 요약 · 검수 전 전체 인원과 확인된 대피 인원을 구분합니다. 확인 질문: - 전체 이용 인원을 아나요? - 대피 인원 확인자는 누구인가요? - 미확인 구역은 어디인가요? 준비 검토: - 인원·구역별 보고 준비 - 추가 구조 자원 검토 주의: 대피 신고 한 건으로 모든 구역 확인을 끝내지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## A-05 창고·일반 가연물 교육용 · 비공식 요약 · 검수 전 가연물과 추가 저장 위험을 함께 확인합니다. 확인 질문: - 보관 품목을 아나요? - 위험물·배터리가 있나요? - 건물 구조 정보가 있나요? 준비 검토: - 품목·시설 정보 확보 - 장비·용수 가용성 확인 주의: 창고 용도만으로 일반 화재에 한정하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## B-01 유류 저장 창고 교육용 · 비공식 요약 · 검수 전 유류 존재와 물질 식별을 구분합니다. 확인 질문: - 물질명·표지가 있나요? - 유출은 직접 확인됐나요? - SDS·관계인 정보가 있나요? 준비 검토: - 물질별 자료·전문 지원 검토 - 적합 장비·약제 보유 확인 주의: 물질 미확인 상태에서 약제·진압 방법을 확정하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## B-02 주유 시설 화재 교육용 · 비공식 요약 · 검수 전 공급·저장 정보를 담당자와 확인합니다. 확인 질문: - 저장시설 정보를 받았나요? - 공급 상태 확인자는 누구인가요? - 인접 위험을 보고했나요? 준비 검토: - 시설 담당자 연락 준비 - 유류 대응 자원 점검 주의: 외부 화염만으로 저장시설 상태를 확정하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## B-03 도료·용제 보관 교육용 · 비공식 요약 · 검수 전 제품명과 성분 정보가 필요합니다. 확인 질문: - 제품명·성분을 아나요? - 용기가 손상됐나요? - 반응성 물질도 있나요? 준비 검토: - SDS 확인 담당 배정 - 전문 대응 자원 검토 주의: 검은 연기만으로 성분을 식별하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## B-04 연료 유출 의심 교육용 · 비공식 요약 · 검수 전 신고·확인·확산 범위를 구분합니다. 확인 질문: - 유출 관찰자는 누구인가요? - 물질·범위를 확인했나요? - 배수로 정보가 있나요? 준비 검토: - 현장 관찰·환경 지원 확인 - 유출 대응 장비 검토 주의: 표식은 확인 위치와 오차를 포함해야 합니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## B-05 위험물 운송 차량 교육용 · 비공식 요약 · 검수 전 적재물 정보를 확보해야 합니다. 확인 질문: - 표지·적재 자료가 있나요? - 용기 손상 정보가 있나요? - 운전자에게 확인했나요? 준비 검토: - 적재물 정보 확인 - 전문 지원 연락 준비 주의: 차량 외관만으로 적재 물질을 판정하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## C-01 전기실 화재 교육용 · 비공식 요약 · 검수 전 통전 상태는 확인 주체와 함께 기록합니다. 확인 질문: - 전원 차단 보고자는 누구인가요? - 재투입 가능성 정보가 있나요? - 전기 담당자가 있나요? 준비 검토: - 전력·시설 담당 협조 - 보호장비 적합성 확인 주의: 미확인 차단을 완료로 표시하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## C-02 분전반·배선 발화 교육용 · 비공식 요약 · 검수 전 전원·위험 구역 정보를 확인합니다. 확인 질문: - 분전반 위치를 아나요? - 회로 정보를 확보했나요? - 주변 인명 정보가 있나요? 준비 검토: - 전기 담당 연락 준비 - 위험 구역 보고 준비 주의: 영상만으로 전압·차단을 판단하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## C-03 변압 시설 주변 교육용 · 비공식 요약 · 검수 전 시설과 인접 위험을 따로 등록합니다. 확인 질문: - 운영자에게 연락했나요? - 전원 확인 기록이 있나요? - 접근·낙하 위험이 있나요? 준비 검토: - 전력기관 지원 검토 - 시설·장비 정보 확인 주의: 임의 전압·안전거리 수치를 만들지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## C-04 침수와 전기 위험 교육용 · 비공식 요약 · 검수 전 침수 관찰과 차단을 분리합니다. 확인 질문: - 침수 구역이 어디인가요? - 전기시설 위치를 아나요? - 차단을 누가 확인했나요? 준비 검토: - 전력 담당 협조 확인 - 접근 위험 검토 주의: 침수를 전기 위험 해소로 해석하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## C-05 전원 차단 보고 정정 교육용 · 비공식 요약 · 검수 전 관련 판단·명령을 재검토합니다. 확인 질문: - 원래 보고자는 누구인가요? - 정정 이유는 무엇인가요? - 관련 명령이 있나요? 준비 검토: - 정정·수신 대상 확인 - 관련 명령 재검토 주의: 기존 보고를 삭제해 이력을 잃지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## K-01 식당 주방·기름 화재 교육용 · 비공식 요약 · 검수 전 주방 화재와 가스 위험을 따로 확인합니다. 확인 질문: - 식용유 사용을 확인했나요? - 가스 공급 확인자는 누구인가요? - 내부 인원을 확인했나요? 준비 검토: - 주방 대응 승인 자료 확인 - 가스 확인·장비 담당 배정 주의: 식당이라는 이유로 K급이나 차단을 확정하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## K-02 튀김기 화재 의심 교육용 · 비공식 요약 · 검수 전 기구·유지·열원 정보가 필요합니다. 확인 질문: - 어떤 조리기구인가요? - 동식물유 사용을 확인했나요? - 열원 확인자는 누구인가요? 준비 검토: - 기구·대응 자료 확인 - 시설 담당 연락 준비 주의: 소화 수단을 임의로 정하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## K-03 후드·덕트 연기 교육용 · 비공식 요약 · 검수 전 연기와 실제 연소 위치를 구분합니다. 확인 질문: - 어디서 연기가 보이나요? - 덕트 정보가 있나요? - 주변 구역에도 연기가 있나요? 준비 검토: - 구조·관찰 구역 확인 - 전문 장비 검토 주의: 연기 출구를 발화 위치로 확정하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## K-04 주방과 전기 위험 교육용 · 비공식 요약 · 검수 전 주방·전기·가스를 병행 확인합니다. 확인 질문: - 열원은 전기인가요? - 공급·전원을 확인했나요? - 다른 기구도 영향을 받나요? 준비 검토: - 전기·가스 확인 경로 준비 - 위험별 장비 점검 주의: 한 위험 확인으로 다른 위험까지 해소하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## K-05 주방화재 후 잔여 위험 교육용 · 비공식 요약 · 검수 전 화염 감소 이후 확인을 유지합니다. 확인 질문: - 잔여 열원이 있나요? - 공급·전원을 재확인했나요? - 종료 담당자가 누구인가요? 준비 검토: - 추가 관찰 담당 배정 - 자원 복귀 상태 정리 주의: 불꽃이 사라졌다고 종료를 확정하지 않습니다. 참고: [소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) · 1.7.1 정의 · 확인 판본 2024-07-25 · 용어 정의 참고 · 전술 근거 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## G-01 가스 냄새·누출 의심 교육용 · 비공식 요약 · 검수 전 냄새는 단서이며 누출 확정이 아닙니다. 확인 질문: - 냄새 관찰 위치는 어디인가요? - 가스 종류를 아나요? - 계측·공급 보고가 있나요? 준비 검토: - 가스 장비 준비 확인 - 시설·가스 담당 협조 주의: RGB 영상으로 가스 성분·농도를 판정하지 않습니다. 참고: [긴급구조대응활동 및 현장지휘에 관한 규칙](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) · 제10조 · 2026-07-01 시행 표시 · 지휘 제도 참고 · 카드 행동은 자체 설계 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## G-02 가스 용기 미확인 교육용 · 비공식 요약 · 검수 전 용기 종류·상태는 출처가 필요합니다. 확인 질문: - 표지가 있나요? - 현장 확인자는 누구인가요? - 공급·저장 방식을 아나요? 준비 검토: - 용기·물질 자료 확보 - 전문 지원 배정 주의: 외형만으로 가스 종류를 확정하지 않습니다. 참고: [긴급구조대응활동 및 현장지휘에 관한 규칙](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) · 제10조 · 2026-07-01 시행 표시 · 지휘 제도 참고 · 카드 행동은 자체 설계 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## G-03 가스 공급 차단 확인 교육용 · 비공식 요약 · 검수 전 요청과 완료를 구분합니다. 확인 질문: - 차단 요청자는 누구인가요? - 완료 확인자는 누구인가요? - 시각·대상 시설이 있나요? 준비 검토: - 담당기관 확인 기록 - 관련 임무 재검토 주의: 차단 요청을 차단 완료로 처리하지 않습니다. 참고: [긴급구조대응활동 및 현장지휘에 관한 규칙](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) · 제10조 · 2026-07-01 시행 표시 · 지휘 제도 참고 · 카드 행동은 자체 설계 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## G-04 가스 계측 보고 교육용 · 비공식 요약 · 검수 전 위치·시각·단위·장비 상태가 필요합니다. 확인 질문: - 장비·교정 정보가 있나요? - 위치·시각을 기록했나요? - 단위를 확인했나요? 준비 검토: - 계측 양식 확인 - 전문 담당 검토 주의: 오래된 측정으로 전체 구역 안전을 선언하지 않습니다. 참고: [긴급구조대응활동 및 현장지휘에 관한 규칙](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) · 제10조 · 2026-07-01 시행 표시 · 지휘 제도 참고 · 카드 행동은 자체 설계 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## G-05 폭발음 신고 교육용 · 비공식 요약 · 검수 전 소리 신고와 손상 확인을 구분합니다. 확인 질문: - 소리를 누가 들었나요? - 손상을 직접 확인했나요? - 추가 인명 정보가 있나요? 준비 검토: - 신고·현장 정보 대조 - 추가 전문 자원 검토 주의: 소리만으로 원인 물질을 판정하지 않습니다. 참고: [긴급구조대응활동 및 현장지휘에 관한 규칙](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) · 제10조 · 2026-07-01 시행 표시 · 지휘 제도 참고 · 카드 행동은 자체 설계 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## R-01 신고 위치 보완 교육용 · 비공식 요약 · 검수 전 주소·관찰 위치를 확인합니다. 확인 질문: - 주소를 확인했나요? - 건물명을 아나요? - 층·구역을 구분했나요? 준비 검토: - 위치 후보 기록 - 출동 담당자에게 공유 주의: 추정 좌표를 확정 위치처럼 표시하지 않습니다. 참고: [긴급구조대응활동 및 현장지휘에 관한 규칙](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) · 제10조 · 2026-07-01 시행 표시 · 지휘 제도 참고 · 카드 행동은 자체 설계 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## R-02 인명·대피 정보 교육용 · 비공식 요약 · 검수 전 확인된 인원과 추정을 구분합니다. 확인 질문: - 확인자는 누구인가요? - 확인 구역은 어디인가요? - 미확인 구역이 남았나요? 준비 검토: - 인명 확인표 준비 - 구조·보고 담당 배정 주의: 대피 인원을 전체 인원으로 간주하지 않습니다. 참고: [긴급구조대응활동 및 현장지휘에 관한 규칙](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) · 제10조 · 2026-07-01 시행 표시 · 지휘 제도 참고 · 카드 행동은 자체 설계 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## R-03 차량·장비 준비 교육용 · 비공식 요약 · 검수 전 보유·준비·가용은 다릅니다. 확인 질문: - 차량에 장비가 있나요? - 작동을 확인했나요? - 다른 사건 배정이 있나요? 준비 검토: - 차량별 준비 확인 - 부족·불가 보고 주의: 보유만으로 준비 완료를 표시하지 않습니다. 참고: [긴급구조대응활동 및 현장지휘에 관한 규칙](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) · 제10조 · 2026-07-01 시행 표시 · 지휘 제도 참고 · 카드 행동은 자체 설계 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## R-04 관계기관 지원 교육용 · 비공식 요약 · 검수 전 요청·접수·도착을 구분합니다. 확인 질문: - 연락 주체가 정해졌나요? - 접수를 확인했나요? - 상태를 갱신했나요? 준비 검토: - 요청·접수 기록 - 현장 연락 담당 확인 주의: 연락 시도를 지원 도착으로 표시하지 않습니다. 참고: [긴급구조대응활동 및 현장지휘에 관한 규칙](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) · 제10조 · 2026-07-01 시행 표시 · 지휘 제도 참고 · 카드 행동은 자체 설계 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## R-05 복합 위험 재판단 교육용 · 비공식 요약 · 검수 전 새 보고의 영향을 검토합니다. 확인 질문: - 무엇이 달라졌나요? - 직접 확인한 출처가 있나요? - 영향받는 명령이 있나요? 준비 검토: - 관찰·판단 버전 연결 - 기존 명령 재검토 주의: 상충 정보를 조용히 덮어쓰지 않습니다. 참고: [긴급구조대응활동 및 현장지휘에 관한 규칙](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) · 제10조 · 2026-07-01 시행 표시 · 지휘 제도 참고 · 카드 행동은 자체 설계 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## D-01 드론 정찰 계획 교육용 · 비공식 요약 · 검수 전 비행 조건과 관찰 목적을 확인합니다. 확인 질문: - 운용팀이 있나요? - 기상·공역·통신을 확인했나요? - 귀환 조건이 있나요? 준비 검토: - 운용 담당 확인 - 기체·센서·배터리 점검 주의: 출동 승인만으로 비행 조건을 충족하지 않습니다. 참고: [소방 무인비행장치 운용규정](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000260620) · 운용인력·임무 · 확인 판본 2025-07-01 · 운용 역할 참고 · 개별 비행 허가 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## D-02 드론 운용 불가 교육용 · 비공식 요약 · 검수 전 드론 없이 관찰을 지속합니다. 확인 질문: - 불가 사유가 있나요? - 대체 영상이 있나요? - 선착 보고 경로가 있나요? 준비 검토: - 대체 관찰 준비 - 차량 출동 상태 공유 주의: 드론 실패로 출동을 자동 중지하지 않습니다. 참고: [소방 무인비행장치 운용규정](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000260620) · 운용인력·임무 · 확인 판본 2025-07-01 · 운용 역할 참고 · 개별 비행 허가 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## D-03 영상 지연·정지 교육용 · 비공식 요약 · 검수 전 연결과 영상 신선도를 구분합니다. 확인 질문: - 프레임 시각이 있나요? - 화면이 멈췄나요? - 대체 보고가 있나요? 준비 검토: - 지연·정지 표시 - 텍스트 관찰로 전환 주의: 정지 프레임에 실시간 표식을 유지하지 않습니다. 참고: [소방 무인비행장치 운용규정](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000260620) · 운용인력·임무 · 확인 판본 2025-07-01 · 운용 역할 참고 · 개별 비행 허가 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## D-04 열화상 관찰 교육용 · 비공식 요약 · 검수 전 열원 후보와 위험 확정을 구분합니다. 확인 질문: - 보정 정보가 있나요? - 반사·가림이 있나요? - 다른 출처로 확인했나요? 준비 검토: - 후보·프레임 기록 - 현장 확인 검토 주의: 열화상으로 내부 인원·가스를 확정하지 않습니다. 참고: [소방 무인비행장치 운용규정](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000260620) · 운용인력·임무 · 확인 판본 2025-07-01 · 운용 역할 참고 · 개별 비행 허가 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## D-05 드론·차량 선착 교육용 · 비공식 요약 · 검수 전 도착 기록으로 선착을 비교합니다. 확인 질문: - 준비 시간이 포함됐나요? - 도착 시각이 있나요? - 차량 도착을 확인했나요? 준비 검토: - ETA 구분 - 실제 도착 보고 주의: 늦게 도착한 드론을 선행 성공으로 표시하지 않습니다. 참고: [소방 무인비행장치 운용규정](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000260620) · 운용인력·임무 · 확인 판본 2025-07-01 · 운용 역할 참고 · 개별 비행 허가 아님 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## E-01 통신 미수신 교육용 · 비공식 요약 · 검수 전 전달과 수신 확인을 구분합니다. 확인 질문: - 미수신 대상은 누구인가요? - 대체 연락을 했나요? - 복창 확인이 있나요? 준비 검토: - 미수신 목록 준비 - 대체 절차 확인 주의: 전송 성공을 사람이 확인한 것으로 처리하지 않습니다. 참고: [긴급구조대응활동 및 현장지휘에 관한 규칙](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) · 제10조 · 2026-07-01 시행 표시 · 지휘 제도 참고 · 카드 행동은 자체 설계 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## E-02 지휘권 인계 교육용 · 비공식 요약 · 검수 전 책임자·위임을 명시합니다. 확인 질문: - 현재 지휘관은 누구인가요? - 인계를 확인했나요? - 위임 범위를 기록했나요? 준비 검토: - 사건 권한 갱신 - 미완료 임무 인계 주의: 직책만으로 승인 권한을 부여하지 않습니다. 참고: [긴급구조대응활동 및 현장지휘에 관한 규칙](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) · 제10조 · 2026-07-01 시행 표시 · 지휘 제도 참고 · 카드 행동은 자체 설계 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## E-03 명령 취소·대체 교육용 · 비공식 요약 · 검수 전 취소 기록과 전달을 구분합니다. 확인 질문: - 명령 ID가 있나요? - 대상이 확인했나요? - 진행 임무를 확인했나요? 준비 검토: - 버전·수신 상태 확인 - 미수신 대상 재확인 주의: 화면 삭제만으로 취소 완료를 간주하지 않습니다. 참고: [긴급구조대응활동 및 현장지휘에 관한 규칙](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) · 제10조 · 2026-07-01 시행 표시 · 지휘 제도 참고 · 카드 행동은 자체 설계 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## E-04 잔여 위험·재평가 교육용 · 비공식 요약 · 검수 전 감소 이후 최신 정보를 점검합니다. 확인 질문: - 위험이 남았나요? - 관찰 시각이 최신인가요? - 미완료 임무가 있나요? 준비 검토: - 관찰·이행 갱신 - 종료 담당 검토 주의: 화염 감소는 모든 위험 해소와 다릅니다. 참고: [긴급구조대응활동 및 현장지휘에 관한 규칙](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) · 제10조 · 2026-07-01 시행 표시 · 지휘 제도 참고 · 카드 행동은 자체 설계 검수자: 미배정 · 검수일: 없음 · 상태: 초안 ## E-05 훈련 종료·복기 교육용 · 비공식 요약 · 검수 전 정보·결정·이행을 연결합니다. 확인 질문: - 종료 조건을 확인했나요? - 수신·이행이 기록됐나요? - 개선할 확인 항목이 있나요? 준비 검토: - 시간축·근거 정리 - 개입·미완료 기록 주의: AI 결과를 전문가 검수 정답으로 표시하지 않습니다. 참고: [긴급구조대응활동 및 현장지휘에 관한 규칙](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) · 제10조 · 2026-07-01 시행 표시 · 지휘 제도 참고 · 카드 행동은 자체 설계 검수자: 미배정 · 검수일: 없음 · 상태: 초안
docs/LIBRARY_HTML_SPEC.md
# HTML 교재 자료실 명세 사용자 요청: 2026 교재 전체 목록, 오른쪽 페이지별 HTML 본문, HTML/PDF 두 탭과 HTML 기본값, 이전/다음/지정 페이지 이동·검색, PDF 추가 후 HTML 자동 생성 및 목록 끝 추가, 마우스 끌기로 순서 변경. 공식 교재14권은 검증된 원문·페이지 텍스트에서 독립 HTML 문서를 생성한다. HTML 탭은 추출 본문을 안전한 DOM으로 읽고 검색어를 강조한다. 그림·표 배치와 스캔은 PDF 탭에서 원문을 확인한다. PDF 파일·페이지 연결·복사·해설·북마크는 유지한다. 페이지 입력은 독립 초안과 이동 버튼으로 처리하고 새 문서/페이지 조회는 이전 응답을 폐기한다. 업로드는 기존 기관 관리자 인증과 동일 Origin 검사 후 허용한다. 업로드 원문과 HTML은 기관 내부 로그인 사용자만 조회한다. 공식 교재는 공개다. 공개 방문자·일반 기관 사용자의 순서는 브라우저에, 관리자 변경 순서는 기관 공통 DB에 저장한다. 기관 공통 순서는 버전 검증으로 다른 변경을 덮지 않는다. 마우스 끌기 및 키보드/모바일 위·아래 버튼을 제공한다. 업로드 PDF64MiB·1500페이지·추출 본문1200만자, 기관30개/512MiB로 제한한다. 별도 Node 프로세스에서 PDF.js로 추출하고60초/메모리 제한을 적용한다. 암호화/손상/상한 초과는 목록 추가 없이 실패한다. 이미지 PDF도 HTML 페이지를 생성하되 텍스트 미추출을 표시하고 원문으로 연결한다. OCR은 구현 범위에 없다. 저장명은 서버 UUID로 생성하며 파일명·PDF 문자열을 HTML로 실행하지 않는다. 원문 SHA와 이용 조건을 보존하고 사용자 업로드를 공식 검증 교재로 표시하지 않는다. 재배포 후 자료는 영구 /data에 남는다. Nginx 업로드 크기 설정과 Compose·격리 E2E 저장 경로를 갱신한다. manual.html/logic.html과 재구현 프롬프트를 함께 갱신하고 단위·API 권한·PC/모바일 브라우저·Linux 배포로 검증한다. 변환 API 참고: https://mozilla.github.io/pdf.js/examples/ (Mozilla 공식 getDocument/getPage 설명). 화면은 PC에서 오른쪽 읽기 영역의 내부 스크롤과 자연스러운 패널 높이를 사용한다. 본문을 grid 행 축소·overflow로 숨기지 않으며 1366×768에서 본문의 실제 노출 높이180px 이상을 확인한다. 스마트폰은 목록 접기·출처 안내 펼치기·간결한 검색·44px 페이지 버튼과 필요한 세로 스크롤을 제공한다.
docs/LIBRARY_HTML_VALIDATION.md
# v0.6.0 HTML 교재 자료실 검증 검증일2026-10-09. 공식 교재14권의 원문 PDF와 페이지 인덱스를 유지하고 전체 HTML을 생성했다. HTML 기본 탭에서 페이지별 본문·이전/다음/지정 페이지·검색 강조를 제공하며 PDF 탭은 같은 원문 페이지를 유지한다. 그림/표 배치와 스캔은 PDF 기준이며 OCR은 구현하지 않았다. - 단위 시험107건/18파일 통과. 실제 PDF 추출·원문 바이트 보존·HTML 이스케이프·기관별 목록/본문/검색 격리·순서 버전/저장 재개·손상 PDF의 부분 목록 방지, HTTP Origin/미인증/대원/업로드 상한 거부를 검증했다. - 새 mjs 변환 스크립트가 logic.html의 소스 지문/재구현 스냅샷에 포함됨을 실패→통과 시험으로 확인했다. - TypeScript, 공식14권 PDF 해시/페이지/전체 HTML 일치, 음원 해시, 문서 지문 및 production build 통과. - 격리 서버 전체 Playwright33건 통과. 교재14권 HTML 기본값·페이지 앞뒤/번호 이동·검색/강조·PDF 탭/동일 페이지·HTML 다운로드, 마우스 드래그/모바일44px 이동/새로고침 순서 보존, 이전 페이지 지연 응답 폐기, 관리자 PDF 업로드의 실제 변환·byte range·원문 다운로드·순서 충돌·로그아웃 접근 차단을 검증했다. 기존 교육·음향·기관 승인/보고·휴대폰 UI 회귀 검증도 통과했다. - 독립 코드 리뷰 P2 1건: 늦은 업로드/순서 응답이 로그아웃 후 기관 목록을 복원하는 문제. 순서 응답 지연에서14개가15개로 바뀌는 실패를 재현하고 요청 epoch/컴포넌트 수명 검사·업로드 abort로 수정했다. 두 종류의 지연 응답 모두 브라우저 시험이 통과했다. - 사용 안내·재구현 프롬프트/manual.html/logic.html을 함께 갱신했다. 업로드는 기존 기관 관리자 계정을 사용하며 자동 계정/공개 익명 업로드를 만들지 않는다. 같은 기관 사용자에게만 공유한다. 배포 확인은 해당 Git SHA Linux Docker 빌드/healthy, Linux PDF 추출 자식 프로세스, 외부 HTML/PDF·페이지/검색·드래그·PC/모바일 검증, 공식14권 HTML·문서 해시와 기존 교육 사건 보존, /api/library/upload 전용65m Nginx 설정으로 수행한다. 운영 기관 DB에 시험 계정을 만들지 않는다. 스마트폰은 브라우저 크기 검증이며 실제 휴대폰 하드웨어는 별도다. 배포 후 시각 검토에서 PC grid 행 축소와 panel overflow 때문에 본문이 숨는 오류를 발견했다. 읽기 영역을 자연스러운 높이의 패널과 내부 스크롤로 수정하고 목록 접기 버튼의 CSS 우선순위를 수정했다. 회귀 시험은 수정 전 실패했고 수정 후1366×768에서 본문 실제 노출 높이180px 이상·전체 문서 화면 안 유지, 스마트폰 읽기 패널 시작600px 이내·44px 페이지 버튼·가로 넘침 없음을 확인했다. PC·390×844 화면도 직접 검토했다. Linux 운영 환경에서 실제 예방실무1 PDF486페이지와3페이지 시험 PDF를 별도 임시 파일로 추출해 한글 본문·페이지 수를 확인했다. 최초 HTML 릴리스의 외부 브라우저6건·14권 HTML/문서 해시·기존 사건 version21/elapsed20 보존·업로드의 미인증401/잘못된 Origin403도 통과했다. 화면 수정 릴리스에는 동일 배포 절차와 외부 검증을 적용한다.
docs/RESPONSIVE_WORKSPACE_SPEC.md
# PC·스마트폰 상황 공유 화면 개선 ## Plan · 요구와 명세 - PC 1366×768와 1920×1080에서 교육 상황본부의 상황도·자원·AI·명령·관찰·복기 제목과 핵심 조작을 한 화면에 유지한다. 긴 기록은 패널 안에서 스크롤한다. - 스마트폰 360×800와 390×844에서 교육 상황도 / AI·명령 / 관찰·드론 / 복기를 버튼으로 전환한다. 개입·진행 조작을 숨기지 않는다. - 기관용 PC는 사건 목록, 현장 상황, 명령·보고를 병렬 배치한다. 스마트폰은 명령·보고 / 현장 상황 / 사건 목록으로 전환하고 대원에게 명령·보고를 먼저 보여준다. - 로그인·기관·배정·역할 검증은 기존 서버 계약을 유지한다. 화면 크기를 권한 판단에 사용하지 않는다. 조회 실패 시 마지막 성공 시각과 갱신 실패를 표시한다. - 메뉴는 스마트폰에서 열기/닫기로 제공하고 조작은 44px 이상, 입력은 16px 이상으로 유지한다. 낮은 화면·확대·긴 문서는 필요한 스크롤을 허용하고 내용을 잘라 버리지 않는다. - 같은 네트워크 시험 실행 명령을 추가한다. 기존 기본 실행은 로컬 전용이다. 외부 접속은 같은 HTTPS 서비스 주소와 기관 계정·배정이 필요하다. - 모든 이후 화면 변경은 PC와 스마트폰을 함께 검증하도록 AGENTS.md·사용 안내·재구현 프롬프트에 기록한다. ## 구현 순서 1. 브라우저 회귀 테스트를 먼저 작성하고 기존 빌드 실패를 확인한다. 2. 공통 모바일 메뉴와 화면 선택, PC 높이 배치·내부 스크롤을 구현한다. 3. 기관용 대원의 모바일 수신→수행→완료 보고와 조회 실패 표시를 검증한다. 4. 설명서·로직 HTML을 동기화하고 기존 단위·타입·빌드·브라우저 검증을 통과시킨다. 5. PC·스마트폰 실제 렌더링을 점검하고 실행 서버를 새 빌드로 교체한다. ## Check · 수용 기준 - PC 상황본부는 기본 배율 기준으로 페이지 전체의 가로·세로 넘침이 없고 긴 목록 마지막 항목에 접근할 수 있다. - 스마트폰은 메뉴와 각 화면 전환, 상황도 확인, 개입·진행 재개, 기관 로그인·수신·수행·완료 보고를 지원한다. - 작은 화면이나 화면 확대에서는 접근 가능성을 위해 스크롤을 허용한다. 다른 사용자의 사건이나 권한은 노출되지 않는다. ## Do / Check / Act · v0.3.3 검증 결과 - PC 1366×768·1920×1080: 페이지 전체 넘침 없음, 핵심 패널 제목·긴 명령 목록 마지막 항목 접근 검증. 시연 시작 후 설정을 접고 지도 높이 180px 이상 유지. - 스마트폰 360×800·390×844: 상황도/AI·명령/관찰·드론/복기 전환, 개입·진행 재개, 메뉴 열기/닫기 및 주요 자료 페이지의 가로 넘침 검증. - 기관용 390×844: 실제 격리 서버에서 대원 로그인→배정 사건 제목·위치→수신→수행→완료 보고 검증. PC 상황실의 5초 자동 조회에 완료 보고가 반영되는 것을 확인. - 조회 실패 표시·마지막 성공 시각·재조회 복구, 기존 권한·계정 회수·지연 조회·사건별 입력 격리 검증. - 단위 64건, 브라우저 20건, 타입 검사, 빌드, 교재 14권 해시, 문서 지문 검사 통과. - PC·스마트폰 렌더링과 수동 지휘 설정, 낮은 가로 화면의 스크롤 대안을 추가 점검. - start:lan의 LAN 주소에서 HTTP 200 확인. 실제 휴대폰 네트워크·방화벽 통과와 외부 HTTPS 배포는 이 검사에 포함하지 않는다. 기관 기본 데이터·계정은 변경하지 않았다. 개선 과정에서 PC의 기본 grid 최소 높이가 긴 기록에 따라 커지는 문제를 minmax(0,1fr)·min-height:0으로 해결했다. 한 화면 배치 때문에 지도가 작아지는 문제는 진행 중 설정 접기로 개선했다. 문서 동기화와 앞으로의 PC·스마트폰 동시 검증을 작업 규칙에 반영했다. 별도 코드 리뷰에서 발견한 두 결함을 추가 보완했다. - 낮은 모바일 화면: 740px 이하에서 패널 최소 높이와 세로 스크롤을 유지한다. 360×621·360×640 지도 195px, 390×650 지도 209px를 독립 리뷰에서 확인했다. - HTTP LAN: randomUUID가 없는 원점에서는 getRandomValues 기반 UUID v4를 사용한다. LAN 원점의 수신→수행→완료 3회 POST·UUID 고유성·사건 버전·브라우저 오류 없음과 독립 리뷰 확인을 통과했다.
docs/UE5_RESTAURANT_SPEC.md
# 식당 화재 UE5 연결 명세
작성일: 2026-10-09. 범위: 교육 시연 `restaurant-gas` 한 개. 기존 사용자의 UE5 구현 및 웹·스마트폰 연결 승인을 실행한다.
## 목표와 실행 환경
UE5.8 C++ 프로젝트에서 소방서·도로·식당 주방·펌프차·드론·불꽃·연기를 구성한다. 기존 웹 서버가 사건의 유일한 상태 원본이다. UE5는 공개된 상태를 읽어서 표현한다. Pixel Streaming으로 PC와 휴대폰에 실제 UE 렌더링을 보낸다. 엔진 미설치 시 연결 대기 안내를 제공하며 다른 렌더러를 UE5라고 표시하지 않는다.
엔진 설치는 Epic 계정 로그인 및 이용약관 수락이 필요하다. 이 환경에서 엔진이 발견되지 않았으므로 네이티브 빌드·실행·영상 품질은 설치 뒤 검증해야 한다. 웹 연결과 소스 검증은 독립적으로 진행한다. 유료 자산·클라우드 배포는 범위에 없다.
## 상태 계약
GET `/api/sessions/{id}/scene`은 DEMO 식당 사건만 읽으며 사건 시간을 진행시키거나 명령을 발행하지 않는다. 다른 시나리오는 409, 없는 사건은 404. 캐시 금지. `packages/simulation/scene.ts`의 `restaurantScene(state)`가 아래 DTO를 생성한다.
```ts
type WorldPoint = { x: number; y: number; z: number }; // UE 센티미터
type RestaurantScene = {
protocol: "ai-firestation.restaurant.v1";
sessionId: string;
version: number;
elapsed: number;
paused: boolean;
status: "active" | "closed";
scenario: { id: "restaurant-gas"; title: string };
station: WorldPoint;
incident: WorldPoint;
pumpRoute: WorldPoint[];
pump: { position: WorldPoint; progress: number; status: string; eta: number };
drone: {
position: WorldPoint;
progress: number;
status: string;
eta: number;
};
fire: { visible: boolean; intensity: number; cells: number[] };
};
```
세계 좌표 변환: `(mapX - 360)*20`, `(mapY - 180)*20`, z는 펌프 0, 이동/정찰 드론 900cm, 대기 드론 120cm. 지도 상수와 ETA는 `packages/core/map-motion.ts`에서 재사용한다. progress는 0~1로 제한한다. 펌프는 polyline의 거리 비율을 따르고 드론은 소방서에서 신고 위치까지 직선이다. fire.cells는 기존 PublicState에 공개된 값만 사용하며 미공개 시 visible=false,intensity=0,cells=[]다. intensity는 공개 cells 평균/100을 0~1로 제한한다. 숨겨진 타임라인·rng·정답·미래 이벤트는 보내지 않는다. 일시정지 동안 위치와 화재 표현은 고정한다.
## 네이티브 구현
프로젝트 `unreal/AIFirestation/AIFirestation.uproject`, 모듈 AIFirestation, 기본맵 `/Game/Maps/RestaurantFire`. UE Python 부트스트랩으로 맵과 재사용 가능한 재질을 생성한다. C++ GameMode/Actor가 실제 장면을 구성한다. 불꽃·연기는 동적으로 만든 재질/메시로 표현하며 누락된 외부 자산을 요구하지 않는다. 소방서와 식당 위치는 DTO 좌표와 동일하다.
`-FireSession=<UUID> -FirestationBaseUrl=http://127.0.0.1:3100` 인수를 받아 0.5초 간격으로 공개 상태를 읽는다. 요청 중복·종료된 actor 콜백·역전 버전·잘못된 protocol/session을 차단한다. 위치를 임의로 진행시켜 서버보다 먼저 도착하지 않는다. `UPixelStreamingInput`의 입력은 아래 카메라 선택만 허용한다.
```json
{ "type": "firestation.camera", "sessionId": "현재 UUID", "camera": "overview" }
```
camera 목록 overview/drone/vehicle/crew. 명령·작전·기관용 API는 호출하지 않는다. 같은 UE 인스턴스에 연결한 시청자는 같은 카메라를 공유한다. 종료/정지 상태와 영상 연결 중단을 구별한다.
## 웹과 스마트폰
`/simulation?session=<UUID>`에서 해당 사건의 상태와 실제 UE 영상, 44px 이상 카메라 버튼, 연결 상태, 2D 상황본부 복귀를 표시한다. 시연 화면의 식당 사건에서 바로 들어간다. 서버 환경변수 `FIRESTATION_UE_SIGNAL_URL`을 사용하며 휴대폰에 localhost를 전달하지 않는다. 미설정이면 스트리밍 실행 안내만 표시한다. ws/wss 및 mixed-content를 확인한다.
공식 frontend `@epicgames-ps/lib-pixelstreamingfrontend-ue5.8@0.1.2`를 브라우저에서만 로드한다. streamerId는 session UUID로 고정하고 자동으로 다른 streamer를 선택하지 않는다. 마이크·키보드·마우스·게임패드 입력은 끈다. 버튼 입력만 카메라 메시지를 보낸다. 영상 재생 이벤트가 실제 발생해야 연결 완료로 표시한다. unmount 시 연결·타이머·이벤트·비디오 DOM을 해제한다. 스마트폰의 재생 제스처와 영상 오류 안내를 제공한다. UE Viewer는 읽기 전용이며 사건 진행은 기존 상황본부에서 제어한다.
## 운영 절차와 문서
엔진 경로 점검 → C++ 빌드 → UE Python 맵/재질 생성 → 공식 UE5.8 signalling 실행 → 해당 UUID로 UE 실행 → 웹 연결 순서. 스크립트는 엔진 경로 및 session UUID를 검증하고 실패를 성공으로 반환하지 않는다. 신호 서버·프로젝트 build 산출물은 Git 및 문서 스냅샷에서 제외한다. 로컬/LAN 연결부터 검증하고 외부망은 HTTPS/WSS 및 TURN을 별도 구성한다.
manual.html과 logic.html은 UE C++/헤더/설정/Python/PowerShell까지 소스 지문과 스냅샷에 포함해 자동 갱신한다. 네이티브 미검증 상태와 설치·빌드 방법, 재구현 프롬프트를 명시한다.
## 검수 기준
1. 공개 상태 투영 단위 시험: 경로·직선·승인 지연·정지·비공개 화재·지원 시나리오 제한.
2. 웹 PC/휴대폰 E2E: 사건별 진입, 연결 대기, 카메라/오류/복귀, 화면 넘침, 다른 시나리오 차단.
3. 기존 typecheck/unit/build/E2E 및 HTML 동기화 통과.
4. UE 설치 뒤 실제 C++ 빌드, 맵 생성, 영상 재생, 두 기기 동시 접속, 정지·도로 경로·카메라 전환. 이 검수 없이 UE 실행 완료를 선언하지 않는다.
docs/UE5_VALIDATION.md
# UE5 식당 현장 구현·검증 기록 날짜: 2026-10-09. 버전: 0.4.0. 명세: UE5_RESTAURANT_SPEC.md. ## 구현한 범위 - restaurant-gas DEMO의 공개 상태를 UE 센티미터 좌표로 제공하는 읽기 전용 API. - 소방서·도로·식당 주방·펌프차·드론·네 시점·노이즈 불꽃/연기 재질을 생성하는 UE5.8 C++/Python 소스. - 공식 Pixel Streaming frontend를 쓰는 PC·스마트폰 Viewer, 같은 사건 스트리머 고정, 카메라 전용 입력, 실제 영상 재생 이벤트 확인. - 엔진 점검·빌드·맵/재질 생성·실행과 공식 고정 버전 신호 서버 설치·실행 스크립트. - UE 텍스트 소스까지 포함하는 manual.html·logic.html 자동 갱신. ## 확인한 결과 - 단위 시험 87개 통과: 기존 엔진/기관용 권한·문서, 장면 API, 접속 설정, 영상 재생 수명, 실행 입력, Python 자산 연결·저장 실패 경로 포함. - 타입 검사 및 Next 최적화 빌드 통과. - 격리 서버/임시 DB 브라우저 시험 24개 통과: 기존 기관 업무와 PC·스마트폰, 식당 현장 진입/대기/재시도/잘못된 사건 차단. - 공식 신호 서버 UE5.8 커밋 e5e243ed038313d3dad4e3fb9087d4393a5df192 설치·Common/Signalling/서버 빌드·기동 확인. 8880 브라우저/8888 UE 포트에서 실제 WebSocket config와 빈 streamerList 응답을 확인했다. - 실행 중인 웹 서버와 신호 서버를 연결한 PC 1366×768·휴대폰 390×844 화면 확인. 브라우저 실행 오류 없음, PC 세로 넘침·휴대폰 가로 넘침 없음. 엔진 부재 때 영상 readyState=0 및 카메라 비활성화를 확인했다. - 별도 리뷰에서 TObjectPtr 배열/삼항식, 재질 저장 실패 처리, 영상 재생 요청을 성공으로 오판하는 문제를 수정하고 회귀 검사했다. ## 아직 수행하지 못한 검증 이 PC에 Unreal Engine이 발견되지 않았다. 따라서 **네이티브 C++ 컴파일, UE 안에서 맵/재질 생성, 실제 3D 영상 재생·화질·두 물리 기기 동시 시청은 미검증**이다. 정적 소스 검토와 가짜 Unreal 실패 시험은 엔진 실행 검증을 대신하지 않는다. 에디터가 생성하는 .uasset/.umap은 저장소에 없으며 포함된 부트스트랩으로 생성해야 한다. UE5.8 설치 후 `unreal/README.md`에 따라 doctor → build → bootstrap → 신호 서버 → 해당 사건 UUID로 UE 실행 → 웹/휴대폰 재생을 검수한다. 드론 선착·펌프 도로 이동·4개 카메라·일시정지·영상 연결 중단도 실제 장면에서 확인해야 한다. Epic 계정 로그인 및 약관 수락은 설치 사용자가 수행한다. ## 신호 서버 의존성 공식 고정 소스의 초기 lock에는 운영 의존성 high/critical 취약점이 있었다. 강제/메이저 갱신 없이 호환 보안 패치를 적용했으며 운영 감사는 high=0, critical=0, moderate=5다. 개발 의존성 취약점도 남는다. setup은 운영 high/critical 또는 감사 실패가 있으면 빌드를 중단한다. 이 로컬 교육 연결을 공개 운영 배포 검증으로 간주하지 않는다. 기관용 실제 사건·자동 명령·실장비·영상 판독·화재 역학은 이 UE 교육 연결의 범위에 포함하지 않았다.
docs/UPGRADE_V0_3_PLAN.md
# AI Firestation v0.3 Implementation Plan
> For agentic workers: Use superpowers:executing-plans inline. 기존 사용자 구현 지시를 유지한다. 최종 fresh-context 리뷰를 한 번 수행한다.
**Goal:** 사용설명서와 재구현 문서를 상시 동기화하고, 공식 2026 교재 자료실과 인증된 기관용 지휘 지원을 구현한다.
**Architecture:** 기존 demo core와 새로운 station domain을 분리한다. SQLite와 인증·권한·감사 계층을 공유하고, 원문 자료실 및 HTML 생성기는 선언적 자료/문서 모델을 사용한다.
**Tech Stack:** Next.js 16.4 / React 19 / TypeScript / Zod / Node.js 22.22.3 node:sqlite / Vitest / Playwright / PDF text extraction.
**Spec:** docs/UPGRADE_V0_3_SPEC.md
## Global Constraints
- 기존 DEMO의 동작·문서 원본 보존. 기관용 자동 발행·가상 수신·자동 완료·실장비 제어 없음.
- 모든 코드 변경은 manual.html·logic.html 및 공개본 지문에 반영.
- 공식 원문·해설·기관 검토 근거를 구분. 전문가 검수/운영 인증을 주장하지 않음.
- 실제 개인정보 입력은 사용자가 수행. 샘플 계정·실제 비밀번호·사건 DB를 Git에 넣지 않음.
- 한국어 UI·주석, 접근성·모바일·오류 표시, 서버에서 권한 검증.
## Review Focus
- 최초 설정 경쟁/재시도와 로그인 차단: 관리자 하나, 재설정 거부, 올바른 토큰·만료.
- 대원 역할의 다른 기관/비배정 사건·명령 접근: 서버 403/404.
- 늦은 승인·새 관찰·취소 후 지연 수신: 충돌/재검토/취소 상태 유지.
- 중복 요청과 같은 자원 동시 배정: 트랜잭션 내 한 번만 반영.
- 소스와 문서가 서로 다른 스테이지로 커밋: 지문 검사 실패, 정확한 생성 절차 안내.
## Task 1: 문서 자동 생성·유지 규칙
Files: packages/docs/*, scripts/docs.ts, scripts/dev.ts, .githooks/pre-commit, AGENTS.md, manual.html, logic.html, apps/web/public/{manual,logic}.html, tests/docs.test.ts.
- [x] RED: 임시 소스 변경 후 지문 변경, 오래된 HTML 거부, 루트/공개본 일치, 프롬프트/오프라인 검색/복사 확인.
- [x] GREEN: `fingerprint(root)`, `renderDocuments(meta)`, `syncDocuments(root)`, `checkDocuments(root)` 구현. 같은 입력은 같은 결과, 시각은 변경 시에만 갱신.
- [x] dev 감시·predev/prebuild·docs:check·pre-commit(스테이지 검증) 연결.
- [x] npm test 및 문서 생성·검사, 작업 기록.
## Task 2: 공식 자료 확보·자료실
Files: packages/library/{catalog,index}, scripts/import-library.py, apps/web/app/library/*, apps/web/public/library/*, tests/library.test.ts.
- [x] 공식 2026 목록과 개별 게시글·첨부·공공누리 확인. 원문 URL·해시·판본·페이지·이용 조건 보존.
- [x] RED: 잘못된 페이지/미확인 이용 조건/검색 결과의 출처 연결/원문과 해설 분리.
- [x] GREEN: 허용 원문 PDF·페이지 텍스트 인덱스·독립 해설·검색·복사·북마크·원문 보기.
- [x] 네트워크 없이 저장 원문·검색 이용 확인, 문서 갱신.
## Task 3: 인증·SQLite·감사
Files: packages/station/{database,auth,types}, apps/web/lib/station-http.ts, apps/web/app/api/station/auth/*, tests/auth.test.ts, tests/database.test.ts.
- [x] RED: bootstrap 재시도/경쟁, 암호 평문 없음, 로그인 속도 제한·만료·로그아웃, 다른 기관 권한 거부, 감사 해시 검증.
- [x] GREEN: WAL/busy_timeout/외래키/BEGIN IMMEDIATE, 사용자·세션·설정·사건·자원·근거·감사·중복 키 테이블.
- [x] 역할 서버 조회·쿠키·같은 사이트 변경 요청·본문 크기 제한. 문서 갱신.
## Task 4: 기관용 지휘 지원 도메인
Files: packages/station/{service,assistant}, apps/web/app/api/station/*, tests/station.test.ts.
- [x] RED: 인증 주체만 stationId/role 결정, 사건 등록·인계, 관찰 추가, 버전 충돌, 자원 충돌, 초안·근거·새 관찰 재검토·승인, 대상별 상태·취소, 종료 미완료 차단, 중복 요청.
- [x] GREEN: `StationService`의 트랜잭션 action 경계. 규칙 기반 확인 제안만 생성, 실장비·가상 시계 입력 거부.
- [x] 사건/자원/사용자/검토 근거 조회·갱신·감사 내보내기 API와 오류 계약. 문서 갱신.
## Task 5: 메뉴·사용설명서·기관용 UI
Files: apps/web/components/nav.tsx, apps/web/app/help/*, apps/web/app/station/*, apps/web/app/globals.css, tests/e2e/upgrade.e2e.ts.
- [x] RED: 사용설명서 2종·HTML 다운로드, 자료실 검색·원문·복사, 관리자 최초 설정·기관 지휘 흐름·대원 확인·접근 거부.
- [x] GREEN: 교육 시연 오른쪽 도움말, 상단 기관용/2026 자료실, 역할별 조작·일시적 오류·버전 충돌 표시.
- [x] 모바일·키보드·출처/검수 라벨. 루트 설명서·재구현 문서 갱신.
## Task 6: 전체 검토·검증·마무리
- [x] 전체 기존 코드/새 코드 검토와 의미 있는 회귀 수정.
- [x] fresh-context 최종 리뷰 1회, 중요 항목 RED→GREEN.
- [x] npm test / typecheck / docs:check / build / e2e, 로컬 배포형 검증.
- [x] README·운영 준비 한계·사용설명서·logic 최종 지문·Git 검사·로컬 저장.
docs/UPGRADE_V0_3_PROGRESS.md
# 실행 기록 — docs/UPGRADE_V0_3_PLAN.md ## 승인·요구사항 2026-10-09 사용자가 코딩 업그레이드를 지시했다. 중앙소방학교 2026년 신임교육 교재로 기관명을 확인했다. 문서 자동 갱신은 이후 변경에도 적용할 저장소 규칙으로 만든다. ## 설계 판단 - Ruling: 기관용 화면을 교육 엔진과 분리한 사람 중심 지휘 지원으로 구현한다. 교육의 자동 도착/완료는 실제 사건에 적용할 수 없다. 비용: 실장비·기관 인증·외부 AI 연동은 별도 검증이 필요하다. - Ruling: 운영 저장은 Node SQLite 트랜잭션으로 구현한다. 비용: Node 22.22.3의 실험적 SQLite API와 단일 호스트 운영을 전제로 하며 기관 규모 확장 시 DB 전환 검토가 필요하다. - Ruling: 공식 자료는 문서별 이용 조건을 확인한 원문만 재배포한다. 비용: 미확인 첨부는 원문 연결만 제공하며 재배포·전체 인덱스 기능을 표시하지 않는다. ## 진행 - [x] T1 문서 자동 생성·유지 규칙 - [x] T2 공식 2026 자료실 - [x] T3 인증·SQLite·감사 - [x] T4 기관용 도메인·API - [x] T5 메뉴·설명서·기관용 UI - [x] T6 전체 검토·검증·문서 최종 갱신 ## 조사 중앙소방학교 공식 목록에 2026-03-17 공통교재 소방전술1·2·3·화재대응능력2급·행정실무·소방차량장비실무·소방법령1~5·예방실무1/2가 게시돼 있다. 개별 첨부·이용 조건 확인 중. 소방청 기본 호스트의 자동 방문자 확인이 도구에서 발생했고 공식 학교 호스트로 열람을 시도한다. ## 중간 검증 - 문서 4건·인증 5건·기관 상태 5건·자료실 3건 RED→GREEN. 기존 교육 포함 53건 통과, 이후 프로토타입 사건명/시드 0 회귀 2건 추가·통과. - 공식 게시글 8개 공공누리 1유형 확인, PDF 14권 다운로드·SHA-256·페이지 인덱스 생성. 소방전술1 화재2 표지·화재대응능력2급 목차를 렌더링해 판본과 문서 구성을 확인. 원문·자체 해설 분리. - 브라우저 9건 통과, 기관 흐름 테스트가 자원 선택 단계를 빠뜨려 실패. 화면/trace에서 필수 선택 미입력 확인, 테스트에 선택 추가. DB 격리 테스트 실행기를 추가해 반복 검증 중. - T5 검증 완료: 격리 기관 DB의 관리자→대원 전체 흐름 포함 브라우저 10/10 통과. 다음은 전체 새 컨텍스트 리뷰와 최종 검증. ## 최종 리뷰와 수정 새 컨텍스트 gpt-6-astra 리뷰(be4b9fd..e344f0c), 원본 파일 변경 없이 임시 DB/3127 포트로 검증했다. - Important: 사건 전환 시 비제어 입력 혼동 — 사건 ID 기반 입력 영역 재마운트. 브라우저 RED 확인, GREEN: 브라우저 회귀 통과. - Important: 로그아웃 뒤 지연 조회 화면 복원 — 인증 세대 번호/조회 취소/이전 응답 폐기. 브라우저 RED 확인, GREEN: 브라우저 회귀 통과. - Important: Host 기반 초기화 — 로컬 CLI 발급 일회용 설정 키, 15분 만료·해시 보관·설정 성공 시 소진. 키 없이 초기화 성공하던 RED 확인 후 인증 테스트 GREEN. - Important: 계정 회수 불가 — 관리자 비활성화/복구/역할 변경/암호 재설정, 대상 세션 폐기, 마지막 활성 관리자 보호, 비밀 제외 감사. 인증 회귀 GREEN. - 리뷰 Minor 감시 누락은 사용자 ‘항상 갱신’ 요구에 직접 영향하므로 Important로 재평가. 소스 수집 규칙과 감시 범위를 통합하고 Next 설정 변경 자동 갱신 테스트 RED→GREEN. - Declined 항목 판단: 실제 119·장비·외부 AI·공인 인증 및 다중 호스트·독립 서명은 미구현 범위를 정확히 표시한 설계 판단을 유지한다. 비용은 실제 현장 연동·확장 전에 별도 검증이 필요하다는 점이다. - Declined 항목 판단: 임의 DB/북마크 변조 자동 복구와 온라인 자동 백업은 이번 변경 범위에서 제공하지 않는다. 비용은 손상·복구 시 운영자 절차가 필요하다는 점이며 서버 정지 후 SQLite/WAL 세트 보관·복구 절차를 유지한다. - GitNexus analyze는 요청에 따라 실행했으나 exit 1, 대용량 생성 파일 제외 이후 인덱스 생성 실패. 기본 소스/테스트/리뷰 검증은 별도 수행하며 인덱스 검증 완료로 표시하지 않는다. ## 완료 Task 1~6 complete. 최종 단위 59/59, 브라우저 13/13, 타입/빌드/문서/PDF 무결성 통과. 모바일 390px 가로 넘침 없음, 오프라인 검색·프롬프트 복사 확인. 최종 결과와 운영 범위는 docs/V0_3_ACCEPTANCE.md. 보류 Minor 없음.
docs/UPGRADE_V0_3_SPEC.md
# AI Firestation v0.3 업그레이드 명세 2026-10-09. 사용자는 사용설명서·HTML 다운로드·상시 문서 동기화·2026 중앙소방학교 교재·소방서용 지휘 지원으로 확장을 지시했다. 자료 기관은 중앙소방학교 2026년 신임교육 교재로 확인했다. 이 요청은 구현 권한을 포함한다. ## 사용자가 얻는 기능 1. 교육 시연 오른쪽 `사용설명서` 메뉴. `/help`에서 `1. 쉽게 따라하기`와 `2. 상세 사용설명서`, HTML 보기·다운로드, `/logic.html`의 재구현 프롬프트 복사·다운로드. 2. 프로젝트 루트 `manual.html`·`logic.html`과 웹 공개본을 같은 생성기로 갱신. 모든 코드 변경을 감지하는 로컬 개발 감시, dev/build 전 생성, 검사·Git pre-commit으로 오래된 문서 차단. 문서에는 실제 소스 지문·버전·생성 시각·구현/미구현 경계를 표시. 핵심 로직과 데이터·권한·API·테스트·파일 구성 및 재구현 프롬프트를 포함. 3. `/library` 중앙소방학교 2026 교재 자료실. 공식 게시글·첨부·판본·이용 조건 확인. 이용 허용된 원문은 변경 없는 PDF로 제공하고 페이지별 검색·텍스트 보기·선택/페이지 복사·출처 포함, 전체/페이지 링크·북마크·인쇄. 해설은 공식 본문과 별도로 자체 작성하며 문서/페이지를 표시. 모든 해설에 전문가 검수 전 표시. 검색 인덱스는 인용·권한 조건을 보존. 4. `/station` 기관용 지휘 지원. 초기 관리자 설정·로그인·로그아웃, 관리자/지휘관/상황요원/대원/감사자 역할. 실제 신고를 사용자가 입력하고 관찰·자원·명령·수신·완료·인계·종료를 기록. 교육 엔진의 가상 도착·완료·화재 감소는 적용하지 않는다. 5. 기관용 규칙 기반 AI는 공개 관찰에서 확인 질문·위험 후보·자료 후보만 제안. 자동 현장 명령 발행 금지. 지휘관이 검토한 근거와 명령을 사람이 승인한다. 교육 카드 초안만으로 기관용 명령을 발행하지 않는다. 6. SQLite 트랜잭션·권한·기관 경계·사건 버전·요청 중복 키·자원 중복 배정 차단. 대상 대원별 수신/수행/완료/취소 확인. 사람과 시각을 남기는 감사 체인·JSON 내보내기·변조 검출. ## 정확한 한계 기관용 화면은 사람이 수행한 지휘를 공유·기록하는 의사결정 지원이다. 현장 적합성·공인 인증·119/무전망/드론/차량 연동은 외부 기관 검증이 필요하며 완료했다고 표시하지 않는다. 현재 규칙 기반 AI를 외부 생성형/영상 AI라고 표시하지 않는다. 자료 이용 조건이 확인되지 않은 첨부는 재배포하지 않고 공식 원문 연결과 상태 표시만 제공한다. ## 계약 - DEMO API와 기존 시뮬레이션은 유지한다. 기관용 저장·권한은 `/api/station` 별도 경계로 구성한다. - 사용자는 stationId·role을 직접 주입할 수 없다. 세션 쿠키에서 서버가 확인한다. - 암호는 scrypt+개별 salt, 세션은 무작위 토큰의 SHA-256만 DB 보관, HttpOnly/SameSite 쿠키, 만료·로그아웃·로그인 속도 제한. - 최초 설정은 로컬 CLI에서 발급한 15분 일회용 키를 요구하며 서버에는 키 해시만 저장하고 성공 시 소진한다. Host·Origin 위조만으로 설정할 수 없다. 관리자 존재 시 다시 실행할 수 없다. 계정 비활성화·복구·역할 변경·암호 재설정은 대상 세션을 모두 폐기하며 마지막 활성 관리자를 보호한다. 기본 계정·고정 암호·자동 로그인 없음. - 기관 사건 상태는 실제 시간·사람의 보고로 바뀐다. 사람이 명령 초안→지휘관 승인→대상별 수신→수행→완료로 전환한다. 새 관찰 이후의 과거 초안은 재검토/승인이 필요하다. - 승인 근거는 별도 기관 검토 기록(문서 ID·판본·페이지·검토자·메모)을 갖는다. 공개 교재 등재가 기관 작전 승인이라는 의미는 아니다. - 자원 배정·변경과 사건 상태 저장·감사·중복 요청 결과 저장은 하나의 트랜잭션에서 처리한다. - 코드 변경마다 문서 지문을 다시 계산한다. build는 문서 생성 후 검사한다. pre-commit은 스테이지의 소스와 문서를 검증하여 다른 소스 지문의 문서가 커밋되지 않게 한다. ## 수용 기준 기존 테스트 유지, 새 인증·권한·관찰·초안·대원별 상태·자원 충돌·기관 경계·중복 요청·감사 검증 테스트. HTML 외부 의존 없이 열림, 실제 코드 지문 변경 시 오래된 문서 검출, 루트/공개본 일치. 자료 제목·페이지·출처·권한 상태·검색·복사·원문 표시 검증. 브라우저에서 설명서 다운로드, 자료 검색, 관리자 설정→사용자/자원/근거→사건→명령 승인→대원 수신·완료 흐름 검증. 모바일 가로 넘침 없음. 최종 코드 리뷰·타입·빌드 통과.
docs/V0_2_REVIEW.md
# v0.2 검토 및 1~2주차 개발 범위 검토일: 2026-10-09. 사용자 요청에 따라 25.1의 방향을 유지하고 25.2의 첫 산출물부터 구현했다. 원본 v0.2 문서의 전체 승인 체크리스트는 보존했다. 이번 완료는 16주 전체 계획 완료가 아니다. ## 승인 범위와 결과 | 항목 | 이번 반영 | |---|---| | 서비스명·공통 바탕 | AI Firestation, core/scenario·engine·commander·kb 경계 | | 트랙 A→B→C | 세 트랙에서 재사용할 TS 코어, A의 화면 골격 우선 | | L0~L3 | 수동·조언·승인·자동, 사람 개입·취소·근거 검증 | | 대표 시나리오 | 식당 가스 의심·창고 유류 의심·주택 인명 미확인 | | KB-0 | 자체 작성 교육 질문·준비 카드 40장, draft·검수자 미지정 | | 가상 드론 | 도착 전 미공개, 도착 후 가상 열 분포·관찰 공유 | | 실제 모드 경계 | API는 DEMO 고정, core OPERATIONS L3·SHADOW 자동 지휘 거부 | | 3~4주차·훈련·게임 | MP4/WebSocket/생성형 AI/Phaser/기관 연동은 이번 범위 제외 | ## 수정·해석할 사항 1. **4.3.6 근거 누락과 보장 모드:** 근거 부족 판단은 기록·표시하되 명령 발행을 보류한다. 교육용 고장 체험을 위한 예외는 강사 전용 후속 기능으로 분리한다. 자동 진행 보장은 검증된 시나리오·근거가 정상인 경우에 한한다. 2. **AI의 의미:** 현재 지휘관은 규칙 기반이다. 외부 생성형 모델이나 영상 인식 모델이 실제 판단한다고 표시하지 않는다. 선택적 문장 생성 어댑터는 타임아웃·실패 시 원래 검수 대상 규칙 문장으로 복귀하며, 웹 시연에는 외부 모델이 연결되지 않았다. 3. **정답 비노출:** 숨겨진 시나리오·미래 시간표를 브라우저·지휘관 입력에서 제외한다. 시나리오 후보 위험은 신고와 주제에 따른 공개 메타데이터이며 위험 확정 판정이 아니다. 4. **화재 분류와 대응단계:** A/B/C/K 후보와 별도 가스 위험을 표시한다. 실제 가스·유류·통전·인명 상태를 냄새·색·RGB 영상으로 확정하지 않는다. 국내 대응 1~3단계의 실제 발령·자원 기준은 기관 승인 규칙 확보 후 구현한다. 현재 시연이 법정 대응단계를 계산한다고 주장하지 않는다. 5. **자료 이용:** 카드 본문은 자체 작성 질문·준비 초안이다. 원문 전술·도면·사진을 복제하지 않았다. KB-1/KB-2 도입 시 문서별 공공누리·저작권·변경 허용·최신 판본·검수 이력을 별도로 확보한다. 6. **게임 계급·정부 지원사업:** 첫 코어에는 노출하지 않았다. 게임·사업화 구현 전에 최신 공식 명칭·공고를 다시 검증한다. ## 공식 자료 확인 범위 | 자료 | 확인 내용 | 적용 한계 | |---|---|---| | [긴급구조대응활동 및 현장지휘에 관한 규칙 제10조](https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777) | 2026-07-01 시행 표시, 지휘·SOP/SSG 제도 참고 | 개별 카드 지시문의 전술 근거가 아님 | | [NFTC 101 확인 판본](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810) | 소화기구 및 자동소화장치의 화재안전기술기준, 2024-07-25 판본 식별 | 최신 판본·정의 원문 전체 대조 보류. NFPC와 NFTC를 혼동해 조항을 단정하지 않음 | | [소방 무인비행장치 운용규정 확인 판본](https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000260620) | 2025-07-01 판본 식별, 운용 역할 참고 | 개별 비행 허가·성능·실제 임무 승인 근거가 아님 | 공식 기준의 최신성·기관 적용성 확인이 완료됐다는 의미가 아니다. 교육 카드의 출처 필드에도 용어·제도 참고 관계를 표시했다. 전문가 검수 없이 공인 소방 매뉴얼이나 기관 승인 훈련으로 표시하지 않는다. ## 다음 개발 묶음 3~4주차에는 검토된 카드 문장 확정, 시연 영상 입력, 서버 주도 시계·실시간 구독, 역할별 화면, 대응단계 규칙의 기관 자문, 명령 음성·복기 강화가 필요하다. 공통 코어를 Phaser에 연결하는 게임 알파는 그 다음 트랙 작업이다.
docs/V0_3_ACCEPTANCE.md
# v0.3.1 최종 검증 — 2026-10-09 ## 사용자 요청 반영 | 요청 | 구현 | |---|---| | 교육 시연 오른쪽 사용설명서 | 주 메뉴 `/help`, 모바일에서도 두 메뉴 표시 | | 쉽게 따라하기·상세 사용설명서·HTML 다운로드 | `/help`, 루트/공개 `manual.html`, 오프라인 검색·인쇄 | | 핵심 내용과 AI 재구현 프롬프트 | 루트/공개 `logic.html`, 소스/API 목록·전체 코드 스냅샷·프롬프트 복사 | | 코드 변경 때 두 문서 상시 갱신 | 공통 소스 규칙 기반 개발 감시·빌드 전 생성/검사·스테이지 pre-commit·AGENTS 규칙 | | 중앙소방학교 2026 교재 활용 | 공식 게시글 8개, 공공누리 1유형 확인, PDF 14권/6,799페이지·해시·페이지 검색·원문/텍스트·복사·북마크·독립 해설 | | 소방서용 지휘 지원 확장 | SQLite·일회용 최초 설정 키·5개 역할·계정 회수·사건/관찰/자원/검토 근거·초안/재검토/사람 승인·대상별 보고·취소 확인·인계·종료·감사 내보내기 | ## 실행한 검증 - `npm test`: 10파일, **59/59 통과**. - `npm run typecheck`: 통과. - `npm run library:check`: PDF 14권의 SHA-256/파일 크기와 페이지 인덱스 일치. - `npm run docs:check`: 루트/공개 4개 HTML의 소스 지문·정확한 렌더 내용 일치. - `npm run build`: Next.js 16.4 프로덕션 빌드 통과, 새 도움말/자료실/기관용 화면·API 포함. - `npm run test:e2e`: 임시 DB/3110 포트 격리, **13/13 통과**. 기존 교육 7건, 도움말/자료실/전체 기관 흐름, 사건 입력 격리, 로그아웃 지연 응답, 계정 회수/재설정/역할 변경 검증. - 추가 브라우저 검사: 390px에서 `/help`, `/station`, `/library` 가로 넘침 없음. 프로덕션 기본 기관 DB는 최초 설정 대기 상태, 기본 관리자 계정 없음. - `file://` HTML 검사: 오프라인 설명서 검색, 구현 프롬프트 복사 상태 확인. - PDF 렌더 검사: 2026 소방전술 I 화재2 표지·화재대응능력2급 목차 확인. 원문을 편집하지 않았다. - 스테이지 검사 회귀: 코드만 추가하고 생성 문서를 빠뜨리면 커밋 검사 거부, 소스와 문서를 함께 스테이지하면 통과. ## 리뷰와 수정 별도 새 컨텍스트 리뷰에서 Important 4건을 확인하고 한 번의 수정 단계에서 회귀 검증했다. 사건 ID별 입력 영역, 인증 세대 번호/조회 취소, 로컬 CLI 일회용 키, 계정 회수/세션 폐기로 수정했다. 감시 누락은 사용자의 상시 갱신 요구에 비춰 Important로 재평가하여 공통 규칙 기반 감시로 수정했다. 미해결 Important/Critical과 보류한 Minor는 없다. 수정 후 재리뷰 대신 실패를 확인한 회귀 테스트와 전체 검증을 사용했다. ## 운영 범위와 판단 - 교육 엔진과 기관용 사람 지휘 지원을 분리했다. 실제 119·무전·드론/차량 영상·장비 제어·외부 LLM·공인 현장 인증은 제공하지 않는다. 이를 사용하려면 해당 기관·장비별 연동과 현장 검증이 필요하다. - 저장은 단일 호스트 Node SQLite다. Node 22.22.3은 실험적 SQLite 경고를 표시한다. 다중 호스트 DB와 독립 감사 서명은 후속 검증 범위다. - 공식 원문은 문서별 이용 조건을 확인해 재배포했다. 미확인 원문을 허용된 자료로 표시하지 않는다. 자체 해설은 전문가 검수 전이고 기관의 공식 해설이 아니다. - 임의 DB/브라우저 데이터 변조의 자동 복구와 온라인 자동 백업은 제공하지 않는다. 서버 정지 후 SQLite/WAL 세트 보관·복구 절차를 README에 기록했으며 운영자가 복구를 수행한다. - GitNexus 인덱싱은 exit 1로 실패했다. 인덱스 완료를 주장하지 않고 직접 소스 리뷰·단위/브라우저/빌드 검증을 사용했다. 공식 출처: [중앙소방학교 자료실](https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/), [공공누리 이용 조건](https://www.kogl.or.kr/info/license.do). 문서별 게시글 URL과 확인 시각은 `packages/library/catalog.json`에 보존한다.
docs/superpowers/plans/2026-10-09-demo-audio.md
# 가상 출동 음향 Implementation Plan **Goal:** 기존 교육 시연에 신고전화→접수→지휘본부 출동 지시→대원 수신/현장 보고와 이동 사이렌을 추가하고 fs.afr.co.kr에 반영한다. **Architecture:** 공개 사건 상태에서 음성 cue를 투영한다. 새 시연은 신고 접수 음성을 먼저 재생하고 사건 시간을 진행한다. 자체 작성 대사는 로컬 한국어 합성 음원으로 제공하며 사용자 작성 명령은 브라우저 한국어 합성으로 읽는다. Web Audio가 명령 음성/사이렌을 혼합하고 볼륨·음소거·정지·세션 수명 관리를 한다. 실제 무전/기관용 사건은 변경하지 않는다. **Scope/authorization:** 사용자가 음향 구현을 직접 요청했다. 이전 승인한 동일 GitHub/VPS 서비스 업데이트까지 진행한다. 기존 승인 흐름과 임무·드론·차량 경로를 보존한다. - [x] 공개 상태 cue 투영 테스트 먼저 작성: 초안/취소 명령 제외, 출동·수신·관찰 순서, ETA 기준 사이렌/지연 승인/종료, 기존 상태를 열면 과거 cue 재방송 금지. - [x] 음향 큐 및 WebAudio 재생기: 클릭 시 context unlock, 로드 오류/타임아웃, 중복/세션 전환/취소, 신고 도입 gate, 소리 정지/재개/음소거, 음성 중 사이렌 낮추기. - [x] 한국어 음원 생성 스크립트와 명세/해시/길이 기록, 전화벨·무전 신호·사이렌 생성. 재현 가능한 소스와 검증을 유지한다. - [x] PC·모바일 공통 켜기/끄기·볼륨·현재 발화 자막, 일시정지와 새 시연 정리. 실제 재생 상태와 오류를 표시한다. 새로고침/차량 창은 사용자 소리 켜기 전 자동 재생하지 않는다. - [x] 의미 있는 단위·실제 WebAudio 브라우저/기존 E2E·화면 넘침 검증, 문서·재구현 프롬프트/manual/logic 갱신, fresh 전체 변경 리뷰. - [ ] Git main 푸시, 해당 SHA Linux build/VPS 이미지 전환, 공개 PC·모바일/음원 파일/문서 해시/기존 영구 상태 유지 확인. Review focus: iOS 사용자 제스처에서 context resume; 종료/개입/백그라운드/새 시연의 음원 정지; 취소·미승인 명령 음성 금지; 지연 승인 뒤 이동 ETA; 실패한 음원에 시연이 갇히지 않음; 실제 운영 페이지에 가상 소리가 없음. 고배속은 오래된 대기 방송을 줄이고 최신 공개 상태만 읽는다. 실기/OS별 동작과 합성 음성임을 문서에 구분한다.
docs/superpowers/plans/2026-10-09-firestation-domain.md
# fs.afr.co.kr 도메인 전환 계획 사용자 지정 도메인 fs.afr.co.kr / VPS72.62.71.171로 기존 HTTPS 웹 배포를 전환한다. DNS A레코드는 로컬/VPS에서 일치 확인했다. 기존 서버 프로젝트와 영구 데이터는 유지한다. 사용자 요청은 도메인 설정·GitHub 푸시·배포 권한에 포함된다. 1. 새 도메인 전용 ACME HTTP vhost를 추가하고 Nginx 검사·인증서 발급 후 HTTPS vhost를 적용한다. 2. 설명서·재구현 프롬프트·배포 안내와 기록을 fs.afr.co.kr로 갱신한다. 이전 주소는 이력에만 보존한다. 기존 주소의 ACME 경로를 유지하는 HTTPS 이동 템플릿을 추가한다. 3. 문서 검사·배포 설정 검토 후 main에 커밋/푸시한다. VPS에서 해당 SHA를 clone/build하고 이전 runtime 설정을 백업한 뒤 FIRESTATION_ORIGIN=https://fs.afr.co.kr 및 새 이미지로 전환한다. 4. 기존 임시 주소를 새 도메인으로301 이동한다. 갱신 dry-run, PC·모바일 시연·설명서 다운로드·교재, Origin/미인증 경계, 영구 교육 사건 보존, 이미지/GitHub SHA/문서 해시 일치를 확인한다. 검수 계약: HTTPS 인증서 정상, 새 HTTP→HTTPS301, 기존 HTTPS→신규 HTTPS301, 올바른 Origin 변경201·잘못된 Origin403, HttpOnly/SameSite/HTTPS Secure, Docker healthy, 동일 .data. 초기 기관 관리자 계정을 자동으로 만들지 않는다. UE 렌더링 완료를 주장하지 않는다. 복구: 이전 image/Origin/runtime와 두 전용 vhost 백업을 복원하고 nginx -t 성공 후 reload한다. 다른 앱·DNS 레코드·기관 데이터를 수정하지 않는다. 진행/명령 결과는 이 계획의 Git-ignored .superpowers/sdd 디렉터리에 기록한다. 배포 검증 결과는 docs/DEPLOYMENT_RESULTS.md와 최종 응답에 남긴다.
docs/superpowers/plans/2026-10-09-hostinger-deploy.md
# Hostinger 배포 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task. **Goal:** 현재 코드를 지정 GitHub main에 보관하고 Hostinger projects/ai_firestation에서 HTTPS 웹 서비스를 실행한다. **Architecture:** Read-only Git deploy key → VPS release directory → Node Docker image → durable /data → localhost3118 → Nginx TLS. **Tech Stack:** Git/GitHub CLI, SSH, Docker Compose, Node22, Nginx, Certbot. **Spec:** docs/DEPLOYMENT_SPEC.md ## Global Constraints - 사용자 요청으로 푸시와 실제 배포가 승인됐다. 같은 기능 브랜치를 사용하며 강제 푸시하지 않는다. - 새 경로 /root/projects/ai_firestation만 변경하고 기존 앱/DB/개인키를 수정하거나 복사하지 않는다. - DB는 영구 volume, 기본 계정 없음, 공개 HTTPS Origin 검증을 유지한다. - 모든 배포 소스와 안내/재구현 프롬프트 및 HTML을 갱신한다. - UE5 native 미검증 제한을 유지한다. ## Review Focus - 영구 데이터와 코드 릴리스 분리/컨테이너 UID 권한. - 실제 Origin, 프록시 HTTPS, 로그인 쿠키, 잘못된 Origin 차단. - Nginx 문법 실패 때 reload 금지, 기존 사이트 보존. - 재시작/health 실패와 업데이트/복구 절차. - Docker 환경/비밀 제외, 배포 파일 문서 지문. ## Task 1: 준비 및 공개 저장소 - [x] GitHub 계정/저장소/빈 main 확인, 전체 코드 최초 푸시. - [x] SSH 기존 연결 설정으로 VPS/경로/런타임/포트 확인. ## Task 2: 배포 산출물과 문서 - [x] Dockerfile/Compose/.dockerignore/전용 Nginx 템플릿 및 배포 안내 작성. - [x] 배포 파일 지문/환경 비밀 제외 시험 먼저 실패 확인 후 collector/watch 확장. - [x] guide/reconstructionPrompt/README 및 manual/logic 동기화. - [x] unit/typecheck/build와 독립 코드 리뷰. ## Task 3: 실제 배포 및 검수 - [x] commit main 푸시, 읽기 전용 deploy key로 새 릴리스 clone 후 SHA 확인. - [x] 이미지 빌드/영구 데이터 권한/환경 설정/Compose 실행과 health 확인. - [x] 전용 HTTP vhost → TLS 발급 → HTTPS 전용 vhost, nginx -t 후 reload. - [x] 해당 인증서 갱신 hook과 타이머/dry-run 확인. - [x] HTTPS PC·스마트폰/교재/문서/Origin 검수, 재시작 후 데이터 유지 확인. - [x] 배포 결과 문서 작성과 접속 주소 확정. 최종 문서 릴리스는 이 기록을 포함해 커밋/푸시/배포한다. 최종 GitHub main·실행 이미지 SHA 일치, health·HTTPS·공개 HTML 해시는 전환 후 명령 출력으로 검증하고 사용자에게 결과를 보고한다.
docs/superpowers/plans/2026-10-09-library-html.md
# HTML 교재 자료실 Implementation Plan **Goal:** 기존14권의 HTML 기본 페이지 보기·PDF 탭과 기관 PDF 업로드/변환·순서 저장을 구현하고 배포한다. **Architecture:** 공식 교재의 기존 인덱스를 재사용하고 기관 자료는 StationDatabase와 인접 영구 파일 저장소에 격리한다. 서버 전용 LibraryStore, PDF 추출 자식 프로세스, 안전한 HTML 생성, 자료 API, React 읽기 화면으로 분리한다. **Tech Stack:** Next/React/TypeScript, SQLite, PDF.js, Playwright/Vitest. **Spec:** docs/LIBRARY_HTML_SPEC.md - [x] 먼저 HTML 이스케이프/페이지·순서·기관 격리·잘못된 PDF 회귀 시험을 작성하고 실패 확인. - [x] packages/library/html.ts·store.ts, scripts/extract-library-pdf.mjs·export-library-html.ts 구현. 실제 PDF 변환·해시·영구 저장·기관 권한·상한·순서 버전 시험. - [x] API 목록/페이지/검색/업로드/순서/HTML·PDF 파일 조회와 동일 Origin·기관 접근 제한 구현. - [x] library-room 및 하위 컴포넌트: HTML 기본 탭, 원문 PDF 탭, 페이지 이동·강조 검색·복사·업로드·드래그/터치 버튼, 오류/오래된 응답 폐기. - [x] PC·모바일 E2E 및 기존 회귀 시험, 타입·빌드·문서 지문 검증. independent code review의 실질 문제 해결. - [x] manual/logic/사용 안내·재구현 프롬프트, 배포 설정 갱신 후 GitHub main과 VPS 릴리스에 반영. 기존 기관·교육 데이터 유지 및 외부 HTML/PDF/화면·파일 검증. 추가 화면 수정: 최초 배포 후 HTML 본문 clipping을 발견하여 자연스러운 패널 높이와 내부 스크롤로 수정했다. PC 실제 노출 높이·스마트폰 페이지 조작 회귀 시험을 추가했으며 단위107건·브라우저33건·타입·빌드를 통과했다. 동일 절차로 화면 수정 릴리스를 배포하고 공개 사이트에서 다시 검증한다. Review focus: 다른 기관/로그아웃의 파일·텍스트 누출, 업로드 실패 시 부분 목록, 파일 경로와 HTML 삽입, 끌기 실패/동시 순서 충돌, 늦은 페이지·검색 응답, 모바일 읽기/조작 크기. 기존 기관 자동 지휘·실장비 통합은 변경하지 않는다.
docs/superpowers/plans/2026-10-09-ue5-restaurant.md
# 식당 화재 UE5 Implementation Plan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development for this plan. **Goal:** 식당 화재 UE5 현장 소스와 실제 Pixel Streaming 웹·스마트폰 연결을 구현하고 가능한 환경에서 검증한다. **Architecture:** 웹 서버 공개 상태 → 읽기 전용 장면 DTO → UE5 HTTP 수신/렌더링 → 세션별 Pixel Streaming → PC·휴대폰. **Tech Stack:** UE5.8 C++, Python editor automation, Next.js/React, Epic frontend 0.1.2, Vitest, Playwright, PowerShell. **Spec:** `docs/UE5_RESTAURANT_SPEC.md` ## Global Constraints - 현재 깨끗한 기능 브랜치 feat/core-demo에서 기존 실시간 앱과 함께 작업한다. 별도 worktree에서 운영 서버가 바뀌는 혼동을 피한다. - 시나리오 restaurant-gas/DEMO만 허용. 실제 기관 지휘에 영향 없음. 공개 관찰 외 위험 정답/미래 정보 노출 금지. - 항상 PC·휴대폰과 manual.html/logic.html 갱신. 한국어 문서/주석. - 구현자는 담당 파일만 수정하고 커밋하지 않는다. 루트가 통합·문서 갱신·검증·커밋한다. - UE 미설치로 네이티브 검증이 막히면 소스 및 웹 검증을 진행하고 미검증 사실을 기록한다. ## Review Focus 공개 데이터 경계, session 스트리머 일치, 이동 경로/정지, 입력 카메라 한정, 비동기 종료, 모바일 재생, 설치 스크립트 실패 처리, 문서 자동 갱신. ## Task 1: 상태 연결 - [x] tests/scene.test.ts에 DTO의 도로/직선/정지/미공개/잘못된 시나리오 시험을 먼저 작성하고 실패 확인. - [x] packages/simulation/scene.ts 및 apps/web/app/api/sessions/[id]/scene/route.ts 구현. 명세 DTO 준수. - [x] 단위 테스트/typecheck 및 API 제한 확인. 파일 소유권은 이 태스크에만 있다. ## Task 2: UE 현장과 실행 절차 - [x] unreal/AIFirestation 아래 C++ 프로젝트, Config, Python 부트스트랩 구현. 실제 자산 경로/생성 절차 일치 확인. - [x] scripts/unreal.ps1, scripts/unreal-stream.ps1 점검/빌드/부트스트랩/실행 구현. 실패/UUID/경로 검증 먼저 시험. - [x] UE 프로젝트 생성/입력/HTTP 계약과 빌드 도구를 검토. 엔진 미설치 시 실행 검증 대기라고 보고. - [x] unreal/README.md에 명령/카메라 공유/빌드 제한 기록. ## Task 3: 웹·휴대폰 Viewer - [x] tests/e2e/simulation.e2e.ts에 식당 진입/대기/휴대폰 화면/잘못된 사건 시험 작성. - [x] apps/web/app/simulation/*, apps/web/components/unreal-viewer.tsx 및 해당 CSS 구현. 공식 라이브러리 API 확인. - [x] command-room.tsx에 식당 사건 진입 링크 추가. API DTO는 Task 1 소유, 수정하지 않는다. - [x] PC/휴대폰 카메라 버튼44px, 실제 영상 이벤트와 정리 검증. package.json 변경은 루트에 요청. ## Task 4: 통합·문서·검수 (루트) - [x] 공식 frontend 의존성 및 UE 명령 scripts 추가. - [x] docs collector/watcher에 UE 소스 변경 시험을 먼저 추가하고 실패 확인한 후 확장. - [x] guideSections/logic 재구현 프롬프트와 README 수정, 자동 HTML 동기화. - [x] fresh reviewer 검토 및 수정, unit/typecheck/build/E2E/docs 통과. - [ ] 엔진 설치 후 native build/맵 생성/실제 영상 PC·휴대폰 검수. 현재 설치 부재로 미완료; UE5_VALIDATION.md에 기록.
package-lock.json
{
"name": "ai-firestation",
"version": "0.6.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "ai-firestation",
"version": "0.6.0",
"dependencies": {
"@epicgames-ps/lib-pixelstreamingfrontend-ue5.8": "0.1.2",
"lucide-react": "1.53.0",
"next": "16.4.0",
"pdfjs-dist": "6.4.299",
"react": "19.3.0",
"react-dom": "19.3.0",
"zod": "4.6.5"
},
"devDependencies": {
"@playwright/test": "1.64.0",
"@types/node": "26.6.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"prettier": "3.9.9",
"tsx": "4.23.15",
"typescript": "7.0.2",
"vitest": "5.0.3"
}
},
"node_modules/@emnapi/runtime": {
"version": "1.11.3",
"resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.11.3.tgz",
"integrity": "sha512-Xz4Tpyki7XyrpbUK1jR1AhdAdaXyhhY4lZ3neLodmhpuWfy2PAQN5B46sAiU4liOXGLkHypn/qU+jvfWSCYYLA==",
"license": "MIT",
"optional": true,
"dependencies": {
"tslib": "^2.4.0"
}
},
"node_modules/@epicgames-ps/lib-pixelstreamingcommon-ue5.8": {
"version": "0.1.0",
"resolved": "https://registry.npmjs.org/@epicgames-ps/lib-pixelstreamingcommon-ue5.8/-/lib-pixelstreamingcommon-ue5.8-0.1.0.tgz",
"integrity": "sha512-Za+1lC0MotucgSRBkCnRx4KSGXJAe2E6AcdJIGsG1SKa51sOIYtvACwIQ31C5SF6fUZiHwBk06JixfHmRkmhxA==",
"license": "MIT",
"dependencies": {
"@protobuf-ts/runtime": "^2.9.4",
"@types/ws": "^8.5.14",
"ws": "^8.18.0"
}
},
"node_modules/@epicgames-ps/lib-pixelstreamingfrontend-ue5.8": {
"version": "0.1.2",
"resolved": "https://registry.npmjs.org/@epicgames-ps/lib-pixelstreamingfrontend-ue5.8/-/lib-pixelstreamingfrontend-ue5.8-0.1.2.tgz",
"integrity": "sha512-Ii/dGs/LHzdkD4iT2Az5trvswha8dQvvtJxfpnfvZKMtbg7VFpiBNlZORmhQodeYeKCPWbKpACHBB+MBop06HA==",
"license": "MIT",
"dependencies": {
"@epicgames-ps/lib-pixelstreamingcommon-ue5.8": "^0.1.0",
"sdp": "^3.2.0"
}
},
"node_modules/@esbuild/aix-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/android-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/darwin-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/freebsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-loong64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-mips64el": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-ppc64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-riscv64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-s390x": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/linux-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/netbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openbsd-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/openharmony-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/sunos-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-arm64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-ia32": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
"cpu": [
"ia32"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@esbuild/win32-x64": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=18"
}
},
"node_modules/@img/colour": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/@img/colour/-/colour-1.1.0.tgz",
"integrity": "sha512-Td76q7j57o/tLVdgS746cYARfSyxk8iEfRxewL9h4OMzYhbW4TAcppl0mT4eyqXddh6L/jwoM75mo7ixa/pCeQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=18"
}
},
"node_modules/@img/sharp-darwin-arm64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-arm64/-/sharp-darwin-arm64-0.35.5.tgz",
"integrity": "sha512-QRUlFQ0WxvdWyqqG/WtI3iupfD5rBzmCHXSdPsY91sAtVtTo7Q4cb6zOccZ3gqEqkr0f1As1ehLqmEpDsRf+lg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-darwin-x64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-darwin-x64/-/sharp-darwin-x64-0.35.5.tgz",
"integrity": "sha512-+BR255RhDlpygUpOc/Jdt1nT6DQ3XG/ERo5wbcdOf5Q320dKtPCKPLR1LJs9VGXRaMa8l1uUa0tkCNOXiAxZUw==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-darwin-x64": "1.3.4"
}
},
"node_modules/@img/sharp-freebsd-wasm32": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-freebsd-wasm32/-/sharp-freebsd-wasm32-0.35.5.tgz",
"integrity": "sha512-Y/z91nEZ4uIBX5X3nfTovjU9lHNKFYbL2lpHCLVNmXQK03VIZvXBBt0KxbPGp2SdGSF+2mQU4e+hQaWOt86iAw==",
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"dependencies": {
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-arm64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-arm64/-/sharp-libvips-darwin-arm64-1.3.4.tgz",
"integrity": "sha512-5R89nBYiRdUlSWJxPhO+GVtaXzXSxKnRu/xqMn3KTA3L9EB9Oy/P+Nn2f2vlhPuUdy/Zusb2DarbyTpGCfEDuw==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-darwin-x64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-darwin-x64/-/sharp-libvips-darwin-x64-1.3.4.tgz",
"integrity": "sha512-iR2OKH80yi0U+dUplyh3/xdpFvps6YkCwsXenIJxqxR1v9o+xtKTGbS9H7cps+2Vxjc8B1j96p75NmTGjIhtpQ==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"darwin"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm/-/sharp-libvips-linux-arm-1.3.4.tgz",
"integrity": "sha512-LmRtTsOHuvM2+wlO2Db37dx5MiZhB0FvSunciw48YjdOkZz9KAiRbm8ujeMOA1INqmei5NapFxYEK1D1ZSidmw==",
"cpu": [
"arm"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-arm64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-arm64/-/sharp-libvips-linux-arm64-1.3.4.tgz",
"integrity": "sha512-Y3dgX/6lE2QhQb+Gxy0WZxfg9MEm/JBjamZpS2IklP7xIQoKN4hzAm7KcMVGtaVDt3neE9OKBC7vAfonA/Lr1A==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-ppc64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-ppc64/-/sharp-libvips-linux-ppc64-1.3.4.tgz",
"integrity": "sha512-Le6boB8Tai0Nis+gIxIpKx68UDVVIqdR8Tin5Yf1z2LJJQLDJvCDRqRu+jC2qCoD+eIomonmOwB4smBRxfVpYQ==",
"cpu": [
"ppc64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-riscv64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-riscv64/-/sharp-libvips-linux-riscv64-1.3.4.tgz",
"integrity": "sha512-aHkkIEHPRdQEegJN20MLmGtxYD9R2wQr3Cwpddnu5+YKMt6Uzax7S9h5gpZTo8wyrGuZSlfQ63OevL5mTyOC7Q==",
"cpu": [
"riscv64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-s390x": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-s390x/-/sharp-libvips-linux-s390x-1.3.4.tgz",
"integrity": "sha512-ra/mB6MikESDUO7Yg+Mi95bFBb9GsObURuhnOv3OqknjGe9sZrG8tCe9q0xSIGrtLgvgw0gKnFWcK4blSgQOuQ==",
"cpu": [
"s390x"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linux-x64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linux-x64/-/sharp-libvips-linux-x64-1.3.4.tgz",
"integrity": "sha512-GJ//SSXbnwSDes02umB3nDJLFcQzw8a18V8fyhqr6tV515tOEMdImjjxj1AoafMRz56F3PHgftnj1QEKSU1zkw==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-arm64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-arm64/-/sharp-libvips-linuxmusl-arm64-1.3.4.tgz",
"integrity": "sha512-hvulFwtjUcagsis6BBxHwGFwWoNZjgYmULGVrZcyfNbjA8hKILbRxGg15/7w5HDyXHXUos/j6baAWqnCyQ2DWA==",
"cpu": [
"arm64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-libvips-linuxmusl-x64": {
"version": "1.3.4",
"resolved": "https://registry.npmjs.org/@img/sharp-libvips-linuxmusl-x64/-/sharp-libvips-linuxmusl-x64-1.3.4.tgz",
"integrity": "sha512-6zXKeE/p39I1AmA3cJG35eyBGNqNddLnUXjhwBnsGjFPWqf5VKkDBEqaEkPDoTEtkxwi2vv8Tcr2mDyP4So7Fg==",
"cpu": [
"x64"
],
"license": "LGPL-3.0-or-later",
"optional": true,
"os": [
"linux"
],
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-linux-arm": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm/-/sharp-linux-arm-0.35.5.tgz",
"integrity": "sha512-LEaXK2WdXVK5ykcw0buWyPMsmLLL2vpHLD6yrNSW+JGEL3BZPA4tpKN6iaMc4AxTTAoaX/sU1rOL51lcIz48ZQ==",
"cpu": [
"arm"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm": "1.3.4"
}
},
"node_modules/@img/sharp-linux-arm64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-arm64/-/sharp-linux-arm64-0.35.5.tgz",
"integrity": "sha512-LYVx5JTsOM2CBzmxreh+nl64/3H6Xb09iSLknqH47z2T2DFFxDeFLP5y4dJwe6H7uGQlHPyEEtIqyo3DYsRwdQ==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-ppc64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-ppc64/-/sharp-linux-ppc64-0.35.5.tgz",
"integrity": "sha512-QVxAAq8evVRI9ia2vqgwrmWucn5Dfv+JdWzj75pD8omHLPSP7f8p20O8jxzjCcuCEQEOtYOZUmX1hkiZ0kdevA==",
"cpu": [
"ppc64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-ppc64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-riscv64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-riscv64/-/sharp-linux-riscv64-0.35.5.tgz",
"integrity": "sha512-LtdreXguaavKODPIfzJ4kffx7UNt1omwtK0rch4EBbbSTXPnxWmYSayXdLJw0fJzQ97kHt1gL/yh4tvU+nCyRQ==",
"cpu": [
"riscv64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-riscv64": "1.3.4"
}
},
"node_modules/@img/sharp-linux-s390x": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-s390x/-/sharp-linux-s390x-0.35.5.tgz",
"integrity": "sha512-UZasTOFiYzotTsGOCu42BfUzP6Tu6Do/947iRm1RsLKvlllxwGcn4RN27LibGWceix4Y+Pmw3jsnTcCQIgWjqA==",
"cpu": [
"s390x"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-s390x": "1.3.4"
}
},
"node_modules/@img/sharp-linux-x64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linux-x64/-/sharp-linux-x64-0.35.5.tgz",
"integrity": "sha512-SxFtLTeJInhAA9Q836kux2vZNeOBQEx658qvbboZScr0wIARym3IcGmW7KpVD5sbVg0Ojy+udFQdayYIZyoNog==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linux-x64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-arm64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-arm64/-/sharp-linuxmusl-arm64-0.35.5.tgz",
"integrity": "sha512-9HbMclmI1zlNkFRs3z9/eBtDjfD0sGlrX1z6b1qwmiFY5ElDLh4BC0LPBdVp7z1DXFiKlIcznf+ZlsuZzLxQqg==",
"cpu": [
"arm64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4"
}
},
"node_modules/@img/sharp-linuxmusl-x64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-linuxmusl-x64/-/sharp-linuxmusl-x64-0.35.5.tgz",
"integrity": "sha512-4KOphqB035HrVdqLZfCgMzzERrQkkzOwRhl4OAkRO1YCldbaFjySXMaK534Mo0V+LndnlJk+sbUyLeU0ULyD1A==",
"cpu": [
"x64"
],
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-libvips-linuxmusl-x64": "1.3.4"
}
},
"node_modules/@img/sharp-wasm32": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-wasm32/-/sharp-wasm32-0.35.5.tgz",
"integrity": "sha512-Ptsga1su4tQx+LLF1ECS9U6nz5kmrXKo6XVbtR48Ke3ZRxxgaWBu7IDtEe1quo8hiupwm6WFqxVlXaSf7IINGQ==",
"license": "Apache-2.0 AND LGPL-3.0-or-later AND MIT",
"optional": true,
"dependencies": {
"@emnapi/runtime": "^1.11.3"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-webcontainers-wasm32": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-webcontainers-wasm32/-/sharp-webcontainers-wasm32-0.35.5.tgz",
"integrity": "sha512-hfhF/FmoQyTUkA0bIKFOtw536BQSeBMe6BF6QyWlrPxT754+TFLaZ7sKKTfvvM0yJgKgaYTwnFCIZ/GuDw5SUA==",
"cpu": [
"wasm32"
],
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/sharp-wasm32": "0.35.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-arm64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-arm64/-/sharp-win32-arm64-0.35.5.tgz",
"integrity": "sha512-X4t7g+7ZA5DKblCBEXGjUqqemj4vczING/5viFwAL8h4N3qYeyjwdCvRLHi4EdOUI+2Z7UFlp1VM+p/AuEtm6Q==",
"cpu": [
"arm64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-ia32": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-ia32/-/sharp-win32-ia32-0.35.5.tgz",
"integrity": "sha512-5Zm82LoBc43nhwNybZlG7Y1KO//Zhsn306fQl29ZOuStHLGTo3BWL83q3cznX0poxSAMuYL1On/BHBxkBeKr6A==",
"cpu": [
"ia32"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": "^20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@img/sharp-win32-x64": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/@img/sharp-win32-x64/-/sharp-win32-x64-0.35.5.tgz",
"integrity": "sha512-x76eH0vEiHlcMQu8Y8IenntaACtddpT6W0wmXtWrnKcnKI7ME5DdgqhAD6SEWOEl1v2zDvkZDhFA9KnURwpfqg==",
"cpu": [
"x64"
],
"license": "Apache-2.0 AND LGPL-3.0-or-later",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
}
},
"node_modules/@jridgewell/resolve-uri": {
"version": "3.1.2",
"resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz",
"integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/@jridgewell/sourcemap-codec": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.6.0.tgz",
"integrity": "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@jridgewell/trace-mapping": {
"version": "0.3.31",
"resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz",
"integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/resolve-uri": "^3.1.0",
"@jridgewell/sourcemap-codec": "^1.4.14"
}
},
"node_modules/@napi-rs/canvas": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.10.tgz",
"integrity": "sha512-V+qptzsGnPMFxEbhwc6eDNMe5eDrfiBIp8qGpuRjAVIX4Kn2zxvwQ1Xco6Fwe+3tYIe3iLW8LM9D+SvjyJhqbQ==",
"license": "MIT",
"optional": true,
"workspaces": [
"e2e/*"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
},
"optionalDependencies": {
"@napi-rs/canvas-android-arm64": "1.0.10",
"@napi-rs/canvas-darwin-arm64": "1.0.10",
"@napi-rs/canvas-darwin-x64": "1.0.10",
"@napi-rs/canvas-linux-arm-gnueabihf": "1.0.10",
"@napi-rs/canvas-linux-arm64-gnu": "1.0.10",
"@napi-rs/canvas-linux-arm64-musl": "1.0.10",
"@napi-rs/canvas-linux-riscv64-gnu": "1.0.10",
"@napi-rs/canvas-linux-x64-gnu": "1.0.10",
"@napi-rs/canvas-linux-x64-musl": "1.0.10",
"@napi-rs/canvas-win32-arm64-msvc": "1.0.10",
"@napi-rs/canvas-win32-x64-msvc": "1.0.10"
}
},
"node_modules/@napi-rs/canvas-android-arm64": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.10.tgz",
"integrity": "sha512-0sDMvme+7fj6bHERUaBs5SvEyWaORc91VUkaB4RrtG7p6dBrBNGy1of8cyEKDxygd1FlHtW+Thunj8lSixzY7A==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"android"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-arm64": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.10.tgz",
"integrity": "sha512-pdHL1TEfFMrnchRYqmPWXKN7hR4XtOYmzJqnh4XhpfWD4/eeSokOGVzrdFP/iQ3sfr2rf0939gOGq9uCp8BGmg==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-darwin-x64": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.10.tgz",
"integrity": "sha512-iuuk5plGxAcxurxv2ycF2yR20y5zvaBZ4heQdhPpg3EMFD21PhlY1V+YwSRmYRWzbvKL474UYdw3WIg2qfafyw==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm-gnueabihf": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.10.tgz",
"integrity": "sha512-kH7GFR5Unpm77pKSEsmkqvzeQCRD7/MQ7upmeLLjg4XpQ62dX/R6THFsp6vkF1Pkco2aKUq9KhoLM4W5twdk7g==",
"cpu": [
"arm"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-gnu": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.10.tgz",
"integrity": "sha512-0/Hj7IwM9pmzdwxiqkp4YLJ8dKqASBrWQ3Y7dWUSU/4dgvCL9O6YNvX7xVUJjonQvdD3IE9ESS/nXa5srQqoVw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-arm64-musl": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.10.tgz",
"integrity": "sha512-fmjKM0P2MLF9O6XzCQsRjhB+jlLzaf6tFQU36hMPMsYup+VThYvpjjhqNLOc/Ee3tHUE3r13H2BSJx/yXz4pFA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-riscv64-gnu": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.10.tgz",
"integrity": "sha512-O/BUrGwrs3pVP9PpD/3++ZiOpjHMJlmiB7aBgswP8rbe4egPfiFkFxmcCczoLvZa72TaQX5lJ8Udedb6rc/Hvg==",
"cpu": [
"riscv64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-gnu": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.10.tgz",
"integrity": "sha512-48HkZPQeAN/R+9NPpY64tceoyCUW5xYYtHKZnC+BG11qiihXJCbH+xfbgGU+OdYp1Q4s84HDl9ILU0KBK6SBOQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-linux-x64-musl": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.10.tgz",
"integrity": "sha512-QYHVi7WZ9v+Wm9OQyHLMTnJ8QiBPVX94BgbCHFJC/4bCFMfvX7fRzo4FJPgkRP0ISz3ulYNow/FCbQOPjzhYjg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-arm64-msvc": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.10.tgz",
"integrity": "sha512-bWK+YajM/8sL0mMgLtug2s063FVBPzFV4QAn987CQomMKlmzrBY6Trz43KY+24hq7IOa3qNhUAgP/XMEF50ZEQ==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@napi-rs/canvas-win32-x64-msvc": {
"version": "1.0.10",
"resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.10.tgz",
"integrity": "sha512-9WtzW85PaIhtB27iXaR/9RrLCqHLbqD3Z8Q+kh17NjFxwQXFXU5vWbYH4vEH+drM307TTyK3hidZgpt09G5T3g==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
},
"funding": {
"type": "github",
"url": "https://github.com/sponsors/Brooooooklyn"
}
},
"node_modules/@next/env": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/env/-/env-16.4.0.tgz",
"integrity": "sha512-pyLlT5oafGlX8X7xjgGNXz5dNIDSxREm+qD6i9VLSZiZFopA/XWl8NM/KPmLx/8EG/++c49iid3gNWSQW1+2dQ==",
"license": "MIT"
},
"node_modules/@next/swc-darwin-arm64": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-arm64/-/swc-darwin-arm64-16.4.0.tgz",
"integrity": "sha512-yyZ+9MgViAKLLb5l1gPBGjRwnq3teNLb5Zx6JEw8LzNSTjlsnp+baUHFxTgl7PpjCmKf48VQfyipG1eBflb48Q==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-darwin-x64": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-darwin-x64/-/swc-darwin-x64-16.4.0.tgz",
"integrity": "sha512-RePEEejEj1Auf5s9qEcG0JbcDP3ceNUS/ofNX8AhxzgIjTVlZIHMatlp16YplcSMpsVo/ZMQHJTjbMYJznjGUg==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-gnu": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-gnu/-/swc-linux-arm64-gnu-16.4.0.tgz",
"integrity": "sha512-HhsyCqd+NpmvDDVY/LqkAN24f+QVvXh3ie+PfcwKinWL2Mu826JymQxJXGjAIfmJ4VlHjp4Pe/nZtXtUoEMAqw==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-arm64-musl": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-arm64-musl/-/swc-linux-arm64-musl-16.4.0.tgz",
"integrity": "sha512-sN9Pf+Zh+RpZAOmbR7d8KAA8T61CVhawtbEJ2Bk6QrRzx6Z/6haaF4UiwBzytM96GRRRWZh9/WALIEO3npMamA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-gnu": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-gnu/-/swc-linux-x64-gnu-16.4.0.tgz",
"integrity": "sha512-D5M5xLWQVd1CvR+L7BickM1weRfmM41x3L1NWcnKY/oyJJqr1qwaCgmu3dMAhLBOMdZwSuDCRV6DJRRpiUkbDQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-linux-x64-musl": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-linux-x64-musl/-/swc-linux-x64-musl-16.4.0.tgz",
"integrity": "sha512-8d5WLS6a9YalqpOgXdlbvonFu+sIgjA+CO6tGzetrpPVcUL56foa7B7664h41rAlbLi5fJwK4vWji3zf4QDy+w==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-arm64-msvc": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-win32-arm64-msvc/-/swc-win32-arm64-msvc-16.4.0.tgz",
"integrity": "sha512-sQuOIiW3snb48UwrBcDMvFA/u03qtw2LeuSIxFIQ9AJuW9UG/4Ddsfo+eV73PnZwBhyncMtsKVh7PrzMol2/fA==",
"cpu": [
"arm64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@next/swc-win32-x64-msvc": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/@next/swc-win32-x64-msvc/-/swc-win32-x64-msvc-16.4.0.tgz",
"integrity": "sha512-6/33lCuxpdHUy27RegyGM/5uTpF7YZd3UR4D0gpkOA/tWY5YR7msbQjqPgCZmSmxrrTLGCXYA4A8vT2H5DPGTQ==",
"cpu": [
"x64"
],
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">= 10"
}
},
"node_modules/@oxc-project/types": {
"version": "0.153.0",
"resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.153.0.tgz",
"integrity": "sha512-jSNrRIyvPVLL5E1z/tS5zLAprosGz/FuI1PWwEOz59j3E4TTTzSpqKb/RDGqJhhW0QHYISGXxq582wGx6T11Tw==",
"dev": true,
"license": "MIT",
"peer": true,
"funding": {
"url": "https://github.com/sponsors/oxc-project"
}
},
"node_modules/@playwright/test": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/@playwright/test/-/test-1.64.0.tgz",
"integrity": "sha512-oTN9FNYuN6bqQMwiho6jJKLCG2/QOYCixGXuCjRmzOq23eODeukuY2XiAVoVpAECbuezMt1MHQjAiQxK8F+Y+g==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/@protobuf-ts/runtime": {
"version": "2.11.1",
"resolved": "https://registry.npmjs.org/@protobuf-ts/runtime/-/runtime-2.11.1.tgz",
"integrity": "sha512-KuDaT1IfHkugM2pyz+FwiY80ejWrkH1pAtOBOZFuR6SXEFTsnb/jiQWQ1rCIrcKx2BtyxnxW6BWwsVSA/Ie+WQ==",
"license": "(Apache-2.0 AND BSD-3-Clause)"
},
"node_modules/@rolldown/binding-android-arm-eabi": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm-eabi/-/binding-android-arm-eabi-1.2.13.tgz",
"integrity": "sha512-Vu/NICrgdRvUOeaJZIyB2FP63s6fO/4ud5l8dXmxDx9yDyn+mN0y+gsvsVviSli+T361Aqo0h46EuMoobrwawA==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-android-arm64": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.2.13.tgz",
"integrity": "sha512-6LEoIrGy5vvCd2boNnv2soPSUZj/PRvhp8R/t9KlX+mIZxThIE/ZqxrGXxmuNX0FuvQFvLdZr7S4HrYpYBVhEQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-arm64": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.2.13.tgz",
"integrity": "sha512-CaJSRzlQ6Q+zkDMVA9OkVxBjVoPRCDEE3v0FSJ9kIQBgRjFApA3Si4ijhbNVUji7ZJt+PfO0e46qy4nPInHUPA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-darwin-x64": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.2.13.tgz",
"integrity": "sha512-e4r340I5hxSzJkD3YJBxgzq/rJ3wwxwdLLb2FPt1Zpyem20nrQPT11DDEEkazwxaHumH8wpglsp/Ozgj/wT6mw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-freebsd-x64": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.2.13.tgz",
"integrity": "sha512-hoBWDZ4/K/mJKVMbsgUd1jfpA2DiOrJ54mpegObU9TBHBBrtX4FLIXJJQRDPZ/2Dn91GvPob+Z2hwKJzf4E5SQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm-gnueabihf": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.2.13.tgz",
"integrity": "sha512-oF9fZu3vTWnTiVAkDFxU9PJLt1nh20K9RRpuHsR7ZnE2L7XookBbDBVLojCVSLGbvtSlxeUNnjAj6DbNsKyzbg==",
"cpu": [
"arm"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-gnu": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.2.13.tgz",
"integrity": "sha512-DTpb/+jMiqWMPgphn4ksW1zW+XLJ6dJJ9FMvnIk9I291LhCctMPhMYJrP5t9p6C9qa3KXadDdUEZHBaPh3kNxQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-arm64-musl": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.2.13.tgz",
"integrity": "sha512-xU+Kvv9N2A3SY35zFh1FWWNjxbadE/BKeqnkXe7vScU4GZsw757BlVmDLITZEq8DPpk6m7uoDYkK7P+x/uU5Ow==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-ppc64-gnu": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.2.13.tgz",
"integrity": "sha512-XMk/hnhh25oQ756Lclb1Q11h4ll0xC0Esa5w4Vzp+mAE2S3AGJgeM0SonrIgY8fHhjarb6hcoTuwoFG0VVP2ig==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-s390x-gnu": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.2.13.tgz",
"integrity": "sha512-U1BoTHYmlywE2n3HUWjzZ+8EQsKr+OwpxYuDWaubx42tUL8QSL7nOqRz/NVHanibbFP3M1Rxuprz/wNcbMKjqw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-gnu": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.2.13.tgz",
"integrity": "sha512-J4cm80Pc6wEwV5AZ42o62sIQgDk+Y7R2s646jIwuvO+4V46QB58N4vVuMPWCBaNgursXLpmUWJV//WQAkPvHBQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-linux-x64-musl": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.2.13.tgz",
"integrity": "sha512-W0euZS40U4V6JkmEnVCIX9mcsL6cbXQ4X8c2w+MMOIjFg68TXUW+u3RT+2e1t1pv9mg/EP3pDtK3POXdQ7Rxyw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-openharmony-arm64": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.2.13.tgz",
"integrity": "sha512-do/AF/4n9MRshia3a5aXoy8xM2c6mgfT6D7H/dY6my5z+hfCIR4ab7mk4IRj++nsEQpKy39kd0DQnDq+r692tw==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"openharmony"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-arm64-msvc": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.2.13.tgz",
"integrity": "sha512-6FtUf2cJZobutJdxnGYX8z0vtbgqU0b2v8vfBhkXb9wp2MppMFtkunE+IaOntyi01rkIQoPW2GHABtfzsC0BRQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/binding-win32-x64-msvc": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.2.13.tgz",
"integrity": "sha512-UKhFaZTeTcbKrb3iU82m17jVcPEAoqDkCN5MBqgPyZSwp/cQmib21crTS0DdH7T1B7wZRWEI89YQAOJMet5yhg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MIT",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
},
"node_modules/@rolldown/pluginutils": {
"version": "1.0.1",
"resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz",
"integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==",
"dev": true,
"license": "MIT",
"peer": true
},
"node_modules/@swc/helpers": {
"version": "0.5.23",
"resolved": "https://registry.npmjs.org/@swc/helpers/-/helpers-0.5.23.tgz",
"integrity": "sha512-5lSsMOTXURePglDfvuAQUqkGek9Hg2kksOYay2m0+XR++b2NWYL/4sWyuvVBIs8oKnJaxkdi9whaL/sqN13afw==",
"license": "Apache-2.0",
"dependencies": {
"tslib": "^2.8.0"
}
},
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
"integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/deep-eql": "*",
"assertion-error": "^2.0.1"
}
},
"node_modules/@types/deep-eql": {
"version": "4.0.2",
"resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz",
"integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/estree": {
"version": "1.0.9",
"resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz",
"integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==",
"dev": true,
"license": "MIT"
},
"node_modules/@types/node": {
"version": "26.6.4",
"resolved": "https://registry.npmjs.org/@types/node/-/node-26.6.4.tgz",
"integrity": "sha512-ldVPDCzj7fsaGZrLB0NuHuTvJcsNasysBAqMolr/cgxrLd1xbqxIr3XJiPnHHJUCxj5sNF1vnRj9aWnrVh5Jcg==",
"license": "MIT",
"dependencies": {
"undici-types": "~8.9.0"
}
},
"node_modules/@types/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react/-/react-19.3.0.tgz",
"integrity": "sha512-N0rFCuH9YoxG9/m61l9MfpJKfmLOVU0em7ipIz6TRgSSkvReLB9vL85GB+yr8Bs5leqpvg96JSwF4ZS1s4viQg==",
"devOptional": true,
"license": "MIT",
"dependencies": {
"csstype": "^3.2.2"
}
},
"node_modules/@types/react-dom": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-ZI7bU42mZXXKHn/qNLEw2IrbiINU7X5+vfgdixBHkCNpYWXjKgfQ/P+uyGb5CjOLB9UcnTeg3rylQtV2hym44Q==",
"dev": true,
"license": "MIT",
"peerDependencies": {
"@types/react": "^19.3.0"
}
},
"node_modules/@types/ws": {
"version": "8.18.2",
"resolved": "https://registry.npmjs.org/@types/ws/-/ws-8.18.2.tgz",
"integrity": "sha512-67MQl+fpWKVTT1NYdnmo3U4sc/xPo/zQBncVnI74qmQa0z/b+1g6iYqNmGCPbxO+zz2aklb08a0oHfegiVd0/w==",
"license": "MIT",
"dependencies": {
"@types/node": "*"
}
},
"node_modules/@typescript/typescript-aix-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-aix-ppc64/-/typescript-aix-ppc64-7.0.2.tgz",
"integrity": "sha512-MTKKkWB7p/0E9xi1d1tHtZ5PiLkGEMIq88pK2CubZjOsLtYTLqhgIgi6zepFa+9GHZ6h05NMCkQxGKiPXMxXtQ==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"aix"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-arm64/-/typescript-darwin-arm64-7.0.2.tgz",
"integrity": "sha512-gowzar9MwS/aRWp6f3a4KUqzRjAZjOsmGNCM6LcTgXum+dBfgsBVMN+AgvOCCbguXyick6LJhpBszxMebJ8syA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-darwin-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-darwin-x64/-/typescript-darwin-x64-7.0.2.tgz",
"integrity": "sha512-SZ9xZInqApNlNGc9s0W1VSsktYSOe9cFqNOIqmN1Gs8SmkjKZYFt017G4VwPxASInODuAdbTW7sXiFUf893RgA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-arm64/-/typescript-freebsd-arm64-7.0.2.tgz",
"integrity": "sha512-W5NH4y/J0plIIS5b2xvTEkU7JFxyqdMAOgf+Ilhl0vHQXKO5dZoxd+C/jEtq56c4F3wk71RB4BMRQ2XdI+bwYQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-freebsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-freebsd-x64/-/typescript-freebsd-x64-7.0.2.tgz",
"integrity": "sha512-UMGDx5sTpzNw3WiPebH7l90IWfJggEd+egHt/q6p7/Cm3zqoV7VxkGXt+3DxPIw8CcmvAB0j3sVVfbhX+M4Tpw==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"freebsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm/-/typescript-linux-arm-7.0.2.tgz",
"integrity": "sha512-gffT3xPz9sR7j/YJExkyPntrI0P2EP9XbOyWzth2/Gs0RstK+90RBcO0ncXoXy/beYll1SXw846Nf2zdnEz0QQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-arm64/-/typescript-linux-arm64-7.0.2.tgz",
"integrity": "sha512-Qh4eU4/y3yDjnfjjyPYihMj5/ODIlmt+Bzu17OI+fiSRDW57QmU5SiN63exPRNJPKUzcc1INa1NXdrJ+MqHjUQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-loong64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-loong64/-/typescript-linux-loong64-7.0.2.tgz",
"integrity": "sha512-uEHck9i8hoAzXPiYRib1O7miOnz23SxIeVl6F4LXox+qov1K35jHcEW6VHKvZI+pyvl7fZEP4MCU5LYvIq1GuQ==",
"cpu": [
"loong64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-mips64el": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-mips64el/-/typescript-linux-mips64el-7.0.2.tgz",
"integrity": "sha512-R4KvAMnE43W5Qeqb0Ly56O3mWMWIAgsMyz36DCaycd5nbg/9kzm0liw3JocfRqyJY0KPmzFjbswozXyW0DnIYA==",
"cpu": [
"mips64el"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-ppc64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-ppc64/-/typescript-linux-ppc64-7.0.2.tgz",
"integrity": "sha512-DORx5b3sd/4S7eayxm4FQv+A7CrkUIGRaHiwI8oiHTAI1fAPWhF4J0vAlkC8biAlHSVVwxMQ3tjZ2/DVbnQiiA==",
"cpu": [
"ppc64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-riscv64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-riscv64/-/typescript-linux-riscv64-7.0.2.tgz",
"integrity": "sha512-wf0jqEDOjrPRnKwYRyyJDRo11KMbvMFrU+q4zqKyChODBzvlkbhNQfKvLxQCcwTpdDaXSHZTVuh0JoCrKCUMHQ==",
"cpu": [
"riscv64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-s390x": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-s390x/-/typescript-linux-s390x-7.0.2.tgz",
"integrity": "sha512-IkwJc3L7yhytWd/ewjyxNDfOmswCm9GWMJT/ue/dU4aZNbwZeYAetq42VyLmsmSjvoX7z74X6ZaYCtzAr0EuGw==",
"cpu": [
"s390x"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-linux-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-linux-x64/-/typescript-linux-x64-7.0.2.tgz",
"integrity": "sha512-EYdf2cNg7rgCWJnxCdJ+F3V39O8ihb37eHAu1LK8oAFizgTQbPOK7zHHXbPt8rX24COqODXeI3sIf0fCXG7H/A==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"linux"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-arm64/-/typescript-netbsd-arm64-7.0.2.tgz",
"integrity": "sha512-+polYF4MF04aPpO5FTkHran9yUQDSXqy5GiSDKpsll5jy3l3+g9QLhpf39T+ePtefhXLOGrLl0QIjkQP6VnelA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-netbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-netbsd-x64/-/typescript-netbsd-x64-7.0.2.tgz",
"integrity": "sha512-8YIT0EHM/3dq10ZOVF/A7pc/YSMtbcecct4rWtexrnSCHOPcpC2KTLXfTCR6vDpnSiY12heNb1GiN/wu+T/FyA==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"netbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-arm64/-/typescript-openbsd-arm64-7.0.2.tgz",
"integrity": "sha512-APT8+ClYnuYm1u9+kgGXoMj2VzWzcymwh2gNSQVySHfkRDGOTVkoWLjCmOQSaO+PoqQ57B0flRp9SA+7GnnkzQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-openbsd-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-openbsd-x64/-/typescript-openbsd-x64-7.0.2.tgz",
"integrity": "sha512-yX7s+Q0Dln0Dt9tEzZsAjXXR/+ytBM7AlglaqyeMPxQszJ1JhlJdZ6jLA+IzldHtflX81em7lDao1xXu+aRRkg==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"openbsd"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-sunos-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-sunos-x64/-/typescript-sunos-x64-7.0.2.tgz",
"integrity": "sha512-dLJDGaLZ1D4HPQn62u1n8mBDkJREwMsAkCdkwd4Ieqw+x3TUyTsqY0YiBCtE6H6OzzgGk3iuZ3vFWRS+E8/d1g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"sunos"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-arm64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-arm64/-/typescript-win32-arm64-7.0.2.tgz",
"integrity": "sha512-Gyl1Vy6OsWesLzmq+EP0Fb7b4Nid5232AvcA2SFcdYreldpNtYFFofPjnt62y9hQy7VTaZp65ICJjuAQRaVcIQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@typescript/typescript-win32-x64": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/@typescript/typescript-win32-x64/-/typescript-win32-x64-7.0.2.tgz",
"integrity": "sha512-0BQ3HkAHHlKLSp1qRvf3SUhGpGsDuhB/jgFw75guyqbxJqEaS0Cw/VFO8i2nHglJUzQCRtMMR/IBAKE3ETMC4g==",
"cpu": [
"x64"
],
"dev": true,
"license": "Apache-2.0",
"optional": true,
"os": [
"win32"
],
"engines": {
"node": ">=16.20.0"
}
},
"node_modules/@vitest/mocker": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-5.0.3.tgz",
"integrity": "sha512-T8sWAIbkSyAjkwTcaEc3Iu0o9A27X1/kdXrizhZkGuSKScRQtRzclfAMpOTcGdXCsqxeWlpGy3XjqaW8CpLORg==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/trace-mapping": "0.3.31",
"@vitest/spy": "5.0.3",
"estree-walker": "^3.0.3",
"magic-string": "^1.2.3"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"msw": "^2.4.9",
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"msw": {
"optional": true
},
"vite": {
"optional": true
}
}
},
"node_modules/@vitest/spy": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-5.0.3.tgz",
"integrity": "sha512-XhFysQTB8AZ+P4gMi+Lpo99vg2AZi0qKpaB9yXQl37+CaMEAPO3iH/wGVnSyL5MPERiLezpqTVtrR6UZH5GCXg==",
"dev": true,
"license": "MIT",
"funding": {
"url": "https://opencollective.com/vitest"
}
},
"node_modules/assertion-error": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz",
"integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
}
},
"node_modules/baseline-browser-mapping": {
"version": "2.11.28",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.28.tgz",
"integrity": "sha512-br0LOmjJqr6Rt4AEkadbcNf67Zpo7EPwKsWbjnRrJIa1QYFHQBqM4C7F2WTbV8ahTEcg1Eo+Y0aWjjORInYFyA==",
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001815",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001815.tgz",
"integrity": "sha512-uDybUEyfgbyJJ93J1vikTylBX31AkdVYeVPeffMQpf8XN7hSfr0oZOn4VFOIMNvtU5+rHvC5Udg2TgtJKHm23Q==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/browserslist"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/caniuse-lite"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "CC-BY-4.0"
},
"node_modules/chai": {
"version": "6.3.0",
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
"license": "MIT"
},
"node_modules/csstype": {
"version": "3.2.3",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz",
"integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==",
"devOptional": true,
"license": "MIT"
},
"node_modules/detect-libc": {
"version": "2.1.2",
"resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz",
"integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==",
"devOptional": true,
"license": "Apache-2.0",
"engines": {
"node": ">=8"
}
},
"node_modules/es-module-lexer": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
"dev": true,
"license": "MIT"
},
"node_modules/esbuild": {
"version": "0.28.2",
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"bin": {
"esbuild": "bin/esbuild"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"@esbuild/aix-ppc64": "0.28.2",
"@esbuild/android-arm": "0.28.2",
"@esbuild/android-arm64": "0.28.2",
"@esbuild/android-x64": "0.28.2",
"@esbuild/darwin-arm64": "0.28.2",
"@esbuild/darwin-x64": "0.28.2",
"@esbuild/freebsd-arm64": "0.28.2",
"@esbuild/freebsd-x64": "0.28.2",
"@esbuild/linux-arm": "0.28.2",
"@esbuild/linux-arm64": "0.28.2",
"@esbuild/linux-ia32": "0.28.2",
"@esbuild/linux-loong64": "0.28.2",
"@esbuild/linux-mips64el": "0.28.2",
"@esbuild/linux-ppc64": "0.28.2",
"@esbuild/linux-riscv64": "0.28.2",
"@esbuild/linux-s390x": "0.28.2",
"@esbuild/linux-x64": "0.28.2",
"@esbuild/netbsd-arm64": "0.28.2",
"@esbuild/netbsd-x64": "0.28.2",
"@esbuild/openbsd-arm64": "0.28.2",
"@esbuild/openbsd-x64": "0.28.2",
"@esbuild/openharmony-arm64": "0.28.2",
"@esbuild/sunos-x64": "0.28.2",
"@esbuild/win32-arm64": "0.28.2",
"@esbuild/win32-ia32": "0.28.2",
"@esbuild/win32-x64": "0.28.2"
}
},
"node_modules/estree-walker": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz",
"integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/estree": "^1.0.0"
}
},
"node_modules/expect-type": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.4.0.tgz",
"integrity": "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA==",
"dev": true,
"license": "Apache-2.0",
"engines": {
"node": ">=12.0.0"
}
},
"node_modules/fdir": {
"version": "6.5.0",
"resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz",
"integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12.0.0"
},
"peerDependencies": {
"picomatch": "^3 || ^4"
},
"peerDependenciesMeta": {
"picomatch": {
"optional": true
}
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/lightningcss": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.33.0.tgz",
"integrity": "sha512-WkUDrojuJs0xkgGf2udWxa3yGBRxPtxUkB79i6aCZLRgc7PM8fZe9TosfPDcvEpQZbuFASnHYmRLBLUbmLOIIA==",
"dev": true,
"license": "MPL-2.0",
"peer": true,
"dependencies": {
"detect-libc": "^2.0.3"
},
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
},
"optionalDependencies": {
"lightningcss-android-arm64": "1.33.0",
"lightningcss-darwin-arm64": "1.33.0",
"lightningcss-darwin-x64": "1.33.0",
"lightningcss-freebsd-x64": "1.33.0",
"lightningcss-linux-arm-gnueabihf": "1.33.0",
"lightningcss-linux-arm64-gnu": "1.33.0",
"lightningcss-linux-arm64-musl": "1.33.0",
"lightningcss-linux-x64-gnu": "1.33.0",
"lightningcss-linux-x64-musl": "1.33.0",
"lightningcss-win32-arm64-msvc": "1.33.0",
"lightningcss-win32-x64-msvc": "1.33.0"
}
},
"node_modules/lightningcss-android-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.33.0.tgz",
"integrity": "sha512-gEpRTalKdosp4Bb8qWtc2iOgE5SeIHlpS1up9bFq2wAyYhl1UdTObYiHe98zEM9SQvSoqQZ1IQD0JNpg3Ml5pg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"android"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-arm64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.33.0.tgz",
"integrity": "sha512-Sciaz8eenNTKn9b3t7+xr0ipTp9YxKQY4npwQ3mrRuL0BAVHBLyZxofhaKBAVtzmtRZ/zTyo0/to4B1uWG/Djg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-darwin-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.33.0.tgz",
"integrity": "sha512-Z5UPAxzrjlWNNyGy6i65cJzzvgJ5D3T6wMvs+gWpY9d7qRhANrxqAp6LhxIgZhWEw18RfJTGcRxjuLIBr+m8XQ==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"darwin"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-freebsd-x64": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.33.0.tgz",
"integrity": "sha512-QQM/Ti/hQajJwCY+RiWuCZ9sdtI/XQk7nDK5vC8kkdwixezOlDgvDx7+RT+QjK6FcFT4MpsuoBnHIo/O3StRRg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"freebsd"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm-gnueabihf": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.33.0.tgz",
"integrity": "sha512-N7FVBe6iS24MlM6R/4RBTxGhQheZGs7tiQ9U32UtF75NzP5Q7xWPRqLBCKxlRQRk3rY1jCIPLzx7WzOhuUIRLQ==",
"cpu": [
"arm"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.33.0.tgz",
"integrity": "sha512-j2v/itmy4HlNxlc6voKXYgBqNi0Ng2LShg4z7GufpEgs05P+2suBVyi9I6YHq5uoVFx9ETin3eCEhLVyXGQnKg==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-arm64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.33.0.tgz",
"integrity": "sha512-yiO5ROMuYQgXbC60yjZU5CYSFZGKXL0HFATXt9mHJn1+zW55oCtMI9NfcVhYLMFDL7gV7oBPon/EmMMGg2OvtQ==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-gnu": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.33.0.tgz",
"integrity": "sha512-ar+Ju7LmcN0Jo4FpL4hpFybwNG9/3A/Br5KW2n2jyODg3MEZXaDYADdemoNS+BDNfMgKvylJLj4S5tyRActuAg==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-linux-x64-musl": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.33.0.tgz",
"integrity": "sha512-RYiYbkokw0trfKqqzfF55lginwEPrD3OJDfTuJzFs1MK6iFnDenaz1fqLLtX4ITG3OktJQXOeTaw1awrBAlZPw==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"linux"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-arm64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.33.0.tgz",
"integrity": "sha512-1K+MPfLSFVpphzpdbfkhlWk6wBrTObBzS2T6db10PNOZgR9GoVsAWzwNyuhUYYbTp23j+4RrncfujZ4uAzXvwA==",
"cpu": [
"arm64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lightningcss-win32-x64-msvc": {
"version": "1.33.0",
"resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.33.0.tgz",
"integrity": "sha512-OlEICDx/Xl0FqSp4bry8zFnCvGpig3Gl4gCquvYwHuqJKEC1+n9NgDniFvqHGmMv1ZkqDJrDqKKSykTDX+ehuA==",
"cpu": [
"x64"
],
"dev": true,
"license": "MPL-2.0",
"optional": true,
"os": [
"win32"
],
"peer": true,
"engines": {
"node": ">= 12.0.0"
},
"funding": {
"type": "opencollective",
"url": "https://opencollective.com/parcel"
}
},
"node_modules/lucide-react": {
"version": "1.53.0",
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.53.0.tgz",
"integrity": "sha512-OxJ7elNku+iqVzOolk5caxq2LM8Mck6lhA6uZx3g2PlZ53+FvBnT6PlJxGCukCa6oF5wQwgSatn9cH1b4olWmQ==",
"license": "ISC",
"peerDependencies": {
"@types/react": "*",
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/magic-string": {
"version": "1.4.3",
"resolved": "https://registry.npmjs.org/magic-string/-/magic-string-1.4.3.tgz",
"integrity": "sha512-z12OxmaPGE0F4xlpGdjuAUckipLpQlTAuAmyGhNoD7ODpYUzvDfvtxUbjM/jwznyP178oju/KDdyi220wNJ0RQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@jridgewell/sourcemap-codec": "^1.6.0"
}
},
"node_modules/nanoid": {
"version": "3.3.20",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"bin": {
"nanoid": "bin/nanoid.cjs"
},
"engines": {
"node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1"
}
},
"node_modules/next": {
"version": "16.4.0",
"resolved": "https://registry.npmjs.org/next/-/next-16.4.0.tgz",
"integrity": "sha512-N2C+9q89Z/oLrsepSRS4jRMtwmUZAoZuAHU0e9T6+sAB5DdhADmrnOW+zjYijlrYFmVfmQhGVGy9eMFoB7UgKg==",
"license": "MIT",
"dependencies": {
"@next/env": "16.4.0",
"@swc/helpers": "0.5.23",
"baseline-browser-mapping": "^2.9.19",
"caniuse-lite": "^1.0.30001579",
"postcss": "8.5.23",
"styled-jsx": "5.1.6"
},
"bin": {
"next": "dist/bin/next"
},
"engines": {
"node": ">=20.9.0"
},
"optionalDependencies": {
"@next/swc-darwin-arm64": "16.4.0",
"@next/swc-darwin-x64": "16.4.0",
"@next/swc-linux-arm64-gnu": "16.4.0",
"@next/swc-linux-arm64-musl": "16.4.0",
"@next/swc-linux-x64-gnu": "16.4.0",
"@next/swc-linux-x64-musl": "16.4.0",
"@next/swc-win32-arm64-msvc": "16.4.0",
"@next/swc-win32-x64-msvc": "16.4.0",
"sharp": "^0.35.4"
},
"peerDependencies": {
"@opentelemetry/api": "^1.1.0",
"@playwright/test": "^1.51.1",
"babel-plugin-react-compiler": "*",
"react": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"react-dom": "^18.2.0 || 19.0.0-rc-de68d2f4-20241204 || ^19.0.0",
"sass": "^1.3.0"
},
"peerDependenciesMeta": {
"@opentelemetry/api": {
"optional": true
},
"@playwright/test": {
"optional": true
},
"babel-plugin-react-compiler": {
"optional": true
},
"sass": {
"optional": true
}
}
},
"node_modules/obug": {
"version": "2.2.1",
"resolved": "https://registry.npmjs.org/obug/-/obug-2.2.1.tgz",
"integrity": "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q==",
"dev": true,
"funding": [
"https://github.com/sponsors/sxzz",
"https://opencollective.com/debug"
],
"license": "MIT",
"engines": {
"node": ">=12.20.0"
}
},
"node_modules/pdfjs-dist": {
"version": "6.4.299",
"resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.4.299.tgz",
"integrity": "sha512-AVl138zALtfaAPvADulE0PZThbYzCBS79nL4pOSL/6Sm/4AH5A21BD9VHt97OlCuzJuCpmeZtAtkinisF4Vb1g==",
"license": "Apache-2.0",
"engines": {
"node": ">=22.13.0 || >=24"
},
"optionalDependencies": {
"@napi-rs/canvas": "^1.0.10"
}
},
"node_modules/picocolors": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz",
"integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "4.0.7",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=12"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/playwright": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.64.0.tgz",
"integrity": "sha512-kVzTnYFYEyQ9RGzBjNCd2+yEqWmVbxl3AHPynv3qQ6JTjv4a+kwRcnqQwIpuP3NnrnIrE/Q6wWAIAvMfIE2/Yg==",
"devOptional": true,
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.64.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/playwright-core": {
"version": "1.64.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.64.0.tgz",
"integrity": "sha512-T9r+MZkTECl2+oUcZ26YgZzTrYidTsIhYYkJix+6iDOymOb1FB2RLLSOxRGnZGv5DStt+aduIVbt+E8WygXYpg==",
"devOptional": true,
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=20"
}
},
"node_modules/postcss": {
"version": "8.5.23",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.23.tgz",
"integrity": "sha512-g50586zr4bZmwFiTlflMu8E0bDTb5I5gertgwAKmsdUlTQIhZtunzUlD1WSzwcVWPoAVpsrA6vlfCD7oXvRwgg==",
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"dependencies": {
"nanoid": "^3.3.16",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.1"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/prettier": {
"version": "3.9.9",
"resolved": "https://registry.npmjs.org/prettier/-/prettier-3.9.9.tgz",
"integrity": "sha512-Z/CJHIkdujO/OtN7nXUii0Rf3VT5SRuhjBA82Xvu2XhBUgX3nhP67T0LHceBdQLex7OOFGTox+Q5Yg8Jk2Qivg==",
"dev": true,
"license": "MIT",
"bin": {
"prettier": "bin/prettier.cjs"
},
"engines": {
"node": ">=14"
},
"funding": {
"url": "https://github.com/prettier/prettier?sponsor=1"
}
},
"node_modules/react": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react/-/react-19.3.0.tgz",
"integrity": "sha512-E8LUcbtBWt20bbl2YoHfx4ZDBdxVTfOKtCZn9cDSJ4l6/nuoApcpIBcj47t2wZoVX8g2ZHuMHbiShgCR1T5Sog==",
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/react-dom": {
"version": "19.3.0",
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.3.0.tgz",
"integrity": "sha512-JDk8dgif51OjFoDE70+OT9ICyYr+69HlmihNwp1+Nsfbna3t5sIiCa9ZJktDmQ4/1b/rn26hIAR2uYXDMr5r0Q==",
"license": "MIT",
"dependencies": {
"scheduler": "^0.28.0"
},
"peerDependencies": {
"react": "^19.3.0"
}
},
"node_modules/rolldown": {
"version": "1.2.13",
"resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.2.13.tgz",
"integrity": "sha512-/5apOkwWxtACcQtZ5WlrAsKS4JwXVThQPJZ3RqIruRY9UG9M8e0VlXN068xum/JRKA4aIUoJ13ghe/kGCvidiQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@oxc-project/types": "=0.153.0",
"@rolldown/pluginutils": "^1.0.0"
},
"bin": {
"rolldown": "bin/cli.mjs"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"optionalDependencies": {
"@rolldown/binding-android-arm-eabi": "1.2.13",
"@rolldown/binding-android-arm64": "1.2.13",
"@rolldown/binding-darwin-arm64": "1.2.13",
"@rolldown/binding-darwin-x64": "1.2.13",
"@rolldown/binding-freebsd-x64": "1.2.13",
"@rolldown/binding-linux-arm-gnueabihf": "1.2.13",
"@rolldown/binding-linux-arm64-gnu": "1.2.13",
"@rolldown/binding-linux-arm64-musl": "1.2.13",
"@rolldown/binding-linux-ppc64-gnu": "1.2.13",
"@rolldown/binding-linux-s390x-gnu": "1.2.13",
"@rolldown/binding-linux-x64-gnu": "1.2.13",
"@rolldown/binding-linux-x64-musl": "1.2.13",
"@rolldown/binding-openharmony-arm64": "1.2.13",
"@rolldown/binding-win32-arm64-msvc": "1.2.13",
"@rolldown/binding-win32-x64-msvc": "1.2.13"
}
},
"node_modules/scheduler": {
"version": "0.28.0",
"resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.28.0.tgz",
"integrity": "sha512-juorfCmIkIw8tT+p5BXSm6PJjQF/ycEYmKyzURCIt/RaZIhL+PulbQ9Yu2z1HdOJDdqDTlxA1+xKBmHXJsczAw==",
"license": "MIT"
},
"node_modules/sdp": {
"version": "3.2.2",
"resolved": "https://registry.npmjs.org/sdp/-/sdp-3.2.2.tgz",
"integrity": "sha512-xZocWwfyp4hkbN4hLWxMjmv2Q8aNa9MhmOZ7L9aCZPT+dZsgRr6wZRrSYE3HTdyk/2pZKPSgqI7ns7Een1xMSA==",
"license": "MIT"
},
"node_modules/semver": {
"version": "7.8.5",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.8.5.tgz",
"integrity": "sha512-Y7/KDsb8LjooZpwaqGyulO6DQlksgCncchHGk+sZIY4SBvUocMBEFH5Ur1fI4dV+Jvl0w6cjvucaIi40puRioA==",
"license": "ISC",
"optional": true,
"bin": {
"semver": "bin/semver.js"
},
"engines": {
"node": ">=10"
}
},
"node_modules/sharp": {
"version": "0.35.5",
"resolved": "https://registry.npmjs.org/sharp/-/sharp-0.35.5.tgz",
"integrity": "sha512-Ywn4OnzGukp7CDMrp08RQ50YKmuwG47brZgIVPTvBaaAfQlRlygrRqSrxdCiL9M+LlzLBiJ68IR1QqvzHyjC7g==",
"license": "Apache-2.0",
"optional": true,
"dependencies": {
"@img/colour": "^1.1.0",
"detect-libc": "^2.1.2",
"semver": "^7.8.5"
},
"engines": {
"node": ">=20.9.0"
},
"funding": {
"url": "https://opencollective.com/libvips"
},
"optionalDependencies": {
"@img/sharp-darwin-arm64": "0.35.5",
"@img/sharp-darwin-x64": "0.35.5",
"@img/sharp-freebsd-wasm32": "0.35.5",
"@img/sharp-libvips-darwin-arm64": "1.3.4",
"@img/sharp-libvips-darwin-x64": "1.3.4",
"@img/sharp-libvips-linux-arm": "1.3.4",
"@img/sharp-libvips-linux-arm64": "1.3.4",
"@img/sharp-libvips-linux-ppc64": "1.3.4",
"@img/sharp-libvips-linux-riscv64": "1.3.4",
"@img/sharp-libvips-linux-s390x": "1.3.4",
"@img/sharp-libvips-linux-x64": "1.3.4",
"@img/sharp-libvips-linuxmusl-arm64": "1.3.4",
"@img/sharp-libvips-linuxmusl-x64": "1.3.4",
"@img/sharp-linux-arm": "0.35.5",
"@img/sharp-linux-arm64": "0.35.5",
"@img/sharp-linux-ppc64": "0.35.5",
"@img/sharp-linux-riscv64": "0.35.5",
"@img/sharp-linux-s390x": "0.35.5",
"@img/sharp-linux-x64": "0.35.5",
"@img/sharp-linuxmusl-arm64": "0.35.5",
"@img/sharp-linuxmusl-x64": "0.35.5",
"@img/sharp-webcontainers-wasm32": "0.35.5",
"@img/sharp-win32-arm64": "0.35.5",
"@img/sharp-win32-ia32": "0.35.5",
"@img/sharp-win32-x64": "0.35.5"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
}
}
},
"node_modules/source-map-js": {
"version": "1.2.2",
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
"license": "BSD-3-Clause",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/std-env": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
"dev": true,
"license": "MIT"
},
"node_modules/styled-jsx": {
"version": "5.1.6",
"resolved": "https://registry.npmjs.org/styled-jsx/-/styled-jsx-5.1.6.tgz",
"integrity": "sha512-qSVyDTeMotdvQYoHWLNGwRFJHC+i+ZvdBRYosOFgC+Wg1vx4frN2/RG/NA7SYqqvKNLf39P2LSRA2pu6n0XYZA==",
"license": "MIT",
"dependencies": {
"client-only": "0.0.1"
},
"engines": {
"node": ">= 12.0.0"
},
"peerDependencies": {
"react": ">= 16.8.0 || 17.x.x || ^18.0.0-0 || ^19.0.0-0"
},
"peerDependenciesMeta": {
"@babel/core": {
"optional": true
},
"babel-plugin-macros": {
"optional": true
}
}
},
"node_modules/tinybench": {
"version": "6.2.1",
"resolved": "https://registry.npmjs.org/tinybench/-/tinybench-6.2.1.tgz",
"integrity": "sha512-IAbQaPJIIdhVxi0mqy9lez1wEwje0UQf9F1vwtbelEG5Nbgn0nPOxCvU+mNeNS9gyJTAhrHp74CbUaSynQpZcQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=20.0.0"
}
},
"node_modules/tinyexec": {
"version": "1.3.1",
"resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.3.1.tgz",
"integrity": "sha512-GCvB3aoys96IuDFBMcTB46JOR6mdMtAToqwiW8JlWhsoh1mhHi/xn9ss/Dg7N555GiJyEt2qzoG/NHCwM6h1EA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=18"
}
},
"node_modules/tinyglobby": {
"version": "0.2.17",
"resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz",
"integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==",
"dev": true,
"license": "MIT",
"dependencies": {
"fdir": "^6.5.0",
"picomatch": "^4.0.4"
},
"engines": {
"node": ">=12.0.0"
},
"funding": {
"url": "https://github.com/sponsors/SuperchupuDev"
}
},
"node_modules/tslib": {
"version": "2.8.1",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz",
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
"license": "0BSD"
},
"node_modules/tsx": {
"version": "4.23.15",
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.15.tgz",
"integrity": "sha512-Yiex1Ovn8z2xPpOWckIiysV1SSyRMY9BkLF++q0yKiDxCqRhosKfMg3janKkiLBwZ5c/YryloKwGZcrEmtwxKw==",
"dev": true,
"license": "MIT",
"dependencies": {
"esbuild": "~0.28.0"
},
"bin": {
"tsx": "dist/cli.mjs"
},
"engines": {
"node": ">=18.0.0"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
}
},
"node_modules/typescript": {
"version": "7.0.2",
"resolved": "https://registry.npmjs.org/typescript/-/typescript-7.0.2.tgz",
"integrity": "sha512-8FYau96o3NKOhbjKi/qNvG/W5jhzxkbdm5sj9AbZ/5T5sWqn3hJgLfGx27sRKZWTvyzCP8dLRBTf5tBTSRVUNA==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc"
},
"engines": {
"node": ">=16.20.0"
},
"optionalDependencies": {
"@typescript/typescript-aix-ppc64": "7.0.2",
"@typescript/typescript-darwin-arm64": "7.0.2",
"@typescript/typescript-darwin-x64": "7.0.2",
"@typescript/typescript-freebsd-arm64": "7.0.2",
"@typescript/typescript-freebsd-x64": "7.0.2",
"@typescript/typescript-linux-arm": "7.0.2",
"@typescript/typescript-linux-arm64": "7.0.2",
"@typescript/typescript-linux-loong64": "7.0.2",
"@typescript/typescript-linux-mips64el": "7.0.2",
"@typescript/typescript-linux-ppc64": "7.0.2",
"@typescript/typescript-linux-riscv64": "7.0.2",
"@typescript/typescript-linux-s390x": "7.0.2",
"@typescript/typescript-linux-x64": "7.0.2",
"@typescript/typescript-netbsd-arm64": "7.0.2",
"@typescript/typescript-netbsd-x64": "7.0.2",
"@typescript/typescript-openbsd-arm64": "7.0.2",
"@typescript/typescript-openbsd-x64": "7.0.2",
"@typescript/typescript-sunos-x64": "7.0.2",
"@typescript/typescript-win32-arm64": "7.0.2",
"@typescript/typescript-win32-x64": "7.0.2"
}
},
"node_modules/undici-types": {
"version": "8.9.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-8.9.0.tgz",
"integrity": "sha512-KTDyRTYX8sWmKXAikPHHSyc63CRPETMctyjKFupcC6OBLXT3xsN0e9aF7m+mIXutFWpUXuedtowG7iLOzp0kQg==",
"license": "MIT"
},
"node_modules/vite": {
"version": "8.3.4",
"resolved": "https://registry.npmjs.org/vite/-/vite-8.3.4.tgz",
"integrity": "sha512-IftLE8phJhdNRIGNEV4JCCvfNScoZQVfcXaN8TQropWGJtOHcu+iAB9pHcuGvmEb/kddw0LBe/jS8ztdC6xdiQ==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"lightningcss": "^1.33.0",
"picomatch": "^4.0.7",
"postcss": "^8.5.29",
"rolldown": "~1.2.12",
"tinyglobby": "^0.2.17"
},
"bin": {
"vite": "bin/vite.js"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
},
"funding": {
"url": "https://github.com/vitejs/vite?sponsor=1"
},
"optionalDependencies": {
"fsevents": "~2.3.3"
},
"peerDependencies": {
"@types/node": "^20.19.0 || >=22.12.0",
"@vitejs/devtools": "^0.7.1",
"esbuild": "^0.27.0 || ^0.28.0",
"jiti": ">=1.21.0",
"less": "^4.0.0",
"sass": "^1.70.0",
"sass-embedded": "^1.70.0",
"stylus": ">=0.54.8",
"sugarss": "^5.0.0",
"terser": "^5.16.0",
"tsx": "^4.8.1",
"yaml": "^2.4.2"
},
"peerDependenciesMeta": {
"@types/node": {
"optional": true
},
"@vitejs/devtools": {
"optional": true
},
"esbuild": {
"optional": true
},
"jiti": {
"optional": true
},
"less": {
"optional": true
},
"sass": {
"optional": true
},
"sass-embedded": {
"optional": true
},
"stylus": {
"optional": true
},
"sugarss": {
"optional": true
},
"terser": {
"optional": true
},
"tsx": {
"optional": true
},
"yaml": {
"optional": true
}
}
},
"node_modules/vite/node_modules/postcss": {
"version": "8.5.29",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.29.tgz",
"integrity": "sha512-49cGhUbXj8Qenv0iTMxA1cFBzxXoctpC9Ujd77t1WcbJIr6nF/eI7g/8MgxrYldFRuAXvja7xQRwavoW7kgrxQ==",
"dev": true,
"funding": [
{
"type": "opencollective",
"url": "https://opencollective.com/postcss/"
},
{
"type": "tidelift",
"url": "https://tidelift.com/funding/github/npm/postcss"
},
{
"type": "github",
"url": "https://github.com/sponsors/ai"
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.19",
"picocolors": "^1.1.1",
"source-map-js": "^1.2.2"
},
"engines": {
"node": "^10 || ^12 || >=14"
}
},
"node_modules/vitest": {
"version": "5.0.3",
"resolved": "https://registry.npmjs.org/vitest/-/vitest-5.0.3.tgz",
"integrity": "sha512-xMw97S3rjdtj5dkVat7jCsqWBpvchs3RlpQctUqwJD0KkERk40vz2fJ77lDwW/Vzh/pk18eItYAzkodhSes3jQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"@types/chai": "^5.2.2",
"@vitest/mocker": "5.0.3",
"chai": "^6.2.2",
"es-module-lexer": "^2.3.2",
"expect-type": "^1.4.0",
"magic-string": "^1.2.3",
"obug": "^2.1.4",
"picomatch": "^4.0.7",
"std-env": "^4.2.0",
"tinybench": "^6.1.4",
"tinyexec": "^1.3.0",
"tinyglobby": "^0.2.17",
"why-is-node-running": "3.2.1"
},
"bin": {
"vitest": "vitest.mjs"
},
"engines": {
"node": "^22.12.0 || ^24.0.0 || >=26.0.0"
},
"funding": {
"url": "https://opencollective.com/vitest"
},
"peerDependencies": {
"@edge-runtime/vm": "*",
"@opentelemetry/api": "^1.9.0",
"@types/node": "^22.0.0 || >=24.0.0",
"@vitest/browser-playwright": "5.0.3",
"@vitest/browser-preview": "5.0.3",
"@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0",
"@vitest/coverage-istanbul": "5.0.3",
"@vitest/coverage-v8": "5.0.3",
"@vitest/ui": "5.0.3",
"happy-dom": "*",
"jsdom": "*",
"vite": "^6.4.0 || ^7.0.0 || ^8.0.0"
},
"peerDependenciesMeta": {
"@edge-runtime/vm": {
"optional": true
},
"@opentelemetry/api": {
"optional": true
},
"@types/node": {
"optional": true
},
"@vitest/browser-playwright": {
"optional": true
},
"@vitest/browser-preview": {
"optional": true
},
"@vitest/browser-webdriverio": {
"optional": true
},
"@vitest/coverage-istanbul": {
"optional": true
},
"@vitest/coverage-v8": {
"optional": true
},
"@vitest/ui": {
"optional": true
},
"happy-dom": {
"optional": true
},
"jsdom": {
"optional": true
},
"vite": {
"optional": false
}
}
},
"node_modules/why-is-node-running": {
"version": "3.2.1",
"resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-3.2.1.tgz",
"integrity": "sha512-Tb2FUhB4vUsGQlfSquQLYkApkuPAFQXGFzxWKHHumVz2dK+X1RUm/HnID4+TfIGYJ1kTcwOaCk/buYCEJr6YjQ==",
"dev": true,
"license": "MIT",
"bin": {
"why-is-node-running": "cli.js"
},
"engines": {
"node": ">=20.11"
}
},
"node_modules/ws": {
"version": "8.22.0",
"resolved": "https://registry.npmjs.org/ws/-/ws-8.22.0.tgz",
"integrity": "sha512-Ydggc987+RO0AnWtZ/7Wq9FtNvcrL1b/RO0ud9mWjUPgDrsAAwQSF51sm2hm1XofbU/4jkpGEsLFsZZxU+1DOg==",
"license": "MIT",
"engines": {
"node": ">=10.0.0"
},
"peerDependencies": {
"bufferutil": "^4.0.1",
"utf-8-validate": ">=5.0.2"
},
"peerDependenciesMeta": {
"bufferutil": {
"optional": true
},
"utf-8-validate": {
"optional": true
}
}
},
"node_modules/zod": {
"version": "4.6.5",
"resolved": "https://registry.npmjs.org/zod/-/zod-4.6.5.tgz",
"integrity": "sha512-v5l/aFXZQeai4awLbOpSoHecE9UiMrnfx75tEXLjNonXVARxQ5mOeipTjROUchszUNCqnE+hqAMujRsRHsut2Q==",
"license": "MIT",
"funding": {
"url": "https://github.com/sponsors/colinhacks"
}
}
}
}
package.json
{
"name": "ai-firestation",
"version": "0.6.0",
"private": true,
"type": "module",
"scripts": {
"dev": "tsx scripts/dev.ts",
"prebuild": "npm run library:html && npm run library:check && npm run audio:check && npm run docs:sync && npm run docs:check",
"build": "next build apps/web",
"start": "next start apps/web --hostname 127.0.0.1 --port 3100",
"start:lan": "next start apps/web --hostname 0.0.0.0 --port 3100",
"test": "vitest run",
"typecheck": "tsc --noEmit",
"simulate": "tsx scripts/simulate.ts",
"cards:export": "tsx scripts/export-cards.ts",
"test:e2e": "tsx scripts/e2e.ts",
"docs:sync": "tsx scripts/docs.ts",
"docs:check": "tsx scripts/docs.ts --check",
"hooks:install": "git config core.hooksPath .githooks",
"library:check": "tsx scripts/check-library.ts",
"library:html": "tsx scripts/export-library-html.ts",
"setup:key": "tsx scripts/setup-key.ts",
"unreal:doctor": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/unreal.ps1 -Action doctor",
"unreal:build": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/unreal.ps1 -Action build",
"unreal:bootstrap": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/unreal.ps1 -Action bootstrap",
"unreal:run": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/unreal.ps1 -Action run",
"unreal:stream:setup": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/unreal-stream.ps1 -Action setup",
"unreal:stream:run": "powershell -NoProfile -ExecutionPolicy Bypass -File scripts/unreal-stream.ps1 -Action run",
"audio:generate": "tsx scripts/export-demo-audio.ts",
"audio:check": "tsx scripts/check-demo-audio.ts"
},
"dependencies": {
"@epicgames-ps/lib-pixelstreamingfrontend-ue5.8": "0.1.2",
"lucide-react": "1.53.0",
"next": "16.4.0",
"pdfjs-dist": "6.4.299",
"react": "19.3.0",
"react-dom": "19.3.0",
"zod": "4.6.5"
},
"devDependencies": {
"@playwright/test": "1.64.0",
"@types/node": "26.6.4",
"@types/react": "19.3.0",
"@types/react-dom": "19.3.0",
"prettier": "3.9.9",
"tsx": "4.23.15",
"typescript": "7.0.2",
"vitest": "5.0.3"
}
}
packages/core/commander/index.ts
import { cards } from "../../kb";
import {
DomainError,
type Mode,
type Level,
type Decision,
type PublicState,
type ActionKey,
} from "../types";
export function assertLevel(mode: Mode, level: Level) {
if (!Number.isInteger(level) || level < 0 || level > 3)
throw new DomainError("잘못된 자율 수준입니다.");
if (mode === "OPERATIONS" && (level === 3 || level === 0))
throw new DomainError(
"실제 운영의 자동 지휘 수준은 허용하지 않습니다.",
403,
);
if (mode === "SHADOW" && level !== 1)
throw new DomainError("SHADOW는 조언 수준만 허용합니다.", 403);
}
export function assertEvidence(mode: Mode, ids: string[]) {
if (!ids.length || ids.some((id) => !cards.some((c) => c.id === id)))
throw new DomainError("근거 카드가 없어 명령을 보류합니다.", 422);
if (mode === "OPERATIONS" || mode === "SHADOW")
throw new DomainError(
"교육용 초안은 실제 운영 명령에 사용할 수 없습니다.",
403,
);
}
type Rule = {
key: ActionKey;
trigger: string;
title: string;
target: string;
text: string;
cardIds: string[];
reason: string;
};
export function decide(view: PublicState): Decision[] {
if (view.level === 0 || view.paused || view.status === "closed") return [];
const primary = view.scenario.primaryCard;
const rules: Rule[] = [
{
key: "DISPATCH",
trigger: "report",
title: "신고 정리 · 가상 출동 준비",
target: "펌프1 · 구조1",
text: "신고 위치·인명 미확인을 공유하고 가상 출동·장비 준비를 확인합니다.",
cardIds: ["R-01", "R-03"],
reason: "신고는 접수됐지만 내부 인원과 연료는 아직 미확인입니다.",
},
{
key: "RECON",
trigger: "dispatch-ready",
title: "드론 선행 정찰",
target: "드론팀",
text: "가상 운용 조건을 확인하고 외부 관찰 임무를 시작합니다.",
cardIds: ["D-01", "D-05"],
reason:
"가상 차량 준비 보고를 받았습니다. 외부 관찰을 보완할 수 있습니다.",
},
{
key: "VERIFY",
trigger: "drone-arrived",
title: "관찰 반영 · 추가 확인",
target: "선착대",
text: "내부 인명·물질·통전·공급 상태를 확인하고 출처와 시각을 보고합니다.",
cardIds: [
view.scenario.risks.some((r) => r.includes("가스")) ? "G-01" : primary,
"R-02",
],
reason:
"드론은 외부 정보만 제공합니다. 영상으로 내부 안전을 확정하지 않습니다.",
},
{
key: "CONTROL",
trigger: "unit-arrived",
title: "가상 대응 임무 · 현장 재평가",
target: "펌프1 · 구조1",
text: "교육용 확인 목록과 준비 상태를 검토하고 가상 대응 임무를 수행·보고합니다.",
cardIds: [primary, "R-05"],
reason:
"현장 확인을 담당할 선착대가 도착했습니다. 세부 전술은 이 데모에서 지정하지 않습니다.",
},
{
key: "SUPPORT",
trigger: "onsite-check",
title: "지원 · 확인 정보 보완",
target: "상황실",
text: "관계기관 확인과 부족 자원 지원을 가상 요청하고 접수 상태를 기록합니다.",
cardIds: ["R-04"],
reason: "현장 보고 후에도 미확인 위험은 각각 재확인이 필요합니다.",
},
{
key: "MONITOR",
trigger: "fire-controlled",
title: "잔여 위험 확인",
target: "선착대",
text: "가상 화재 감소 후 잔여 위험과 미완료 임무를 확인합니다.",
cardIds: ["E-04"],
reason: "화염 감소 보고가 모든 위험 해소를 뜻하지는 않습니다.",
},
{
key: "CLOSE",
trigger: "all-clear",
title: "가상 사건 종료 · 복기",
target: "지휘관",
text: "가상 인명·잔여 위험 확인 보고와 임무 이행 기록을 모아 훈련 종료를 준비합니다.",
cardIds: ["E-05"],
reason: "가상 확인 보고가 접수됐습니다. 미완료 임무가 없으면 종료합니다.",
},
];
return rules
.filter(
(r) =>
view.observations.some((o) => o.key === r.trigger) &&
!view.decisions.some((d) => d.actionKey === r.key),
)
.map((r) => ({
id: `D-${r.key}`,
actionKey: r.key,
title: r.title,
target: r.target,
text: r.text,
reason: r.reason,
cardIds: r.cardIds,
at: view.elapsed,
basisVersion: view.version,
basisObservationKeys: view.observations.map((o) => o.key),
source: "script",
evidenceStatus: r.cardIds.every((id) => cards.some((c) => c.id === id))
? "교육용 초안"
: "근거 부족",
}));
}
packages/core/commander/narration.ts
import type { Decision } from "../types";
type Adapter = (decision: Decision, signal: AbortSignal) => Promise<string>;
export async function explain(
decision: Decision,
adapter?: Adapter,
timeoutMs = 1500,
): Promise<{ text: string; source: "rules" | "model"; fallback: boolean }> {
if (!adapter)
return { text: decision.reason, source: "rules", fallback: false };
const controller = new AbortController();
let timer: ReturnType<typeof setTimeout> | undefined;
try {
const text = await Promise.race([
adapter(decision, controller.signal),
new Promise<never>((_resolve, reject) => {
timer = setTimeout(() => {
controller.abort();
reject(new Error("설명 시간 초과"));
}, timeoutMs);
}),
]);
if (
typeof text !== "string" ||
!text.trim() ||
text.length > 500 ||
/\d\s*(m|미터|리터|℃|bar|MPa)/i.test(text)
)
throw new Error("설명 형식 오류");
return { text: text.trim(), source: "model", fallback: false };
} catch {
return { text: decision.reason, source: "rules", fallback: true };
} finally {
if (timer) clearTimeout(timer);
controller.abort();
}
}
packages/core/engine/index.ts
import { parseScenario, publicScenario, type Scenario } from "../scenario";
import { assertEvidence, assertLevel, decide } from "../commander";
import { DRONE_TRAVEL_SECONDS, PUMP_TRAVEL_SECONDS } from "../map-motion";
import {
DomainError,
type Session,
type PublicState,
type Mode,
type Level,
type Action,
type Decision,
type Command,
} from "../types";
function log(s: Session, type: string, text: string, commandId?: string) {
s.log.push({
sequence: s.log.length + 1,
at: s.elapsed,
type,
text,
...(commandId ? { commandId } : {}),
});
}
function addCommand(
s: Session,
d: Decision,
actor: "ai" | "human",
draft = false,
) {
assertEvidence(s.mode, d.cardIds);
s.commands.push({
...d,
id: `CMD-${s.commands.length + 1}`,
status: draft ? "draft" : "issued",
actor,
issuedAt: draft ? null : s.elapsed,
version: 1,
});
log(
s,
draft ? "draft" : "issued",
`[${d.target}] ${d.text}`,
s.commands.at(-1)!.id,
);
}
function stale(s: Session, c: Command) {
return s.observations.some(
(o) => !(c.basisObservationKeys ?? []).includes(o.key),
);
}
function reviewDraft(s: Session, c: Command) {
const fresh = decide({
...publicState(s),
level: 1,
paused: false,
decisions: s.decisions.filter((d) => d.actionKey !== c.actionKey),
}).find((d) => d.actionKey === c.actionKey);
if (!fresh)
throw new DomainError("현재 관찰에서 재검토할 수 없는 제안입니다.", 409);
const reason = `공개 관찰 ${s.observations.length}건으로 재검토했습니다. ${fresh.reason}`;
Object.assign(c, { ...fresh, id: c.id, reason, version: c.version + 1 });
const i = s.decisions.findIndex((d) => d.actionKey === c.actionKey);
if (i >= 0) s.decisions[i] = { ...fresh, reason };
log(s, "review", `${c.id} · ${reason}`, c.id);
}
function commander(s: Session) {
for (const d of decide(publicState(s))) {
s.decisions.push(d);
log(s, "decision", `${d.title} — ${d.reason}`);
if (d.evidenceStatus === "근거 부족") {
log(s, "blocked", "근거 부족으로 명령 발행을 보류합니다.");
continue;
}
}
if (s.paused || s.level < 2 || s.mode === "OPERATIONS" || s.mode === "SHADOW")
return;
// 조언·승인 모드에서 쌓인 판단도 수준 전환 후 연결한다. 취소 기록은 유지한다.
for (const d of s.decisions.filter(
(d) => d.id.startsWith("D-") && d.evidenceStatus !== "근거 부족",
)) {
const command = s.commands.find((c) => c.actionKey === d.actionKey);
if (!command) addCommand(s, d, "ai", s.level === 2);
else if (command.status === "draft" && s.level === 3) {
if (stale(s, command)) reviewDraft(s, command);
assertEvidence(s.mode, command.cardIds);
command.status = "issued";
command.issuedAt = s.elapsed;
command.actor = "ai";
command.version++;
log(s, "issued", `자동 모드 전환 · ${command.text}`, command.id);
}
}
}
export function createSession(
scenario: Scenario,
options: { seed: number; mode: Mode; level: Level; id?: string },
): Session {
assertLevel(options.mode, options.level);
if (
!Number.isSafeInteger(options.seed) ||
options.seed < 0 ||
options.seed > 0xffffffff
)
throw new DomainError("시드는 유효한 양의 정수여야 합니다.");
const clean = parseScenario(scenario);
const fire = Array<number>(48).fill(0);
fire[clean.hidden.origin[1] * 8 + clean.hidden.origin[0]] = 45;
const s: Session = {
id: options.id ?? `simulation-${scenario.id}-${options.seed}`,
scenario: clean,
mode: options.mode,
level: options.level,
seed: options.seed,
rng: options.seed,
elapsed: 0,
version: 1,
paused: false,
status: "active",
observations: clean.initial.map((o) => ({ ...o, at: 0 })),
decisions: [],
commands: [],
log: [],
fireCells: fire,
};
log(s, "report", clean.briefing);
commander(s);
return s;
}
function random(s: Session) {
s.rng = (Math.imul(s.rng, 1664525) + 1013904223) >>> 0;
return s.rng / 4294967296;
}
function updateFire(s: Session) {
const cooling = s.commands.some(
(c) => c.actionKey === "CONTROL" && c.status === "complete",
);
const old = [...s.fireCells];
s.fireCells = old.map((heat, i) => {
if (cooling) return Math.max(0, heat - 5);
if (heat > 0) return Math.min(100, heat + 1 + Math.floor(random(s) * 3));
const x = i % 8,
y = Math.floor(i / 8);
const neighbors = [
x > 0 ? i - 1 : -1,
x < 7 ? i + 1 : -1,
y > 0 ? i - 8 : -1,
y < 5 ? i + 8 : -1,
];
return neighbors.some((j) => j >= 0 && old[j] > 60) && random(s) < 0.025
? 10
: 0;
});
}
function arrivalAt(s: Session, key: "drone-arrived" | "unit-arrived") {
const actionKey = key === "drone-arrived" ? "RECON" : "DISPATCH";
const travel =
key === "drone-arrived" ? DRONE_TRAVEL_SECONDS : PUMP_TRAVEL_SECONDS;
const command = s.commands.find(
(c) =>
c.actionKey === actionKey &&
c.issuedAt !== null &&
c.status !== "cancelled",
);
const earliest = s.scenario.timeline.find((e) => e.key === key)?.at ?? 0;
return command ? Math.max(earliest, command.issuedAt! + travel) : Infinity;
}
export function tick(input: Session, seconds: number): Session {
if (!Number.isInteger(seconds) || seconds < 0 || seconds > 30)
throw new DomainError("시간 이동은 0~30초 정수여야 합니다.");
if (input.paused || input.status === "closed" || seconds === 0)
return structuredClone(input);
const s = structuredClone(input);
for (let i = 0; i < seconds && s.status === "active"; i++) {
s.elapsed++;
s.version++;
updateFire(s);
for (const c of s.commands) {
if (
c.issuedAt === null ||
["draft", "cancelled", "complete"].includes(c.status)
)
continue;
const age = s.elapsed - c.issuedAt;
if (age >= 8 && c.status !== "complete") {
c.status = "complete";
log(s, "complete", `${c.target} · 가상 임무 완료 보고`, c.id);
} else if (age >= 4 && c.status === "acknowledged") {
c.status = "executing";
log(s, "progress", `${c.target} · 가상 수행 중`, c.id);
} else if (age >= 2 && c.status === "issued") {
c.status = "acknowledged";
log(s, "ack", `${c.target} · 가상 수신 확인`, c.id);
}
}
for (const event of s.scenario.timeline) {
if (
event.at > s.elapsed ||
s.observations.some((o) => o.key === event.key)
)
continue;
if (
event.requires &&
!s.commands.some(
(c) => c.actionKey === event.requires && c.status === "complete",
)
)
continue;
// 늦은 승인에서도 발행 후 이동 시간을 보장한다. 현장 보고는 도착 이후다.
if (
(event.key === "drone-arrived" || event.key === "unit-arrived") &&
s.elapsed < arrivalAt(s, event.key)
)
continue;
if (
event.key === "onsite-check" &&
!s.observations.some((o) => o.key === "unit-arrived")
)
continue;
const { at: _at, requires: _requires, ...observation } = event;
s.observations.push({ ...observation, at: s.elapsed });
log(s, "observation", event.text);
}
commander(s);
if (
s.observations.some((o) => o.key === "close-ready") &&
s.commands.every((c) => ["complete", "cancelled"].includes(c.status))
) {
s.status = "closed";
log(s, "closed", "가상 사건 종료 · 복기 기록을 확인하십시오.");
}
}
return s;
}
export function applyAction(input: Session, action: Action): Session {
const s = structuredClone(input);
if (s.status === "closed")
throw new DomainError("종료된 사건은 변경할 수 없습니다.", 409);
switch (action.kind) {
case "intervene":
if (s.paused) return s;
s.paused = true;
log(s, "intervention", action.reason ?? "사람이 지휘에 개입했습니다.");
break;
case "resume":
s.paused = false;
log(s, "resume", "가상 진행을 재개합니다.");
break;
case "level":
assertLevel(s.mode, action.level);
s.level = action.level;
log(s, "level", `지휘 수준을 L${action.level}로 변경했습니다.`);
break;
case "cancel": {
const c = s.commands.find((c) => c.id === action.commandId);
if (!c) throw new DomainError("명령이 없습니다.", 404);
if (c.status === "cancelled") return s;
if (c.status === "complete")
throw new DomainError("완료된 명령은 취소할 수 없습니다.", 409);
c.status = "cancelled";
c.version++;
log(s, "cancel", `${c.id} 가상 명령 취소`, c.id);
break;
}
case "approve": {
const c = s.commands.find((c) => c.id === action.commandId);
if (!c) throw new DomainError("명령이 없습니다.", 404);
if (c.status !== "draft") {
if (c.actor === "human" && c.status !== "cancelled") return s;
throw new DomainError("승인할 수 없는 명령입니다.", 409);
}
if (s.paused)
throw new DomainError(
"개입 중에는 발행할 수 없습니다. 재개 후 승인하십시오.",
409,
);
if (stale(s, c))
throw new DomainError(
"새 관찰이 들어왔습니다. 제안을 재검토한 후 승인하십시오.",
409,
);
assertEvidence(s.mode, c.cardIds);
c.status = "issued";
c.issuedAt = s.elapsed;
c.actor = "human";
c.version++;
log(s, "issued", `사람 승인 · ${c.text}`, c.id);
break;
}
case "review": {
const c = s.commands.find((c) => c.id === action.commandId);
if (!c) throw new DomainError("명령이 없습니다.", 404);
if (c.status !== "draft")
throw new DomainError("승인 대기 명령만 재검토할 수 있습니다.", 409);
reviewDraft(s, c);
break;
}
case "manual": {
if (s.paused)
throw new DomainError("재개 후 수동 명령을 발행하십시오.", 409);
assertEvidence(s.mode, action.cardIds);
const d: Decision = {
id: `H-${s.decisions.length + 1}`,
actionKey: action.actionKey,
title: "사람의 가상 지휘",
target: action.target,
text: action.text,
reason: "사용자가 교육용 근거 카드를 확인했습니다.",
cardIds: action.cardIds,
at: s.elapsed,
basisVersion: s.version,
basisObservationKeys: s.observations.map((o) => o.key),
source: "script",
evidenceStatus: "교육용 초안",
};
s.decisions.push(d);
addCommand(s, d, "human");
break;
}
}
s.version++;
if (!s.paused) commander(s);
return s;
}
export function publicState(s: Session): PublicState {
const dispatched = s.commands.find(
(c) =>
c.actionKey === "DISPATCH" &&
c.issuedAt !== null &&
c.status !== "cancelled",
);
const recon = s.commands.find(
(c) =>
c.actionKey === "RECON" &&
c.issuedAt !== null &&
c.status !== "cancelled",
);
const droneAt =
s.observations.find((o) => o.key === "drone-arrived")?.at ?? null;
const unitAt = s.observations.find((o) => o.key === "unit-arrived")?.at;
const view: PublicState = {
id: s.id,
scenario: publicScenario(s.scenario),
mode: s.mode,
level: s.level,
seed: s.seed,
elapsed: s.elapsed,
version: s.version,
paused: s.paused,
status: s.status,
observations: structuredClone(s.observations),
decisions: structuredClone(s.decisions),
commands: structuredClone(s.commands),
log: structuredClone(s.log),
fireCells: droneAt !== null ? [...s.fireCells] : [],
drone: {
status: droneAt !== null ? "외부 정찰" : recon ? "이동 중" : "대기",
eta:
droneAt !== null
? 0
: recon
? Math.max(0, arrivalAt(s, "drone-arrived") - s.elapsed)
: 20,
arrivedAt: droneAt,
},
units: [
{
id: "pump-1",
name: "펌프1",
status:
unitAt !== undefined ? "현장 도착" : dispatched ? "출동 중" : "준비",
eta:
unitAt !== undefined
? 0
: dispatched
? Math.max(0, arrivalAt(s, "unit-arrived") - s.elapsed)
: 40,
},
{
id: "rescue-1",
name: "구조1",
status:
unitAt !== undefined ? "현장 도착" : dispatched ? "출동 중" : "준비",
eta:
unitAt !== undefined
? 0
: dispatched
? Math.max(0, arrivalAt(s, "unit-arrived") - s.elapsed)
: 40,
},
],
summary: {
completed: s.commands.filter((c) => c.status === "complete").length,
cancelled: s.commands.filter((c) => c.status === "cancelled").length,
interventions: s.log.filter((e) => e.type === "intervention").length,
},
label: "가상 훈련 · 실제 출동 아님",
engineLabel: "규칙 기반 AI 지휘관 · 교육용 각본",
};
return view;
}
packages/core/map-motion.ts
import type { PublicState } from "./types";
type Point = readonly [number, number];
export const STATION_POSITION: Point = [75, 224];
export const INCIDENT_POSITION: Point = [470, 164];
export const PUMP_ROUTE: readonly Point[] = [
STATION_POSITION,
[355, 224],
[355, 110],
[470, 110],
];
export const PUMP_TRAVEL_SECONDS = 40;
export const DRONE_TRAVEL_SECONDS = 12;
// 구간 길이에 비례해 이동시켜 교차로에서도 지도상의 속도를 유지한다.
export function pointOnRoute(route: readonly Point[], progress: number): Point {
if (!route.length) throw new Error("이동 경로에 출발 지점이 필요합니다.");
if (progress <= 0) return route[0];
if (progress >= 1) return route[route.length - 1];
const lengths = route
.slice(1)
.map(([x, y], i) => Math.hypot(x - route[i][0], y - route[i][1]));
let remaining = lengths.reduce((sum, length) => sum + length, 0) * progress;
for (let i = 0; i < lengths.length; i++) {
const length = lengths[i];
if (length === 0) continue;
if (remaining <= length) {
const [x, y] = route[i];
const [nextX, nextY] = route[i + 1];
const fraction = remaining / length;
return [x + (nextX - x) * fraction, y + (nextY - y) * fraction];
}
remaining -= length;
}
return route[route.length - 1];
}
export function mapPositions(state: PublicState | null) {
const pump = state?.units[0];
const drone = state?.drone;
// 공개 ETA만 사용한다. 발행 전에는 대기하고, 늦은 승인도 출발점부터 이동한다.
const pumpProgress =
pump && pump.status !== "준비" ? 1 - pump.eta / PUMP_TRAVEL_SECONDS : 0;
const droneProgress =
drone?.arrivedAt != null
? 1
: drone && drone.status !== "대기"
? 1 - drone.eta / DRONE_TRAVEL_SECONDS
: 0;
return {
pump: pointOnRoute(PUMP_ROUTE, pumpProgress),
drone: pointOnRoute([STATION_POSITION, INCIDENT_POSITION], droneProgress),
};
}
packages/core/scenario/index.ts
import { z } from "zod";
export const observationSchema = z.object({
key: z.string().min(1),
text: z.string().min(1),
source: z.enum(["신고", "드론", "현장", "시스템"]),
status: z.enum(["의심", "미확인", "확인됨"]),
});
export const scenarioSchema = z
.object({
id: z.string().regex(/^[a-z-]+$/),
title: z.string().min(1),
description: z.string(),
briefing: z.string(),
address: z.string(),
classes: z.array(z.enum(["A", "B", "C", "K"])),
risks: z.array(z.string()),
primaryCard: z.string(),
initial: z.array(observationSchema),
hidden: z.object({
truth: z.string(),
origin: z.tuple([
z.number().int().min(0).max(7),
z.number().int().min(0).max(5),
]),
}),
timeline: z.array(
observationSchema.extend({
at: z.number().int().min(0),
requires: z.string().optional(),
}),
),
})
.superRefine((s, ctx) => {
if (s.timeline.some((e, i) => i > 0 && e.at < s.timeline[i - 1].at))
ctx.addIssue({
code: "custom",
message: "시간표는 시각 순서여야 합니다.",
});
const keys = [...s.initial, ...s.timeline].map((o) => o.key);
if (new Set(keys).size !== keys.length)
ctx.addIssue({ code: "custom", message: "관찰 키가 중복됐습니다." });
});
export type Scenario = z.infer<typeof scenarioSchema>;
export type ObservationInput = z.infer<typeof observationSchema>;
export type PublicScenario = Pick<
Scenario,
| "id"
| "title"
| "description"
| "briefing"
| "address"
| "classes"
| "risks"
| "primaryCard"
>;
export function parseScenario(input: unknown): Scenario {
return scenarioSchema.parse(input);
}
export function publicScenario(s: Scenario): PublicScenario {
// 허용 필드 투영으로 강사 전용 정보가 새 필드 추가 시에도 노출되지 않게 한다.
return {
id: s.id,
title: s.title,
description: s.description,
briefing: s.briefing,
address: s.address,
classes: s.classes,
risks: s.risks,
primaryCard: s.primaryCard,
};
}
function timeline(): Scenario["timeline"] {
return [
{
at: 8,
key: "dispatch-ready",
text: "가상 출동대 준비 확인 보고가 접수됐습니다.",
source: "시스템",
status: "확인됨",
requires: "DISPATCH",
},
{
at: 20,
key: "drone-arrived",
text: "드론이 외부를 관찰합니다. 내부 인원·가스 상태는 영상만으로 확인할 수 없습니다.",
source: "드론",
status: "확인됨",
requires: "RECON",
},
{
at: 40,
key: "unit-arrived",
text: "선착대가 도착했습니다. 지휘권과 현장 확인 항목을 공유합니다.",
source: "현장",
status: "확인됨",
requires: "DISPATCH",
},
{
at: 52,
key: "onsite-check",
text: "현장 확인 보고가 추가됐습니다. 물질·통전·공급 상태는 각각 재확인 대상으로 유지합니다.",
source: "현장",
status: "확인됨",
requires: "VERIFY",
},
{
at: 66,
key: "fire-controlled",
text: "가상 화재가 감소했다는 보고입니다. 잔여 위험이 해소됐다는 뜻은 아닙니다.",
source: "현장",
status: "확인됨",
requires: "CONTROL",
},
{
at: 80,
key: "all-clear",
text: "가상 요구조자 확인과 잔여 위험 검토가 보고됐습니다.",
source: "현장",
status: "확인됨",
requires: "MONITOR",
},
{
at: 96,
key: "close-ready",
text: "미완료 임무와 자원 복귀 기록을 확인하고 훈련 종료를 검토합니다.",
source: "시스템",
status: "확인됨",
requires: "CLOSE",
},
];
}
export const scenarios: Scenario[] = [
parseScenario({
id: "restaurant-gas",
title: "식당 화재 · 가스 위험 의심",
description: "주방 화재와 가스 냄새 신고. 확인된 정보를 구분합니다.",
briefing:
"식당에서 연기와 가스 냄새 신고. 내부 인원과 공급 상태는 미확인입니다.",
address: "가상시 중앙로 24 · 중앙식당",
classes: ["A", "K"],
risks: ["가스 위험 의심", "인명 미확인"],
primaryCard: "K-01",
initial: [
{
key: "report",
text: "주방 연기와 가스 냄새 신고입니다. 누출은 미확인입니다.",
source: "신고",
status: "의심",
},
],
hidden: {
truth: "실제 잔류 인원·공급 상태는 강사 전용 설정",
origin: [4, 2],
},
timeline: timeline(),
}),
parseScenario({
id: "warehouse-oil",
title: "창고 화재 · 유류 보관 의심",
description: "저장 물질 확인과 적합한 준비를 검토합니다.",
briefing:
"창고에서 화재 신고. 유류 용기가 있다는 설명이나 물질명은 미확인입니다.",
address: "가상시 산업로 18 · 제일창고",
classes: ["A", "B"],
risks: ["유류 보관 의심", "물질 미확인"],
primaryCard: "B-01",
initial: [
{
key: "report",
text: "검은 연기와 유류 용기 신고입니다. 색만으로 물질을 판정하지 않습니다.",
source: "신고",
status: "의심",
},
],
hidden: {
truth: "실제 잔류 인원·저장 물질은 강사 전용 설정",
origin: [5, 3],
},
timeline: timeline(),
}),
parseScenario({
id: "house-rescue",
title: "주택 화재 · 인명 미확인",
description: "인명 정보와 외부 관찰을 나누는 훈련입니다.",
briefing:
"주택 외부에서 연기가 보입니다. 내부 인원과 출입구 상태는 미확인입니다.",
address: "가상시 소망길 7 · 다가구주택",
classes: ["A"],
risks: ["인명 미확인", "내부 관찰 제한"],
primaryCard: "A-01",
initial: [
{
key: "report",
text: "사람이 안 보인다는 설명은 내부 인원 없음의 근거가 아닙니다.",
source: "신고",
status: "미확인",
},
],
hidden: { truth: "실제 잔류 인원은 강사 전용 설정", origin: [3, 2] },
timeline: timeline(),
}),
];
packages/core/types.ts
import type { Scenario, PublicScenario, ObservationInput } from "./scenario";
export type Mode = "DEMO" | "TRAINING" | "GAME" | "SHADOW" | "OPERATIONS";
export type Level = 0 | 1 | 2 | 3;
export type ActionKey =
"DISPATCH" | "RECON" | "VERIFY" | "CONTROL" | "SUPPORT" | "MONITOR" | "CLOSE";
export type CommandStatus =
"draft" | "issued" | "acknowledged" | "executing" | "complete" | "cancelled";
export type Observation = ObservationInput & { at: number };
export type Decision = {
id: string;
actionKey: ActionKey;
title: string;
target: string;
text: string;
reason: string;
cardIds: string[];
at: number;
basisVersion: number;
basisObservationKeys: string[];
source: "script";
evidenceStatus: "교육용 초안" | "근거 부족";
};
export type Command = Decision & {
status: CommandStatus;
actor: "ai" | "human";
issuedAt: number | null;
version: number;
};
export type LogEntry = {
sequence: number;
at: number;
type: string;
text: string;
commandId?: string;
};
export type Session = {
id: string;
scenario: Scenario;
mode: Mode;
level: Level;
seed: number;
rng: number;
elapsed: number;
version: number;
paused: boolean;
status: "active" | "closed";
observations: Observation[];
decisions: Decision[];
commands: Command[];
log: LogEntry[];
fireCells: number[];
};
export type PublicState = Omit<Session, "scenario" | "rng" | "fireCells"> & {
scenario: PublicScenario;
fireCells: number[];
drone: { status: string; eta: number; arrivedAt: number | null };
units: { id: string; name: string; status: string; eta: number }[];
summary: { completed: number; cancelled: number; interventions: number };
label: string;
engineLabel: string;
};
export type Action =
| { kind: "intervene"; reason?: string }
| { kind: "resume" }
| { kind: "level"; level: Level }
| { kind: "cancel"; commandId: string }
| { kind: "approve"; commandId: string }
| { kind: "review"; commandId: string }
| {
kind: "manual";
actionKey: ActionKey;
target: string;
text: string;
cardIds: string[];
};
export class DomainError extends Error {
constructor(
message: string,
public code: number = 400,
) {
super(message);
this.name = "DomainError";
}
}
packages/docs/build.ts
import { createHash } from "node:crypto";
import {
existsSync,
readdirSync,
readFileSync,
mkdirSync,
writeFileSync,
} from "node:fs";
import { resolve, relative, dirname } from "node:path";
import { execFileSync } from "node:child_process";
import { renderDocuments, type SourceFile } from "./render";
const normalized = (s: string) => s.replace(/\r\n/g, "\n");
export const isDocumentSource = (p: string) =>
!/(^|\/)(node_modules|\.next|\.data|\.git|\.gitnexus|artifacts|test-results|playwright-report|public\/library|Binaries|Intermediate|Saved|DerivedDataCache|\.vs)(\/|$)/.test(
p,
) &&
!/(^|\/)\.env(?:\.|$)/.test(p) &&
!["docs/document-build.json"].includes(p) &&
(/^(apps|packages|scripts|tests|docs|unreal|deploy)\/.*\.(ts|tsx|mjs|css|json|md|py|yml|yaml|cpp|h|cs|ini|uproject|ps1|sh|conf|template)$/.test(
p,
) ||
/^(\.gitignore|\.gitattributes|\.dockerignore|deploy\/Dockerfile|package(-lock)?\.json|tsconfig\.json|vitest\.config\.ts|playwright\.config\.ts|README\.md|AGENTS\.md|\.githooks\/pre-commit)$/.test(
p,
));
export function sourceFiles(root: string, staged = false): SourceFile[] {
if (staged) {
const paths = execFileSync("git", ["ls-files", "--cached", "-z"], {
cwd: root,
encoding: "utf8",
})
.split("\0")
.filter(isDocumentSource)
.sort();
return paths.map((path) => ({
path,
text: normalized(
execFileSync("git", ["show", `:${path}`], {
cwd: root,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
}),
),
}));
}
const paths: string[] = [];
function walk(dir: string) {
if (!existsSync(dir)) return;
for (const entry of readdirSync(dir, { withFileTypes: true })) {
const full = resolve(dir, entry.name);
const rel = relative(root, full).replace(/\\/g, "/");
if (entry.isDirectory()) {
if (
!/(^|\/)(node_modules|\.next|\.data|\.git|\.gitnexus|artifacts|test-results|playwright-report|Binaries|Intermediate|Saved|DerivedDataCache|\.vs)$/.test(
rel,
) &&
rel !== "apps/web/public/library"
)
walk(full);
} else if (isDocumentSource(rel)) paths.push(rel);
}
}
for (const dir of [
"apps",
"packages",
"scripts",
"tests",
"docs",
"unreal",
"deploy",
".githooks",
])
walk(resolve(root, dir));
for (const file of [
".gitignore",
".gitattributes",
".dockerignore",
"package.json",
"package-lock.json",
"tsconfig.json",
"vitest.config.ts",
"playwright.config.ts",
"README.md",
"AGENTS.md",
])
if (existsSync(resolve(root, file))) paths.push(file);
return [...new Set(paths)].sort().map((path) => ({
path,
text: normalized(readFileSync(resolve(root, path), "utf8")),
}));
}
function digest(files: SourceFile[]) {
const h = createHash("sha256");
for (const f of files) h.update(`${f.path}\0${f.text}\0`);
return h.digest("hex");
}
export function sourceFingerprint(root: string, staged = false) {
return digest(sourceFiles(root, staged));
}
const outputs = [
"manual.html",
"logic.html",
"apps/web/public/manual.html",
"apps/web/public/logic.html",
];
export function syncDocuments(root: string) {
const files = sourceFiles(root);
const fingerprint = digest(files);
const version = JSON.parse(
readFileSync(resolve(root, "package.json"), "utf8"),
).version;
const file = resolve(root, "docs/document-build.json");
let previous: { fingerprint?: string; generatedAt?: string } = {};
if (existsSync(file)) {
try {
previous = JSON.parse(readFileSync(file, "utf8"));
} catch {
/* 손상된 메타데이터는 새로 생성한다. */
}
}
const generatedAt =
previous.fingerprint === fingerprint && previous.generatedAt
? previous.generatedAt
: new Date().toISOString();
const rendered = renderDocuments({
version,
fingerprint,
generatedAt,
files,
});
for (const path of outputs) {
const text = path.includes("manual") ? rendered.manual : rendered.logic;
const target = resolve(root, path);
mkdirSync(dirname(target), { recursive: true });
if (!existsSync(target) || readFileSync(target, "utf8") !== text)
writeFileSync(target, text, "utf8");
}
mkdirSync(dirname(file), { recursive: true });
const metadata =
JSON.stringify(
{ version, fingerprint, generatedAt, sourceCount: files.length },
null,
2,
) + "\n";
if (!existsSync(file) || readFileSync(file, "utf8") !== metadata)
writeFileSync(file, metadata, "utf8");
return { fingerprint, generatedAt, sourceCount: files.length };
}
export function checkDocuments(root: string, staged = false): true {
const read = (path: string) =>
normalized(
staged
? execFileSync("git", ["show", `:${path}`], {
cwd: root,
encoding: "utf8",
maxBuffer: 32 * 1024 * 1024,
})
: readFileSync(resolve(root, path), "utf8"),
);
try {
const files = sourceFiles(root, staged);
const fingerprint = digest(files);
const meta = JSON.parse(read("docs/document-build.json"));
const version = JSON.parse(read("package.json")).version;
if (meta.fingerprint !== fingerprint || meta.version !== version)
throw new Error("문서 지문이 현재 코드와 다릅니다.");
const rendered = renderDocuments({ ...meta, files });
for (const path of outputs)
if (
read(path) !==
(path.includes("manual") ? rendered.manual : rendered.logic)
)
throw new Error(`문서 내용이 일치하지 않습니다: ${path}`);
return true;
} catch (e) {
throw new Error(
`문서 갱신 검사 실패: ${e instanceof Error ? e.message : "오류"}. npm run docs:sync 후 설명서·로직·메타데이터를 함께 저장하세요.`,
);
}
}
packages/docs/content.ts
export type GuideSection = {
id: string;
title: string;
paragraphs: string[];
steps?: string[];
};
export const guideSections: GuideSection[] = [
{
id: "quickstart",
title: "1. 쉽게 따라하기",
paragraphs: [
"처음에는 교육 시연으로 화면과 지휘 흐름을 익히세요. 기관용 지휘 지원은 별도의 로그인과 사람의 확인을 사용합니다.",
],
steps: [
"교육 시연 → 시나리오를 고르고 시연 실행을 누릅니다. 기본 음향 켜짐에서는 전화벨·접수요원·신고자·접수 확인을 먼저 듣고 출동 지시와 사이렌으로 이어집니다. 기본 L3는 규칙 기반 자동 진행입니다. 음향 버튼으로 소리를 끄고 볼륨을 조절합니다.",
"드론은 가상 관찰입니다. 의심과 미확인을 확인하고 명령의 카드 ID를 눌러 근거를 읽습니다.",
"스마트폰에서는 메뉴 버튼으로 이동합니다. 교육 상황본부는 상황도·AI·명령·관찰·드론·복기를 화면 선택 버튼으로 바꾸고, 기관용은 대원 로그인 후 명령·보고에서 수신 확인 → 수행 시작 → 완료 보고를 누릅니다.",
"개입 · 일시정지 → 내가 지휘 → 진행 재개 순서로 사람 지휘를 연습합니다.",
"식당 시연에서 UE5 3D 현장을 열면 같은 사건의 PC·스마트폰 영상 화면으로 이동합니다. UE5 엔진과 스트리밍 서버를 먼저 실행해야 실제 3D 영상이 나타납니다. 연결 대기 화면은 실행된 UE5 영상이 아닙니다.",
"2026 소방 교재는 HTML 탭으로 열립니다. 왼쪽에서 교재를 고르고 이전/다음 또는 PDF 페이지 입력 후 페이지 이동으로 읽습니다. PC의 긴 본문은 오른쪽 읽기 영역 안에서 스크롤하며 스마트폰에서는 교재 선택·순서 변경으로 목록을 펼칩니다. 자료 출처·HTML 이용 안내를 누르면 출처와 읽기 한계를 확인합니다. 본문 검색 결과를 누르면 해당 페이지와 검색어 강조가 표시됩니다. PDF 탭은 같은 페이지의 원문을 보여줍니다.",
"기관용 지휘 지원 → 로컬 터미널에서 npm run setup:key 실행 → 15분 안에 설정 키 입력 → 최초 관리자 설정 또는 로그인. 관리자는 사용자·자원·검토 근거를 먼저 등록합니다.",
"사건 등록 → 관찰 추가 → 자원 배정 → 명령 초안 작성 → 담당 지휘관 승인 → 대상 대원 수신·수행·완료 보고 순서입니다.",
"새 관찰이 들어오면 과거 초안을 재검토합니다. 취소한 명령은 취소 확인 대상으로 유지합니다. 미완료 임무가 있으면 사건을 종료할 수 없습니다.",
"사용설명서와 구현 로직 HTML을 내려받아 보관합니다. 코드가 바뀌면 두 문서도 새 소스 지문으로 갱신됩니다.",
],
},
{
id: "detailed",
title: "2. 상세 사용설명서",
paragraphs: [
"교육 시연과 기관용 지휘 지원은 데이터와 상태 전이가 분리돼 있습니다. 교육 시연은 가상의 신고·시간표·임무 완료를 사용합니다. 기관용은 사람이 입력한 실제 시각과 보고만 기록하며 자동 작전·실장비 명령을 보내지 않습니다.",
"현재 AI는 규칙 기반 확인 제안입니다. 신고의 가스 냄새·유류 보관·통전·배터리·인명 미확인 등을 위험 후보로 정리하고 추가 질문과 자료 확인을 제시합니다. 영상·냄새·색으로 물질이나 내부 안전을 확정하지 않습니다. 외부 생성형 AI·실제 영상 인식은 연결되지 않았습니다.",
],
},
{
id: "demo",
title: "교육 시연의 조작과 복기",
paragraphs: [
"L0 사람 지휘, L1 AI 조언, L2 사람 승인, L3 AI 자동 지휘입니다. 자동 지휘는 가상 모드 전용입니다.",
"식당·창고·주택 3개 정상 시연은 가상 96초에 종료됩니다. 1/2/8배속을 선택할 수 있습니다. 승인 지연 시 자원 ETA는 발행 시각 이후의 이동 시간을 반영합니다.",
"펌프1은 소방서에서 도로를 따라 두 교차로를 거쳐 현장 진입 지점에 도착합니다. 드론은 같은 소방서에서 신고 위치까지 직선으로 이동합니다. 차량 이동은 가상 40초, 드론 비행은 가상 12초이며 정상 자동 시연에서는 드론이 20초, 펌프1이 40초에 도착합니다. 늦게 승인하면 출발과 도착도 늦어집니다. 실제 도로·비행 제한·출동 시간을 계산하는 기능은 아닙니다.",
"개입은 서버 사건 시간과 자동 지휘를 멈춥니다. 단순 재생 정지는 해당 브라우저의 재생 제어입니다. 차량 화면은 읽기 전용 조회이며 실제 차량 수신 확인이 아닙니다.",
"음향 켜짐은 자체 작성한 한국어 합성 음원으로 신고전화·접수·지휘본부의 발행 명령·대원의 출동 수신·드론 및 선착 보고를 읽습니다. 실제 신고/무전 녹음은 아닙니다. 새 시연은 약25초의 신고 접수 대화 동안 가상 시간을 기다렸다가 출동을 시작합니다. 음향을 끄면 도입 대기를 생략합니다. 96초는 가상 사건 시간이며 도입 음성의 실제 재생 시간은 별도입니다.",
"출동 사이렌은 공개 차량 상태가 출동 중이고 ETA가 남았을 때만 재생합니다. 명령의 가상 임무 완료 표시가 먼저 나타나도 차량이 도착하기 전에는 사이렌이 이어집니다. 음성 방송 중에는 사이렌을 낮추고 현장 도착·출동 취소·개입·재생 정지·백그라운드 이동·종료 시 멈춥니다. 드론 이동에는 사이렌이 없습니다.",
"현재 발화의 역할과 자막을 신고 요약 줄에 표시합니다. 긴 문장은 자막 버튼을 눌러 전체 내용을 펼쳐 봅니다. 음향/볼륨은 PC와 스마트폰에서 조작하며 개입/종료 화면에도 음소거 버튼이 남습니다. 첫 재생은 시연 실행 또는 음향 켜기 클릭으로 허용합니다. 새로고침·차량 창은 음향 꺼짐으로 시작하고 과거 신고/명령을 다시 읽지 않습니다. 새 시연·화면 이동은 이전 재생을 정리합니다.",
"미승인 초안·취소 명령은 읽지 않습니다. 고배속에서는20가상초보다 오래된 대기 방송을 줄이고 종료 시 종료 안내로 전환합니다. 사용자가 작성한 명령은 실제 입력 문장을 브라우저 한국어 합성으로 읽으며 OS/브라우저 지원에 따라 소리가 제공되지 않을 수 있습니다. 사용자 작성 음성은 실제 재생20초로 제한하며 일시정지·백그라운드 대기 시간은 제외합니다. 음원 오류는 자막으로 표시하고 사건 진행을 계속합니다. 재생 실패 시 음향 버튼을 눌러 다시 허용하세요. 실제 휴대폰 하드웨어/OS별 재생은 별도 검증이 필요합니다.",
"매뉴얼 카드 40장은 교육용 비공식·검수 전 초안입니다. 공식 교재의 원문과 구분하며 실제 운영 명령의 승인 근거로 대체하지 않습니다.",
],
},
{
id: "unreal",
title: "식당 화재 UE5 현장 · PC·스마트폰 연결",
paragraphs: [
"식당 화재 restaurant-gas 교육 사건 한 개에 UE5.8 C++ 현장을 연결합니다. 소방서·도로·식당 주방·펌프차·드론·불꽃·연기를 구성하며 웹 서버의 공개 사건 상태만 읽습니다. 펌프차는 기존 도로 경로, 드론은 같은 소방서에서 신고 위치까지 직선으로 이동합니다. UE 화면만 열어서는 사건 시간이 진행되지 않으며 기존 상황본부에서 재생·개입을 조작합니다.",
"현재 작업 환경에는 Unreal Engine 설치가 발견되지 않았습니다. 네이티브 C++ 빌드·맵 생성·실제 영상과 화질 검증은 엔진 설치 후 수행해야 합니다. 소스와 웹 연결이 있어도 이 검증 전에는 UE5 실행 완료로 판단하지 않습니다. Epic Games Launcher에서 계정 로그인·이용약관 수락 후 UE5.8을 설치하고 Visual Studio C++ 빌드 도구를 준비합니다.",
"프로젝트는 unreal/AIFirestation/AIFirestation.uproject입니다. unreal/README.md의 점검 → C++ 빌드 → Python 맵·재질 생성 → 공식 UE5.8 신호 서버 → 해당 사건 UUID로 UE 실행 순서로 준비합니다. UE가 폴링하는 GET /api/sessions/{id}/scene은 시연을 변경하지 않는 읽기 전용 API이며 DEMO 식당 시나리오만 지원합니다.",
"서버의 FIRESTATION_UE_SIGNAL_URL을 ws://PC의-LAN-IP:8880으로 지정하고 웹 서버를 다시 실행하면 같은 LAN의 스마트폰에서도 연결합니다. HTTP 로컬 시험과 운영 HTTPS를 구별하세요. HTTPS 화면에는 WSS 신호 서버가 필요하고 외부 네트워크에서는 HTTPS/WSS·STUN/TURN 및 네트워크 접근 설정을 별도로 준비해야 합니다. 스마트폰에 127.0.0.1 영상 주소를 사용하지 않습니다.",
"3D 현장의 전체·드론·차량·대원 카메라 버튼은 영상에만 영향을 줍니다. 같은 UE 인스턴스를 시청하는 사용자는 카메라를 공유합니다. 다른 사건의 스트리머를 대신 선택하지 않으며 영상 재생 성공·연결 대기·연결 실패를 구분합니다. 재생 버튼은 스마트폰의 영상 재생 허용을 위한 조작입니다.",
"현장 화재 표현은 공개된 화재 셀을 기준으로 합니다. 정찰 전 미확인은 화재 없음이라는 뜻이 아니며, 숨겨진 정답·미래 상태를 영상에 먼저 보여주지 않습니다. 장면의 불꽃·연기는 교육용 시각 표현이며 검증된 화재 역학·물질 판별·안전 판정이 아닙니다. 기관용 로그인·실제 사건·실장비 명령과 이 시연 연결은 분리됩니다.",
"C++·헤더·Build.cs·ini·uproject·Python·PowerShell 변경도 사용설명서와 구현 로직 지문에 포함됩니다. 빌드·캐시·UE 에디터가 만든 이진 맵/재질은 제외하며 포함된 부트스트랩으로 다시 생성합니다.",
],
},
{
id: "library",
title: "2026 중앙소방학교 교재 활용",
paragraphs: [
"자료실에 게시글 제목·게시일·발행기관·공공누리 조건·원문 URL·파일 해시·페이지 수를 표시합니다. 확인된 이용 조건이 있는 첨부만 원문 PDF와 검색 텍스트를 제공합니다.",
"제목·본문 검색 결과에는 문서와 PDF 페이지를 표시합니다. 인쇄된 책 쪽수와 PDF 페이지 번호가 다를 수 있으므로 승인 근거 등록 시 원문에서 함께 확인하세요.",
"기본 HTML 탭은 현재 페이지의 추출 본문을 읽기 좋은 글자로 표시합니다. 이전/다음, PDF 페이지 번호 입력 후 페이지 이동, 본문 검색과 검색어 강조를 제공합니다. PDF 탭으로 전환해도 문서·페이지를 유지합니다. 공식14권의 전체 HTML과 PDF를 다운로드할 수 있습니다. HTML의 페이지 링크 및 브라우저 찾기(Ctrl+F)를 사용할 수 있습니다. 표·그림·수식은 PDF가 기준이며 이미지 PDF의 OCR은 제공하지 않습니다.",
"목록의 교재를 마우스로 끌거나 위·아래 이동 버튼으로 순서를 바꿉니다. 공개 방문자/일반 기관 사용자는 해당 브라우저에, 관리자는 기관 공통 순서로 저장합니다. 새로고침 후에도 유지하며 동시 변경은 최신 목록을 다시 읽고 이동해야 합니다. 스마트폰은 교재 선택·순서 변경을 펼쳐 교재를 고릅니다.",
"기관용 지휘 지원에서 관리자 로그인 후 교재 화면으로 돌아오면 PDF 교재·매뉴얼 추가가 표시됩니다. PDF·제목·발행기관·이용 조건을 입력하면 원문을 보존하고 HTML·검색 인덱스를 자동 생성하여 목록 맨 뒤에 추가합니다. 같은 기관 로그인 사용자만 업로드 자료의 본문·검색·HTML/PDF를 조회합니다. 사용자 업로드는 공식 검증 교재와 구분합니다. 최초 관리자 설정이 안 됐다면 먼저 일회용 설정 키로 기관 계정을 설정해야 합니다.",
"업로드 상한은 파일64MiB·1500페이지·추출1200만자, 기관30개/512MiB입니다. 변환은60초로 제한합니다. 손상/암호화/상한 초과는 목록을 변경하지 않으며 오류를 표시합니다. 스캔 페이지는 텍스트가 없음을 표시하고 PDF 원문을 제공합니다. 영구 저장소는 FIRESTATION_LIBRARY_DIR 또는 기관 DB 옆 library이며 Docker에서는 /data/library입니다. .data 전체를 백업하세요.",
"선택/페이지 복사는 문서명·페이지·공식 출처를 포함합니다. 북마크는 이 브라우저에 보관됩니다. 원문 PDF는 공식 본문을 변경하지 않습니다.",
"해설은 서비스의 자체 작성 설명이며 공식 지시문이나 전문가 승인 자료가 아닙니다. 원문·AI 확인 제안·기관 검토 기록은 서로 구분합니다.",
],
},
{
id: "accounts",
title: "계정·역할·초기 설정",
paragraphs: [
"최초 관리자 설정은 로컬 터미널에서 npm run setup:key로 발급한 일회용 키를 입력해 한 번만 가능합니다. 키는 15분 뒤 만료되고 설정 성공 시 소진됩니다. Host 헤더만으로 초기화 권한을 인정하지 않습니다. 기본 암호나 자동 관리자 계정이 없습니다. 기관명·사용자명·표시 이름·긴 암호를 직접 입력하세요.",
"관리자는 사용자·역할·자원·기관 검토 근거를 관리합니다. 계정의 비활성화·복구·역할 변경·암호 재설정은 모든 기존 세션을 폐기하고 감사 이력에 남습니다. 마지막 활성 관리자는 해제할 수 없습니다. 비활성 계정의 미완료 임무와 기록은 보존되며 지휘관이 인계·취소를 관리하거나 암호 재설정 뒤 계정을 복구해 보고를 이어갑니다. 지휘관은 담당 사건의 명령 승인·취소·지휘 인계·종료를 수행합니다. 상황요원은 사건·관찰·자원·초안을 준비합니다. 대원은 배정받은 사건을 읽고 자신의 대상 임무를 수신·수행·완료/취소 확인합니다. 감사자는 읽기와 감사 조회만 가능합니다.",
"로그인 세션은 만료되고 로그아웃 시 폐기됩니다. 암호는 해시로 보관하며 로그인 실패가 반복되면 일정 시간 제한됩니다. 소속 기관과 역할은 서버가 확인하며 화면을 조작해 바꿀 수 없습니다.",
],
},
{
id: "screens",
title: "PC 모니터·스마트폰 상황 공유",
paragraphs: [
"PC 상황본부는 화면 높이에 맞춰 상황도·지휘·명령을 나란히 배치하고 긴 관찰·명령·복기는 패널 안에서 읽습니다. 시연 시작 후 설정은 접히며 시연 설정을 누르면 지휘 수준·새 시연을 확인할 수 있습니다. 스마트폰에서는 화면 선택 버튼으로 필요한 정보만 크게 봅니다. 작은 화면·가로 화면·화면 확대·시연 설정 편집·긴 교재와 설명서에서는 필요한 스크롤을 허용합니다.",
"기관용 대원은 등록된 계정으로 로그인하고 배정된 사건을 확인합니다. 선택한 사건 제목·위치를 표시하며 명령·보고가 먼저 열리고 현장 상황·사건 목록으로 전환할 수 있습니다. 수신·수행·완료/취소 확인은 같은 서버에 반영되며 상황요원은 새로고침 또는 5초 조회로 확인합니다. 휴대폰 화면으로 바뀌어도 기관·역할·사건 배정 권한은 동일합니다.",
"기관 화면은 마지막 성공 조회 시각을 표시합니다. 갱신 실패가 보이면 표시 중인 정보는 마지막 조회 내용입니다. 연결 복구나 앱 화면 복귀 시 다시 조회하며 새로고침으로 확인할 수 있습니다. 오프라인 보고 예약·자동 전송은 제공하지 않습니다. 실제 영상·장비 연결이 추가된 것은 아닙니다.",
"휴대폰의 127.0.0.1은 휴대폰 자신을 가리키므로 PC의 로컬 주소를 그대로 입력하면 접속할 수 없습니다. 같은 네트워크 시험은 PC에서 npm run build → npm run start:lan을 실행하고 휴대폰에서 http://PC의-LAN-IP:3100에 접속합니다. PC 주소는 ipconfig로 확인하며 네트워크·방화벽에서 해당 연결이 허용돼 있어야 합니다. 기본 npm start는 PC 로컬 전용입니다.",
"높이가 낮은 휴대폰에서는 지도와 명령이 사라지지 않도록 최소 읽기 높이를 유지하고 세로 스크롤을 허용합니다. HTTP LAN 시험에서도 보고 요청 ID를 생성할 수 있도록 브라우저의 암호학적 난수로 UUID v4를 생성하며 서버의 중복 요청 검증 계약을 유지합니다.",
"출동 중 다른 네트워크에서도 공유하려면 기관이 제공하는 공통 HTTPS 서비스 주소에 접속해야 합니다. 서버 주소 접근, 기관 계정 로그인, 사건 자원 배정이 필요합니다. 이 변경은 공개 서버 배포·네트워크·인증서 설정을 수행하지 않습니다. 이후 모든 화면 업그레이드는 PC와 스마트폰 UI·터치 조작·권한 흐름을 함께 검증합니다.",
],
},
{
id: "station",
title: "기관용 사건·관찰·명령·자원",
paragraphs: [
"기관용 지휘 지원은 인증된 사용자의 신고·관찰·사람 지휘·보고 기록입니다. 실제 119/무전망/드론/차량 인터페이스와 자동 화재 예측을 제공하지 않습니다.",
"사건 등록 시 제목·위치·신고 내용과 담당 지휘관을 입력합니다. 관찰은 출처·확인 상태·내용·접수 시각을 보존합니다. 의심이나 미확인을 확인됨으로 임의 단정하지 마세요.",
"관리자가 등록한 자원과 대원 계정을 사건에 배정합니다. 다른 진행 사건에 배정된 자원은 중복 배정할 수 없습니다.",
"명령 초안에 대상 자원·지시문·기관 검토 근거를 지정합니다. 해당 지휘관이 현재 관찰과 근거를 확인한 뒤 승인합니다. 승인 이후에도 대상마다 수신·수행·완료 상태가 다를 수 있습니다.",
"새 관찰이 들어온 초안은 재검토한 후 승인합니다. 사건을 전환하면 관찰·초안 입력을 초기화하며 같은 사건의 주기 조회에서는 작성 중 입력을 유지합니다. 로그아웃 때 지연 조회를 취소해 이전 사용자 화면이 다시 표시되지 않도록 합니다. 취소 후 늦게 들어온 완료 보고는 명령을 다시 실행 상태로 만들지 않습니다. 완료 전 임무가 남으면 종료를 거부합니다.",
"지휘권 인계는 인계 이력과 새 지휘관을 기록하며 기존 지휘관의 승인 권한을 제한합니다. 변경 충돌 시 최신 상태를 다시 확인합니다. 동일 요청의 재시도는 한 번만 반영합니다.",
],
},
{
id: "evidence",
title: "기관 검토 근거·감사·내보내기",
paragraphs: [
"공식 교재를 등재했다고 개별 작전이 승인된 것은 아닙니다. 관리자는 문서·판본·PDF 페이지·검토자·검토 메모를 기록하고 기관 적용성을 확인한 근거만 등록합니다. 교육 카드 초안은 승인 근거로 사용할 수 없습니다.",
"감사 기록은 행위자·역할·사건·시각·버전·행동을 기록하고 이전 기록의 해시를 연결해 변조 검출을 돕습니다. 이는 기관의 독립 보관·서명·감사를 대신하는 무결성 인증이 아닙니다.",
"사건과 감사 내보내기는 권한에 따라 제공됩니다. 내려받은 기록에는 사용자가 입력한 사건 정보가 들어 있으므로 기관 보관 정책에 맞게 관리하세요.",
],
},
{
id: "hosting",
title: "GitHub·Hostinger VPS 배포와 공통 접속",
paragraphs: [
"공개 웹 접속 주소는 https://fs.afr.co.kr입니다. PC와 스마트폰에서 /demo 교육 시연, /library 교재, /help 설명서를 열 수 있습니다. 기관용 /station은 최초 기관·관리자 설정과 사용자 계정·사건 배정 후 이용합니다. 사용자 지정 도메인 fs.afr.co.kr은 VPS72.62.71.171로 연결합니다. 기존 임시 주소는 새 HTTPS 주소로 이동하며, 이전 주소에서 로그인했다면 새 주소에서 다시 로그인합니다. 도메인을 바꾸면 안내와 서버 Origin·TLS 설정을 함께 갱신합니다.",
"전체 코드는 비공개 GitHub minhokil/ai_firestation의 main에 보관합니다. 서버 배포 설정은 deploy/Dockerfile·compose.yaml·Nginx 템플릿과 deploy/README.md에 있습니다. Node22 이미지의 비root 프로세스가 웹 서비스를 실행하고 호스트 127.0.0.1:3118을 전용 HTTPS Nginx 사이트에 연결합니다.",
"VPS 프로젝트는 /root/projects/ai_firestation입니다. 검증한 Git 커밋 소스는 releases/SHA, 런타임 설정은 루트 compose.yaml/.env, 기관 SQLite·교육 JSON은 루트 .data에 영구 보관합니다. 로컬 기관 DB·계정·개인키는 서버에 복사하지 않으며 최초 관리자도 자동으로 만들지 않습니다.",
"배포 주소는 서버의 FIRESTATION_ORIGIN과 동일한 HTTPS 주소를 사용합니다. PC와 스마트폰에서 같은 주소를 열고 기관용은 사용자 로그인·사건 배정에 따라 조회합니다. 초기 설치 때에는 서버 터미널에서 docker compose exec web npm run setup:key로 일회용 키를 발급한 뒤 /station에서 직접 기관·관리자를 설정합니다. 키를 공개 문서나 GitHub에 기록하지 마세요.",
"이 VPS 배포는 웹 서비스입니다. UE5 네이티브 영상은 별도 엔진과 GPU 실행 호스트가 필요하며 현재 미검증 상태를 유지합니다. UE 연결을 준비하기 전에는 영상 연결 대기를 표시합니다. HTTP 로컬 신호 주소를 HTTPS 서비스에 그대로 넣을 수 없으며 별도 WSS/TURN 연결을 준비해야 합니다.",
"업데이트는 새 릴리스와 이미지 태그로 전환하고 기존 .data를 유지합니다. 이전 이미지·런타임 설정을 보존해 복구합니다. DB 백업은 서비스를 멈춘 뒤 SQLite/WAL/SHM과 교육 JSON을 함께 보관하고 다시 시작합니다. 코드 릴리스와 데이터를 함께 삭제하지 않습니다. 자세한 절차는 deploy/README.md와 배포 검증 기록을 확인하세요.",
"2026-10-09 공개 HTTPS에서 PC·스마트폰 크기 브라우저 시연, 교재 검색·복사, HTML 다운로드, 잘못된 Origin 차단, Secure 쿠키, 컨테이너 재시작 후 교육 기록 보존과 인증서 갱신 시험을 확인했습니다. 실제 휴대폰 하드웨어 및 기관 실전 검수는 별도입니다. 상세 검증과 전송 오류 재시험 기록은 docs/DEPLOYMENT_RESULTS.md에 있습니다.",
],
},
{
id: "maintenance",
title: "문서 갱신·설치·운영 준비",
paragraphs: [
"Node.js 22.22.3 이상에서 npm ci 후 npm run dev 또는 npm run build → npm start로 실행합니다. 현재 로컬 주소는 http://127.0.0.1:3100입니다.",
"npm run docs:sync는 manual.html·logic.html과 웹 공개본을 갱신합니다. npm run docs:check는 소스 지문과 문서 내용을 검사합니다. 개발 감시와 빌드 전 생성, Git pre-commit 검사로 코드와 문서가 따로 바뀌는 것을 차단합니다. npm run audio:check는 교육 음원21개의 해시와 공개 대사의 대응을 확인합니다. 대사를 바꿀 때는 한국어 Windows 음성·PowerShell7 환경에서 npm run audio:generate로 음원/메타데이터를 재생성하고 두 HTML도 갱신합니다. Linux/스마트폰은 배포한 WAV를 재생하므로 Windows 음성 설치가 필요하지 않습니다.",
"새 화면·권한·API를 추가한 개발자는 안내 모델도 수정해야 합니다. Next 설정·프로젝트 설정·테스트 변경도 같은 소스 수집 규칙으로 감시합니다. 생성기는 실제 소스/API 목록·버전·소스 스냅샷·재구현 프롬프트를 자동 갱신하며, 미구현 기능을 완료로 표시하면 안 됩니다.",
"기관 운영 전 HTTPS·내부망·백업/복구·접근 계정·기록 보존·매뉴얼 적용성·드론 및 장비 인터페이스·현장 검수를 기관 담당자와 확인해야 합니다. 기관용 화면 개발 완료가 실전 작전 검증이나 공인 인증을 의미하지 않습니다.",
],
},
];
export const reconstructionPrompt = `너는 한국어 UI를 구현하는 시니어 엔지니어다. 이 logic.html의 소스 지문·파일 목록·소스 스냅샷을 기준으로 AI Firestation을 같은 기능과 계약으로 재구현하라. 아래 요구를 생략하지 마라.
1. Next.js App Router, React, TypeScript strict, Zod, Node.js SQLite를 사용한다. 라이브러리는 포함된 package.json/package-lock.json을 기준으로 재현하고 Node.js 22.22.3 이상에서 검증한다. UI는 어두운 청록 지휘 헤더·밝은 상황도·주황 명령 강조·한국어 표시를 유지하며 모바일·키보드 접근성을 제공한다.
2. 교육 시연과 기관용 지휘 지원을 분리한다. 교육은 3개 시나리오, 숨겨진 정답/미래 시간표 비노출, 시드 재현, L0~L3, 가상 드론 관찰, 개입·승인·취소·복기이다. 실제 운영에서는 자동 작전 발행과 가상 수신/완료를 금지한다. 기존 교육 테스트를 유지한다.
2-1. 가상 상황도는 소방서 (75,224), 신고 위치 (470,164)를 사용한다. 펌프1 경로는 (75,224)→(355,224)→(355,110)→(470,110)이며 마지막 지점은 도로상의 현장 진입 지점이다. 경로 선과 위치 계산에 동일한 좌표를 쓰고 구간 길이에 비례해 일정한 지도 속도로 이동한다. 드론은 같은 소방서에서 신고 위치까지 최단 직선으로 이동한다. 공개 ETA와 서버·지도 공통 상수(차량 40초/드론 12초)로 위치를 계산하고 승인 전 대기·늦은 승인·일시정지·도착을 반영한다. 정상 자동 시연에서는 드론 20초/차량 40초 도착을 유지하고 숨겨진 미래 정보나 실제 지리 경로를 클라이언트에 추가하지 않는다. 경로 모서리·중간 위치·출발·도착을 단위 및 브라우저 테스트로 검증한다.
2-2. 모든 화면을 PC·스마트폰으로 함께 구현하고 검증한다. PC 1366×768/1920×1080에서는 교육 상황본부의 핵심 정보·조작을 화면 안에 배치하고 긴 기록은 패널 내부에서 스크롤한다. 스마트폰 360×800/390×844에서는 공통 메뉴 열기/닫기, 상황도/AI·명령/관찰·드론/복기 선택, 개입·진행 재개를 제공한다. 기관용 PC는 사건 목록/현장 상황/명령·보고를 병렬 배치하고 스마트폰은 화면 선택을 제공하며 대원 로그인 후 명령·보고를 먼저 연다. 터치 44px·입력 16px 이상, 키보드 접근과 낮은 화면/확대/긴 문서의 스크롤 대안을 유지한다. 마지막 성공 조회와 갱신 실패를 구분하고 연결 복구·화면 복귀 시 재조회한다. 오프라인 보고·실장비 연동을 꾸며내지 않는다. 기본 로컬 실행과 start:lan 시험 실행을 구분하고 같은 HTTPS 주소·기관 로그인·사건 배정으로 기기 간 같은 상태를 조회한다. 기관의 인증·권한 계약과 기존 동작을 유지한다.
2-3. 모바일 높이 740px 이하에서는 화면을 억지로 줄이지 말고 지도가 읽을 수 있는 높이와 세로 스크롤을 유지한다. HTTP LAN에서는 crypto.randomUUID가 없을 수 있으므로 crypto.getRandomValues 기반 UUID v4 대안을 사용하고 Math.random을 사용하지 않는다. 비보안 LAN 원점의 브라우저에서 수신/수행/완료 POST와 UUID 계약을 회귀 검사한다. HTTPS를 위한 기존 서버 Origin·쿠키·기관 권한 검증은 약화하지 않는다.
2-4. 식당 restaurant-gas/DEMO 한 개에 UE5.8 C++ 프로젝트 unreal/AIFirestation을 구현한다. 명세 docs/UE5_RESTAURANT_SPEC.md의 protocol ai-firestation.restaurant.v1 DTO를 GET /api/sessions/{id}/scene으로 읽고 사건 시간을 변경하지 않는다. 지도 좌표를 (x-360)*20,(y-180)*20 센티미터로 변환하고 동일 경로/공개 ETA를 사용한다. 공개 전 fireCells를 노출하지 않는다. UE Python 부트스트랩으로 맵/재질을 생성하고 C++가 도로·식당 주방·펌프차·드론·불꽃·연기를 구성한다. 폴링 중복·수명 종료·session/protocol/version 역전·정지/종료를 처리한다. 네이티브 입력은 같은 sessionId의 firestation.camera 메시지와 overview/drone/vehicle/crew 카메라만 허용한다.
2-5. 공식 @epicgames-ps/lib-pixelstreamingfrontend-ue5.8 0.1.2로 /simulation?session=UUID 영상을 연결한다. 신호 URL은 FIRESTATION_UE_SIGNAL_URL로 서버에서 제공한다. 스마트폰에 localhost를 전달하지 않고 HTTPS에는 WSS를 요구한다. 사건 UUID StreamerId 고정, 다른 스트리머 선구독/자동 fallback 차단, 마이크/카메라/키보드/터치/게임패드 입력 해제, 영상 playing 이벤트로 실제 재생 판정, 명시 재시도와 unmount 정리를 구현한다. 같은 인스턴스의 카메라는 공유됨을 표시한다. 구성 없음·엔진 미실행은 연결 대기로 표시하고 UE 영상을 모방해 완료로 표시하지 않는다. UE 미설치 상태의 native 빌드/영상 미검증을 기록하고 설치 후 실제 PC/휴대폰 재생까지 검증한다.
2-6. 교육 시연에 기본 켜짐의 한국어 합성 음원18개와 전화벨/무전 연결음/이동 사이렌3개를 구현한다. 공개 상태 기반 cue와 세션별 중복 방지 cursor를 사용하며 초안/취소/미공개 보고를 방송하지 않는다. 새 시연은 약25초 신고 접수 음성 동안 가상 시간 진행을 기다리고 완료/음소거/오류 시 진행한다. WebAudio context는 클릭에서 unlock하고 비동기 로드/세션 교체/종료/취소의 이전 재생을 폐기한다. 사이렌은 공개 차량 출동 중/ETA>0 기준이며 음성 중 낮춘다. 정지/개입/도착/취소/백그라운드/새 시연/화면 이동 때 소리를 정리한다. 음소거 후 늦은 합성 오류 콜백은 세션 세대/현재 cue 검사로 폐기하고 사용자 작성 음성의20초 제한에서 일시정지 시간을 제외한다. PC·모바일 음향 버튼/44px 볼륨/44px 전체 자막 펼치기/역할 자막, 개입/종료 음소거를 유지하고 새로고침/차량 창에서는 과거 대사를 재생하지 않는다. 고배속 대기 방송20가상초 제한과 종료 안내를 반영한다. 정형 대사는 배포 WAV로 모든 기기에서 재생하며 사용자 작성 명령은 브라우저 한국어 음성을 사용한다. scripts/export-demo-audio.ts와 generate-demo-voices.ps1로 Windows 한국어 음성/PowerShell7에서 재생성하고 audio-recordings.json의 해시/길이와 audio:check 공개 대사 대응을 유지한다. 합성 교육 음성임과 실기/OS 검증 제한을 표시하고 실제 기관용 무전으로 연결하지 않는다. 음원 오류로 시연이 갇히지 않도록 검증하고 기존 승인·이동·기관 경계 계약을 유지한다.
3. 기관용은 로컬 CLI 일회용 설정 키(15분·서버 해시·성공 시 소진), 관리자 최초 설정·로그인·로그아웃·계정 비활성화/복구·역할 변경·암호 재설정·전체 세션 폐기·마지막 활성 관리자 보호, 관리자/지휘관/상황요원/대원/감사자 역할을 구현한다. scrypt 개별 salt, HttpOnly/SameSite 세션 쿠키, 세션 토큰 해시만 저장, 만료·속도 제한·서버 역할/기관 경계 검증을 구현한다. 기본 비밀번호·자동 로그인·기관 ID 주입을 금지한다.
4. SQLite 트랜잭션 안에서 사건 버전·자원 중복 배정·감사·요청 중복 키를 함께 저장한다. 사건 등록·관찰·자원 배정·명령 초안·새 관찰 재검토·담당 지휘관 승인·대상별 수신/수행/완료·취소 확인·지휘권 인계·종료 검사를 구현한다. 대원은 자신의 배정 사건/명령만 조작한다.
5. 기관용 AI는 공개 관찰에서 위험 후보·추가 확인 질문·자료 후보를 제안하는 규칙 기반 지원이다. 인명/가스/물질/통전 상태를 냄새·색·외부 영상만으로 확정하지 않는다. 명령의 현장 판단과 승인은 사람에게 남긴다. 교육 카드 초안은 기관용 승인 근거로 사용하지 않는다. 외부 LLM이나 실제 장비가 연결됐다고 표시하지 않는다.
6. 공식 중앙소방학교 2026 자료의 문서명·게시일·공식 URL·공공누리 조건·SHA-256·페이지 수를 보존한다. 이용 허용된 원문은 변경 없는 PDF로 제공하고 페이지별 검색·원문/텍스트·출처 포함 복사·북마크·인쇄를 제공한다. 독립 해설에는 자체 작성·검수 전을 표시한다. 허용 확인 전 재배포하지 않는다. 첨부는 포함된 수입 스크립트와 메타데이터를 사용하고 해시를 검증한다.
6-1. 자료실은 HTML/PDF 두 탭과 HTML 기본값이다. 공식14권의 인덱스로 이스케이프된 전체 HTML을 생성하고 페이지별 본문을 React 텍스트/mark로 읽는다. 이전/다음/독립 페이지 입력·이동, 동일 페이지 PDF iframe, 본문 검색·강조·출처 복사·북마크·전체 HTML/PDF 다운로드를 유지한다. 그림/표/스캔은 PDF를 기준으로 하고 OCR을 구현했다고 표시하지 않는다. 목록은 드래그와44px 위·아래 이동을 제공한다. 방문자/일반 사용자 순서는 브라우저별, 관리자 순서는 기관 DB의 버전 검증으로 저장한다. PC 읽기 영역은 내부 스크롤과 자연스러운 패널 높이를 사용하여 grid 축소/overflow로 본문을 숨기지 않는다. 1366×768에서 본문 실제 노출 높이180px 이상·문서 전체가 화면 안에 들어옴을 검증한다. 스마트폰은 목록 접기·간결한 검색/출처 안내·44px 페이지 조작을 제공하고 긴 본문에 세로 스크롤을 허용한다. 목록 접기 버튼은 PC에서 숨긴다.
6-2. 기관 관리자 PDF 업로드는 기존 세션·서버 역할·동일 Origin으로 검증한다. UUID 이름으로 /data/library에 원문 PDF와 이스케이프 HTML·JSON을 저장하고 기관별 메타데이터·순서를 SQLite에 원자적으로 반영하여 맨 뒤에 추가한다. 같은 기관의 로그인 사용자만 업로드의 목록·페이지·검색·HTML/PDF 파일에 접근한다. 권한은 변환 후에도 재검증하고 파일/검색 API는 no-store를 사용한다.64MiB/1500페이지/1200만자·기관30개/512MiB,60초·384MiB Node 자식 프로세스에서 PDF.js 추출 및 동시 변환 제한을 적용한다. 실패 시 부분 파일을 지우고 목록/버전을 변경하지 않는다. PDF 파일 조회는 byte range를 지원한다. Nginx는 /api/library/upload만65m를 허용하고 Compose는 FIRESTATION_LIBRARY_DIR=/data/library로 영구 마운트한다. 늦은 페이지/검색/목록 응답과 로그아웃 후 기관 자료 재노출을 검증한다. scripts/extract-library-pdf.mjs도 logic.html 소스 스냅샷에 포함한다.
7. 교육 시연 오른쪽 사용설명서 메뉴, 1. 쉽게 따라하기/2. 상세 사용설명서, HTML 보기·다운로드를 구현한다. 루트 및 공개 manual.html·logic.html은 동일 생성기로 생성한다. 코드가 바뀔 때 안내 모델과 두 HTML을 반드시 함께 갱신한다. 실제 소스/API 목록·지문·버전·생성 시각·재구현 프롬프트·소스 스냅샷을 갱신하고 오프라인에서도 검색·복사·인쇄가 동작하게 한다. dev 감시·build 전 생성·docs:check·스테이지 pre-commit 검사로 오래된 문서를 차단한다. unreal/ C++·헤더·C# 빌드 규칙·설정·uproject·Python·PowerShell와 deploy/Dockerfile·Compose·Nginx 템플릿·.dockerignore도 수집/감시하며 환경 파일·Binaries/Intermediate/Saved/DerivedDataCache 및 생성된 이진 자산은 제외한다.
8. 수용 검증: 기존 단위/브라우저 테스트, 인증·권한·기관 경계·자원 충돌·새 관찰·지연 수신·취소·중복 요청·감사 해시·문서 지문 및 손상 검사를 수행한다. 브라우저에서 설명서 다운로드·자료 검색/복사·관리자 설정→사용자/근거/자원→사건→초안→승인→대원 완료를 검증한다. typecheck/docs:check/build/e2e를 통과시키고 결과와 운영 검증 한계를 기록한다.
9. GitHub minhokil/ai_firestation main에서 버전 소스를 보관하고 Hostinger /root/projects/ai_firestation에서 Docker Node22 웹 서비스를 실행한다. 현재 HTTPS 접속 주소는 https://fs.afr.co.kr이며 도메인을 변경하면 사용 안내·서버 Origin·TLS·Nginx를 함께 갱신한다. nginx-redirect.conf.template으로 이전 주소의 인증서/ACME 경로와 path/query를 보존하며 새 HTTPS로301 이동하고 새 도메인 재로그인을 안내한다. 배포 검증의 실제 성공·실패·재시험과 휴대폰 에뮬레이션/실기 검증 범위를 docs/DEPLOYMENT_RESULTS.md에 기록한다. releases/SHA 코드와 영구 .data를 분리하고 image tag/compose/.env를 보존한다. 비root Node 프로세스·loopback3118·전용 HTTPS Nginx·실제 FIRESTATION_ORIGIN·Secure 쿠키·기존 기관 권한을 유지한다. 로컬 DB/계정/키를 배포하지 않고 기본 관리자를 만들지 않는다. bootstrap 키는 Docker CLI에서 일회용으로 발급한다. Docker health/HTTP리다이렉트/HTTPS/PC·스마트폰/Origin/재시작 데이터 유지 검수를 하고 업데이트 및 이전 이미지 복구·SQLite/WAL 백업 방법을 문서화한다. VPS 웹 배포를 UE 렌더링 또는 실전 지휘 검증 완료라고 표시하지 않는다.
소스 스냅샷의 파일별 경로와 내용을 복원하는 것이 가장 정확하다. 스냅샷에 없는 비밀번호·사건 데이터·기관 승인·외부 API 키를 만들어 넣지 마라. 운영 인증을 주장하지 말고 실제 구현과 외부 검증이 필요한 조건을 사용설명서에 구분해라.`;
packages/docs/render.ts
import { guideSections, reconstructionPrompt } from "./content";
export type SourceFile = { path: string; text: string };
export type DocumentMeta = {
version: string;
fingerprint: string;
generatedAt: string;
files: SourceFile[];
};
export function escapeHtml(value: string) {
return value.replace(
/[&<>"']/g,
(c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
c
]!,
);
}
const style = `:root{color-scheme:light}*{box-sizing:border-box}body{margin:0;background:#f1f4f6;color:#19303c;font:15px/1.8 "Segoe UI","Malgun Gothic",sans-serif}header{padding:32px 6%;background:#203b49;color:white}header p{color:#c2d4dc;font-size:12px}main{max-width:1200px;padding:30px 5%;margin:auto;display:grid;grid-template-columns:220px minmax(0,1fr);gap:25px}nav{position:sticky;top:20px;align-self:start}nav a{display:block;padding:7px 0;color:#276b62;font-size:13px}section{background:white;border:1px solid #d8e0e5;padding:24px;margin-bottom:20px;border-radius:5px;scroll-margin-top:20px}h1{font-size:30px;letter-spacing:-.04em;margin:0}h2{font-size:23px;line-height:1.5;margin:0 0 15px}h3{font-size:17px}p,li{word-break:keep-all}li{margin-bottom:10px}a{color:#237d72}button,.button{display:inline-block;border:1px solid #c8d4da;border-radius:4px;padding:9px 13px;cursor:pointer;background:white;color:#19303c;text-decoration:none;font:inherit;font-size:12px;margin:3px}button:focus-visible,a:focus-visible,input:focus-visible{outline:3px solid #d95332;outline-offset:3px}input{width:100%;padding:10px;border:1px solid #c8d4da;border-radius:4px;font:inherit;font-size:12px}pre{white-space:pre-wrap;overflow-wrap:anywhere;background:#f1f5f6;padding:15px;font:12px/1.7 Consolas,monospace;border-radius:4px;max-height:650px;overflow:auto}code{font-family:Consolas,monospace;font-size:12px}details{margin:10px 0;border-top:1px solid #d8e0e5;padding-top:10px}summary{cursor:pointer;font-size:13px}.badge{font-size:11px;border:1px solid #648d86;padding:3px 8px;display:inline-block;border-radius:3px}.meta{font-size:11px;color:#60747e;overflow-wrap:anywhere}.tools{margin-top:15px}.result{font-size:12px;color:#276b62}.source-list li{font-size:12px}.pending{background:#fff5dc;border-left:3px solid #b58732;padding:12px;font-size:12px}@media(max-width:760px){main{grid-template-columns:1fr;padding:20px 4%}nav{position:static}h1{font-size:25px}section{padding:18px}nav a{display:inline-block;margin-right:12px}}@media print{body{background:white}header{background:white;color:#19303c;padding:15px}header p{color:#19303c}main{display:block;padding:0}nav,.tools,input,button{display:none}section{break-inside:avoid;border:0}pre{max-height:none;overflow:visible}details{display:block}.badge{display:none}}`;
const script = `function copyText(id){const node=document.getElementById(id);const text=node.innerText;const done=()=>document.getElementById('copy-result').textContent='복사했습니다.';if(navigator.clipboard&&window.isSecureContext){navigator.clipboard.writeText(text).then(done).catch(()=>legacyCopy(text,done))}else legacyCopy(text,done)}function legacyCopy(text,done){const t=document.createElement('textarea');t.value=text;document.body.appendChild(t);t.select();try{document.execCommand('copy');done()}finally{t.remove()}}document.getElementById('doc-search').addEventListener('input',e=>{const q=e.target.value.trim().toLowerCase();for(const s of document.querySelectorAll('main section'))s.hidden=!!q&&!s.textContent.toLowerCase().includes(q)});`;
function shell(
meta: DocumentMeta,
title: string,
links: { id: string; title: string }[],
body: string,
) {
return `<!doctype html>\n<html lang="ko"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><meta name="source-fingerprint" content="${meta.fingerprint}"><title>${title} · AI Firestation</title><style>${style}</style></head><body><header><span class="badge">AI FIRESTATION v${escapeHtml(meta.version)}</span><h1>${title}</h1><p>생성 ${escapeHtml(meta.generatedAt)} · 코드 변경 시 이 문서와 구현 로직을 함께 갱신합니다.</p><div class="tools"><a class="button" href="manual.html">사용설명서</a><a class="button" href="logic.html">구현 로직</a><button onclick="window.print()">인쇄 / PDF 저장</button></div></header><main><nav aria-label="문서 목차"><input id="doc-search" placeholder="문서 내용 검색" aria-label="문서 내용 검색">${links.map((l) => `<a href="#${l.id}">${escapeHtml(l.title)}</a>`).join("")}<p class="meta">소스 SHA-256<br>${meta.fingerprint}</p></nav><div>${body}<p class="meta">검증 대상 소스 ${meta.files.length}개 · 로컬 설치와 기관별 검토를 구분합니다.</p><p id="copy-result" class="result" aria-live="polite"></p></div></main><script>${script}</script></body></html>\n`;
}
export function renderDocuments(meta: DocumentMeta) {
const routes = meta.files
.filter((f) => /apps\/web\/app\/.*\/route\.ts$/.test(f.path))
.map((f) =>
f.path
.replace("apps/web/app", "")
.replace("/route.ts", "")
.replace(/\[([^\]]+)\]/g, ":$1"),
);
const inventory = `<section id="inventory"><h2>현재 소스·API 목록</h2><p class="meta">기능 설명의 근거가 되는 파일 목록과 실제 서버 경로를 자동 수집합니다.</p><ul class="source-list">${routes.map((r) => `<li><code>${escapeHtml(r)}</code></li>`).join("")}</ul><details><summary>소스 ${meta.files.length}개 보기</summary><ul class="source-list">${meta.files.map((f) => `<li><code>${escapeHtml(f.path)}</code></li>`).join("")}</ul></details></section>`;
const sections = guideSections
.map(
(s) =>
`<section id="${s.id}"><h2>${escapeHtml(s.title)}</h2>${s.paragraphs.map((p) => `<p>${escapeHtml(p)}</p>`).join("")}${s.steps ? `<ol>${s.steps.map((p) => `<li>${escapeHtml(p)}</li>`).join("")}</ol>` : ""}</section>`,
)
.join("");
const manual = shell(
meta,
"AI Firestation 사용설명서",
[
...guideSections.map(({ id, title }) => ({ id, title })),
{ id: "inventory", title: "소스·API 목록" },
],
sections + inventory,
);
const logicBody = `<section id="architecture"><h2>핵심 구조와 판단 경계</h2><p>교육 엔진 → 공개 관찰 → 규칙 지휘관 → 근거 카드 → 가상 명령·복기. 기관용은 인증 → 역할·기관 검증 → 트랜잭션 → 관찰·자원·사람 승인 → 대상별 보고 → 감사 체인으로 분리합니다.</p><p>공식 원문 자료·비공식 해설·기관 검토 근거를 구분합니다. 자동 현장 작전·실장비 인터페이스·외부 LLM 연결을 구현했다고 표시하지 않습니다.</p><p class="pending">스냅샷에 포함된 코드가 구현의 기준입니다. 실제 기능 추가 시 안내 모델을 수정하고 생성·검사를 수행해야 합니다.</p></section><section id="reconstruction"><h2>AI 재구현 프롬프트</h2><p>아래 프롬프트와 소스 스냅샷을 AI에게 함께 전달하세요. 원문 PDF는 자료 메타데이터의 공식 출처와 파일 해시로 검증합니다.</p><button onclick="copyText('prompt')">재구현 프롬프트 복사</button><pre id="prompt">${escapeHtml(reconstructionPrompt)}</pre></section>${inventory}<section id="snapshot"><h2>검증 대상 소스 스냅샷</h2><p>비밀번호·사건 DB·환경 변수·빌드 결과·원문 PDF는 포함하지 않습니다. 코드와 설정은 아래 경로대로 복원합니다.</p>${meta.files.map((f) => `<details><summary>${escapeHtml(f.path)}</summary><pre>${escapeHtml(f.text)}</pre></details>`).join("")}</section>`;
return {
manual,
logic: shell(
meta,
"AI Firestation 구현 로직",
[
{ id: "architecture", title: "핵심 구조" },
{ id: "reconstruction", title: "AI 재구현 프롬프트" },
{ id: "inventory", title: "소스·API 목록" },
{ id: "snapshot", title: "소스 스냅샷" },
],
logicBody,
),
};
}
packages/docs/watch.ts
import { watch, existsSync } from "node:fs";
import { resolve } from "node:path";
import { isDocumentSource } from "./build";
export function watchDocumentSources(root: string, onChange: () => unknown) {
let timer: ReturnType<typeof setTimeout> | undefined;
function changed(path: string) {
if (!isDocumentSource(path.replace(/\\/g, "/"))) return;
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = undefined;
onChange();
}, 120);
}
const watchers = [
"apps",
"packages",
"scripts",
"tests",
"docs",
"unreal",
"deploy",
".githooks",
]
.filter((dir) => existsSync(resolve(root, dir)))
.map((dir) =>
watch(resolve(root, dir), { recursive: true }, (_event, file) => {
if (file) changed(`${dir}/${file}`);
}),
);
watchers.push(
watch(root, (_event, file) => {
if (file) changed(file);
}),
);
return () => {
for (const watcher of watchers) watcher.close();
if (timer) clearTimeout(timer);
};
}
packages/kb/definitions.ts
type Definition = {
id: string;
title: string;
category: string;
tags: string[];
summary: string;
questions: string[];
preparation: string[];
cautions: string[];
};
function card(
id: string,
title: string,
category: string,
tags: string[],
summary: string,
questions: string[],
preparation: string[],
caution: string,
): Definition {
return {
id,
title,
category,
tags,
summary,
questions,
preparation,
cautions: [caution],
};
}
export const definitions: Definition[] = [
card(
"A-01",
"주택 화재·인원 미확인",
"일반",
["A급", "주택", "인명"],
"외부 관찰과 내부 인원 확인을 구분합니다.",
[
"어느 층에서 연기를 관찰했나요?",
"내부 인원은 직접 확인했나요?",
"출입구 상태를 누가 확인했나요?",
],
["인명 확인 담당·보고 경로 확인", "보호·통신 장비 준비 확인"],
"사람이 보이지 않아도 내부 인원이 없다고 단정하지 않습니다.",
),
card(
"A-02",
"공동주택·상층 연기",
"일반",
["A급", "공동주택"],
"발생 층과 영향을 받는 층을 따로 기록합니다.",
[
"발생 층이 확인됐나요?",
"다른 층에도 연기가 있나요?",
"대피 경로를 확인했나요?",
],
["층별 확인·보고 구역 정리", "구조 자원 가용 상태 확인"],
"발화층과 연기 관찰층을 같은 것으로 기록하지 않습니다.",
),
card(
"A-03",
"사무실·야간 화재",
"일반",
["A급", "야간"],
"시설 용도와 출입 정보를 확인합니다.",
[
"야간 근무자가 있나요?",
"출입 담당자에게 연락했나요?",
"전기·가스 시설 정보가 있나요?",
],
["시설 관계인 연락 확인", "인명·출입 확인 목록 준비"],
"시설이 닫혔다고 무인 상태를 확정하지 않습니다.",
),
card(
"A-04",
"다중이용시설·대피",
"일반",
["A급", "대피"],
"전체 인원과 확인된 대피 인원을 구분합니다.",
[
"전체 이용 인원을 아나요?",
"대피 인원 확인자는 누구인가요?",
"미확인 구역은 어디인가요?",
],
["인원·구역별 보고 준비", "추가 구조 자원 검토"],
"대피 신고 한 건으로 모든 구역 확인을 끝내지 않습니다.",
),
card(
"A-05",
"창고·일반 가연물",
"일반",
["A급", "창고"],
"가연물과 추가 저장 위험을 함께 확인합니다.",
[
"보관 품목을 아나요?",
"위험물·배터리가 있나요?",
"건물 구조 정보가 있나요?",
],
["품목·시설 정보 확보", "장비·용수 가용성 확인"],
"창고 용도만으로 일반 화재에 한정하지 않습니다.",
),
card(
"B-01",
"유류 저장 창고",
"유류",
["B급", "유류", "물질"],
"유류 존재와 물질 식별을 구분합니다.",
[
"물질명·표지가 있나요?",
"유출은 직접 확인됐나요?",
"SDS·관계인 정보가 있나요?",
],
["물질별 자료·전문 지원 검토", "적합 장비·약제 보유 확인"],
"물질 미확인 상태에서 약제·진압 방법을 확정하지 않습니다.",
),
card(
"B-02",
"주유 시설 화재",
"유류",
["B급", "주유소"],
"공급·저장 정보를 담당자와 확인합니다.",
[
"저장시설 정보를 받았나요?",
"공급 상태 확인자는 누구인가요?",
"인접 위험을 보고했나요?",
],
["시설 담당자 연락 준비", "유류 대응 자원 점검"],
"외부 화염만으로 저장시설 상태를 확정하지 않습니다.",
),
card(
"B-03",
"도료·용제 보관",
"유류",
["B급", "용제"],
"제품명과 성분 정보가 필요합니다.",
["제품명·성분을 아나요?", "용기가 손상됐나요?", "반응성 물질도 있나요?"],
["SDS 확인 담당 배정", "전문 대응 자원 검토"],
"검은 연기만으로 성분을 식별하지 않습니다.",
),
card(
"B-04",
"연료 유출 의심",
"유류",
["B급", "유출"],
"신고·확인·확산 범위를 구분합니다.",
[
"유출 관찰자는 누구인가요?",
"물질·범위를 확인했나요?",
"배수로 정보가 있나요?",
],
["현장 관찰·환경 지원 확인", "유출 대응 장비 검토"],
"표식은 확인 위치와 오차를 포함해야 합니다.",
),
card(
"B-05",
"위험물 운송 차량",
"유류",
["B급", "운송"],
"적재물 정보를 확보해야 합니다.",
[
"표지·적재 자료가 있나요?",
"용기 손상 정보가 있나요?",
"운전자에게 확인했나요?",
],
["적재물 정보 확인", "전문 지원 연락 준비"],
"차량 외관만으로 적재 물질을 판정하지 않습니다.",
),
card(
"C-01",
"전기실 화재",
"전기",
["C급", "전기실"],
"통전 상태는 확인 주체와 함께 기록합니다.",
[
"전원 차단 보고자는 누구인가요?",
"재투입 가능성 정보가 있나요?",
"전기 담당자가 있나요?",
],
["전력·시설 담당 협조", "보호장비 적합성 확인"],
"미확인 차단을 완료로 표시하지 않습니다.",
),
card(
"C-02",
"분전반·배선 발화",
"전기",
["C급", "배선"],
"전원·위험 구역 정보를 확인합니다.",
[
"분전반 위치를 아나요?",
"회로 정보를 확보했나요?",
"주변 인명 정보가 있나요?",
],
["전기 담당 연락 준비", "위험 구역 보고 준비"],
"영상만으로 전압·차단을 판단하지 않습니다.",
),
card(
"C-03",
"변압 시설 주변",
"전기",
["C급", "변압기"],
"시설과 인접 위험을 따로 등록합니다.",
[
"운영자에게 연락했나요?",
"전원 확인 기록이 있나요?",
"접근·낙하 위험이 있나요?",
],
["전력기관 지원 검토", "시설·장비 정보 확인"],
"임의 전압·안전거리 수치를 만들지 않습니다.",
),
card(
"C-04",
"침수와 전기 위험",
"전기",
["C급", "침수"],
"침수 관찰과 차단을 분리합니다.",
[
"침수 구역이 어디인가요?",
"전기시설 위치를 아나요?",
"차단을 누가 확인했나요?",
],
["전력 담당 협조 확인", "접근 위험 검토"],
"침수를 전기 위험 해소로 해석하지 않습니다.",
),
card(
"C-05",
"전원 차단 보고 정정",
"전기",
["C급", "정정"],
"관련 판단·명령을 재검토합니다.",
[
"원래 보고자는 누구인가요?",
"정정 이유는 무엇인가요?",
"관련 명령이 있나요?",
],
["정정·수신 대상 확인", "관련 명령 재검토"],
"기존 보고를 삭제해 이력을 잃지 않습니다.",
),
card(
"K-01",
"식당 주방·기름 화재",
"주방",
["K급", "식당", "가스"],
"주방 화재와 가스 위험을 따로 확인합니다.",
[
"식용유 사용을 확인했나요?",
"가스 공급 확인자는 누구인가요?",
"내부 인원을 확인했나요?",
],
["주방 대응 승인 자료 확인", "가스 확인·장비 담당 배정"],
"식당이라는 이유로 K급이나 차단을 확정하지 않습니다.",
),
card(
"K-02",
"튀김기 화재 의심",
"주방",
["K급", "튀김기"],
"기구·유지·열원 정보가 필요합니다.",
[
"어떤 조리기구인가요?",
"동식물유 사용을 확인했나요?",
"열원 확인자는 누구인가요?",
],
["기구·대응 자료 확인", "시설 담당 연락 준비"],
"소화 수단을 임의로 정하지 않습니다.",
),
card(
"K-03",
"후드·덕트 연기",
"주방",
["K급", "후드"],
"연기와 실제 연소 위치를 구분합니다.",
[
"어디서 연기가 보이나요?",
"덕트 정보가 있나요?",
"주변 구역에도 연기가 있나요?",
],
["구조·관찰 구역 확인", "전문 장비 검토"],
"연기 출구를 발화 위치로 확정하지 않습니다.",
),
card(
"K-04",
"주방과 전기 위험",
"주방",
["K급", "C급"],
"주방·전기·가스를 병행 확인합니다.",
[
"열원은 전기인가요?",
"공급·전원을 확인했나요?",
"다른 기구도 영향을 받나요?",
],
["전기·가스 확인 경로 준비", "위험별 장비 점검"],
"한 위험 확인으로 다른 위험까지 해소하지 않습니다.",
),
card(
"K-05",
"주방화재 후 잔여 위험",
"주방",
["K급", "잔여"],
"화염 감소 이후 확인을 유지합니다.",
[
"잔여 열원이 있나요?",
"공급·전원을 재확인했나요?",
"종료 담당자가 누구인가요?",
],
["추가 관찰 담당 배정", "자원 복귀 상태 정리"],
"불꽃이 사라졌다고 종료를 확정하지 않습니다.",
),
card(
"G-01",
"가스 냄새·누출 의심",
"가스",
["가스", "누출"],
"냄새는 단서이며 누출 확정이 아닙니다.",
[
"냄새 관찰 위치는 어디인가요?",
"가스 종류를 아나요?",
"계측·공급 보고가 있나요?",
],
["가스 장비 준비 확인", "시설·가스 담당 협조"],
"RGB 영상으로 가스 성분·농도를 판정하지 않습니다.",
),
card(
"G-02",
"가스 용기 미확인",
"가스",
["가스", "용기"],
"용기 종류·상태는 출처가 필요합니다.",
["표지가 있나요?", "현장 확인자는 누구인가요?", "공급·저장 방식을 아나요?"],
["용기·물질 자료 확보", "전문 지원 배정"],
"외형만으로 가스 종류를 확정하지 않습니다.",
),
card(
"G-03",
"가스 공급 차단 확인",
"가스",
["가스", "차단"],
"요청과 완료를 구분합니다.",
[
"차단 요청자는 누구인가요?",
"완료 확인자는 누구인가요?",
"시각·대상 시설이 있나요?",
],
["담당기관 확인 기록", "관련 임무 재검토"],
"차단 요청을 차단 완료로 처리하지 않습니다.",
),
card(
"G-04",
"가스 계측 보고",
"가스",
["가스", "센서"],
"위치·시각·단위·장비 상태가 필요합니다.",
[
"장비·교정 정보가 있나요?",
"위치·시각을 기록했나요?",
"단위를 확인했나요?",
],
["계측 양식 확인", "전문 담당 검토"],
"오래된 측정으로 전체 구역 안전을 선언하지 않습니다.",
),
card(
"G-05",
"폭발음 신고",
"가스",
["가스", "폭발"],
"소리 신고와 손상 확인을 구분합니다.",
[
"소리를 누가 들었나요?",
"손상을 직접 확인했나요?",
"추가 인명 정보가 있나요?",
],
["신고·현장 정보 대조", "추가 전문 자원 검토"],
"소리만으로 원인 물질을 판정하지 않습니다.",
),
card(
"R-01",
"신고 위치 보완",
"지휘",
["신고", "위치"],
"주소·관찰 위치를 확인합니다.",
["주소를 확인했나요?", "건물명을 아나요?", "층·구역을 구분했나요?"],
["위치 후보 기록", "출동 담당자에게 공유"],
"추정 좌표를 확정 위치처럼 표시하지 않습니다.",
),
card(
"R-02",
"인명·대피 정보",
"지휘",
["인명", "대피"],
"확인된 인원과 추정을 구분합니다.",
[
"확인자는 누구인가요?",
"확인 구역은 어디인가요?",
"미확인 구역이 남았나요?",
],
["인명 확인표 준비", "구조·보고 담당 배정"],
"대피 인원을 전체 인원으로 간주하지 않습니다.",
),
card(
"R-03",
"차량·장비 준비",
"지휘",
["자원", "준비"],
"보유·준비·가용은 다릅니다.",
["차량에 장비가 있나요?", "작동을 확인했나요?", "다른 사건 배정이 있나요?"],
["차량별 준비 확인", "부족·불가 보고"],
"보유만으로 준비 완료를 표시하지 않습니다.",
),
card(
"R-04",
"관계기관 지원",
"지휘",
["지원", "기관"],
"요청·접수·도착을 구분합니다.",
["연락 주체가 정해졌나요?", "접수를 확인했나요?", "상태를 갱신했나요?"],
["요청·접수 기록", "현장 연락 담당 확인"],
"연락 시도를 지원 도착으로 표시하지 않습니다.",
),
card(
"R-05",
"복합 위험 재판단",
"지휘",
["복합", "재판단"],
"새 보고의 영향을 검토합니다.",
[
"무엇이 달라졌나요?",
"직접 확인한 출처가 있나요?",
"영향받는 명령이 있나요?",
],
["관찰·판단 버전 연결", "기존 명령 재검토"],
"상충 정보를 조용히 덮어쓰지 않습니다.",
),
card(
"D-01",
"드론 정찰 계획",
"드론",
["드론", "정찰"],
"비행 조건과 관찰 목적을 확인합니다.",
["운용팀이 있나요?", "기상·공역·통신을 확인했나요?", "귀환 조건이 있나요?"],
["운용 담당 확인", "기체·센서·배터리 점검"],
"출동 승인만으로 비행 조건을 충족하지 않습니다.",
),
card(
"D-02",
"드론 운용 불가",
"드론",
["드론", "기상"],
"드론 없이 관찰을 지속합니다.",
["불가 사유가 있나요?", "대체 영상이 있나요?", "선착 보고 경로가 있나요?"],
["대체 관찰 준비", "차량 출동 상태 공유"],
"드론 실패로 출동을 자동 중지하지 않습니다.",
),
card(
"D-03",
"영상 지연·정지",
"드론",
["드론", "영상"],
"연결과 영상 신선도를 구분합니다.",
["프레임 시각이 있나요?", "화면이 멈췄나요?", "대체 보고가 있나요?"],
["지연·정지 표시", "텍스트 관찰로 전환"],
"정지 프레임에 실시간 표식을 유지하지 않습니다.",
),
card(
"D-04",
"열화상 관찰",
"드론",
["드론", "열화상"],
"열원 후보와 위험 확정을 구분합니다.",
["보정 정보가 있나요?", "반사·가림이 있나요?", "다른 출처로 확인했나요?"],
["후보·프레임 기록", "현장 확인 검토"],
"열화상으로 내부 인원·가스를 확정하지 않습니다.",
),
card(
"D-05",
"드론·차량 선착",
"드론",
["드론", "ETA"],
"도착 기록으로 선착을 비교합니다.",
[
"준비 시간이 포함됐나요?",
"도착 시각이 있나요?",
"차량 도착을 확인했나요?",
],
["ETA 구분", "실제 도착 보고"],
"늦게 도착한 드론을 선행 성공으로 표시하지 않습니다.",
),
card(
"E-01",
"통신 미수신",
"지휘",
["통신", "미수신"],
"전달과 수신 확인을 구분합니다.",
["미수신 대상은 누구인가요?", "대체 연락을 했나요?", "복창 확인이 있나요?"],
["미수신 목록 준비", "대체 절차 확인"],
"전송 성공을 사람이 확인한 것으로 처리하지 않습니다.",
),
card(
"E-02",
"지휘권 인계",
"지휘",
["지휘권", "인계"],
"책임자·위임을 명시합니다.",
[
"현재 지휘관은 누구인가요?",
"인계를 확인했나요?",
"위임 범위를 기록했나요?",
],
["사건 권한 갱신", "미완료 임무 인계"],
"직책만으로 승인 권한을 부여하지 않습니다.",
),
card(
"E-03",
"명령 취소·대체",
"지휘",
["취소", "대체"],
"취소 기록과 전달을 구분합니다.",
["명령 ID가 있나요?", "대상이 확인했나요?", "진행 임무를 확인했나요?"],
["버전·수신 상태 확인", "미수신 대상 재확인"],
"화면 삭제만으로 취소 완료를 간주하지 않습니다.",
),
card(
"E-04",
"잔여 위험·재평가",
"지휘",
["잔여", "재발화"],
"감소 이후 최신 정보를 점검합니다.",
["위험이 남았나요?", "관찰 시각이 최신인가요?", "미완료 임무가 있나요?"],
["관찰·이행 갱신", "종료 담당 검토"],
"화염 감소는 모든 위험 해소와 다릅니다.",
),
card(
"E-05",
"훈련 종료·복기",
"지휘",
["종료", "복기"],
"정보·결정·이행을 연결합니다.",
[
"종료 조건을 확인했나요?",
"수신·이행이 기록됐나요?",
"개선할 확인 항목이 있나요?",
],
["시간축·근거 정리", "개입·미완료 기록"],
"AI 결과를 전문가 검수 정답으로 표시하지 않습니다.",
),
];
packages/kb/index.ts
import { z } from "zod";
import { definitions } from "./definitions";
export const sources = {
classification: {
title: "소화기구 및 자동소화장치의 화재안전기술기준(NFTC 101)",
url: "https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000244810",
location: "1.7.1 정의 · 확인 판본 2024-07-25",
relation: "용어 정의 참고 · 전술 근거 아님",
},
command: {
title: "긴급구조대응활동 및 현장지휘에 관한 규칙",
url: "https://law.go.kr/lsLinkCommonInfo.do?chrClsCd=010202&lsJoLnkSeq=1022097777",
location: "제10조 · 2026-07-01 시행 표시",
relation: "지휘 제도 참고 · 카드 행동은 자체 설계",
},
drone: {
title: "소방 무인비행장치 운용규정",
url: "https://www.law.go.kr/LSW/admRulLsInfoP.do?admRulSeq=2100000260620",
location: "운용인력·임무 · 확인 판본 2025-07-01",
relation: "운용 역할 참고 · 개별 비행 허가 아님",
},
};
export const cardSchema = z.object({
id: z.string(),
title: z.string(),
category: z.string(),
tags: z.array(z.string()),
summary: z.string(),
questions: z.array(z.string()).min(3).max(5),
preparation: z.array(z.string()).min(2),
cautions: z.array(z.string()).min(1),
label: z.literal("교육용 · 비공식 요약 · 검수 전"),
review: z.object({
status: z.literal("draft"),
reviewer: z.null(),
reviewedAt: z.null(),
}),
sources: z
.array(
z.object({
title: z.string(),
url: z.url(),
location: z.string(),
relation: z.string(),
}),
)
.min(1),
authoring: z.literal(
"자체 작성 교육 질문·준비 초안. 원문 전술을 복제한 자료가 아닙니다.",
),
});
export type ManualCard = z.infer<typeof cardSchema>;
export const cards: ManualCard[] = definitions.map((d) =>
cardSchema.parse({
...d,
label: "교육용 · 비공식 요약 · 검수 전",
review: { status: "draft", reviewer: null, reviewedAt: null },
sources: [
sources[
d.category === "드론"
? "drone"
: ["일반", "유류", "전기", "주방"].includes(d.category)
? "classification"
: "command"
],
],
authoring:
"자체 작성 교육 질문·준비 초안. 원문 전술을 복제한 자료가 아닙니다.",
}),
);
export function searchCards(query: string, category?: string): ManualCard[] {
const q = query.trim().toLocaleLowerCase("ko");
return cards.filter(
(c) =>
(!category || c.category === category) &&
(!q ||
[c.id, c.title, c.summary, ...c.tags, ...c.questions]
.join(" ")
.toLocaleLowerCase("ko")
.includes(q)),
);
}
export function validateCard(input: unknown): boolean {
const result = cardSchema.safeParse(input);
if (!result.success) return false;
// 초안에는 전술 수치를 넣지 않는다. 승인 원문 계약은 후속으로 분리한다.
return ![...result.data.preparation, ...result.data.cautions].some((s) =>
/\d\s*(m|미터|L|리터|분|초|℃|도|bar|MPa)/i.test(s),
);
}
packages/library/catalog.json
{
"publisher": "중앙소방학교",
"edition": "2026 공통교재",
"documents": [
{
"id": "nfsa-2026-106805-1",
"title": "예방실무1",
"filename": "1.예방실무1.pdf",
"year": 2026,
"publisher": "소방청 · 중앙소방학교",
"publishedAt": "2026-03-17",
"verifiedAt": "2026-10-09T05:13:23.182821+00:00",
"postUrl": "https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/?boardId=bbs_0000000000000035&mode=view&cntId=106805",
"downloadUrl": "https://www.nfsa.go.kr/board/file/bbs_0000000000000035/677300/FILE_000000000026787/2026031714254154128/pdfFileDownload",
"license": "KOGL-1",
"licenseStatement": "소방청이 창작한 '2026년 공통교재 [예방실무1/예방실무2]' 저작물은 공공누리 \"제 1유형\" 조건에 따라 이용할 수 있습니다.",
"licenseUrl": "https://www.kogl.or.kr/info/license.do",
"pdfUrl": "/library/nfsa-2026-106805-1.pdf",
"textUrl": "/library/nfsa-2026-106805-1.json",
"status": "verified",
"pageCount": 486,
"sha256": "ae8ca6f0ed1f093a50fcfb1d8538c5f31a6ceeb375198e4dc0d8c93cfe007bcf",
"bytes": 19827526,
"extraction": "PDF 텍스트 추출 · 표/그림/스캔은 원문 확인"
},
{
"id": "nfsa-2026-106805-2",
"title": "예방실무2",
"filename": "2.예방실무2.pdf",
"year": 2026,
"publisher": "소방청 · 중앙소방학교",
"publishedAt": "2026-03-17",
"verifiedAt": "2026-10-09T05:13:49.750862+00:00",
"postUrl": "https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/?boardId=bbs_0000000000000035&mode=view&cntId=106805",
"downloadUrl": "https://www.nfsa.go.kr/board/file/bbs_0000000000000035/677300/FILE_000000000026788/2026031714254406278/pdfFileDownload",
"license": "KOGL-1",
"licenseStatement": "소방청이 창작한 '2026년 공통교재 [예방실무1/예방실무2]' 저작물은 공공누리 \"제 1유형\" 조건에 따라 이용할 수 있습니다.",
"licenseUrl": "https://www.kogl.or.kr/info/license.do",
"pdfUrl": "/library/nfsa-2026-106805-2.pdf",
"textUrl": "/library/nfsa-2026-106805-2.json",
"status": "verified",
"pageCount": 544,
"sha256": "20eb278a1782c170c97920b170db26840e0398801a92b5efa9cd42add91a6e78",
"bytes": 44278508,
"extraction": "PDF 텍스트 추출 · 표/그림/스캔은 원문 확인"
},
{
"id": "nfsa-2026-106806-1",
"title": "_소방법령1",
"filename": "3._소방법령1.pdf",
"year": 2026,
"publisher": "소방청 · 중앙소방학교",
"publishedAt": "2026-03-17",
"verifiedAt": "2026-10-09T05:14:29.197263+00:00",
"postUrl": "https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/?boardId=bbs_0000000000000035&mode=view&cntId=106806",
"downloadUrl": "https://www.nfsa.go.kr/board/file/bbs_0000000000000035/677301/FILE_000000000026789/202603171427144615/pdfFileDownload",
"license": "KOGL-1",
"licenseStatement": "소방청이 창작한 '2026년 공통교재 [소방법령1~5]' 저작물은 공공누리 \"제 1유형\" 조건에 따라 이용할 수 있습니다.",
"licenseUrl": "https://www.kogl.or.kr/info/license.do",
"pdfUrl": "/library/nfsa-2026-106806-1.pdf",
"textUrl": "/library/nfsa-2026-106806-1.json",
"status": "verified",
"pageCount": 374,
"sha256": "ec3a6bd24227c8f1820e3597de106f1364ae18f7d261ecb15cb7634b3a970d86",
"bytes": 3036647,
"extraction": "PDF 텍스트 추출 · 표/그림/스캔은 원문 확인"
},
{
"id": "nfsa-2026-106806-2",
"title": "소방법령2",
"filename": "4. 소방법령2.pdf",
"year": 2026,
"publisher": "소방청 · 중앙소방학교",
"publishedAt": "2026-03-17",
"verifiedAt": "2026-10-09T05:14:48.198853+00:00",
"postUrl": "https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/?boardId=bbs_0000000000000035&mode=view&cntId=106806",
"downloadUrl": "https://www.nfsa.go.kr/board/file/bbs_0000000000000035/677301/FILE_000000000026791/2026031714271555234/pdfFileDownload",
"license": "KOGL-1",
"licenseStatement": "소방청이 창작한 '2026년 공통교재 [소방법령1~5]' 저작물은 공공누리 \"제 1유형\" 조건에 따라 이용할 수 있습니다.",
"licenseUrl": "https://www.kogl.or.kr/info/license.do",
"pdfUrl": "/library/nfsa-2026-106806-2.pdf",
"textUrl": "/library/nfsa-2026-106806-2.json",
"status": "verified",
"pageCount": 656,
"sha256": "5b478e7cb5f0f97b38dc2febd2726b638354a52abff86d784350d92e3c77c634",
"bytes": 5438067,
"extraction": "PDF 텍스트 추출 · 표/그림/스캔은 원문 확인"
},
{
"id": "nfsa-2026-106806-3",
"title": "소방법령3",
"filename": "5. 소방법령3.pdf",
"year": 2026,
"publisher": "소방청 · 중앙소방학교",
"publishedAt": "2026-03-17",
"verifiedAt": "2026-10-09T05:15:25.508169+00:00",
"postUrl": "https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/?boardId=bbs_0000000000000035&mode=view&cntId=106806",
"downloadUrl": "https://www.nfsa.go.kr/board/file/bbs_0000000000000035/677301/FILE_000000000026792/2026031714271640788/pdfFileDownload",
"license": "KOGL-1",
"licenseStatement": "소방청이 창작한 '2026년 공통교재 [소방법령1~5]' 저작물은 공공누리 \"제 1유형\" 조건에 따라 이용할 수 있습니다.",
"licenseUrl": "https://www.kogl.or.kr/info/license.do",
"pdfUrl": "/library/nfsa-2026-106806-3.pdf",
"textUrl": "/library/nfsa-2026-106806-3.json",
"status": "verified",
"pageCount": 438,
"sha256": "95635440ea0b1340d6ade4c882faafcfe597879a18e07a312eb862fc936423c7",
"bytes": 12489076,
"extraction": "PDF 텍스트 추출 · 표/그림/스캔은 원문 확인"
},
{
"id": "nfsa-2026-106806-4",
"title": "소방법령4",
"filename": "6. 소방법령4.pdf",
"year": 2026,
"publisher": "소방청 · 중앙소방학교",
"publishedAt": "2026-03-17",
"verifiedAt": "2026-10-09T05:16:01.856679+00:00",
"postUrl": "https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/?boardId=bbs_0000000000000035&mode=view&cntId=106806",
"downloadUrl": "https://www.nfsa.go.kr/board/file/bbs_0000000000000035/677301/FILE_000000000026794/2026031714273070859/pdfFileDownload",
"license": "KOGL-1",
"licenseStatement": "소방청이 창작한 '2026년 공통교재 [소방법령1~5]' 저작물은 공공누리 \"제 1유형\" 조건에 따라 이용할 수 있습니다.",
"licenseUrl": "https://www.kogl.or.kr/info/license.do",
"pdfUrl": "/library/nfsa-2026-106806-4.pdf",
"textUrl": "/library/nfsa-2026-106806-4.json",
"status": "verified",
"pageCount": 434,
"sha256": "29c4102ece6d3886b782a2c17ce6c081198a5b9c7dbead8adc9c218824fef294",
"bytes": 4064389,
"extraction": "PDF 텍스트 추출 · 표/그림/스캔은 원문 확인"
},
{
"id": "nfsa-2026-106806-5",
"title": "소방법령5",
"filename": "7. 소방법령5.pdf",
"year": 2026,
"publisher": "소방청 · 중앙소방학교",
"publishedAt": "2026-03-17",
"verifiedAt": "2026-10-09T05:16:25.597277+00:00",
"postUrl": "https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/?boardId=bbs_0000000000000035&mode=view&cntId=106806",
"downloadUrl": "https://www.nfsa.go.kr/board/file/bbs_0000000000000035/677301/FILE_000000000026795/2026031714273652214/pdfFileDownload",
"license": "KOGL-1",
"licenseStatement": "소방청이 창작한 '2026년 공통교재 [소방법령1~5]' 저작물은 공공누리 \"제 1유형\" 조건에 따라 이용할 수 있습니다.",
"licenseUrl": "https://www.kogl.or.kr/info/license.do",
"pdfUrl": "/library/nfsa-2026-106806-5.pdf",
"textUrl": "/library/nfsa-2026-106806-5.json",
"status": "verified",
"pageCount": 652,
"sha256": "349d8b4155be13aa85e10b2a3856db51e95e317ac97ff0c490a4b159553747e3",
"bytes": 11455406,
"extraction": "PDF 텍스트 추출 · 표/그림/스캔은 원문 확인"
},
{
"id": "nfsa-2026-106807-1",
"title": "소방차량장비실무-저용량",
"filename": "8. 소방차량장비실무-저용량.pdf",
"year": 2026,
"publisher": "소방청 · 중앙소방학교",
"publishedAt": "2026-03-17",
"verifiedAt": "2026-10-09T05:16:59.876517+00:00",
"postUrl": "https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/?boardId=bbs_0000000000000035&mode=view&cntId=106807",
"downloadUrl": "https://www.nfsa.go.kr/board/file/bbs_0000000000000035/106807/FILE_000000000026804/2026031719271403160/pdfFileDownload",
"license": "KOGL-1",
"licenseStatement": "소방청이 창작한 '2026년 공통교재 [소방차량장비실무]' 저작물은 공공누리 \"제 1유형\" 조건에 따라 이용할 수 있습니다.",
"licenseUrl": "https://www.kogl.or.kr/info/license.do",
"pdfUrl": "/library/nfsa-2026-106807-1.pdf",
"textUrl": "/library/nfsa-2026-106807-1.json",
"status": "verified",
"pageCount": 258,
"sha256": "b67b5fa6d7e9fdf1dc676e4c913ac0bdc2e7c88063913d59296b50f95a83323e",
"bytes": 22700039,
"extraction": "PDF 텍스트 추출 · 표/그림/스캔은 원문 확인"
},
{
"id": "nfsa-2026-106808-1",
"title": "행정실무",
"filename": "9. 행정실무.pdf",
"year": 2026,
"publisher": "소방청 · 중앙소방학교",
"publishedAt": "2026-03-17",
"verifiedAt": "2026-10-09T05:17:14.193762+00:00",
"postUrl": "https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/?boardId=bbs_0000000000000035&mode=view&cntId=106808",
"downloadUrl": "https://www.nfsa.go.kr/board/file/bbs_0000000000000035/677305/FILE_000000000026797/2026031714324050823/pdfFileDownload",
"license": "KOGL-1",
"licenseStatement": "소방청이 창작한 '2026년 공통교재 [행정실무]' 저작물은 공공누리 \"제 1유형\" 조건에 따라 이용할 수 있습니다.",
"licenseUrl": "https://www.kogl.or.kr/info/license.do",
"pdfUrl": "/library/nfsa-2026-106808-1.pdf",
"textUrl": "/library/nfsa-2026-106808-1.json",
"status": "verified",
"pageCount": 444,
"sha256": "652b7818bac2521b12b8ffddd1b85e3fe576fea1a9d971316f75aff6b26dbbb2",
"bytes": 5710673,
"extraction": "PDF 텍스트 추출 · 표/그림/스캔은 원문 확인"
},
{
"id": "nfsa-2026-106809-1",
"title": "소방전술1(화재2)",
"filename": "11. 소방전술1(화재2).pdf",
"year": 2026,
"publisher": "소방청 · 중앙소방학교",
"publishedAt": "2026-03-17",
"verifiedAt": "2026-10-09T05:17:36.088319+00:00",
"postUrl": "https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/?boardId=bbs_0000000000000035&mode=view&cntId=106809",
"downloadUrl": "https://www.nfsa.go.kr/board/file/bbs_0000000000000035/677306/FILE_000000000026798/2026031714344462168/pdfFileDownload",
"license": "KOGL-1",
"licenseStatement": "소방청이 창작한 '2026년 공통교재 [소방전술1]' 저작물은 공공누리 \"제 1유형\" 조건에 따라 이용할 수 있습니다.",
"licenseUrl": "https://www.kogl.or.kr/info/license.do",
"pdfUrl": "/library/nfsa-2026-106809-1.pdf",
"textUrl": "/library/nfsa-2026-106809-1.json",
"status": "verified",
"pageCount": 370,
"sha256": "3519543c5934478745698cf729330e6a2325f328494e26c875c203f2c7768383",
"bytes": 38517915,
"extraction": "PDF 텍스트 추출 · 표/그림/스캔은 원문 확인"
},
{
"id": "nfsa-2026-106809-2",
"title": "소방전술1(화재1)",
"filename": "10. 소방전술1(화재1).pdf",
"year": 2026,
"publisher": "소방청 · 중앙소방학교",
"publishedAt": "2026-03-17",
"verifiedAt": "2026-10-09T05:18:02.537724+00:00",
"postUrl": "https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/?boardId=bbs_0000000000000035&mode=view&cntId=106809",
"downloadUrl": "https://www.nfsa.go.kr/board/file/bbs_0000000000000035/677306/FILE_000000000026799/2026031714344588990/pdfFileDownload",
"license": "KOGL-1",
"licenseStatement": "소방청이 창작한 '2026년 공통교재 [소방전술1]' 저작물은 공공누리 \"제 1유형\" 조건에 따라 이용할 수 있습니다.",
"licenseUrl": "https://www.kogl.or.kr/info/license.do",
"pdfUrl": "/library/nfsa-2026-106809-2.pdf",
"textUrl": "/library/nfsa-2026-106809-2.json",
"status": "verified",
"pageCount": 662,
"sha256": "0be262d510b34fd2de7ba06087c8885d6ce01d4f2a8bc8eaac5ffc4a38f728fc",
"bytes": 54294554,
"extraction": "PDF 텍스트 추출 · 표/그림/스캔은 원문 확인"
},
{
"id": "nfsa-2026-106810-1",
"title": "소방전술2(구조)-저용량",
"filename": "12. 소방전술2(구조)-저용량.pdf",
"year": 2026,
"publisher": "소방청 · 중앙소방학교",
"publishedAt": "2026-03-17",
"verifiedAt": "2026-10-09T05:18:50.881293+00:00",
"postUrl": "https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/?boardId=bbs_0000000000000035&mode=view&cntId=106810",
"downloadUrl": "https://www.nfsa.go.kr/board/file/bbs_0000000000000035/106810/FILE_000000000026803/2026031719264598783/pdfFileDownload",
"license": "KOGL-1",
"licenseStatement": "소방청이 창작한 '2026년 공통교재 [소방전술2]' 저작물은 공공누리 \"제 1유형\" 조건에 따라 이용할 수 있습니다.",
"licenseUrl": "https://www.kogl.or.kr/info/license.do",
"pdfUrl": "/library/nfsa-2026-106810-1.pdf",
"textUrl": "/library/nfsa-2026-106810-1.json",
"status": "verified",
"pageCount": 525,
"sha256": "e02f9d3f7a07da6cd62cc1296a885ccb413819accf703fac1417149977d1f918",
"bytes": 10779691,
"extraction": "PDF 텍스트 추출 · 표/그림/스캔은 원문 확인"
},
{
"id": "nfsa-2026-106811-1",
"title": "소방전술3(구급)-저용량",
"filename": "13. 소방전술3(구급)-저용량.pdf",
"year": 2026,
"publisher": "소방청 · 중앙소방학교",
"publishedAt": "2026-03-17",
"verifiedAt": "2026-10-09T05:19:17.754469+00:00",
"postUrl": "https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/?boardId=bbs_0000000000000035&mode=view&cntId=106811",
"downloadUrl": "https://www.nfsa.go.kr/board/file/bbs_0000000000000035/106811/FILE_000000000026802/2026031719261801627/pdfFileDownload",
"license": "KOGL-1",
"licenseStatement": "소방청이 창작한 '2026년 공통교재 [소방전술3]' 저작물은 공공누리 \"제 1유형\" 조건에 따라 이용할 수 있습니다.",
"licenseUrl": "https://www.kogl.or.kr/info/license.do",
"pdfUrl": "/library/nfsa-2026-106811-1.pdf",
"textUrl": "/library/nfsa-2026-106811-1.json",
"status": "verified",
"pageCount": 533,
"sha256": "ea37ee944e692942d82746ba52eac64c33c2ea5e7f57b2b16a6113c5287d0cd4",
"bytes": 11319088,
"extraction": "PDF 텍스트 추출 · 표/그림/스캔은 원문 확인"
},
{
"id": "nfsa-2026-106812-1",
"title": "화재대응능력2급-저용량",
"filename": "14. 화재대응능력2급-저용량.pdf",
"year": 2026,
"publisher": "소방청 · 중앙소방학교",
"publishedAt": "2026-03-17",
"verifiedAt": "2026-10-09T05:19:46.904415+00:00",
"postUrl": "https://www.nfsa.go.kr/nfsa/releaseinformation/archive/materials/?boardId=bbs_0000000000000035&mode=view&cntId=106812",
"downloadUrl": "https://www.nfsa.go.kr/board/file/bbs_0000000000000035/106812/FILE_000000000026801/2026031719254517892/pdfFileDownload",
"license": "KOGL-1",
"licenseStatement": "소방청이 창작한 '2026년 공통교재 [화재대응능력2급]' 저작물은 공공누리 \"제 1유형\" 조건에 따라 이용할 수 있습니다.",
"licenseUrl": "https://www.kogl.or.kr/info/license.do",
"pdfUrl": "/library/nfsa-2026-106812-1.pdf",
"textUrl": "/library/nfsa-2026-106812-1.json",
"status": "verified",
"pageCount": 423,
"sha256": "93de57e1076105a9c78c58ef8d1386e20f4218adcca151f685e9dfdc64215025",
"bytes": 14161033,
"extraction": "PDF 텍스트 추출 · 표/그림/스캔은 원문 확인"
}
]
}
packages/library/html.ts
import type { LibraryDocument } from "./index";
export type LibraryPage = { page: number; text: string };
export const escapeHtml = (text: string) =>
text.replace(
/[&<>"']/g,
(c) =>
({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[
c
]!,
);
export function renderLibraryHtml(doc: LibraryDocument, pages: LibraryPage[]) {
const e = escapeHtml;
return `<!doctype html><html lang="ko"><meta charset="utf-8"><meta name="viewport" content="width=device-width, initial-scale=1"><title>${e(doc.title)}</title><style>body{max-width:960px;margin:0 auto;padding:24px;font-family:system-ui,sans-serif;color:#19303c;line-height:1.8}nav{display:flex;flex-wrap:wrap;gap:12px}a{color:#237d72}article{border-top:1px solid #d8e0e5;margin-top:32px;scroll-margin-top:20px}pre{white-space:pre-wrap;overflow-wrap:anywhere;font:inherit}small{overflow-wrap:anywhere}@media print{nav{display:none}article{break-before:page}}</style><body><h1>${e(doc.title)}</h1><p>${e(doc.publisher)} · ${doc.year}년 · ${e(doc.licenseStatement)}</p><p>페이지별 추출 본문입니다. 그림·표 배치·스캔은 PDF 원문을 확인하세요. 검색은 브라우저의 찾기(Ctrl+F)를 사용합니다.</p><nav aria-label="페이지 목록">${pages.map((p) => `<a href="#page-${p.page}">${p.page}페이지</a>`).join(" ")}</nav>${pages.map((p) => `<article id="page-${p.page}"><h2>${p.page}페이지 / ${doc.pageCount}</h2><nav>${p.page > 1 ? `<a href="#page-${p.page - 1}">이전 페이지</a>` : ""}${p.page < doc.pageCount ? `<a href="#page-${p.page + 1}">다음 페이지</a>` : ""}</nav><pre>${e(p.text || "이 페이지에 추출 가능한 텍스트가 없습니다. PDF 원문을 확인하세요.")}</pre></article>`).join("")}<footer><small>출처: ${e(doc.postUrl)}<br>원문 SHA-256: ${e(doc.sha256)}</small></footer></body></html>`;
}
packages/library/index.ts
import { readFileSync } from "node:fs";
import { join } from "node:path";
import data from "./catalog.json";
import { DomainError } from "../core/types";
export const catalog = data;
export type LibraryDocument = (typeof data.documents)[number] & {
source?: "uploaded";
htmlUrl?: string;
};
export type SearchResult = {
documentId: string;
title: string;
page: number;
snippet: string;
pdfUrl: string;
postUrl: string;
};
const cache = new Map<string, { page: number; text: string }[]>();
export function documentById(id: string) {
return catalog.documents.find((d) => d.id === id);
}
function pages(id: string) {
const doc = documentById(id);
if (!doc || doc.status !== "verified")
throw new DomainError("교재를 찾을 수 없습니다.", 404);
if (!cache.has(id)) {
const root = process.cwd().replace(/[\\/]apps[\\/]web$/, "");
const index = JSON.parse(
readFileSync(
join(root, "apps/web/public/library", `${doc.id}.json`),
"utf8",
),
) as { sha256: string; pages: { page: number; text: string }[] };
if (index.sha256 !== doc.sha256 || index.pages.length !== doc.pageCount)
throw new Error("교재 인덱스 불일치");
cache.set(id, index.pages);
}
return cache.get(id)!;
}
export function pageText(id: string, page: number) {
const doc = documentById(id);
if (!doc || !Number.isInteger(page) || page < 1 || page > doc.pageCount)
throw new DomainError("문서와 PDF 페이지를 확인하세요.", 400);
return pages(id)[page - 1].text;
}
export function searchPages(
query: string,
documentId?: string,
): SearchResult[] {
const q = query.trim();
if (q.length < 2 || q.length > 80)
throw new DomainError("검색어는 2~80자로 입력하세요.", 400);
if (documentId && !documentById(documentId))
throw new DomainError("문서를 찾을 수 없습니다.", 404);
const terms = q.toLocaleLowerCase().split(/\s+/),
results: SearchResult[] = [];
for (const doc of catalog.documents.filter(
(d) => !documentId || d.id === documentId,
)) {
for (const page of pages(doc.id)) {
const lower = page.text.toLocaleLowerCase();
if (terms.every((t) => lower.includes(t))) {
const start = Math.max(0, lower.indexOf(terms[0]) - 70);
results.push({
documentId: doc.id,
title: doc.title,
page: page.page,
snippet: page.text.slice(start, start + 240).replace(/\s+/g, " "),
pdfUrl: `${doc.pdfUrl}#page=${page.page}`,
postUrl: doc.postUrl,
});
if (results.length >= 50) return results;
}
}
}
return results;
}
export function explanation(doc: LibraryDocument) {
const topic = doc.title.includes("법령")
? "법령 인용 시 시행일·개정 여부와 국가법령정보센터 현행 조문을 대조하세요. 2026 교재 발행 이후 개정 사항이 있을 수 있습니다."
: doc.title.includes("전술") || doc.title.includes("대응")
? "훈련 목적의 개념·절차를 공부하는 자료입니다. 현장 적용 시 담당 지휘관과 기관의 승인된 절차·자원·현장 보고를 확인하세요."
: doc.title.includes("차량")
? "장비의 구성과 점검 항목을 찾는 학습 자료입니다. 실제 보유 장비 모델의 제작사 지침과 기관 점검 기준을 함께 확인하세요."
: "신임교육 학습 자료입니다. 해당 장의 정의·체계·예시를 원문에서 확인하고 기관 업무에 맞는 적용성을 검토하세요.";
return `자체 작성 해설 · 소방 전문가 검수 전\n${topic}\n검색 텍스트는 원문을 찾기 위한 보조 기능입니다. 그림·표·수치·주의 사항은 원문 PDF에서 확인하세요. PDF 페이지와 책에 인쇄된 쪽수가 다를 수 있습니다. 원문을 읽은 뒤 문서명·판본·페이지·검토자를 기록하세요.`;
}
packages/library/store.ts
import {
mkdirSync,
readFileSync,
writeFileSync,
unlinkSync,
existsSync,
} from "node:fs";
import { join } from "node:path";
import { createHash, randomUUID } from "node:crypto";
import { execFile } from "node:child_process";
import { promisify } from "node:util";
import { z } from "zod";
import { StationDatabase } from "../station/database";
import { StationAuth, type User } from "../station/auth";
import { DomainError } from "../core/types";
import {
catalog,
documentById,
pageText,
type LibraryDocument,
type SearchResult,
} from "./index";
import { renderLibraryHtml, type LibraryPage } from "./html";
export const MAX_PDF_BYTES = 64 * 1024 * 1024;
let converting = false;
const execute = promisify(execFile);
const metadata = z
.object({
title: z.string().trim().min(1).max(120),
publisher: z.string().trim().min(1).max(100),
licenseStatement: z.string().trim().min(1).max(500),
filename: z
.string()
.max(200)
.regex(/\.pdf$/i),
})
.strict();
export class LibraryStore {
private auth: StationAuth;
constructor(
private db: StationDatabase,
readonly directory: string,
) {
this.auth = new StationAuth(db);
db.sql
.exec(`CREATE TABLE IF NOT EXISTS library_documents(id TEXT PRIMARY KEY,station_id TEXT NOT NULL REFERENCES stations(id),body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS library_orders(station_id TEXT PRIMARY KEY REFERENCES stations(id),version INTEGER NOT NULL,ids TEXT NOT NULL);`);
}
list(user: User | null) {
if (user)
this.auth.require(user, [
"admin",
"commander",
"dispatcher",
"crew",
"auditor",
]);
const added = user
? this.db.sql
.prepare(
"SELECT body FROM library_documents WHERE station_id=? ORDER BY rowid",
)
.all(user.stationId)
.map((r) => JSON.parse(r.body as string) as LibraryDocument)
: [];
const documents: LibraryDocument[] = [...catalog.documents, ...added];
const order = user
? (this.db.sql
.prepare("SELECT version,ids FROM library_orders WHERE station_id=?")
.get(user.stationId) as { version: number; ids: string } | undefined)
: undefined;
const ids: string[] = order ? JSON.parse(order.ids) : [];
const byId = new Map(documents.map((d) => [d.id, d]));
return {
documents: [
...ids
.map((id) => byId.get(id))
.filter((d): d is LibraryDocument => !!d),
...documents.filter((d) => !ids.includes(d.id)),
],
version: order?.version ?? 0,
};
}
document(id: string, user: User | null) {
const official = documentById(id);
if (official) return official as LibraryDocument;
if (!user) throw new DomainError("기관 자료는 로그인 후 확인하세요.", 401);
this.auth.require(user, [
"admin",
"commander",
"dispatcher",
"crew",
"auditor",
]);
const row = this.db.sql
.prepare("SELECT body FROM library_documents WHERE id=? AND station_id=?")
.get(id, user.stationId);
if (!row) throw new DomainError("교재를 찾을 수 없습니다.", 404);
return JSON.parse(row.body as string) as LibraryDocument;
}
private pages(doc: LibraryDocument): LibraryPage[] {
if (doc.source !== "uploaded") {
const root = process.cwd().replace(/[\\/]apps[\\/]web$/, "");
return JSON.parse(
readFileSync(
join(root, "apps/web/public/library", doc.id + ".json"),
"utf8",
),
).pages;
}
return JSON.parse(
readFileSync(join(this.directory, doc.id + ".json"), "utf8"),
);
}
page(id: string, page: number, user: User | null) {
const doc = this.document(id, user);
if (!Number.isInteger(page) || page < 1 || page > doc.pageCount)
throw new DomainError("1~전체 페이지 사이의 정수를 입력하세요.", 400);
return doc.source === "uploaded"
? this.pages(doc)[page - 1].text
: pageText(id, page);
}
search(
query: string,
id: string | undefined,
user: User | null,
): SearchResult[] {
const q = query.trim();
if (q.length < 2 || q.length > 80)
throw new DomainError("검색어는 2~80자로 입력하세요.", 400);
const documents = id
? [this.document(id, user)]
: this.list(user).documents;
const terms = q.toLocaleLowerCase().split(/\s+/),
results: SearchResult[] = [];
for (const doc of documents)
for (const page of this.pages(doc)) {
const lower = page.text.toLocaleLowerCase();
if (terms.every((t) => lower.includes(t))) {
const start = Math.max(0, lower.indexOf(terms[0]) - 70);
results.push({
documentId: doc.id,
title: doc.title,
page: page.page,
snippet: page.text.slice(start, start + 240).replace(/\s+/g, " "),
pdfUrl: `${doc.pdfUrl}#page=${page.page}`,
postUrl: doc.postUrl,
});
if (results.length >= 50) return results;
}
}
return results;
}
reorder(user: User, ids: string[], version: number) {
this.auth.require(user, ["admin"]);
return this.db.transaction(() => {
const current = this.list(user);
if (current.version !== version)
throw new DomainError(
"목록이 변경됐습니다. 다시 불러온 뒤 이동하세요.",
409,
);
if (
ids.length !== current.documents.length ||
new Set(ids).size !== ids.length ||
ids.some((id) => !current.documents.some((d) => d.id === id))
)
throw new DomainError("전체 교재 목록을 중복 없이 지정하세요.", 400);
this.db.sql
.prepare("INSERT OR REPLACE INTO library_orders VALUES(?,?,?)")
.run(user.stationId, version + 1, JSON.stringify(ids));
this.db.audit(user.stationId, user, "library", "reorder", { ids });
return this.list(user);
});
}
async upload(user: User, input: unknown, bytes: Buffer) {
this.auth.require(user, ["admin"]);
const data = metadata.parse(input);
if (bytes.length > MAX_PDF_BYTES)
throw new DomainError("PDF는64MiB 이하로 올리세요.", 413);
if (!bytes.subarray(0, 1024).includes(Buffer.from("%PDF-")))
throw new DomainError("PDF 원문 파일을 선택하세요.", 400);
if (converting)
throw new DomainError(
"다른 PDF를 변환 중입니다. 잠시 후 다시 올리세요.",
503,
);
const quota = () => {
const docs = this.list(user).documents.filter(
(d) => d.source === "uploaded",
);
if (
docs.length >= 30 ||
docs.reduce((n, d) => n + d.bytes, 0) + bytes.length > 512 * 1024 * 1024
)
throw new DomainError(
"기관 업로드 한도(30개/512MiB)를 초과했습니다.",
413,
);
};
quota();
converting = true;
const id = "upload-" + randomUUID(),
pdf = join(this.directory, id + ".pdf"),
index = join(this.directory, id + ".json"),
html = join(this.directory, id + ".html");
let committed = false;
try {
mkdirSync(this.directory, { recursive: true, mode: 0o700 });
writeFileSync(pdf, bytes, { mode: 0o600, flag: "wx" });
const root = process.cwd().replace(/[\\/]apps[\\/]web$/, "");
try {
await execute(
process.execPath,
[
"--max-old-space-size=384",
join(root, "scripts/extract-library-pdf.mjs"),
pdf,
index,
],
{ timeout: 60000, maxBuffer: 128 * 1024, windowsHide: true },
);
} catch {
throw new DomainError(
"PDF를 변환하지 못했습니다. 손상·암호·1500페이지/변환 시간 상한을 확인하세요.",
422,
);
}
const pages = JSON.parse(readFileSync(index, "utf8")) as LibraryPage[];
const doc: LibraryDocument = {
...data,
id,
source: "uploaded",
year: new Date().getFullYear(),
publishedAt: new Date().toISOString().slice(0, 10),
verifiedAt: new Date().toISOString(),
postUrl: "기관 내부 업로드",
downloadUrl: `/api/library/files/${id}?format=pdf`,
license: "기관 내부 이용 조건",
licenseUrl: "",
pdfUrl: `/api/library/files/${id}?format=pdf`,
htmlUrl: `/api/library/files/${id}?format=html`,
textUrl: "",
status: "uploaded",
pageCount: pages.length,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
extraction: "PDF 텍스트 추출 · 그림/표/스캔은 원문 확인",
};
writeFileSync(html, renderLibraryHtml(doc, pages), {
mode: 0o600,
flag: "wx",
});
// 비동기 변환 중 계정 회수나 순서 변경이 있었으면 최신 권한/목록을 확인한다.
this.auth.require(user, ["admin"]);
this.db.transaction(() => {
quota();
const current = this.list(user);
this.db.sql
.prepare("INSERT INTO library_documents VALUES(?,?,?)")
.run(id, user.stationId, JSON.stringify(doc));
this.db.sql
.prepare("INSERT OR REPLACE INTO library_orders VALUES(?,?,?)")
.run(
user.stationId,
current.version + 1,
JSON.stringify([...current.documents.map((d) => d.id), id]),
);
this.db.audit(user.stationId, user, "library", "upload", {
id,
title: doc.title,
sha256: doc.sha256,
pageCount: doc.pageCount,
});
});
committed = true;
return doc;
} finally {
converting = false;
if (!committed)
for (const file of [pdf, index, html])
if (existsSync(/* turbopackIgnore: true */ file)) unlinkSync(file);
}
}
file(id: string, format: "html" | "pdf", user: User | null) {
const doc = this.document(id, user);
if (doc.source === "uploaded")
// 업로드는 런타임 영구 저장소에서 읽고 배포 추적에 포함하지 않는다.
return readFileSync(
/* turbopackIgnore: true */ join(
/* turbopackIgnore: true */ this.directory,
id + "." + format,
),
);
const root = process.cwd().replace(/[\\/]apps[\\/]web$/, "");
return format === "pdf"
? readFileSync(join(root, "apps/web/public/library", id + ".pdf"))
: Buffer.from(renderLibraryHtml(doc, this.pages(doc)));
}
}
packages/simulation/audio-recordings.json
{
"provenance": "자체 작성 교육 대사 / Windows System.Speech 한국어 합성. 효과음은 자체 수식 생성. 실제 신고·무전 녹음 아님.",
"recordings": [
{
"role": "교육 안내",
"text": "가상 화재 훈련입니다. 신고 전화를 접수합니다.",
"file": "/audio/demo/6ddc1223e5e94bc8.wav",
"sha256": "b44c9b1e97683fdf4c6632fdc4c69aab2d1b5278bc7baae4070485fb71910f51",
"bytes": 182582,
"duration": 4.139138321995465
},
{
"role": "접수요원",
"text": "119 상황실입니다. 위치와 보이는 상황을 말씀해 주세요.",
"file": "/audio/demo/a1e4846d48a098b6.wav",
"sha256": "3dd7ff3e1f9945c5ef528a3d8e14883f355bb1b92f5e87ee58637f4a4dc84cdb",
"bytes": 207418,
"duration": 4.702312925170068
},
{
"role": "신고자",
"text": "가상시 중앙로 24 · 중앙식당입니다. 주방에서 연기가 나고 가스 냄새가 나요. 안에 사람이 있는지는 모르겠어요.",
"file": "/audio/demo/6103e71e612416a3.wav",
"sha256": "6adee8257739c791397b8812226a3a9f02e4458ff2f0c7a27d1a417c5c4a11ea",
"bytes": 374230,
"duration": 8.484897959183673
},
{
"role": "접수요원",
"text": "신고 접수했습니다. 출동대를 보내겠습니다. 안전한 곳에서 기다려 주세요.",
"file": "/audio/demo/d3152d726d5bbb5d.wav",
"sha256": "2885652a9b62c559c09903260efbb021593589fe65d52fc4117aaa1ee6cda5f7",
"bytes": 277958,
"duration": 6.301859410430839
},
{
"role": "지휘본부",
"text": "지휘본부. 펌프1 · 구조1. 신고 위치·인명 미확인을 공유하고 가상 출동·장비 준비를 확인합니다.",
"file": "/audio/demo/ba5164500646c930.wav",
"sha256": "bcc055c13b222edf891c507de100cb57803488b962df6a479277e920514d9556",
"bytes": 336858,
"duration": 7.6374603174603175
},
{
"role": "대원",
"text": "펌프1, 구조1. 출동 지령 수신했습니다. 신고 현장으로 이동합니다.",
"file": "/audio/demo/f2d3f954eaf5326a.wav",
"sha256": "a597856eeb17de09d1988004c78ac4071f888f286cabe2dcf904c5b1cc4322f4",
"bytes": 277842,
"duration": 6.299229024943311
},
{
"role": "지휘본부",
"text": "지휘본부. 드론팀. 가상 운용 조건을 확인하고 외부 관찰 임무를 시작합니다.",
"file": "/audio/demo/7ea14a1b04ceb4a1.wav",
"sha256": "f91557d79d4ba53c1943746aa12d1c6e0e9eb916b28f32557449ea785357ac76",
"bytes": 283284,
"duration": 6.422630385487528
},
{
"role": "지휘본부",
"text": "지휘본부. 선착대. 내부 인명·물질·통전·공급 상태를 확인하고 출처와 시각을 보고합니다.",
"file": "/audio/demo/b019d7c291cd361a.wav",
"sha256": "36a85f7461116635081ea2bf8c5a452adb2e35609f70d25d47ce79ef5bfee001",
"bytes": 326098,
"duration": 7.393469387755102
},
{
"role": "드론팀",
"text": "드론팀 보고. 신고 위치에 도착했습니다. 외부를 관찰합니다. 내부 인원과 가스 상태는 미확인입니다.",
"file": "/audio/demo/8ffc21d6b6e22b7d.wav",
"sha256": "d9c806431cb47243cda1dfb56b501cffb04bd1ee003476b1e0f77b4e80c44c74",
"bytes": 366574,
"duration": 8.311292517006803
},
{
"role": "지휘본부",
"text": "지휘본부. 펌프1 · 구조1. 교육용 확인 목록과 준비 상태를 검토하고 가상 대응 임무를 수행·보고합니다.",
"file": "/audio/demo/b8f0ae15dc1bb894.wav",
"sha256": "6c8860b59988a7b0b1e5875c8f59ace1e8db43949b276f98cdd74ff159536d27",
"bytes": 349272,
"duration": 7.9189569160997735
},
{
"role": "대원",
"text": "펌프1, 구조1 현장 도착. 현장 확인과 지휘권 공유를 시작합니다.",
"file": "/audio/demo/9135197877f5a3a1.wav",
"sha256": "f86c0497f167696978ce5c27d01788194878199bbbc79ba610e94b7363b7b0b2",
"bytes": 261990,
"duration": 5.939773242630386
},
{
"role": "지휘본부",
"text": "지휘본부. 상황실. 관계기관 확인과 부족 자원 지원을 가상 요청하고 접수 상태를 기록합니다.",
"file": "/audio/demo/22bf63c0fb55c529.wav",
"sha256": "1541fb0155ab18aa5a2b7b68b927927163e468c38c1ad25defe1b276c27ad738",
"bytes": 335734,
"duration": 7.611972789115646
},
{
"role": "지휘본부",
"text": "지휘본부. 선착대. 가상 화재 감소 후 잔여 위험과 미완료 임무를 확인합니다.",
"file": "/audio/demo/adc9070e1f331459.wav",
"sha256": "f9dc8ac7aa46d41832a0febc6ab988624e05b386aca06a414ad089d4450c27c8",
"bytes": 289620,
"duration": 6.566303854875283
},
{
"role": "대원",
"text": "현장 보고. 가상 화재가 감소했습니다. 잔여 위험을 계속 확인합니다.",
"file": "/audio/demo/a277f6c35c3c2ba6.wav",
"sha256": "4b39f1dafeb4463797b9abab537f6d08e77ee721626b7fb529dbea0be818bcea",
"bytes": 263546,
"duration": 5.975056689342404
},
{
"role": "지휘본부",
"text": "지휘본부. 지휘관. 가상 인명·잔여 위험 확인 보고와 임무 이행 기록을 모아 훈련 종료를 준비합니다.",
"file": "/audio/demo/ba3c70560a757a67.wav",
"sha256": "a9f23ba42466206d30615faeb9f8b610a8fe432d64bc6026722f39f8d88f2d2a",
"bytes": 343178,
"duration": 7.780770975056689
},
{
"role": "교육 안내",
"text": "가상 훈련이 종료됐습니다. 사건 타임라인에서 판단과 명령을 복기하세요.",
"file": "/audio/demo/b547c282c56f3e7f.wav",
"sha256": "d849f999e2059b671a60ca6b9ca6da5ea5c77d8274289a5e9229898924359808",
"bytes": 257890,
"duration": 5.8468027210884355
},
{
"role": "신고자",
"text": "가상시 산업로 18 · 제일창고입니다. 창고에서 연기가 나요. 유류 용기가 있다고 들었는데 물질 이름은 모르겠어요.",
"file": "/audio/demo/12ed221c9e8bfd0c.wav",
"sha256": "42783b1d97d9c690834d71ca8e35cbd43903660a4ab889b2ccfe467ab6ef287d",
"bytes": 373016,
"duration": 8.457369614512471
},
{
"role": "신고자",
"text": "가상시 소망길 7 · 다가구주택입니다. 집에서 연기가 보여요. 안에 사람이 있는지, 출입구가 안전한지는 모르겠어요.",
"file": "/audio/demo/7c4bbb27b82073db.wav",
"sha256": "780afbc2719b886f41c7ab64932a9a3d75554ccf79bfb5755a114bd04030b848",
"bytes": 384546,
"duration": 8.718820861678005
}
],
"effects": {
"telephone": {
"file": "/audio/demo/telephone.wav",
"sha256": "bd8dc1fb1d8b07077a21a9b3965a59f01670344c708179f203c1b149c737a20d",
"bytes": 52964,
"duration": 1.2
},
"radio": {
"file": "/audio/demo/radio.wav",
"sha256": "fc4daa2eb9678e76681e9fa8420c0cb3e57b897ccb5fbe0b5e448390c959fd02",
"bytes": 7982,
"duration": 0.18
},
"siren": {
"file": "/audio/demo/siren.wav",
"sha256": "85a23641b4e4534a3ab04afba198f395e8a50b1f2645263d526926b85a8b6448",
"bytes": 352844,
"duration": 8
}
}
}
packages/simulation/demo-audio.ts
import type { PublicScenario } from "../core/scenario";
import type { PublicState } from "../core/types";
export type AudioCue = {
id: string;
role:
| "교육 안내"
| "전화벨"
| "접수요원"
| "신고자"
| "지휘본부"
| "대원"
| "드론팀";
text: string;
at: number;
effect?: "telephone";
commandId?: string;
};
export function introCues(scenario: PublicScenario): AudioCue[] {
const caller =
{
"restaurant-gas":
"주방에서 연기가 나고 가스 냄새가 나요. 안에 사람이 있는지는 모르겠어요.",
"warehouse-oil":
"창고에서 연기가 나요. 유류 용기가 있다고 들었는데 물질 이름은 모르겠어요.",
"house-rescue":
"집에서 연기가 보여요. 안에 사람이 있는지, 출입구가 안전한지는 모르겠어요.",
}[scenario.id] ?? scenario.briefing;
return [
{
id: "intro:notice",
role: "교육 안내",
text: "가상 화재 훈련입니다. 신고 전화를 접수합니다.",
at: 0,
},
{
id: "intro:ring",
role: "전화벨",
text: "신고 전화 연결",
effect: "telephone",
at: 0,
},
{
id: "intro:operator",
role: "접수요원",
text: "119 상황실입니다. 위치와 보이는 상황을 말씀해 주세요.",
at: 0,
},
{
id: `intro:caller:${scenario.id}`,
role: "신고자",
text: `${scenario.address}입니다. ${caller}`,
at: 0,
},
{
id: "intro:received",
role: "접수요원",
text: "신고 접수했습니다. 출동대를 보내겠습니다. 안전한 곳에서 기다려 주세요.",
at: 0,
},
];
}
// 명령과 관찰은 공개된 뒤에만 음성으로 투영한다. 초안·취소는 방송하지 않는다.
export function audioEvents(state: PublicState): AudioCue[] {
if (state.mode !== "DEMO") return [];
const result: AudioCue[] = [];
for (const command of state.commands) {
if (command.issuedAt === null || command.status === "cancelled") continue;
result.push({
id: `order:${command.id}`,
role: "지휘본부",
text: `지휘본부. ${command.target}. ${command.text}`,
at: command.issuedAt,
commandId: command.id,
});
if (command.actionKey === "DISPATCH") {
const ack = state.log.find(
(e) => e.type === "ack" && e.commandId === command.id,
);
if (ack)
result.push({
id: `ack:${command.id}`,
role: "대원",
text: "펌프1, 구조1. 출동 지령 수신했습니다. 신고 현장으로 이동합니다.",
at: ack.at,
commandId: command.id,
});
}
}
const reports: Record<string, { role: AudioCue["role"]; text: string }> = {
"drone-arrived": {
role: "드론팀",
text: "드론팀 보고. 신고 위치에 도착했습니다. 외부를 관찰합니다. 내부 인원과 가스 상태는 미확인입니다.",
},
"unit-arrived": {
role: "대원",
text: "펌프1, 구조1 현장 도착. 현장 확인과 지휘권 공유를 시작합니다.",
},
"fire-controlled": {
role: "대원",
text: "현장 보고. 가상 화재가 감소했습니다. 잔여 위험을 계속 확인합니다.",
},
};
for (const observation of state.observations) {
const report = reports[observation.key];
if (report)
result.push({
id: `observation:${observation.key}`,
...report,
at: observation.at,
});
}
if (state.status === "closed")
result.push({
id: "training-end",
role: "교육 안내",
text: "가상 훈련이 종료됐습니다. 사건 타임라인에서 판단과 명령을 복기하세요.",
at: state.elapsed,
});
return result.sort((a, b) => a.at - b.at);
}
export function sirenShouldPlay(state: PublicState) {
return (
state.mode === "DEMO" &&
!state.paused &&
state.status === "active" &&
state.units.some((unit) => unit.status === "출동 중" && unit.eta > 0)
);
}
export class DemoAudioCursor {
private sessionId = "";
private heard = new Set<string>();
attach(state: PublicState, fresh = false) {
this.sessionId = state.id;
this.heard = new Set(fresh ? [] : audioEvents(state).map((c) => c.id));
}
collect(state: PublicState): AudioCue[] {
if (this.sessionId !== state.id) {
this.attach(state);
return [];
}
if (state.paused) return [];
const next = audioEvents(state).filter((c) => !this.heard.has(c.id));
next.forEach((c) => this.heard.add(c.id));
// 고배속이나 느린 로드에서 오래된 방송이 현재 상황 뒤로 계속 밀리지 않게 한다.
return next.filter((c) =>
state.status === "closed"
? c.id === "training-end"
: state.elapsed - c.at <= 20,
);
}
}
packages/simulation/scene.ts
import { DomainError, type PublicState } from "../core/types";
import {
STATION_POSITION,
INCIDENT_POSITION,
PUMP_ROUTE,
PUMP_TRAVEL_SECONDS,
DRONE_TRAVEL_SECONDS,
pointOnRoute,
} from "../core/map-motion";
export type WorldPoint = { x: number; y: number; z: number };
export type RestaurantScene = {
protocol: "ai-firestation.restaurant.v1";
sessionId: string;
version: number;
elapsed: number;
paused: boolean;
status: "active" | "closed";
scenario: { id: "restaurant-gas"; title: string };
station: WorldPoint;
incident: WorldPoint;
pumpRoute: WorldPoint[];
pump: { position: WorldPoint; progress: number; status: string; eta: number };
drone: {
position: WorldPoint;
progress: number;
status: string;
eta: number;
};
fire: { visible: boolean; intensity: number; cells: number[] };
};
const clamp = (value: number) => Math.max(0, Math.min(1, value));
const worldPoint = ([x, y]: readonly [number, number], z = 0): WorldPoint => ({
x: (x - 360) * 20,
y: (y - 180) * 20,
z,
});
export function restaurantScene(state: PublicState): RestaurantScene {
if (state.mode !== "DEMO" || state.scenario.id !== "restaurant-gas")
throw new DomainError(
"UE 현장은 교육 시연 식당 화재 사건만 지원합니다.",
409,
);
const pump = state.units[0];
const drone = state.drone;
// 공개 ETA만 사용하므로 늦은 승인과 정지도 서버 상태에 그대로 따른다.
const pumpProgress =
pump && pump.status !== "준비"
? clamp(1 - pump.eta / PUMP_TRAVEL_SECONDS)
: 0;
const droneProgress =
drone.arrivedAt !== null
? 1
: drone.status !== "대기"
? clamp(1 - drone.eta / DRONE_TRAVEL_SECONDS)
: 0;
const cells = [...state.fireCells];
return {
protocol: "ai-firestation.restaurant.v1",
sessionId: state.id,
version: state.version,
elapsed: state.elapsed,
paused: state.paused,
status: state.status,
scenario: { id: "restaurant-gas", title: state.scenario.title },
station: worldPoint(STATION_POSITION),
incident: worldPoint(INCIDENT_POSITION),
pumpRoute: PUMP_ROUTE.map((point) => worldPoint(point)),
pump: {
position: worldPoint(pointOnRoute(PUMP_ROUTE, pumpProgress)),
progress: pumpProgress,
status: pump?.status ?? "준비",
eta: pump?.eta ?? 0,
},
drone: {
position: worldPoint(
pointOnRoute([STATION_POSITION, INCIDENT_POSITION], droneProgress),
drone.status === "대기" && drone.arrivedAt === null ? 120 : 900,
),
progress: droneProgress,
status: drone.status,
eta: drone.eta,
},
fire: {
visible: cells.length > 0,
intensity: cells.length
? clamp(cells.reduce((sum, cell) => sum + cell, 0) / cells.length / 100)
: 0,
cells,
},
};
}
packages/station/assistant.ts
import type { Incident } from "./service";
export function suggestions(i: Incident) {
const input = [i.report, ...i.observations.map((o) => o.text)].join(" ");
const rules = [
{
test: /가스|냄새|LPG|LNG/i,
risk: "가스 누출·가연성 분위기 후보",
question:
"물질·누출 여부·측정 결과·차단 책임자를 현장 보고로 확인했습니까?",
topic: "가스",
},
{
test: /기름|유류|휘발|경유/,
risk: "유류 관련 후보",
question: "물질명·보관량·누출 범위·기관 승인 대응 근거가 확인됐습니까?",
topic: "유류",
},
{
test: /전기|통전|전력|감전/,
risk: "전기 위험 후보",
question: "통전 상태와 전원 차단 권한·확인 보고가 있습니까?",
topic: "전기",
},
{
test: /배터리|리튬|충전/,
risk: "배터리 위험 후보",
question: "배터리 종류·위치·현장 변화에 관한 확인 보고가 있습니까?",
topic: "배터리",
},
{
test: /사람|인명|갇|주민|실종/,
risk: "인명 관련 정보",
question: "요구조자 정보와 확인되지 않은 구역을 구분했습니까?",
topic: "인명",
},
];
return rules
.filter((r) => r.test.test(input))
.map(({ risk, question, topic }) => ({
risk,
question,
topic,
label: "규칙 기반 확인 제안 · 현장 판단 필요",
}));
}
packages/station/auth.ts
import {
randomBytes,
randomUUID,
scryptSync,
timingSafeEqual,
createHash,
} from "node:crypto";
import { z } from "zod";
import { DomainError } from "../core/types";
import { StationDatabase } from "./database";
import { roles, type Role, type User } from "./types";
export { roles, roleNames, type Role, type User } from "./types";
const userInput = z
.object({
username: z.string().regex(/^[a-zA-Z0-9_-]{3,64}$/),
displayName: z.string().trim().min(1).max(80),
password: z.string().min(12).max(200),
role: z.enum(roles),
})
.strict();
type Row = {
id: string;
station_id: string;
username: string;
display_name: string;
role: Role;
password_hash: string;
salt: string;
active: number;
};
function publicUser(row: Row): User {
return {
id: row.id,
stationId: row.station_id,
username: row.username,
displayName: row.display_name,
role: row.role,
active: !!row.active,
};
}
const tokenHash = (token: string) =>
createHash("sha256").update(token).digest("hex");
export class StationAuth {
constructor(readonly db: StationDatabase) {}
configured() {
return !!this.db.sql.prepare("SELECT id FROM stations LIMIT 1").get();
}
issueSetupToken() {
return this.db.transaction(() => {
if (this.configured())
throw new DomainError("최초 관리자 설정이 이미 완료됐습니다.", 409);
const token = randomBytes(32).toString("hex");
this.db.sql
.prepare("INSERT OR REPLACE INTO setup_control VALUES(1,?,?)")
.run(tokenHash(token), Date.now() + 15 * 60 * 1000);
return token;
});
}
bootstrap(input: unknown) {
const data = z
.object({
stationName: z.string().trim().min(1).max(100),
username: userInput.shape.username,
displayName: userInput.shape.displayName,
password: userInput.shape.password,
setupKey: z.string().min(32).max(200),
})
.strict()
.parse(input);
return this.db.transaction(() => {
if (this.configured())
throw new DomainError("최초 관리자 설정이 이미 완료됐습니다.", 409);
const key = this.db.sql
.prepare("SELECT * FROM setup_control WHERE id=1 AND expires_at>?")
.get(Date.now()) as { token_hash: string } | undefined;
if (
!key ||
!timingSafeEqual(
Buffer.from(tokenHash(data.setupKey), "hex"),
Buffer.from(key.token_hash, "hex"),
)
)
throw new DomainError(
"설정 키가 없거나 만료됐습니다. 로컬 터미널에서 npm run setup:key를 실행하세요.",
403,
);
const stationId = randomUUID();
this.db.sql
.prepare("INSERT INTO stations VALUES(?,?)")
.run(stationId, data.stationName);
const user = this.insert(stationId, { ...data, role: "admin" });
this.db.audit(stationId, user, "account", "bootstrap", {
stationName: data.stationName,
});
this.db.sql.prepare("DELETE FROM setup_control").run();
return this.session(user);
});
}
private insert(stationId: string, input: z.infer<typeof userInput>) {
const id = randomUUID(),
salt = randomBytes(16).toString("hex"),
hash = scryptSync(input.password, salt, 64).toString("hex");
if (
this.db.sql
.prepare("SELECT id FROM users WHERE username=?")
.get(input.username)
)
throw new DomainError("이미 등록된 사용자명입니다.", 409);
this.db.sql
.prepare(
"INSERT INTO users(id,station_id,username,display_name,role,password_hash,salt) VALUES(?,?,?,?,?,?,?)",
)
.run(
id,
stationId,
input.username,
input.displayName,
input.role,
hash,
salt,
);
return {
id,
stationId,
username: input.username,
displayName: input.displayName,
role: input.role,
};
}
createUser(actor: User, input: unknown) {
this.require(actor, ["admin"]);
const data = userInput.parse(input);
return this.db.transaction(() => {
const user = this.insert(actor.stationId, data);
this.db.audit(actor.stationId, actor, "account", "create", {
id: user.id,
role: user.role,
});
return user;
});
}
updateUser(actor: User, input: unknown) {
this.require(actor, ["admin"]);
const d = z
.object({
userId: z.string().uuid(),
active: z.boolean().optional(),
role: z.enum(roles).optional(),
password: userInput.shape.password.optional(),
})
.strict()
.refine(
(d) =>
d.active !== undefined ||
d.role !== undefined ||
d.password !== undefined,
"변경 내용을 지정하세요.",
)
.parse(input);
return this.db.transaction(() => {
const row = this.db.sql
.prepare("SELECT * FROM users WHERE id=? AND station_id=?")
.get(d.userId, actor.stationId) as Row | undefined;
if (!row) throw new DomainError("기관 사용자를 찾을 수 없습니다.", 404);
const active = d.active ?? !!row.active,
role = d.role ?? row.role;
const count = this.db.sql
.prepare(
"SELECT count(*) AS n FROM users WHERE station_id=? AND role='admin' AND active=1",
)
.get(actor.stationId) as { n: number };
if (
row.active &&
row.role === "admin" &&
(!active || role !== "admin") &&
count.n <= 1
)
throw new DomainError("마지막 활성 관리자를 해제할 수 없습니다.", 409);
const salt = d.password ? randomBytes(16).toString("hex") : row.salt,
hash = d.password
? scryptSync(d.password, salt, 64).toString("hex")
: row.password_hash;
this.db.sql
.prepare(
"UPDATE users SET active=?,role=?,salt=?,password_hash=? WHERE id=?",
)
.run(active ? 1 : 0, role, salt, hash, row.id);
this.db.sql
.prepare("DELETE FROM auth_sessions WHERE user_id=?")
.run(row.id);
this.db.sql
.prepare("DELETE FROM login_attempts WHERE username=?")
.run(row.username);
this.db.audit(actor.stationId, actor, "account", "update", {
userId: row.id,
active,
role,
passwordReset: !!d.password,
previous: { active: !!row.active, role: row.role },
});
return publicUser({ ...row, active: active ? 1 : 0, role });
});
}
private session(user: User) {
const token = randomBytes(32).toString("hex");
this.db.sql
.prepare("INSERT INTO auth_sessions VALUES(?,?,?)")
.run(tokenHash(token), user.id, Date.now() + 8 * 60 * 60 * 1000);
return { user, token };
}
login(input: unknown) {
const data = z
.object({ username: z.string().max(64), password: z.string().max(200) })
.strict()
.parse(input);
const result = this.db.transaction(() => {
const attempt = this.db.sql
.prepare("SELECT * FROM login_attempts WHERE username=?")
.get(data.username) as
{ failures: number; blocked_until: number } | undefined;
if (attempt && attempt.blocked_until > Date.now())
return {
error: "로그인 시도가 제한됐습니다. 잠시 후 다시 시도하세요.",
};
const row = this.db.sql
.prepare("SELECT * FROM users WHERE username=?")
.get(data.username) as Row | undefined;
// 없는 계정에도 같은 해시 계산을 적용합니다.
const actual = scryptSync(
data.password,
row?.salt ?? "0000000000000000",
64,
),
expected = row
? Buffer.from(row.password_hash, "hex")
: Buffer.alloc(64);
if (!row || !row.active || !timingSafeEqual(actual, expected)) {
const count =
(attempt && attempt.blocked_until === 0 ? attempt.failures : 0) + 1;
this.db.sql
.prepare("INSERT OR REPLACE INTO login_attempts VALUES(?,?,?)")
.run(
data.username,
count,
count >= 5 ? Date.now() + 15 * 60 * 1000 : 0,
);
return { error: "사용자명 또는 암호를 확인하세요." };
}
this.db.sql
.prepare("DELETE FROM login_attempts WHERE username=?")
.run(data.username);
const user = publicUser(row);
this.db.audit(user.stationId, user, "account", "login", {});
return this.session(user);
});
if ("error" in result) throw new DomainError(result.error!, 429);
return result;
}
authenticate(token: string) {
const row = this.db.sql
.prepare(
"SELECT u.* FROM auth_sessions s JOIN users u ON s.user_id=u.id WHERE s.token_hash=? AND s.expires_at>? AND u.active=1",
)
.get(tokenHash(token), Date.now()) as Row | undefined;
if (!row) throw new DomainError("로그인이 필요합니다.", 401);
return publicUser(row);
}
logout(token: string) {
this.db.sql
.prepare("DELETE FROM auth_sessions WHERE token_hash=?")
.run(tokenHash(token));
}
require(actor: User, allowed: readonly Role[]) {
const row = this.db.sql
.prepare("SELECT * FROM users WHERE id=? AND active=1")
.get(actor.id) as Row | undefined;
if (
!row ||
row.station_id !== actor.stationId ||
row.role !== actor.role ||
!allowed.includes(row.role)
)
throw new DomainError("이 작업의 권한이 없습니다.", 403);
}
users(actor: User, includeInactive = false) {
this.require(actor, ["admin", "commander", "dispatcher", "auditor"]);
return (
this.db.sql
.prepare(
`SELECT * FROM users WHERE station_id=? ${includeInactive && actor.role === "admin" ? "" : "AND active=1"} ORDER BY display_name`,
)
.all(actor.stationId) as Row[]
).map(publicUser);
}
}
packages/station/database.ts
import { DatabaseSync } from "node:sqlite";
import { mkdirSync } from "node:fs";
import { dirname } from "node:path";
import { createHash } from "node:crypto";
export class StationDatabase {
readonly sql: DatabaseSync;
constructor(path: string) {
mkdirSync(dirname(path), { recursive: true });
this.sql = new DatabaseSync(path);
this.sql
.exec(`PRAGMA journal_mode=WAL; PRAGMA foreign_keys=ON; PRAGMA busy_timeout=5000;
CREATE TABLE IF NOT EXISTS stations(id TEXT PRIMARY KEY,name TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS setup_control(id INTEGER PRIMARY KEY CHECK(id=1),token_hash TEXT NOT NULL,expires_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS users(id TEXT PRIMARY KEY,station_id TEXT NOT NULL REFERENCES stations(id),username TEXT NOT NULL UNIQUE,display_name TEXT NOT NULL,role TEXT NOT NULL,password_hash TEXT NOT NULL,salt TEXT NOT NULL,active INTEGER NOT NULL DEFAULT 1);
CREATE TABLE IF NOT EXISTS auth_sessions(token_hash TEXT PRIMARY KEY,user_id TEXT NOT NULL REFERENCES users(id),expires_at INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS login_attempts(username TEXT PRIMARY KEY,failures INTEGER NOT NULL,blocked_until INTEGER NOT NULL);
CREATE TABLE IF NOT EXISTS incidents(id TEXT PRIMARY KEY,station_id TEXT NOT NULL REFERENCES stations(id),version INTEGER NOT NULL,body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS resources(id TEXT PRIMARY KEY,station_id TEXT NOT NULL REFERENCES stations(id),name TEXT NOT NULL,kind TEXT NOT NULL,crew_user_id TEXT NOT NULL REFERENCES users(id),incident_id TEXT REFERENCES incidents(id));
CREATE TABLE IF NOT EXISTS evidence(id TEXT PRIMARY KEY,station_id TEXT NOT NULL REFERENCES stations(id),body TEXT NOT NULL);
CREATE TABLE IF NOT EXISTS idempotency(station_id TEXT NOT NULL,user_id TEXT NOT NULL,key TEXT NOT NULL,request_hash TEXT NOT NULL,response TEXT NOT NULL,PRIMARY KEY(station_id,user_id,key));
CREATE TABLE IF NOT EXISTS audit(station_id TEXT NOT NULL,seq INTEGER NOT NULL,event_json TEXT NOT NULL,prev_hash TEXT NOT NULL,hash TEXT NOT NULL,PRIMARY KEY(station_id,seq));
CREATE TRIGGER IF NOT EXISTS audit_no_update BEFORE UPDATE ON audit BEGIN SELECT RAISE(ABORT,'감사 수정 금지'); END;
CREATE TRIGGER IF NOT EXISTS audit_no_delete BEFORE DELETE ON audit BEGIN SELECT RAISE(ABORT,'감사 삭제 금지'); END;
PRAGMA user_version=1;`);
}
transaction<T>(fn: () => T): T {
this.sql.exec("BEGIN IMMEDIATE");
try {
const value = fn();
this.sql.exec("COMMIT");
return value;
} catch (error) {
this.sql.exec("ROLLBACK");
throw error;
}
}
audit(
stationId: string,
actor: { id: string; role: string },
kind: string,
event: string,
payload: unknown,
) {
const last = this.sql
.prepare(
"SELECT seq,hash FROM audit WHERE station_id=? ORDER BY seq DESC LIMIT 1",
)
.get(stationId) as { seq: number; hash: string } | undefined;
const seq = (last?.seq ?? 0) + 1,
previous = last?.hash ?? "";
const json = JSON.stringify({
at: new Date().toISOString(),
actor: actor.id,
role: actor.role,
kind,
event,
payload,
});
const hash = createHash("sha256")
.update(`${stationId}\n${seq}\n${previous}\n${json}`)
.digest("hex");
this.sql
.prepare("INSERT INTO audit VALUES(?,?,?,?,?)")
.run(stationId, seq, json, previous, hash);
}
auditRows(stationId: string) {
return this.sql
.prepare("SELECT * FROM audit WHERE station_id=? ORDER BY seq")
.all(stationId) as {
seq: number;
event_json: string;
prev_hash: string;
hash: string;
}[];
}
verifyAudit(stationId: string) {
let previous = "",
seq = 0;
for (const row of this.auditRows(stationId)) {
seq++;
const hash = createHash("sha256")
.update(`${stationId}\n${seq}\n${previous}\n${row.event_json}`)
.digest("hex");
if (row.seq !== seq || row.prev_hash !== previous || row.hash !== hash)
return { valid: false, count: seq };
previous = row.hash;
}
return { valid: true, count: seq, head: previous };
}
close() {
this.sql.close();
}
}
packages/station/service.ts
import { randomUUID, createHash } from "node:crypto";
import { z } from "zod";
import { DomainError } from "../core/types";
import { StationDatabase } from "./database";
import { StationAuth, type User } from "./auth";
export type Resource = {
id: string;
name: string;
kind: string;
crewUserId: string;
incidentId: string | null;
};
export type Evidence = {
id: string;
documentId: string;
documentTitle: string;
page: number;
reviewer: string;
notes: string;
registeredBy: string;
at: string;
};
type Recipient = {
resourceId: string;
userId: string;
status:
"pending" | "acknowledged" | "executing" | "complete" | "cancelConfirmed";
at: string;
};
export type Command = {
id: string;
text: string;
evidenceIds: string[];
basis: number;
status: "draft" | "issued" | "complete" | "cancelled";
recipients: Recipient[];
createdBy: string;
createdAt: string;
approvedBy?: string;
approvedAt?: string;
cancelledAt?: string;
};
export type Incident = {
id: string;
title: string;
address: string;
report: string;
commanderId: string;
createdAt: string;
closedAt?: string;
status: "open" | "closed";
version: number;
observationRevision: number;
observations: {
id: string;
text: string;
source: string;
certainty: "confirmed" | "suspected" | "unconfirmed";
at: string;
actorId: string;
}[];
commands: Command[];
handovers: { from: string; to: string; at: string }[];
};
type Row = { id: string; station_id: string; version: number; body: string };
type LibraryDoc = {
id: string;
pageCount: number;
status: string;
title: string;
};
const text = z.string().trim().min(1).max(4000),
id = z.string().min(1).max(100),
base = { version: z.number().int().positive(), requestId: z.string().uuid() };
const actionSchema = z.discriminatedUnion("type", [
z
.object({
...base,
type: z.literal("observe"),
text,
source: text.max(100),
certainty: z.enum(["confirmed", "suspected", "unconfirmed"]),
})
.strict(),
z.object({ ...base, type: z.literal("assign"), resourceId: id }).strict(),
z
.object({
...base,
type: z.literal("draft"),
text,
resourceIds: z.array(id).min(1).max(40),
evidenceIds: z.array(id).min(1).max(20),
})
.strict(),
...(["approve", "review", "cancel"] as const).map((type) =>
z.object({ ...base, type: z.literal(type), commandId: id }).strict(),
),
...(["ack", "execute", "complete", "cancelAck"] as const).map((type) =>
z
.object({ ...base, type: z.literal(type), commandId: id, resourceId: id })
.strict(),
),
z.object({ ...base, type: z.literal("handover"), commanderId: id }).strict(),
z.object({ ...base, type: z.literal("close") }).strict(),
]);
export class StationService {
constructor(
readonly db: StationDatabase,
readonly auth: StationAuth,
readonly document: (id: string) => LibraryDoc | undefined,
) {}
resources(actor: User): Resource[] {
this.auth.require(actor, [
"admin",
"commander",
"dispatcher",
"crew",
"auditor",
]);
const rows = this.db.sql
.prepare("SELECT * FROM resources WHERE station_id=?")
.all(actor.stationId) as {
id: string;
name: string;
kind: string;
crew_user_id: string;
incident_id: string | null;
}[];
return rows
.filter((r) => actor.role !== "crew" || r.crew_user_id === actor.id)
.map((r) => ({
id: r.id,
name: r.name,
kind: r.kind,
crewUserId: r.crew_user_id,
incidentId: r.incident_id,
}));
}
evidence(actor: User): Evidence[] {
this.auth.require(actor, [
"admin",
"commander",
"dispatcher",
"crew",
"auditor",
]);
return this.db.sql
.prepare("SELECT body FROM evidence WHERE station_id=?")
.all(actor.stationId)
.map((r) => JSON.parse(r.body as string) as Evidence);
}
addResource(actor: User, input: unknown) {
this.auth.require(actor, ["admin"]);
const d = z
.object({ name: text.max(100), kind: text.max(100), crewUserId: id })
.strict()
.parse(input);
return this.db.transaction(() => {
const crew = this.auth
.users(actor)
.find((u) => u.id === d.crewUserId && u.role === "crew");
if (!crew)
throw new DomainError("같은 기관 대원 계정을 지정하세요.", 400);
const resource: Resource = {
id: randomUUID(),
name: d.name,
kind: d.kind,
crewUserId: d.crewUserId,
incidentId: null,
};
this.db.sql
.prepare("INSERT INTO resources VALUES(?,?,?,?,?,NULL)")
.run(resource.id, actor.stationId, d.name, d.kind, d.crewUserId);
this.db.audit(actor.stationId, actor, "resource", "create", resource);
return resource;
});
}
addEvidence(actor: User, input: unknown) {
this.auth.require(actor, ["admin"]);
const d = z
.object({
documentId: id,
page: z.number().int().positive(),
reviewer: text.max(100),
notes: text,
})
.strict()
.parse(input),
doc = this.document(d.documentId);
if (!doc || doc.status !== "verified" || d.page > doc.pageCount)
throw new DomainError(
"검증된 공식 교재와 유효한 PDF 페이지가 필요합니다.",
400,
);
return this.db.transaction(() => {
const e: Evidence = {
...d,
id: randomUUID(),
documentTitle: doc.title,
registeredBy: actor.id,
at: new Date().toISOString(),
};
this.db.sql
.prepare("INSERT INTO evidence VALUES(?,?,?)")
.run(e.id, actor.stationId, JSON.stringify(e));
this.db.audit(actor.stationId, actor, "evidence", "register", e);
return e;
});
}
create(actor: User, input: unknown) {
this.auth.require(actor, ["admin", "commander", "dispatcher"]);
const d = z
.object({
title: text.max(200),
address: text.max(500),
report: text,
commanderId: id,
})
.strict()
.parse(input);
return this.db.transaction(() => {
this.commander(actor, d.commanderId);
const incident: Incident = {
...d,
id: randomUUID(),
createdAt: new Date().toISOString(),
status: "open",
version: 1,
observationRevision: 0,
observations: [],
commands: [],
handovers: [],
};
this.db.sql
.prepare("INSERT INTO incidents VALUES(?,?,?,?)")
.run(incident.id, actor.stationId, 1, JSON.stringify(incident));
this.db.audit(actor.stationId, actor, "incident", "create", incident);
return incident;
});
}
private commander(actor: User, commanderId: string) {
if (
!this.auth
.users(actor)
.some(
(u) =>
u.id === commanderId && ["admin", "commander"].includes(u.role),
)
)
throw new DomainError("기관 지휘관 계정을 지정하세요.", 400);
}
private row(actor: User, incidentId: string) {
this.auth.require(actor, [
"admin",
"commander",
"dispatcher",
"crew",
"auditor",
]);
const row = this.db.sql
.prepare("SELECT * FROM incidents WHERE id=? AND station_id=?")
.get(incidentId, actor.stationId) as Row | undefined;
if (!row) throw new DomainError("사건을 찾을 수 없습니다.", 404);
if (
actor.role === "crew" &&
!this.resources(actor).some((r) => r.incidentId === incidentId) &&
!(JSON.parse(row.body) as Incident).commands.some((c) =>
c.recipients.some((r) => r.userId === actor.id),
)
)
throw new DomainError("배정 사건의 조회 권한이 없습니다.", 403);
return row;
}
private visible(actor: User, i: Incident) {
return actor.role === "crew"
? {
...i,
commands: i.commands
.filter((c) => c.recipients.some((r) => r.userId === actor.id))
.map((c) => ({
...c,
recipients: c.recipients.filter((r) => r.userId === actor.id),
})),
}
: i;
}
get(actor: User, id: string) {
return this.visible(
actor,
JSON.parse(this.row(actor, id).body) as Incident,
);
}
list(actor: User) {
this.auth.require(actor, [
"admin",
"commander",
"dispatcher",
"crew",
"auditor",
]);
const rows = this.db.sql
.prepare(
"SELECT id FROM incidents WHERE station_id=? ORDER BY rowid DESC",
)
.all(actor.stationId);
return rows.flatMap((row) => {
try {
return [this.get(actor, row.id as string)];
} catch (e) {
if (e instanceof DomainError && e.code === 403) return [];
throw e;
}
});
}
act(actor: User, incidentId: string, input: unknown): Incident {
const d = actionSchema.parse(input);
return this.db.transaction(() => {
const row = this.row(actor, incidentId),
digest = createHash("sha256")
.update(JSON.stringify({ incidentId, ...d }))
.digest("hex");
const previous = this.db.sql
.prepare(
"SELECT * FROM idempotency WHERE station_id=? AND user_id=? AND key=?",
)
.get(actor.stationId, actor.id, d.requestId) as
{ request_hash: string; response: string } | undefined;
if (previous) {
if (previous.request_hash !== digest)
throw new DomainError(
"중복 요청 키에 다른 내용이 사용됐습니다.",
409,
);
return this.visible(actor, JSON.parse(previous.response));
}
const i = JSON.parse(row.body) as Incident;
if (i.version !== d.version)
throw new DomainError("변경 충돌: 최신 사건을 다시 확인하세요.", 409);
if (i.status === "closed")
throw new DomainError("종료된 사건은 변경할 수 없습니다.", 409);
const now = new Date().toISOString();
const leader = () => {
this.auth.require(actor, ["admin", "commander"]);
if (actor.role !== "admin" && i.commanderId !== actor.id)
throw new DomainError("담당 지휘관만 변경할 수 있습니다.", 403);
};
const prepare = () =>
this.auth.require(actor, ["admin", "commander", "dispatcher"]);
const command = (commandId: string) => {
const c = i.commands.find((c) => c.id === commandId);
if (!c) throw new DomainError("명령을 찾을 수 없습니다.", 404);
return c;
};
switch (d.type) {
case "observe":
prepare();
i.observations.push({
id: randomUUID(),
text: d.text,
source: d.source,
certainty: d.certainty,
at: now,
actorId: actor.id,
});
i.observationRevision++;
break;
case "assign": {
prepare();
const r = this.resources(actor).find((r) => r.id === d.resourceId);
if (!r) throw new DomainError("기관 자원을 확인하세요.", 404);
if (r.incidentId && r.incidentId !== i.id)
throw new DomainError("다른 진행 사건에 배정된 자원입니다.", 409);
this.db.sql
.prepare(
"UPDATE resources SET incident_id=? WHERE id=? AND station_id=?",
)
.run(i.id, r.id, actor.stationId);
break;
}
case "draft": {
prepare();
const resources = this.resources(actor),
evidence = this.evidence(actor);
const targets = [...new Set(d.resourceIds)].map((id) =>
resources.find((r) => r.id === id && r.incidentId === i.id),
);
if (
targets.some((r) => !r) ||
d.evidenceIds.some((id) => !evidence.some((e) => e.id === id))
)
throw new DomainError(
"배정 자원과 기관 검토 근거를 확인하세요.",
400,
);
i.commands.push({
id: randomUUID(),
text: d.text,
evidenceIds: [...new Set(d.evidenceIds)],
basis: i.observationRevision,
status: "draft",
createdBy: actor.id,
createdAt: now,
recipients: targets.map((r) => ({
resourceId: r!.id,
userId: r!.crewUserId,
status: "pending",
at: now,
})),
});
break;
}
case "review": {
leader();
const c = command(d.commandId);
if (c.status !== "draft")
throw new DomainError("초안만 재검토할 수 있습니다.", 409);
c.basis = i.observationRevision;
break;
}
case "approve": {
leader();
const c = command(d.commandId);
if (c.status !== "draft")
throw new DomainError("승인 가능한 초안이 아닙니다.", 409);
if (c.basis !== i.observationRevision)
throw new DomainError(
"새 관찰을 확인하고 초안을 재검토하세요.",
409,
);
c.status = "issued";
c.approvedBy = actor.id;
c.approvedAt = now;
break;
}
case "cancel": {
leader();
const c = command(d.commandId);
if (["cancelled", "complete"].includes(c.status))
throw new DomainError("이미 취소 또는 완료된 명령입니다.", 409);
if (c.status === "draft")
for (const r of c.recipients) r.status = "cancelConfirmed";
c.status = "cancelled";
c.cancelledAt = now;
break;
}
case "ack":
case "execute":
case "complete":
case "cancelAck": {
this.auth.require(actor, ["crew"]);
const c = command(d.commandId),
r = c.recipients.find(
(r) => r.resourceId === d.resourceId && r.userId === actor.id,
);
if (!r)
throw new DomainError(
"자신의 대상 임무만 보고할 수 있습니다.",
403,
);
if (d.type === "cancelAck") {
if (c.status !== "cancelled")
throw new DomainError("취소 명령이 아닙니다.", 409);
r.status = "cancelConfirmed";
} else {
if (c.status === "cancelled")
throw new DomainError(
"취소된 명령의 보고를 반영할 수 없습니다.",
409,
);
if (c.status !== "issued")
throw new DomainError("발행된 명령이 아닙니다.", 409);
const allowed = {
ack: "pending",
execute: "acknowledged",
complete: "executing",
} as const;
if (r.status !== allowed[d.type])
throw new DomainError(
"수신 → 수행 → 완료 순서로 보고하세요.",
409,
);
r.status =
d.type === "ack"
? "acknowledged"
: d.type === "execute"
? "executing"
: "complete";
if (c.recipients.every((r) => r.status === "complete"))
c.status = "complete";
}
r.at = now;
break;
}
case "handover":
leader();
this.commander(actor, d.commanderId);
i.handovers.push({ from: i.commanderId, to: d.commanderId, at: now });
i.commanderId = d.commanderId;
break;
case "close":
leader();
if (
i.commands.some(
(c) =>
c.status !== "complete" &&
!(
c.status === "cancelled" &&
c.recipients.every((r) => r.status === "cancelConfirmed")
),
)
)
throw new DomainError(
"미완료 임무 또는 취소 확인이 남아 있습니다.",
409,
);
i.status = "closed";
i.closedAt = now;
this.db.sql
.prepare(
"UPDATE resources SET incident_id=NULL WHERE incident_id=? AND station_id=?",
)
.run(i.id, actor.stationId);
break;
}
i.version++;
const response = JSON.stringify(i);
this.db.sql
.prepare(
"UPDATE incidents SET version=?,body=? WHERE id=? AND station_id=?",
)
.run(i.version, response, i.id, actor.stationId);
this.db.audit(actor.stationId, actor, "incident", d.type, {
incidentId: i.id,
version: i.version,
input: d,
});
this.db.sql
.prepare("INSERT INTO idempotency VALUES(?,?,?,?,?)")
.run(actor.stationId, actor.id, d.requestId, digest, response);
return this.visible(actor, i);
});
}
audit(actor: User) {
this.auth.require(actor, ["admin", "auditor", "commander"]);
return {
integrity: this.db.verifyAudit(actor.stationId),
records: this.db.auditRows(actor.stationId),
};
}
}
packages/station/types.ts
export const roles = [
"admin",
"commander",
"dispatcher",
"crew",
"auditor",
] as const;
export type Role = (typeof roles)[number];
export type User = {
id: string;
stationId: string;
username: string;
displayName: string;
role: Role;
active?: boolean;
};
export const roleNames: Record<Role, string> = {
admin: "관리자",
commander: "지휘관",
dispatcher: "상황요원",
crew: "대원",
auditor: "감사자",
};
playwright.config.ts
import { defineConfig } from "@playwright/test";
export default defineConfig({
testDir: "tests/e2e",
testMatch: "**/*.e2e.ts",
fullyParallel: false,
workers: 1,
timeout: 30000,
use: {
baseURL: process.env.FIRESTATION_TEST_URL ?? "http://127.0.0.1:3100",
viewport: { width: 1440, height: 1000 },
trace: "retain-on-failure",
},
reporter: "list",
});
scripts/check-demo-audio.ts
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import manifest from "../packages/simulation/audio-recordings.json";
import { scenarios } from "../packages/core/scenario";
import { createSession, publicState, tick } from "../packages/core/engine";
import { audioEvents, introCues } from "../packages/simulation/demo-audio";
for (const clip of [
...manifest.recordings,
...Object.values(manifest.effects),
]) {
if (!/^\/audio\/demo\/[a-z0-9-]+\.wav$/.test(clip.file))
throw Error("음원 경로 검사 실패");
const bytes = readFileSync(resolve("apps/web/public", clip.file.slice(1)));
if (
bytes.length !== clip.bytes ||
createHash("sha256").update(bytes).digest("hex") !== clip.sha256 ||
!Number.isFinite(clip.duration) ||
clip.duration <= 0
)
throw Error(`교육 음원 손상: ${clip.file}`);
}
for (const scenario of scenarios) {
let state = createSession(scenario, { seed: 42, mode: "DEMO", level: 3 });
const cues = introCues(publicState(state).scenario);
for (let i = 0; i <= 96; i++) {
cues.push(...audioEvents(publicState(state)));
state = tick(state, 1);
}
for (const cue of cues)
if (
!cue.effect &&
!manifest.recordings.some(
(r) => r.role === cue.role && r.text === cue.text,
)
)
throw Error(`대사에 대응하는 음원 누락: ${cue.text}`);
}
console.log(
`교육 대사${manifest.recordings.length}개·효과음3개 해시 및 공개 대사 대응 일치`,
);
scripts/check-library.ts
import { createHash } from "node:crypto";
import { readFileSync } from "node:fs";
import { resolve } from "node:path";
import { catalog } from "../packages/library";
import { renderLibraryHtml } from "../packages/library/html";
for (const d of catalog.documents) {
const bytes = readFileSync(resolve("apps/web/public/library", `${d.id}.pdf`));
if (
createHash("sha256").update(bytes).digest("hex") !== d.sha256 ||
bytes.length !== d.bytes
)
throw Error(`원문 무결성 오류: ${d.title}`);
const index = JSON.parse(
readFileSync(resolve("apps/web/public/library", `${d.id}.json`), "utf8"),
);
if (index.sha256 !== d.sha256 || index.pages.length !== d.pageCount)
throw Error(`페이지 인덱스 오류: ${d.title}`);
if (
readFileSync(resolve("apps/web/public/library", `${d.id}.html`), "utf8") !==
renderLibraryHtml(d, index.pages)
)
throw Error(`HTML 본문 불일치: ${d.title}. library:html을 실행하세요.`);
}
console.log(
`공식 교재 ${catalog.documents.length}권 PDF 해시·페이지 인덱스 일치`,
);
scripts/dev.ts
import { spawn } from "node:child_process";
import { watchDocumentSources } from "../packages/docs/watch";
import { resolve } from "node:path";
const root = process.cwd();
let pending = false,
running = false;
function generate() {
return new Promise<number>((done) => {
const child = spawn(
process.execPath,
[resolve(root, "node_modules/tsx/dist/cli.mjs"), "scripts/docs.ts"],
{ cwd: root, stdio: "inherit", windowsHide: true },
);
child.on("error", () => done(1));
child.on("exit", (code) => done(code ?? 1));
});
}
async function refresh() {
if (running) {
pending = true;
return;
}
running = true;
await generate();
running = false;
if (pending) {
pending = false;
void refresh();
}
}
if ((await generate()) !== 0) process.exit(1);
const stopWatching = watchDocumentSources(root, () => void refresh());
const child = spawn(
process.execPath,
[
resolve(root, "node_modules/next/dist/bin/next"),
"dev",
"apps/web",
"--hostname",
"127.0.0.1",
"--port",
"3100",
],
{ cwd: root, stdio: "inherit", windowsHide: true },
);
function stop() {
stopWatching();
child.kill();
}
process.on("SIGINT", stop);
process.on("SIGTERM", stop);
child.on("exit", (code) => {
stopWatching();
process.exit(code ?? 0);
});
scripts/docs.ts
import { resolve } from "node:path";
import { syncDocuments, checkDocuments } from "../packages/docs/build";
const root = resolve(process.cwd());
try {
if (process.argv.includes("--check")) {
checkDocuments(root, process.argv.includes("--staged"));
console.log("manual.html · logic.html 소스 지문/내용 일치");
} else {
const result = syncDocuments(root);
console.log(
`문서 갱신: ${result.fingerprint.slice(0, 12)} · 소스 ${result.sourceCount}개`,
);
}
} catch (e) {
console.error(e instanceof Error ? e.message : e);
process.exitCode = 1;
}
scripts/e2e.ts
import { spawn } from "node:child_process";
import { mkdtempSync, rmSync, existsSync } from "node:fs";
import { resolve, join, sep } from "node:path";
import { tmpdir } from "node:os";
import { StationDatabase } from "../packages/station/database";
import { StationAuth } from "../packages/station/auth";
const root = process.cwd(),
temporary = mkdtempSync(join(tmpdir(), "firestation-e2e-"));
if (!existsSync(resolve(root, "apps/web/.next/BUILD_ID")))
throw Error("npm run build를 먼저 실행하세요.");
const url = "http://127.0.0.1:3110",
env = {
...process.env,
FIRESTATION_DB: join(temporary, "station.db"),
FIRESTATION_DEMO_FILE: join(temporary, "sessions.json"),
FIRESTATION_TEST_URL: url,
FIRESTATION_TEST_SETUP_KEY: "",
};
const db = new StationDatabase(env.FIRESTATION_DB);
env.FIRESTATION_TEST_SETUP_KEY = new StationAuth(db).issueSetupToken();
db.close();
const server = spawn(
process.execPath,
[
resolve(root, "node_modules/next/dist/bin/next"),
"start",
"apps/web",
"--hostname",
"127.0.0.1",
"--port",
"3110",
],
{ env, stdio: ["ignore", "pipe", "pipe"], windowsHide: true },
);
let exited = false;
server.on("exit", () => {
exited = true;
});
server.stdout?.on("data", (d) => process.stdout.write(d));
server.stderr?.on("data", (d) => process.stderr.write(d));
let code = 1;
try {
let ready = false;
for (let i = 0; i < 60 && !exited; i++) {
try {
ready = (
await fetch(url + "/api/station/auth", {
signal: AbortSignal.timeout(500),
})
).ok;
} catch {}
if (ready) break;
await new Promise((r) => setTimeout(r, 250));
}
if (!ready || exited) throw Error("격리 브라우저 테스트 서버 시작 실패");
const runner = spawn(
process.execPath,
[
resolve(root, "node_modules/@playwright/test/cli.js"),
"test",
...process.argv.slice(2),
],
{ env, stdio: "inherit", windowsHide: true },
);
code = await new Promise<number>((done) => {
runner.on("error", () => done(1));
runner.on("exit", (n) => done(n ?? 1));
});
} finally {
const stopped = new Promise<void>((done) => {
if (exited) done();
else server.once("exit", () => done());
});
server.kill();
await stopped;
if (!resolve(temporary).startsWith(resolve(tmpdir()) + sep))
throw Error("임시 경로 밖 삭제 거부");
rmSync(temporary, { recursive: true, force: true });
}
process.exitCode = code;
scripts/export-cards.ts
import { writeFileSync, mkdirSync } from "node:fs";
import { cards } from "../packages/kb";
mkdirSync("docs", { recursive: true });
const text = [
"# KB-0 교육용 카드 40장\n\n교육용 · 비공식 요약 · 검수 전. 자체 작성한 질문·준비 초안입니다. 실제 현장 전술이 아니며 소방 자문 검수가 필요합니다. 참고 출처는 용어·지휘 제도의 맥락이며 각 행동을 승인한 근거가 아닙니다.\n",
];
for (const c of cards)
text.push(
`## ${c.id} ${c.title}\n\n${c.label}\n\n${c.summary}\n\n확인 질문:\n\n${c.questions.map((q) => `- ${q}`).join("\n")}\n\n준비 검토:\n\n${c.preparation.map((p) => `- ${p}`).join("\n")}\n\n주의: ${c.cautions.join(" ")}\n\n참고: ${c.sources.map((s) => `[${s.title}](${s.url}) · ${s.location} · ${s.relation}`).join("\n")}\n\n검수자: 미배정 · 검수일: 없음 · 상태: 초안\n`,
);
writeFileSync("docs/KB0_교육카드_40장.md", text.join("\n"), "utf8");
console.log(`교육 카드 ${cards.length}장 내보내기 완료`);
scripts/export-demo-audio.ts
import { createHash } from "node:crypto";
import { mkdirSync, writeFileSync, readFileSync } from "node:fs";
import { resolve } from "node:path";
import { execFileSync } from "node:child_process";
import { scenarios } from "../packages/core/scenario";
import { createSession, publicState, tick } from "../packages/core/engine";
import {
audioEvents,
introCues,
type AudioCue,
} from "../packages/simulation/demo-audio";
const root = process.cwd(),
output = resolve(root, "apps/web/public/audio/demo");
mkdirSync(output, { recursive: true });
mkdirSync(resolve(root, ".data"), { recursive: true });
const cues = new Map<string, AudioCue>();
for (const scenario of scenarios) {
let session = createSession(scenario, { seed: 42, mode: "DEMO", level: 3 });
for (const cue of introCues(publicState(session).scenario))
if (!cue.effect) cues.set(`${cue.role}\0${cue.text}`, cue);
for (let i = 0; i <= 96; i++) {
for (const cue of audioEvents(publicState(session)))
cues.set(`${cue.role}\0${cue.text}`, cue);
session = tick(session, 1);
}
}
const plan = [...cues.values()].map((cue) => ({
role: cue.role,
text: cue.text,
filename: `${createHash("sha256").update(`${cue.role}\0${cue.text}`).digest("hex").slice(0, 16)}.wav`,
}));
const planPath = resolve(root, ".data/demo-audio-plan.json");
writeFileSync(planPath, JSON.stringify(plan, null, 2));
execFileSync(
"pwsh.exe",
[
"-NoProfile",
"-File",
resolve(root, "scripts/generate-demo-voices.ps1"),
"-Plan",
planPath,
"-OutputDirectory",
output,
],
{ stdio: "inherit", windowsHide: true },
);
// 로열티 음원 대신 수식으로 전화벨·무전 연결음·차량 사이렌을 합성한다.
function effect(
name: string,
duration: number,
sample: (time: number, i: number) => number,
) {
const rate = 22050,
count = Math.round(duration * rate),
wav = Buffer.alloc(44 + count * 2);
wav.write("RIFF", 0);
wav.writeUInt32LE(wav.length - 8, 4);
wav.write("WAVEfmt ", 8);
wav.writeUInt32LE(16, 16);
wav.writeUInt16LE(1, 20);
wav.writeUInt16LE(1, 22);
wav.writeUInt32LE(rate, 24);
wav.writeUInt32LE(rate * 2, 28);
wav.writeUInt16LE(2, 32);
wav.writeUInt16LE(16, 34);
wav.write("data", 36);
wav.writeUInt32LE(count * 2, 40);
for (let i = 0; i < count; i++)
wav.writeInt16LE(
Math.round(Math.max(-1, Math.min(1, sample(i / rate, i))) * 32767),
44 + i * 2,
);
writeFileSync(resolve(output, `${name}.wav`), wav);
}
effect(
"telephone",
1.2,
(t) =>
(t % 0.6 < 0.32 ? 1 : 0) *
0.18 *
(Math.sin(2 * Math.PI * 480 * t) + Math.sin(2 * Math.PI * 620 * t)),
);
effect(
"radio",
0.18,
(t) =>
0.13 * Math.sin(2 * Math.PI * 1400 * t) * Math.sin((Math.PI * t) / 0.18),
);
let phase = 0;
effect("siren", 8, (t) => {
phase +=
(2 * Math.PI * (900 + 300 * Math.sin(2 * Math.PI * 0.5 * t))) / 22050;
return 0.32 * Math.sin(phase) + 0.06 * Math.sin(phase * 2);
});
function info(filename: string) {
const bytes = readFileSync(resolve(output, filename));
let dataSize = 0,
byteRate = 0;
for (let offset = 12; offset + 8 <= bytes.length;) {
const chunk = bytes.toString("ascii", offset, offset + 4),
size = bytes.readUInt32LE(offset + 4);
if (chunk === "fmt ") byteRate = bytes.readUInt32LE(offset + 16);
if (chunk === "data") dataSize = size;
offset += 8 + size + (size % 2);
}
if (!dataSize || !byteRate) throw Error(`WAV 형식 오류: ${filename}`);
return {
file: `/audio/demo/${filename}`,
sha256: createHash("sha256").update(bytes).digest("hex"),
bytes: bytes.length,
duration: dataSize / byteRate,
};
}
writeFileSync(
resolve(root, "packages/simulation/audio-recordings.json"),
JSON.stringify(
{
provenance:
"자체 작성 교육 대사 / Windows System.Speech 한국어 합성. 효과음은 자체 수식 생성. 실제 신고·무전 녹음 아님.",
recordings: plan.map(({ filename, ...cue }) => ({
...cue,
...info(filename),
})),
effects: Object.fromEntries(
["telephone", "radio", "siren"].map((name) => [
name,
info(`${name}.wav`),
]),
),
},
null,
2,
) + "\n",
);
console.log(`교육 음원 ${plan.length}개와 효과음3개 생성 완료`);
scripts/export-library-html.ts
import { readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { catalog } from "../packages/library";
import { renderLibraryHtml } from "../packages/library/html";
const root = process.cwd();
for (const doc of catalog.documents) {
const path = join(root, "apps/web/public/library", doc.id);
const index = JSON.parse(readFileSync(path + ".json", "utf8"));
writeFileSync(path + ".html", renderLibraryHtml(doc, index.pages));
}
console.log(`공식 교재${catalog.documents.length}권의 전체 HTML 생성 완료`);
scripts/extract-library-pdf.mjs
// 외부 PDF는 별도 제한 프로세스에서 텍스트만 추출한다. PDF 스크립트를 실행하지 않는다.
import { readFileSync, writeFileSync } from "node:fs";
import { getDocument } from "pdfjs-dist/legacy/build/pdf.mjs";
const [input, output] = process.argv.slice(2);
let task;
try {
task = getDocument({
data: new Uint8Array(readFileSync(input)),
isEvalSupported: false,
disableFontFace: true,
useSystemFonts: false,
stopAtErrors: true,
});
const pdf = await task.promise;
if (pdf.numPages < 1 || pdf.numPages > 1500) throw Error("페이지 상한 초과");
const pages = [];
let count = 0;
for (let n = 1; n <= pdf.numPages; n++) {
const page = await pdf.getPage(n),
content = await page.getTextContent();
let text = "",
lastY;
for (const item of content.items) {
if (!("str" in item)) continue;
const y = item.transform[5];
if (
lastY !== undefined &&
Math.abs(lastY - y) > 2 &&
!text.endsWith("\n")
)
text += "\n";
text += item.str + (item.hasEOL ? "\n" : " ");
lastY = y;
}
text = text.trim();
count += text.length;
if (count > 12000000) throw Error("추출 본문 상한 초과");
pages.push({ page: n, text });
page.cleanup();
}
writeFileSync(output, JSON.stringify(pages));
} catch {
console.error("PDF 변환 실패: 손상/암호화/페이지·본문 상한을 확인하세요.");
process.exitCode = 1;
} finally {
await task?.destroy();
}
scripts/generate-demo-voices.ps1
param([Parameter(Mandatory=$true)][string]$Plan, [Parameter(Mandatory=$true)][string]$OutputDirectory)
$ErrorActionPreference = 'Stop'
Add-Type -AssemblyName System.Speech
$taskSynth = [System.Speech.Synthesis.SpeechSynthesizer]::new()
try {
$taskVoice = $taskSynth.GetInstalledVoices() | Where-Object { $_.Enabled -and $_.VoiceInfo.Culture.Name -eq 'ko-KR' } | Select-Object -First 1
if (-not $taskVoice) { throw '한국어 Windows 음성을 먼저 설치하세요.' }
$taskSynth.SelectVoice($taskVoice.VoiceInfo.Name)
$taskFormat = [System.Speech.AudioFormat.SpeechAudioFormatInfo]::new(22050, [System.Speech.AudioFormat.AudioBitsPerSample]::Sixteen, [System.Speech.AudioFormat.AudioChannel]::Mono)
$taskEntries = Get-Content -LiteralPath $Plan -Raw -Encoding UTF8 | ConvertFrom-Json
foreach ($taskEntry in $taskEntries) {
if ($taskEntry.filename -notmatch '^[a-f0-9]{16}\.wav$') { throw '음원 파일명 검사 실패' }
$taskFile = Join-Path $OutputDirectory $taskEntry.filename
$taskText = [System.Security.SecurityElement]::Escape($taskEntry.text)
$taskPitch = if ($taskEntry.role -eq '신고자') { '+8%' } elseif ($taskEntry.role -eq '지휘본부') { '-12%' } elseif ($taskEntry.role -eq '대원') { '-6%' } else { '+0%' }
$taskSynth.Rate = 2
$taskSynth.SetOutputToWaveFile($taskFile, $taskFormat)
$taskSynth.SpeakSsml('<speak version="1.0" xmlns="http://www.w3.org/2001/10/synthesis" xml:lang="ko-KR"><prosody pitch="' + $taskPitch + '">' + $taskText + '</prosody></speak>')
$taskSynth.SetOutputToNull()
}
} finally { $taskSynth.Dispose() }
scripts/import-library.py
"""공공누리 1유형을 확인한 중앙소방학교 2026 공통교재 원문과 검색 인덱스를 수입한다."""
import argparse
import hashlib
import json
import re
import sys
import urllib.request
from datetime import datetime, timezone
from html import unescape
from pathlib import Path
from pypdf import PdfReader
BASE = 'https://www.nfsa.go.kr'
POSTS = list(range(106805, 106813))
ROOT = Path(__file__).resolve().parents[1]
PUBLIC = ROOT / 'apps/web/public/library'
CATALOG = ROOT / 'packages/library/catalog.json'
def fetch(url):
# 이용 가능한 공식 URL만 사용하며 게시글의 허락 조건을 먼저 확인한다.
request = urllib.request.Request(url, headers={'User-Agent': 'AI-Firestation-Library/0.3'})
with urllib.request.urlopen(request, timeout=90) as response:
result = response.read(160 * 1024 * 1024)
if len(result) >= 160 * 1024 * 1024:
raise ValueError('원문 크기 상한을 초과했습니다.')
return result
def parse_post(post_id):
url = f'{BASE}/nfsa/releaseinformation/archive/materials/?boardId=bbs_0000000000000035&mode=view&cntId={post_id}'
html = fetch(url).decode('utf-8')
# 별도 원문은 수정하지 않는다. 이 문자열은 게시글의 이용 허락 확인 기록이다.
license_match = re.search(r'<p[^>]*>([^<]*저작물은 공공누리[^<]*)</p>', html)
license_text = unescape(license_match.group(1)).strip() if license_match else ''
if not re.search(r'제\s*1유형', license_text):
raise ValueError(f'{post_id}: 공공누리 1유형 확인 실패. 재배포하지 않습니다.')
files = []
for match in re.finditer(r"openPdfViewer\('([^']+)',\s*'([^']+)',\s*'([^']+)',\s*'([^']+)'[\s\S]*?Jnit_boardDownload\('([^']+)'", html):
filename = unescape(match.group(4))
path = match.group(5).split(';jsessionid=')[0]
files.append({'filename': filename, 'downloadUrl': BASE + path})
if not files:
raise ValueError(f'{post_id}: PDF 첨부를 찾지 못했습니다.')
return url, license_text, files
def main():
parser = argparse.ArgumentParser()
parser.add_argument('--metadata-only', action='store_true')
args = parser.parse_args()
PUBLIC.mkdir(parents=True, exist_ok=True)
CATALOG.parent.mkdir(parents=True, exist_ok=True)
documents = []
for post in POSTS:
post_url, license_text, files = parse_post(post)
for index, source in enumerate(files):
doc_id = f'nfsa-2026-{post}-{index+1}'
path = PUBLIC / f'{doc_id}.pdf'
doc = {'id':doc_id,'title':re.sub(r'^\d+\.\s*','',source['filename']).removesuffix('.pdf'),'filename':source['filename'],'year':2026,'publisher':'소방청 · 중앙소방학교','publishedAt':'2026-03-17','verifiedAt':datetime.now(timezone.utc).isoformat(),'postUrl':post_url,'downloadUrl':source['downloadUrl'],'license':'KOGL-1','licenseStatement':license_text,'licenseUrl':'https://www.kogl.or.kr/info/license.do','pdfUrl':f'/library/{doc_id}.pdf','textUrl':f'/library/{doc_id}.json','status':'verified'}
if args.metadata_only:
doc['status']='link-only';documents.append(doc);continue
if not path.exists():
data = fetch(source['downloadUrl'])
if not data.startswith(b'%PDF-'):
raise ValueError(f'{doc_id}: 다운로드 결과가 PDF가 아닙니다.')
path.write_bytes(data)
data = path.read_bytes()
reader = PdfReader(path)
pages = [{'page':i+1,'text':page.extract_text() or ''} for i,page in enumerate(reader.pages)]
doc.update({'pageCount':len(pages),'sha256':hashlib.sha256(data).hexdigest(),'bytes':len(data),'extraction':'PDF 텍스트 추출 · 표/그림/스캔은 원문 확인'})
(PUBLIC / f'{doc_id}.json').write_text(json.dumps({'documentId':doc_id,'sha256':doc['sha256'],'pages':pages},ensure_ascii=False,separators=(',',':')),encoding='utf-8')
documents.append(doc)
print(f"{doc_id}: {doc['title']} / {len(pages)} pages / {len(data)} bytes",flush=True)
CATALOG.write_text(json.dumps({'publisher':'중앙소방학교','edition':'2026 공통교재','documents':documents},ensure_ascii=False,indent=2)+'\n',encoding='utf-8')
print(f'완료: {len(documents)}개 원문·검색 인덱스',flush=True)
if __name__ == '__main__':
sys.stdout.reconfigure(encoding='utf-8')
main()
scripts/setup-key.ts
import { station } from "../apps/web/lib/station";
const { auth, db } = station();
try {
console.log(
"15분 동안 한 번만 사용하는 최초 설정 키입니다. 설정 화면에 입력하세요.",
);
console.log(auth.issueSetupToken());
} finally {
db.close();
}
scripts/simulate.ts
import { scenarios } from "../packages/core/scenario";
import { createSession, tick, publicState } from "../packages/core/engine";
for (const scenario of scenarios) {
let s = createSession(scenario, { seed: 42, mode: "DEMO", level: 3 });
for (let i = 0; i < 120; i++) s = tick(s, 1);
const view = publicState(s);
console.log(
JSON.stringify(
{
scenario: view.scenario.title,
status: view.status,
elapsed: view.elapsed,
commands: view.commands.length,
complete: view.summary.completed,
observations: view.observations.length,
publicHasHidden: "hidden" in view.scenario,
},
null,
2,
),
);
if (view.status !== "closed") process.exitCode = 1;
}
scripts/unreal-stream.ps1
[CmdletBinding()]
param([ValidateSet('setup','run')][string]$Action = 'run', [string]$InfrastructureRoot, [ValidateRange(1024,65535)][int]$PlayerPort = 8880, [ValidateRange(1024,65535)][int]$StreamerPort = 8888)
$ErrorActionPreference = 'Stop'
$root = Split-Path $PSScriptRoot -Parent
$allowed = [IO.Path]::GetFullPath((Join-Path $root '.unreal-runtime'))
if (-not $InfrastructureRoot) { $InfrastructureRoot = Join-Path $allowed 'PixelStreamingInfrastructure' }
$InfrastructureRoot = [IO.Path]::GetFullPath($InfrastructureRoot)
if ($InfrastructureRoot -match '["`\r\n&|<>]') { throw '경로에 셸 특수문자를 사용할 수 없습니다.' }
if (-not $InfrastructureRoot.StartsWith($allowed + [IO.Path]::DirectorySeparatorChar, [StringComparison]::OrdinalIgnoreCase)) { throw '신호 서버 경로는 프로젝트 .unreal-runtime 내부여야 합니다.' }
$revision = 'e5e243ed038313d3dad4e3fb9087d4393a5df192'
if ($Action -eq 'setup') {
if (-not (Test-Path -LiteralPath $InfrastructureRoot)) {
New-Item -ItemType Directory -Path $allowed -Force | Out-Null
& git clone --branch UE5.8 --single-branch https://github.com/EpicGamesExt/PixelStreamingInfrastructure.git $InfrastructureRoot
if ($LASTEXITCODE -ne 0) { throw '공식 신호 서버 clone 실패' }
& git -C $InfrastructureRoot checkout --detach $revision
if ($LASTEXITCODE -ne 0) { throw '공식 신호 서버 버전 고정 실패' }
}
}
if (-not (Test-Path -LiteralPath (Join-Path $InfrastructureRoot '.git'))) { throw '먼저 unreal-stream.ps1 -Action setup을 실행하십시오.' }
$head = & git -C $InfrastructureRoot rev-parse HEAD
if ($LASTEXITCODE -ne 0 -or $head -ne $revision) { throw '신호 서버 고정 버전이 일치하지 않습니다.' }
Push-Location $InfrastructureRoot
try {
if ($Action -eq 'setup') {
& npm.cmd ci
if ($LASTEXITCODE -ne 0) { throw '신호 서버 npm ci 실패' }
# 고정 공식 소스의 호환 범위 보안 패치만 적용한다. 강제/메이저 갱신은 하지 않는다.
& npm.cmd audit fix --ignore-scripts
$fixCode = $LASTEXITCODE
if ($fixCode -notin @(0, 1)) { throw "신호 서버 호환 보안 패치 실패: $fixCode" }
$auditText = (& npm.cmd audit --omit=dev --json) -join "`n"
$auditCode = $LASTEXITCODE
if ($auditCode -notin @(0, 1)) { throw "신호 서버 의존성 감사 실패: $auditCode" }
try { $audit = $auditText | ConvertFrom-Json } catch { throw '신호 서버 의존성 감사 응답을 해석할 수 없습니다.' }
if ($null -eq $audit.metadata -or $null -eq $audit.metadata.vulnerabilities -or $null -ne $audit.error) { throw '신호 서버 의존성 감사 결과가 없습니다.' }
$vulnerabilities = $audit.metadata.vulnerabilities
if ($null -eq $vulnerabilities.high -or $null -eq $vulnerabilities.critical -or $vulnerabilities.high -ne 0 -or $vulnerabilities.critical -ne 0) { throw '신호 서버 운영 의존성에 high/critical 취약점이 남았습니다.' }
Write-Output "운영 의존성 감사: high=0 critical=0 moderate=$($vulnerabilities.moderate). 개발/중간 위험 취약점은 별도 검토가 필요합니다."
foreach ($part in @('Common','Signalling','SignallingWebServer')) {
Push-Location (Join-Path $InfrastructureRoot $part)
try { & npm.cmd run build; if ($LASTEXITCODE -ne 0) { throw "신호 서버 build 실패: $part" } } finally { Pop-Location }
}
} else {
$entry = Join-Path $InfrastructureRoot 'SignallingWebServer\dist\index.js'
if (-not (Test-Path -LiteralPath $entry)) { throw 'setup으로 신호 서버를 빌드하십시오.' }
# 공식 저장소의 예제 config.json 대신 영상 신호 전용 설정을 사용한다.
$config = Join-Path $allowed 'firestation-signalling.json'
[IO.File]::WriteAllText($config, '{"serve":false,"rest_api":false}', [Text.UTF8Encoding]::new($false))
& node $entry --config_file $config --player_port $PlayerPort --streamer_port $StreamerPort
if ($LASTEXITCODE -ne 0) { throw "신호 서버 실행 실패: $LASTEXITCODE" }
}
} finally { Pop-Location }
scripts/unreal.ps1
[CmdletBinding()]
param(
[ValidateSet('doctor','build','bootstrap','run')][string]$Action = 'doctor',
[string]$EngineRoot = $env:UE_ENGINE_ROOT,
[string]$Session,
[string]$BaseUrl = 'http://127.0.0.1:3100',
[string]$SignalUrl = 'ws://127.0.0.1:8888'
)
$ErrorActionPreference = 'Stop'
# 입력은 셸 코드로 합성하지 않고 각 실행 인수로 전달한다.
if ($Action -eq 'run' -and $Session -notmatch '^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$') { throw 'Session UUID 형식이 필요합니다.' }
foreach ($item in @(@($BaseUrl, 'https?'), @($SignalUrl, 'wss?'))) {
$uri = $null
if (-not [Uri]::TryCreate($item[0], [UriKind]::Absolute, [ref]$uri) -or $uri.Scheme -notmatch ('^' + $item[1] + '$') -or $uri.UserInfo -or $item[0] -match '[\s"`]' -or $uri.Query -or $uri.Fragment) { throw 'URL 형식이 잘못되었습니다.' }
}
if (-not $EngineRoot) { $EngineRoot = 'C:\Program Files\Epic Games\UE_5.8' }
if ($EngineRoot -match '["`\r\n&|<>]') { throw '엔진 경로에 셸 특수문자를 사용할 수 없습니다.' }
$EngineRoot = [IO.Path]::GetFullPath($EngineRoot)
$editor = Join-Path $EngineRoot 'Engine\Binaries\Win64\UnrealEditor.exe'
$editorCmd = Join-Path $EngineRoot 'Engine\Binaries\Win64\UnrealEditor-Cmd.exe'
$build = Join-Path $EngineRoot 'Engine\Build\BatchFiles\Build.bat'
if (-not (Test-Path -LiteralPath $editor)) { throw "UE5.8 엔진이 없습니다: $EngineRoot. Epic Launcher에서 설치하고 UE_ENGINE_ROOT를 지정하십시오." }
$version = Get-Content -LiteralPath (Join-Path $EngineRoot 'Engine\Build\Build.version') -Raw | ConvertFrom-Json
if ($version.MajorVersion -ne 5 -or $version.MinorVersion -ne 8) { throw 'UE5.8 엔진이 필요합니다.' }
$root = Split-Path $PSScriptRoot -Parent
$project = Join-Path $root 'unreal\AIFirestation\AIFirestation.uproject'
if ($Action -eq 'doctor') {
Write-Output "UE5.8 엔진 확인: $EngineRoot"
$vswhere = Join-Path ${env:ProgramFiles(x86)} 'Microsoft Visual Studio\Installer\vswhere.exe'
if (-not (Test-Path -LiteralPath $vswhere)) { throw 'MSVC Build Tools / Visual Studio Installer가 없습니다.' }
$msvc = & $vswhere -latest -products '*' -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath
if (-not $msvc) { throw 'MSVC C++ Build Tools가 없습니다.' }
Write-Output "MSVC 확인: $msvc"
Write-Output "맵 생성 여부: $(Test-Path -LiteralPath (Join-Path $root 'unreal\AIFirestation\Content\Maps\RestaurantFire.umap'))"
Write-Output "신호 서버 준비 여부: $(Test-Path -LiteralPath (Join-Path $root '.unreal-runtime\PixelStreamingInfrastructure\SignallingWebServer\dist\index.js'))"
exit 0
}
if ($Action -eq 'build') { & $build AIFirestationEditor Win64 Development "-Project=$project" -WaitMutex -NoHotReloadFromIDE; if ($LASTEXITCODE -ne 0) { throw "UE 빌드 실패: $LASTEXITCODE" }; exit 0 }
if ($Action -eq 'bootstrap') {
$python = Join-Path $root 'unreal\AIFirestation\Scripts\bootstrap.py'
& $editorCmd $project '-run=pythonscript' "-script=$python" '-unattended' '-nosplash' '-NullRHI'
if ($LASTEXITCODE -ne 0) { throw "UE 맵 생성 실패: $LASTEXITCODE" }
if (-not (Test-Path -LiteralPath (Join-Path $root 'unreal\AIFirestation\Content\Maps\RestaurantFire.umap'))) { throw 'UE 맵 생성 결과가 없습니다.' }
exit 0
}
if (-not (Test-Path -LiteralPath (Join-Path $root 'unreal\AIFirestation\Content\Maps\RestaurantFire.umap'))) { throw '먼저 bootstrap을 실행하십시오.' }
& $editor $project '/Game/Maps/RestaurantFire' '-game' "-FireSession=$($Session.ToLowerInvariant())" "-FirestationBaseUrl=$BaseUrl" "-PixelStreamingURL=$SignalUrl" "-PixelStreamingID=$($Session.ToLowerInvariant())" '-RenderOffscreen' '-AudioMixer' '-Unattended' '-ResX=1920' '-ResY=1080' '-ForceRes'
if ($LASTEXITCODE -ne 0) { throw "UE 실행 실패: $LASTEXITCODE" }
tests/auth.test.ts
import { describe, it, expect, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { StationDatabase } from "../packages/station/database";
import { StationAuth } from "../packages/station/auth";
const records: { dir: string; db: StationDatabase }[] = [];
function fixture() {
const dir = mkdtempSync(join(tmpdir(), "firestation-auth-"));
const db = new StationDatabase(join(dir, "station.db"));
records.push({ dir, db });
return { db, auth: new StationAuth(db) };
}
afterEach(() => {
for (const { dir, db } of records.splice(0)) {
db.close();
if (!resolve(dir).startsWith(resolve(tmpdir())))
throw new Error("임시 경로 밖");
rmSync(dir, { recursive: true, force: true });
}
});
const setup = {
stationName: "검증 소방서",
username: "admin-test",
displayName: "관리자",
password: "Strong-test-password-2026!",
};
function bootstrap(auth: StationAuth) {
return auth.bootstrap({ ...setup, setupKey: auth.issueSetupToken() });
}
describe("기관 인증·감사·SQLite", () => {
it("설정 키 없이는 Host 위조와 관계없이 초기화하지 못하며 키는 만료·소진된다", () => {
const { auth, db } = fixture();
expect(() => auth.bootstrap(setup)).toThrow();
const setupKey = auth.issueSetupToken();
expect(() =>
auth.bootstrap({ ...setup, setupKey: "forged-token" }),
).toThrow();
db.sql.prepare("update setup_control set expires_at=0").run();
expect(() => auth.bootstrap({ ...setup, setupKey })).toThrow();
const result = bootstrap(auth);
expect(result.user.role).toBe("admin");
expect(() => auth.bootstrap({ ...setup, setupKey })).toThrow();
});
it("관리자가 계정 비활성화·복구·암호 재설정·세션 폐기를 수행하고 마지막 관리자를 보호한다", () => {
const { auth } = fixture();
const admin = bootstrap(auth).user;
const crew = auth.createUser(admin, {
username: "revoke-crew",
displayName: "회수 대상",
password: "Original-password-2026!",
role: "crew",
});
const old = auth.login({
username: crew.username,
password: "Original-password-2026!",
});
auth.updateUser(admin, { userId: crew.id, active: false });
expect(() => auth.authenticate(old.token)).toThrow();
expect(() =>
auth.login({
username: crew.username,
password: "Original-password-2026!",
}),
).toThrow();
auth.updateUser(admin, {
userId: crew.id,
active: true,
password: "Replacement-password-2026!",
});
expect(() =>
auth.login({
username: crew.username,
password: "Original-password-2026!",
}),
).toThrow();
const fresh = auth.login({
username: crew.username,
password: "Replacement-password-2026!",
});
expect(fresh.user.id).toBe(crew.id);
auth.updateUser(admin, {
userId: crew.id,
password: "Third-password-value-2026!",
});
expect(() => auth.authenticate(fresh.token)).toThrow();
expect(() =>
auth.updateUser(admin, { userId: admin.id, active: false }),
).toThrow(/관리자/);
expect(() =>
auth.updateUser(admin, { userId: admin.id, role: "crew" }),
).toThrow(/관리자/);
expect(() =>
auth.updateUser(admin, { userId: crypto.randomUUID(), active: false }),
).toThrow();
expect(() =>
auth.updateUser(crew, {
userId: admin.id,
password: "Illegal-password-2026!",
}),
).toThrow(/권한/);
});
it("최초 관리자만 설정할 수 있고 암호와 세션 토큰 평문은 저장하지 않는다", () => {
const { auth, db } = fixture();
expect(auth.configured()).toBe(false);
const created = bootstrap(auth);
expect(created.user.role).toBe("admin");
expect(auth.configured()).toBe(true);
expect(() => bootstrap(auth)).toThrow(/설정|관리자/);
const row = db.sql.prepare("select * from users").get();
expect(JSON.stringify(row)).not.toContain(setup.password);
const token = auth.login({
username: setup.username,
password: setup.password,
}).token;
expect(auth.authenticate(token).id).toBe(created.user.id);
expect(
JSON.stringify(db.sql.prepare("select * from auth_sessions").all()),
).not.toContain(token);
});
it("로그아웃·만료·삭제 사용자·임의 토큰은 인증하지 않는다", () => {
const { auth, db } = fixture();
bootstrap(auth);
const login = auth.login({
username: setup.username,
password: setup.password,
});
auth.logout(login.token);
expect(() => auth.authenticate(login.token)).toThrow(/로그인/);
const second = auth.login({
username: setup.username,
password: setup.password,
});
db.sql.prepare("update auth_sessions set expires_at=0").run();
expect(() => auth.authenticate(second.token)).toThrow(/로그인/);
expect(() => auth.authenticate("arbitrary")).toThrow(/로그인/);
});
it("실패를 반복하면 제한되며 사용자 등록은 관리자만 가능하다", () => {
const { auth } = fixture();
const admin = bootstrap(auth).user;
const crew = auth.createUser(admin, {
username: "crew-test",
displayName: "대원",
password: "Crew-test-password-2026!",
role: "crew",
});
expect(() =>
auth.createUser(crew, {
username: "bad-admin",
displayName: "공격",
password: "Strong-pass-2026!",
role: "admin",
}),
).toThrow(/권한/);
for (let i = 0; i < 5; i++)
expect(() =>
auth.login({ username: setup.username, password: "incorrect" }),
).toThrow();
expect(() =>
auth.login({ username: setup.username, password: setup.password }),
).toThrow(/제한|잠시/);
});
it("감사 체인을 검증하고 append-only 제약으로 기록 수정을 거부한다", () => {
const { auth, db } = fixture();
const admin = bootstrap(auth).user;
auth.createUser(admin, {
username: "audit-test",
displayName: "감사자",
password: "Audit-test-password-2026!",
role: "auditor",
});
expect(db.verifyAudit(admin.stationId)).toMatchObject({ valid: true });
expect(() =>
db.sql.prepare("update audit set event_json=?").run("tampered"),
).toThrow();
expect(() => db.sql.prepare("delete from audit").run()).toThrow();
});
it("트랜잭션 오류는 데이터와 감사를 함께 되돌린다", () => {
const { auth, db } = fixture();
const admin = bootstrap(auth).user;
const before = db.sql.prepare("select count(*) as n from audit").get();
expect(() =>
db.transaction(() => {
db.audit(admin.stationId, admin, "test", "event", {});
throw new Error("중단");
}),
).toThrow("중단");
expect(db.sql.prepare("select count(*) as n from audit").get()).toEqual(
before,
);
});
});
tests/cards.test.ts
import { describe, expect, it } from "vitest";
import { cards, searchCards, validateCard } from "../packages/kb";
describe("교육용 카드", () => {
it("40개 상황별 질문·준비·주의·출처·미검수 라벨을 제공한다", () => {
expect(cards).toHaveLength(40);
expect(new Set(cards.map((c) => c.id)).size).toBe(40);
for (const card of cards) {
expect(card.questions.length).toBeGreaterThanOrEqual(3);
expect(card.preparation.length).toBeGreaterThanOrEqual(2);
expect(card.cautions.length).toBeGreaterThanOrEqual(1);
expect(card.sources.length).toBeGreaterThan(0);
expect(card.review.status).toBe("draft");
expect(card.label).toContain("검수 전");
expect(validateCard(card)).toBe(true);
}
});
it("가스 위험을 검색할 때 관련 확인 카드와 출처를 돌려준다", () => {
const found = searchCards("가스");
expect(found.some((c) => c.id === "G-01")).toBe(true);
expect(searchCards("존재하지않는검색어")).toEqual([]);
expect(
found.every((c) => c.sources.every((s) => s.url.startsWith("https://"))),
).toBe(true);
});
it("검수 전 초안을 기관 승인 자료로 위장하거나 미출처 수치를 넣을 수 없다", () => {
const card = cards[0];
expect(validateCard({ ...card, label: "기관 승인" })).toBe(false);
expect(validateCard({ ...card, preparation: ["안전거리 100m 확보"] })).toBe(
false,
);
});
});
tests/commander.test.ts
import { describe, expect, it } from "vitest";
import { createSession } from "../packages/core/engine";
import { scenarios } from "../packages/core/scenario";
import { explain } from "../packages/core/commander/narration";
describe("설명 어댑터 장애 대체", () => {
const decision = createSession(scenarios[0], {
seed: 1,
mode: "DEMO",
level: 3,
}).decisions[0];
it("모델 장애와 시간 초과에도 규칙 설명을 반환하고 대체 사실을 표시한다", async () => {
const fail = await explain(
decision,
async () => {
throw new Error("연결 장애");
},
10,
);
expect(fail.source).toBe("rules");
expect(fail.fallback).toBe(true);
expect(fail.text).toBe(decision.reason);
const slow = await explain(decision, () => new Promise(() => {}), 5);
expect(slow.fallback).toBe(true);
});
it("형식이 잘못된 모델 설명을 사용하지 않는다", async () => {
const result = await explain(decision, async () => "", 10);
expect(result.fallback).toBe(true);
});
});
tests/demo-audio-player.test.ts
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { DemoAudioPlayer } from "../apps/web/components/demo-audio-player";
import { introCues } from "../packages/simulation/demo-audio";
import { scenarios, publicScenario } from "../packages/core/scenario";
class Source {
loop = false;
started = false;
stopped = false;
buffer = null;
onended: (() => void) | null = null;
connect() {}
disconnect() {}
start() {
this.started = true;
}
stop() {
this.stopped = true;
this.onended?.();
}
}
class Context {
static last: Context;
state = "suspended";
currentTime = 0;
destination = {};
sources: Source[] = [];
constructor() {
Context.last = this;
}
async resume() {
this.state = "running";
}
async suspend() {
this.state = "suspended";
}
async close() {
this.state = "closed";
}
createGain() {
return { gain: { value: 1, setTargetAtTime() {} }, connect() {} };
}
createBufferSource() {
const source = new Source();
this.sources.push(source);
return source;
}
async decodeAudioData() {
return { duration: 1 };
}
}
const settle = async () => {
await new Promise((r) => setTimeout(r, 0));
};
class Utterance {
onend: (() => void) | null = null;
onerror: (() => void) | null = null;
constructor(public text: string) {}
}
let spoken: Utterance;
beforeEach(() => {
vi.stubGlobal("window", {
speechSynthesis: {
cancel() {},
pause() {},
resume() {},
getVoices: () => [],
speak: (utterance: Utterance) => {
spoken = utterance;
},
},
});
vi.stubGlobal("SpeechSynthesisUtterance", Utterance);
vi.stubGlobal("AudioContext", Context);
vi.stubGlobal(
"fetch",
vi.fn(async () => ({
ok: true,
arrayBuffer: async () => new ArrayBuffer(4),
})),
);
});
afterEach(() => {
vi.useRealTimers();
vi.unstubAllGlobals();
});
describe("교육 음향 재생기의 중단·비동기 수명", () => {
it("음소거 뒤 늦게 도착한 합성 음성 오류가 꺼짐 상태를 덮어쓰지 않는다", async () => {
const player = new DemoAudioPlayer(() => {});
player.unlock();
await settle();
player.enqueue([
{
id: "order:custom",
role: "지휘본부",
text: "사용자 작성 명령입니다",
at: 0,
},
]);
const lateError = spoken.onerror!;
expect(player.snapshot.phase).toBe("playing");
player.mute();
lateError();
expect(player.snapshot.phase).toBe("off");
player.dispose();
});
it("일시정지 시간은 사용자 작성 음성의 20초 제한에 포함하지 않는다", async () => {
vi.useFakeTimers();
const player = new DemoAudioPlayer(() => {});
player.unlock();
await vi.advanceTimersByTimeAsync(0);
player.enqueue([
{
id: "order:custom",
role: "지휘본부",
text: "사용자 작성 명령입니다",
at: 0,
},
]);
await vi.advanceTimersByTimeAsync(5000);
player.setPaused(true);
await vi.advanceTimersByTimeAsync(25000);
expect(player.snapshot.phase).toBe("paused");
expect(player.snapshot.cue?.id).toBe("order:custom");
player.setPaused(false);
await vi.advanceTimersByTimeAsync(14000);
expect(player.snapshot.phase).toBe("playing");
await vi.advanceTimersByTimeAsync(1000);
expect(player.snapshot.phase).toBe("ready");
player.dispose();
});
it("개입 중 음향을 다시 켜도 사건의 정지를 풀거나 소리를 재생하지 않는다", async () => {
const player = new DemoAudioPlayer(() => {});
player.unlock();
await settle();
player.setPaused(true);
player.mute();
player.unlock();
await settle();
expect(Context.last.state).toBe("suspended");
expect(player.snapshot.phase).toBe("paused");
player.dispose();
});
it("발행 명령이 취소되면 이미 재생 중인 음원도 즉시 중단한다", async () => {
const player = new DemoAudioPlayer(() => {});
player.unlock();
await settle();
player.enqueue([
{
...introCues(publicScenario(scenarios[0]))[0],
id: "order:CMD-1",
commandId: "CMD-1",
},
]);
await settle();
expect(Context.last.sources[0].started).toBe(true);
player.reconcile(new Set(), 0, false);
expect(Context.last.sources[0].stopped).toBe(true);
player.dispose();
});
it("일시정지는 context와 이동 사이렌을 멈추고 음소거는 모든 소스를 정리한다", async () => {
const player = new DemoAudioPlayer(() => {});
player.unlock();
await settle();
player.enqueue([introCues(publicScenario(scenarios[0]))[0]]);
player.setSiren(true);
await settle();
const context = Context.last;
expect(context.sources.filter((s) => s.started)).toHaveLength(2);
player.setPaused(true);
await settle();
expect(context.state).toBe("suspended");
expect(context.sources.find((s) => s.loop)?.stopped).toBe(true);
player.mute();
expect(context.sources.every((s) => s.stopped)).toBe(true);
player.dispose();
await settle();
expect(context.state).toBe("closed");
});
it("새 시연으로 교체하는 동안 이전 음원 응답이 늦게 도착해도 재생하지 않는다", async () => {
let finish!: (r: unknown) => void;
vi.stubGlobal(
"fetch",
() =>
new Promise((resolve) => {
finish = resolve;
}),
);
const player = new DemoAudioPlayer(() => {});
player.unlock();
await settle();
player.enqueue([introCues(publicScenario(scenarios[0]))[0]]);
player.clear();
finish({ ok: true, arrayBuffer: async () => new ArrayBuffer(4) });
await settle();
expect(Context.last.sources).toHaveLength(0);
player.dispose();
});
it("도입 재생 중 음소거 또는 음원 오류가 나도 사건 진행 gate를 한 번 해제한다", async () => {
const player = new DemoAudioPlayer(() => {}),
done = vi.fn();
player.unlock();
await settle();
player.startIntro(introCues(publicScenario(scenarios[0])), done);
player.mute();
player.clear();
expect(done).toHaveBeenCalledTimes(1);
player.dispose();
vi.stubGlobal("fetch", async () => {
throw Error("연결 실패");
});
const failing = new DemoAudioPlayer(() => {}),
proceed = vi.fn();
failing.unlock();
await settle();
failing.startIntro(introCues(publicScenario(scenarios[0])), proceed);
await settle();
expect(proceed).toHaveBeenCalledTimes(1);
expect(failing.snapshot.phase).toBe("error");
failing.dispose();
});
});
tests/demo-audio.test.ts
import { describe, expect, it } from "vitest";
import {
applyAction as act,
createSession,
publicState,
tick,
} from "../packages/core/engine";
import { scenarios } from "../packages/core/scenario";
import {
audioEvents,
DemoAudioCursor,
introCues,
sirenShouldPlay,
} from "../packages/simulation/demo-audio";
const session = (level: 2 | 3 = 3) =>
createSession(scenarios[0], {
id: "sound-test",
seed: 42,
mode: "DEMO",
level,
});
describe("가상 출동 음향의 공개 상태 계약", () => {
it("신고전화는 안내·벨·접수요원·신고자·접수 확인 순서이고 시나리오의 공개 위치를 쓴다", () => {
const cues = introCues(publicState(session()).scenario);
expect(cues.map((c) => c.role)).toEqual([
"교육 안내",
"전화벨",
"접수요원",
"신고자",
"접수요원",
]);
expect(cues[3].text).toContain("중앙로 24");
expect(JSON.stringify(cues)).not.toContain("강사 전용");
});
it("미승인 초안은 방송하지 않고 승인 후 같은 명령을 한 번만 읽는다", () => {
let s = session(2);
const cursor = new DemoAudioCursor();
cursor.attach(publicState(s));
expect(audioEvents(publicState(s))).toEqual([]);
s = act(s, { kind: "approve", commandId: s.commands[0].id });
expect(cursor.collect(publicState(s)).map((c) => c.commandId)).toEqual([
s.commands[0].id,
]);
expect(cursor.collect(publicState(s))).toEqual([]);
s = act(s, { kind: "cancel", commandId: s.commands[0].id });
expect(audioEvents(publicState(s))).toEqual([]);
});
it("사이렌은 임무 완료8초가 아닌 실제 이동 ETA 기준으로 멈춘다", () => {
let s = session();
expect(sirenShouldPlay(publicState(s))).toBe(true);
s = tick(s, 8);
expect(s.commands[0].status).toBe("complete");
expect(sirenShouldPlay(publicState(s))).toBe(true);
s = tick(tick(s, 30), 2);
expect(sirenShouldPlay(publicState(s))).toBe(false);
});
it("지연 승인·개입·취소·종료에서는 사이렌이 공개 이동 상태와 일치한다", () => {
let s = tick(session(2), 30);
expect(sirenShouldPlay(publicState(s))).toBe(false);
s = act(s, { kind: "approve", commandId: s.commands[0].id });
expect(sirenShouldPlay(publicState(s))).toBe(true);
expect(sirenShouldPlay(publicState(act(s, { kind: "intervene" })))).toBe(
false,
);
expect(
sirenShouldPlay(
publicState(act(s, { kind: "cancel", commandId: s.commands[0].id })),
),
).toBe(false);
expect(sirenShouldPlay({ ...publicState(s), status: "closed" })).toBe(
false,
);
});
it("수신·드론·선착 보고는 해당 공개 이벤트 이후에만 나타난다", () => {
let s = session();
expect(audioEvents(publicState(s)).some((c) => c.role === "대원")).toBe(
false,
);
s = tick(s, 2);
expect(audioEvents(publicState(s)).some((c) => c.id === "ack:CMD-1")).toBe(
true,
);
s = tick(s, 18);
expect(
audioEvents(publicState(s)).some(
(c) => c.id === "observation:drone-arrived",
),
).toBe(true);
expect(
audioEvents(publicState(s)).some(
(c) => c.id === "observation:unit-arrived",
),
).toBe(false);
});
it("새로고침·다른 사건 조회는 과거 방송을 재생하지 않고 종료 시 오래된 대기 방송을 제외한다", () => {
let s = tick(session(), 30);
const cursor = new DemoAudioCursor();
cursor.attach(publicState(s));
expect(cursor.collect(publicState(s))).toEqual([]);
s = tick(s, 10);
expect(
cursor
.collect(publicState(s))
.some((c) => c.id === "observation:unit-arrived"),
).toBe(true);
const ended = tick(tick(s, 30), 30);
expect(cursor.collect(publicState(ended)).map((c) => c.id)).toEqual([
"training-end",
]);
expect(
cursor.collect({ ...publicState(s), id: "another-session" }),
).toEqual([]);
});
});
tests/docs.test.ts
import { describe, expect, it, afterEach } from "vitest";
import {
mkdtempSync,
mkdirSync,
writeFileSync,
readFileSync,
rmSync,
} from "node:fs";
import { tmpdir } from "node:os";
import { join, resolve } from "node:path";
import { execFileSync } from "node:child_process";
import {
syncDocuments,
checkDocuments,
sourceFingerprint,
} from "../packages/docs/build";
import { watchDocumentSources } from "../packages/docs/watch";
const roots: string[] = [];
function fixture() {
const root = mkdtempSync(join(tmpdir(), "firestation-docs-"));
roots.push(root);
mkdirSync(join(root, "packages", "sample"), { recursive: true });
writeFileSync(
join(root, "package.json"),
JSON.stringify({ version: "0.3.0" }),
);
writeFileSync(
join(root, "packages", "sample", "index.ts"),
"export const version = 1;\n",
);
return root;
}
afterEach(() => {
for (const root of roots.splice(0)) {
if (!resolve(root).startsWith(resolve(tmpdir())))
throw new Error("임시 경로 밖 삭제 금지");
rmSync(root, { recursive: true, force: true });
}
});
describe("사용설명서·재구현 문서 동기화", () => {
it("PDF 변환용 mjs 소스도 재구현 스냅샷과 변경 지문에 포함한다", () => {
const root = fixture();
mkdirSync(join(root, "scripts"));
const file = join(root, "scripts/extract-library-pdf.mjs");
writeFileSync(file, "// 변환 원본\n");
syncDocuments(root);
expect(readFileSync(join(root, "logic.html"), "utf8")).toContain(
"extract-library-pdf.mjs",
);
const before = sourceFingerprint(root);
writeFileSync(file, "// 변환 갱신\n");
expect(sourceFingerprint(root)).not.toBe(before);
});
it("배포 설정도 지문에 포함하되 서버 환경 파일은 스냅샷에서 제외한다", () => {
const root = fixture();
mkdirSync(join(root, "deploy"));
writeFileSync(join(root, "deploy/Dockerfile"), "FROM node:22\n");
writeFileSync(join(root, "deploy/compose.yaml"), "services: {}\n");
writeFileSync(join(root, "deploy/nginx.conf.template"), "server {}\n");
writeFileSync(join(root, "deploy/.env"), "SECRET=server-only");
writeFileSync(join(root, ".dockerignore"), ".data\n");
syncDocuments(root);
const logic = readFileSync(join(root, "logic.html"), "utf8");
for (const file of [
"deploy/Dockerfile",
"deploy/compose.yaml",
"deploy/nginx.conf.template",
".dockerignore",
])
expect(logic).toContain(file);
expect(logic).not.toContain("server-only");
const before = sourceFingerprint(root);
writeFileSync(
join(root, "deploy/nginx.conf.template"),
"server { listen 443; }\n",
);
expect(sourceFingerprint(root)).not.toBe(before);
});
it("UE 소스·설정·실행 스크립트 변경을 문서에 반영하고 엔진 산출물은 제외한다", async () => {
const root = fixture();
const sceneDir = join(root, "unreal/AIFirestation/Source/AIFirestation");
mkdirSync(sceneDir, { recursive: true });
writeFileSync(join(sceneDir, "RestaurantScene.cpp"), "// 초기 장면\n");
mkdirSync(join(root, "unreal/AIFirestation/Intermediate"));
writeFileSync(
join(root, "unreal/AIFirestation/Intermediate/cache.cpp"),
"private-generated-cache",
);
syncDocuments(root);
const before = sourceFingerprint(root);
expect(readFileSync(join(root, "logic.html"), "utf8")).toContain(
"RestaurantScene.cpp",
);
expect(readFileSync(join(root, "logic.html"), "utf8")).not.toContain(
"private-generated-cache",
);
const stop = watchDocumentSources(root, () => syncDocuments(root));
try {
writeFileSync(join(sceneDir, "RestaurantScene.cpp"), "// 갱신 장면\n");
await expect
.poll(
() => {
try {
return checkDocuments(root);
} catch {
return false;
}
},
{ timeout: 4000 },
)
.toBe(true);
expect(sourceFingerprint(root)).not.toBe(before);
for (const extension of ["h", "cs", "ini", "uproject", "ps1"]) {
const current = sourceFingerprint(root);
writeFileSync(join(sceneDir, `example.${extension}`), "검증 소스");
expect(sourceFingerprint(root)).not.toBe(current);
}
} finally {
stop();
}
});
it("코드만 스테이지하고 설명서를 빠뜨린 커밋은 거부한다", () => {
const root = fixture();
syncDocuments(root);
execFileSync("git", ["init", "-q"], { cwd: root });
execFileSync("git", ["add", "."], { cwd: root });
expect(checkDocuments(root, true)).toBe(true);
writeFileSync(
join(root, "packages/sample/index.ts"),
"export const version=3;",
);
syncDocuments(root);
execFileSync("git", ["add", "packages/sample/index.ts"], { cwd: root });
expect(() => checkDocuments(root, true)).toThrow(/지문|갱신/);
execFileSync("git", ["add", "."], { cwd: root });
expect(checkDocuments(root, true)).toBe(true);
});
it("Next 설정 파일 변경도 감시해 두 HTML을 자동 갱신한다", async () => {
const root = fixture();
mkdirSync(join(root, "apps/web"), { recursive: true });
writeFileSync(join(root, "apps/web/next.config.ts"), "export default {};");
syncDocuments(root);
const before = sourceFingerprint(root);
const stop = watchDocumentSources(root, () => syncDocuments(root));
try {
writeFileSync(
join(root, "apps/web/next.config.ts"),
"export default {reactStrictMode:true};",
);
await expect
.poll(
() => {
try {
return checkDocuments(root);
} catch {
return false;
}
},
{ timeout: 4000 },
)
.toBe(true);
expect(sourceFingerprint(root)).not.toBe(before);
} finally {
stop();
}
});
it("루트와 공개 HTML을 같은 지문으로 생성하며 오프라인 안내와 재구현 프롬프트를 포함한다", () => {
const root = fixture();
syncDocuments(root);
const manual = readFileSync(join(root, "manual.html"), "utf8");
const logic = readFileSync(join(root, "logic.html"), "utf8");
expect(manual).toContain("1. 쉽게 따라하기");
expect(manual).toContain("2. 상세 사용설명서");
expect(logic).toContain("AI 재구현 프롬프트");
expect(logic).toContain("export const version = 1;");
expect(logic).not.toContain('<script src="http');
expect(manual).toEqual(
readFileSync(join(root, "apps/web/public/manual.html"), "utf8"),
);
expect(checkDocuments(root)).toBe(true);
});
it("어떤 코드 변경도 지문을 바꾸며 오래된 문서를 거부한다", () => {
const root = fixture();
syncDocuments(root);
const before = sourceFingerprint(root);
writeFileSync(
join(root, "packages/sample/index.ts"),
"export const version = 2;\n",
);
expect(sourceFingerprint(root)).not.toBe(before);
expect(() => checkDocuments(root)).toThrow(/문서|갱신/);
syncDocuments(root);
expect(checkDocuments(root)).toBe(true);
});
it("같은 코드에서 반복 생성해도 결과가 바뀌지 않으며 HTML 손상을 검출한다", () => {
const root = fixture();
syncDocuments(root);
const before = readFileSync(join(root, "manual.html"), "utf8");
syncDocuments(root);
expect(readFileSync(join(root, "manual.html"), "utf8")).toBe(before);
writeFileSync(
join(root, "apps/web/public/manual.html"),
before.replace("쉽게 따라하기", "훼손된 안내"),
);
expect(() => checkDocuments(root)).toThrow(/문서|일치/);
});
it("소스 스냅샷의 HTML을 이스케이프하고 비밀·데이터·빌드 결과를 제외한다", () => {
const root = fixture();
writeFileSync(
join(root, "packages/sample/index.ts"),
'export const label = "</script><img src=x onerror=alert(1)>";',
);
writeFileSync(join(root, ".env"), "SECRET=should-not-appear");
mkdirSync(join(root, ".data"));
writeFileSync(join(root, ".data/private.json"), "sensitive-incident");
syncDocuments(root);
const logic = readFileSync(join(root, "logic.html"), "utf8");
expect(logic).toContain("<img");
expect(logic).not.toContain("<img src=x");
expect(logic).not.toContain("should-not-appear");
expect(logic).not.toContain("sensitive-incident");
});
});
tests/e2e/audio.e2e.ts
import { test, expect } from "@playwright/test";
test("신고 전화부터 지휘·사이렌까지 실제 WebAudio로 재생하고 개입하면 정지한다", async ({
page,
}) => {
test.setTimeout(60000);
await page.goto("/demo");
await page.getByRole("button", { name: "시연 실행", exact: true }).click();
const caption = page.locator(".sound-caption");
await expect(caption).toHaveAttribute("data-audio-cue", "intro:notice");
await expect(caption).toHaveAttribute("data-audio-cue", "intro:operator", {
timeout: 10000,
});
await expect(caption).toHaveAttribute(
"data-audio-cue",
"intro:caller:restaurant-gas",
{ timeout: 10000 },
);
await expect(page.locator(".situation-map-panel .mono").first()).toHaveText(
"T+00:00",
);
await expect(caption).toHaveAttribute("data-audio-cue", "intro:received", {
timeout: 13000,
});
await expect(caption).toHaveAttribute("data-audio-cue", "order:CMD-1", {
timeout: 10000,
});
await expect(caption).toHaveAttribute("data-siren", "true");
await page.getByRole("button", { name: "재생 정지", exact: true }).click();
await expect(caption).toHaveAttribute("data-audio-phase", "paused");
await expect(caption).toHaveAttribute("data-siren", "false");
await page.getByRole("button", { name: "재생 시작", exact: true }).click();
await expect(caption).toHaveAttribute("data-siren", "true");
await page
.getByRole("button", { name: "개입 · 일시정지", exact: true })
.click();
await expect(caption).toHaveAttribute("data-audio-phase", "paused");
await expect(caption).toHaveAttribute("data-siren", "false");
await page.getByRole("button", { name: "음향 켜짐", exact: true }).click();
await expect(caption).toHaveAttribute("data-audio-phase", "off");
});
test("음원 로드가 실패해도 사건 진행을 계속하고 음향 오류를 알린다", async ({
page,
}) => {
await page.route("**/audio/demo/*.wav", (route) =>
route.fulfill({ status: 503, body: "" }),
);
await page.goto("/demo");
await page.getByRole("button", { name: "시연 실행", exact: true }).click();
await expect(page.locator(".sound-caption")).toHaveAttribute(
"data-audio-phase",
"error",
);
await expect(page.locator(".sound-caption")).toContainText("실패");
await expect(
page.locator(".situation-map-panel .mono").first(),
).not.toHaveText("T+00:00");
});
test("스마트폰에서 음향·44px 볼륨을 조작하고 새로고침은 과거 신고를 다시 틀지 않는다", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/demo");
await page.getByRole("button", { name: "시연 실행", exact: true }).click();
await expect(page.locator(".sound-caption")).toHaveAttribute(
"data-audio-phase",
"playing",
);
const slider = page.getByLabel("교육 음향 볼륨"),
bounds = await slider.boundingBox();
expect(bounds!.height).toBeGreaterThanOrEqual(44);
await slider.fill("20");
await page.getByRole("button", { name: "음향 켜짐", exact: true }).click();
await expect(page.locator(".sound-caption")).toHaveAttribute(
"data-audio-phase",
"off",
);
expect(
await page.evaluate(
() =>
document.documentElement.scrollWidth <= innerWidth &&
document.documentElement.scrollHeight <= innerHeight + 1,
),
).toBe(true);
const expand = page.getByRole("button", { name: "음향 자막 전체 보기" });
expect((await expand.boundingBox())!.height).toBeGreaterThanOrEqual(44);
await expand.click();
await expect(page.locator(".sound-caption")).toHaveAttribute(
"data-expanded",
"true",
);
await expect(page.locator(".sound-caption span")).toHaveCSS(
"white-space",
"normal",
);
await page.getByRole("button", { name: "음향 자막 접기" }).click();
await page.reload();
await expect(
page.getByRole("button", { name: "음향 꺼짐", exact: true }),
).toBeVisible();
await expect(page.locator(".sound-caption")).toHaveAttribute(
"data-audio-phase",
"off",
);
});
tests/e2e/demo.e2e.ts
import { test, expect } from "@playwright/test";
import type { PublicState } from "../../packages/core/types";
async function movingSession(
request: import("@playwright/test").APIRequestContext,
) {
let state: PublicState = await (
await request.post("/api/sessions", {
data: { scenarioId: "restaurant-gas", seed: 42, level: 3 },
})
).json();
return {
get state() {
return state;
},
async advance(seconds: number) {
const r = await request.post(`/api/sessions/${state.id}`, {
data: { version: state.version, action: { kind: "tick", seconds } },
});
expect(r.ok()).toBe(true);
state = await r.json();
},
};
}
function marker(page: import("@playwright/test").Page, name: string) {
return page
.locator(".incident-map svg")
.getByText(name, { exact: true })
.locator("..");
}
async function coords(page: import("@playwright/test").Page, name: string) {
return (await marker(page, name).getAttribute("transform"))!
.match(/-?[\d.]+/g)!
.map(Number);
}
test("펌프1은 도로 구간과 회전을 따라 현장 진입 지점에 도착한다", async ({
page,
request,
}) => {
const s = await movingSession(request);
for (const [seconds, x, y] of [
[0, 75, 224],
[20, 329.5, 224],
[10, 355, 122.25],
[10, 470, 110],
]) {
if (seconds) await s.advance(seconds);
await page.goto(`/demo?session=${s.state.id}&view=vehicle`);
await expect(marker(page, "펌프1")).toBeVisible();
const p = await coords(page, "펌프1");
expect(p[0]).toBeCloseTo(x);
expect(p[1]).toBeCloseTo(y);
}
});
test("드론은 소방서에서 직선 비행해 펌프1보다 먼저 신고 위치에 도착한다", async ({
page,
request,
}) => {
const s = await movingSession(request);
for (const [seconds, x, y] of [
[8, 75, 224],
[6, 272.5, 194],
[6, 470, 164],
]) {
await s.advance(seconds);
await page.goto(`/demo?session=${s.state.id}&view=vehicle`);
await expect(marker(page, "드론")).toBeVisible();
const p = await coords(page, "드론");
expect(p[0]).toBeCloseTo(x);
expect(p[1]).toBeCloseTo(y);
}
expect(s.state.drone.eta).toBe(0);
expect(s.state.units[0].eta).toBe(20);
});
test("소개→가상 시연→개입·수동 전환→카드 검색", async ({ page }) => {
await page.goto("/");
await expect(
page.getByRole("heading", {
name: "신고부터 복기까지, AI와 함께 지휘하다.",
}),
).toBeVisible();
await page.getByRole("link", { name: "가상 시연 시작", exact: true }).click();
await expect(
page.getByText("가상 훈련 · 실제 출동 아님", { exact: true }),
).toBeVisible();
await expect(
page.getByRole("button", { name: "시연 실행", exact: true }),
).toBeEnabled();
await page.getByRole("button", { name: "시연 실행", exact: true }).click();
await page
.getByRole("button", { name: "개입 · 일시정지", exact: true })
.click();
await expect(
page.getByText("사람이 개입했습니다", { exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "내가 지휘", exact: true }).click();
await expect(
page
.getByRole("complementary")
.getByText("L0 · 사람 지휘", { exact: true }),
).toBeVisible();
await page.getByRole("link", { name: "매뉴얼 카드", exact: true }).click();
await page.getByPlaceholder("상황·위험·카드 ID 검색").fill("가스");
await expect(
page.getByRole("button", { name: /G-01 가스 냄새/ }),
).toBeVisible();
await page.getByRole("button", { name: /G-01 가스 냄새/ }).click();
await expect(
page.getByText("가스 장비 준비 확인", { exact: true }),
).toBeVisible();
});
test("API는 실제 모드·과거 버전 요청을 거부하고 개인정보 비공개 계약을 유지한다", async ({
request,
}) => {
const invalid = await request.post("/api/sessions", {
data: { scenarioId: "restaurant-gas", seed: 1, mode: "OPERATIONS" },
});
expect(invalid.status()).toBe(400);
const created = await request.post("/api/sessions", {
data: { scenarioId: "restaurant-gas", seed: 42 },
});
expect(created.status()).toBe(201);
const state = await created.json();
expect(state.scenario).not.toHaveProperty("hidden");
expect(state.fireCells).toEqual([]);
const changed = await request.post(`/api/sessions/${state.id}`, {
data: { version: state.version, action: { kind: "tick", seconds: 1 } },
});
expect(changed.status()).toBe(200);
const stale = await request.post(`/api/sessions/${state.id}`, {
data: { version: state.version, action: { kind: "tick", seconds: 1 } },
});
expect(stale.status()).toBe(409);
});
test("모바일에서 가상 표식·핵심 조작을 볼 수 있고 가로 넘침이 없다", async ({
page,
}) => {
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/demo");
await expect(
page.getByRole("button", { name: "시연 실행", exact: true }),
).toBeVisible();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= window.innerWidth,
),
).toBe(true);
});
test("자동 시연은 종료까지 진행하고 차량 보기에서 같은 상태를 공유한다", async ({
page,
context,
}) => {
await page.goto("/demo");
await page.getByRole("button", { name: "음향 켜짐", exact: true }).click();
await page.getByRole("button", { name: "시연 실행", exact: true }).click();
await page.getByLabel("가상 시간 배속").selectOption("8");
await expect(
page.getByRole("link", { name: "차량 화면 열기" }),
).toBeVisible();
const vehicle = await context.newPage();
await vehicle.goto(
(await page
.getByRole("link", { name: "차량 화면 열기" })
.getAttribute("href"))!,
);
await expect(
vehicle.getByRole("heading", { name: "출동 차량 보기", exact: true }),
).toBeVisible();
await expect(
vehicle.getByRole("button", { name: "개입 · 일시정지" }),
).toHaveCount(0);
await expect(
page.getByRole("heading", { name: "가상 훈련이 종료됐습니다." }),
).toBeVisible({ timeout: 20000 });
await expect(
vehicle.getByRole("heading", { name: "가상 훈련이 종료됐습니다." }),
).toBeVisible();
await page.screenshot({ path: "artifacts/command-room.png", fullPage: true });
await page.reload();
await expect(
page.getByRole("heading", { name: "가상 훈련이 종료됐습니다." }),
).toBeVisible();
});
test("소개와 모바일 상황본부 미리보기", async ({ page }) => {
await page.goto("/");
await page.screenshot({ path: "artifacts/home.png", fullPage: true });
await page.setViewportSize({ width: 390, height: 844 });
await page.goto("/demo");
await page.screenshot({ path: "artifacts/mobile.png", fullPage: true });
});
test("L2 초안 상태에서 음향을 켠 후 승인하면 해당 명령을 방송한다", async ({
page,
}) => {
await page.goto("/demo");
await page.getByRole("button", { name: "음향 켜짐", exact: true }).click();
await page.getByLabel("지휘 수준").selectOption("2");
await page.getByRole("button", { name: "시연 실행", exact: true }).click();
await page.getByRole("button", { name: "음향 꺼짐", exact: true }).click();
await expect(page.locator(".sound-caption")).not.toHaveAttribute(
"data-audio-phase",
"playing",
);
await page.getByRole("button", { name: "승인", exact: true }).click();
await expect(page.locator(".sound-caption")).toHaveAttribute(
"data-audio-cue",
"order:CMD-1",
);
await expect(page.locator(".sound-caption")).toHaveAttribute(
"data-audio-phase",
"playing",
);
await expect(
page.getByRole("button", { name: "음향 켜짐", exact: true }),
).toBeVisible();
});
test("늦게 승인한 드론도 지도 안에 표시된다", async ({ page, request }) => {
let state = await (
await request.post("/api/sessions", {
data: { scenarioId: "restaurant-gas", seed: 42, level: 2 },
})
).json();
async function action(action: unknown) {
state = await (
await request.post(`/api/sessions/${state.id}`, {
data: { version: state.version, action },
})
).json();
}
await action({ kind: "tick", seconds: 30 });
await action({ kind: "tick", seconds: 30 });
await action({ kind: "approve", commandId: state.commands[0].id });
await action({ kind: "tick", seconds: 8 });
await action({
kind: "approve",
commandId: state.commands.find(
(c: { actionKey: string }) => c.actionKey === "RECON",
).id,
});
await page.goto(`/demo?session=${state.id}&view=vehicle`);
const marker = page.getByText("드론", { exact: true }).locator("..");
await expect(marker).toBeVisible();
const transform = await marker.getAttribute("transform");
const coords = transform!.match(/[\d.]+/g)!.map(Number);
expect(coords[0]).toBeGreaterThanOrEqual(20);
expect(coords[0]).toBeLessThanOrEqual(700);
expect(coords[1]).toBeGreaterThanOrEqual(20);
expect(coords[1]).toBeLessThanOrEqual(340);
});
tests/e2e/lan.e2e.ts
import { test, expect } from "@playwright/test";
test("HTTP LAN 스마트폰에서도 수신·수행·완료 요청에 유효한 UUID를 보낸다", async ({
page,
baseURL,
}) => {
// LAN 원점의 브라우저 보안 조건을 재현한다. 기관 DB 변경 없이 UI 요청만 검사한다.
const origin = "http://192.0.2.10:3100";
const at = new Date().toISOString();
const user = {
id: "crew",
stationId: "station",
username: "crew",
displayName: "검증 대원",
role: "crew",
};
const recipient = {
resourceId: "resource",
userId: "crew",
status: "pending",
at,
};
const command = {
id: "command",
text: "현장 확인 보고",
evidenceIds: [],
basis: 0,
status: "issued",
recipients: [recipient],
createdBy: "commander",
createdAt: at,
};
const incident = {
id: "incident",
title: "검증 화재",
address: "검증 주소",
report: "검증 신고",
commanderId: "commander",
createdAt: at,
status: "open",
version: 1,
observationRevision: 0,
observations: [],
handovers: [],
commands: [command],
};
const snapshot = {
user,
users: [],
incidents: [incident],
resources: [
{
id: "resource",
name: "검증 펌프",
kind: "펌프",
crewUserId: "crew",
incidentId: "incident",
},
],
evidence: [],
suggestions: {},
};
const writes: { type: string; requestId: string }[] = [];
const errors: string[] = [];
page.on("pageerror", (e) => errors.push(e.message));
await page.setViewportSize({ width: 390, height: 844 });
await page.route("**/*", async (route) => {
const url = route.request().url();
if (url === origin + "/api/station/auth")
return route.fulfill({ json: { configured: true, user } });
if (url === origin + "/api/station")
return route.fulfill({ json: snapshot });
if (url === origin + "/api/station/incidents/incident") {
const data = route.request().postDataJSON();
writes.push({ type: data.type, requestId: data.requestId });
expect(data.version).toBe(incident.version);
expect(data.requestId).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i,
);
recipient.status = {
ack: "acknowledged",
execute: "executing",
complete: "complete",
}[data.type as "ack" | "execute" | "complete"];
if (data.type === "complete") command.status = "complete";
incident.version++;
return route.fulfill({ json: { ok: true } });
}
if (!url.startsWith(origin + "/")) return route.abort();
const response = await route.fetch({ url: url.replace(origin, baseURL!) });
return route.fulfill({ response });
});
await page.goto(origin + "/station");
expect(await page.evaluate(() => isSecureContext)).toBe(false);
expect(await page.evaluate(() => typeof crypto.randomUUID)).toBe("undefined");
for (const name of ["수신 확인", "수행 시작", "완료 보고"]) {
await page.getByRole("button", { name, exact: true }).click();
}
await expect(page.locator(".station-command")).toContainText("완료");
expect(writes.map((w) => w.type)).toEqual(["ack", "execute", "complete"]);
expect(new Set(writes.map((w) => w.requestId)).size).toBe(3);
expect(errors).toEqual([]);
});
tests/e2e/library-html.e2e.ts
import { test, expect } from "@playwright/test";
import { readFileSync } from "node:fs";
const catalog = JSON.parse(
readFileSync("packages/library/catalog.json", "utf8"),
);
test("PC 읽기 영역은 본문을 자르지 않고 모바일에서도 페이지 조작이 가능하다", async ({
page,
}) => {
await page.setViewportSize({ width: 1366, height: 768 });
await page.goto("/library");
await page.getByLabel("PDF 페이지").fill("35");
await page.getByRole("button", { name: "페이지 이동", exact: true }).click();
await expect(page.locator('.html-page[data-page="35"]')).toHaveAttribute(
"aria-busy",
"false",
);
await expect(page.locator(".catalog-toggle")).toBeHidden();
const readable = await page.evaluate(() => {
const article = document.querySelector(".html-page")!;
let top = article.getBoundingClientRect().top;
let bottom = Math.min(article.getBoundingClientRect().bottom, innerHeight);
for (
let parent = article.parentElement;
parent;
parent = parent.parentElement
) {
if (getComputedStyle(parent).overflowY !== "visible") {
const rect = parent.getBoundingClientRect();
top = Math.max(top, rect.top);
bottom = Math.min(bottom, rect.bottom);
}
}
return {
height: bottom - top,
fits: document.documentElement.scrollHeight <= innerHeight + 1,
};
});
expect(readable.height).toBeGreaterThan(180);
expect(readable.fits).toBe(true);
await page.setViewportSize({ width: 390, height: 844 });
await page.evaluate(() => window.scrollTo(0, 0));
await expect(page.locator(".catalog-toggle")).toBeVisible();
expect((await page.locator(".library-reader").boundingBox())!.y).toBeLessThan(
600,
);
const next = page.getByRole("button", { name: "다음 페이지", exact: true });
expect((await next.boundingBox())!.height).toBeGreaterThanOrEqual(44);
await next.click();
await expect(page.locator(".html-page")).toHaveAttribute("data-page", "36");
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
});
test("로그아웃 후 늦은 순서 저장 응답이 기관 목록을 다시 표시하지 않는다", async ({
page,
}) => {
let signedIn = true,
release!: () => void,
received!: () => void,
finished!: () => void;
const gate = new Promise<void>((r) => (release = r)),
seen = new Promise<void>((r) => (received = r)),
done = new Promise<void>((r) => (finished = r));
const privateDoc = {
...catalog.documents[0],
id: "upload-private",
title: "기관 내부 비공개 교재",
source: "uploaded",
};
const user = {
id: "admin",
stationId: "station",
username: "admin",
displayName: "관리자",
role: "admin",
};
await page.route("**/api/library?catalog=1", (route) =>
route.fulfill({
json: {
documents: signedIn
? [...catalog.documents, privateDoc]
: catalog.documents,
version: 0,
user: signedIn ? user : null,
},
}),
);
await page.route("**/api/library/order", async (route) => {
received();
await gate;
await route.fulfill({
json: {
documents: [
catalog.documents[1],
catalog.documents[0],
...catalog.documents.slice(2),
privateDoc,
],
version: 1,
},
});
finished();
});
await page.goto("/library");
await expect(page.locator(".book-row")).toHaveCount(15);
await page
.getByRole("button", { name: "예방실무1 아래로 이동", exact: true })
.click();
await seen;
signedIn = false;
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect(page.locator(".book-row")).toHaveCount(14);
release();
await done;
await page.waitForTimeout(100);
await expect(page.locator(".book-row")).toHaveCount(14);
});
test("교재14권은 HTML 기본 보기·페이지 이동·강조 검색과 원문 PDF 탭을 제공한다", async ({
page,
}) => {
await page.goto("/library");
await expect(page.locator(".book-row")).toHaveCount(14);
await expect(
page.getByRole("tab", { name: "HTML", exact: true }),
).toHaveAttribute("aria-selected", "true");
const article = page.getByRole("article", { name: "HTML 페이지 본문" });
await expect(article).toHaveAttribute("aria-busy", "false");
await expect(
page.getByRole("button", { name: "이전 페이지", exact: true }),
).toBeDisabled();
await page.getByRole("button", { name: "다음 페이지", exact: true }).click();
await expect(article).toHaveAttribute("data-page", "2");
await page.getByLabel("PDF 페이지").fill("35");
await page.getByRole("button", { name: "페이지 이동", exact: true }).click();
await expect(article).toHaveAttribute("data-page", "35");
await page.getByRole("button", { name: "이전 페이지", exact: true }).click();
await expect(article).toHaveAttribute("data-page", "34");
await page.getByLabel("본문 검색어").fill("가스");
await page.getByLabel("검색 범위").selectOption({ label: "예방실무1" });
await page.getByRole("button", { name: "본문 검색", exact: true }).click();
await page.locator(".search-hit").first().click();
await expect(article.locator("mark").first()).toContainText("가스");
const selectedPage = await article.getAttribute("data-page");
await page.getByRole("tab", { name: "PDF", exact: true }).click();
await expect(page.getByTitle("공식 교재 PDF 원문")).toHaveAttribute(
"src",
new RegExp(`#page=${selectedPage}$`),
);
await page.getByRole("tab", { name: "HTML", exact: true }).click();
await expect(article).toHaveAttribute("data-page", selectedPage!);
const download = page.waitForEvent("download");
await page
.getByRole("link", { name: "전체 HTML 다운로드", exact: true })
.click();
expect((await download).suggestedFilename()).toBe("예방실무1.html");
});
test("목록 마우스 끌기와 모바일 위·아래 이동은 새로고침 후에도 보존된다", async ({
page,
}) => {
await page.goto("/library");
const rows = page.locator(".book-row");
await expect(rows).toHaveCount(14);
await rows.nth(0).dragTo(rows.nth(1));
await expect(rows.nth(0).locator("b")).toHaveText("예방실무2");
await page.reload();
await expect(rows.nth(0).locator("b")).toHaveText("예방실무2");
await page.setViewportSize({ width: 390, height: 844 });
await page.getByRole("button", { name: "교재 선택·순서 변경" }).click();
const move = page.getByRole("button", {
name: "예방실무1 위로 이동",
exact: true,
});
expect((await move.boundingBox())!.height).toBeGreaterThanOrEqual(44);
await move.click();
await expect(rows.nth(0).locator("b")).toHaveText("예방실무1");
await page
.getByRole("button", {
name: "예방실무2 544페이지 · 공공누리 1유형",
exact: true,
})
.click();
await expect(page.locator(".html-page")).toHaveAttribute(
"aria-busy",
"false",
);
await expect(page.locator(".html-page pre")).toHaveCSS(
"white-space",
"pre-wrap",
);
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
});
test("이전 페이지의 늦은 응답이 새 페이지 본문을 덮지 않는다", async ({
page,
}) => {
let release!: () => void, received!: () => void;
const gate = new Promise<void>((r) => (release = r)),
seen = new Promise<void>((r) => (received = r));
await page.route(/\/api\/library\?document=.+&page=1$/, async (route) => {
const response = await route.fetch();
received();
await gate;
try {
await route.fulfill({ response });
} catch {}
});
await page.goto("/library");
await seen;
await page.getByRole("button", { name: "다음 페이지", exact: true }).click();
await expect(page.locator(".html-page")).toHaveAttribute("data-page", "2");
await expect(page.locator(".html-page")).toHaveAttribute(
"aria-busy",
"false",
);
const content = await page.locator(".html-page pre").innerText();
release();
await page.waitForTimeout(100);
await expect(page.locator(".html-page pre")).toHaveText(content);
});
tests/e2e/responsive.e2e.ts
import { test, expect, type Page } from "@playwright/test";
async function fitsScreen(page: Page) {
const size = await page.evaluate(() => ({
width: document.documentElement.scrollWidth,
height: document.documentElement.scrollHeight,
screenWidth: innerWidth,
screenHeight: innerHeight,
}));
expect(size.width).toBeLessThanOrEqual(size.screenWidth);
expect(size.height).toBeLessThanOrEqual(size.screenHeight + 1);
}
test("PC 상황본부는 일반 모니터에서 핵심 패널을 한 화면에 유지한다", async ({
page,
request,
}) => {
const created = await request.post("/api/sessions", {
data: { scenarioId: "restaurant-gas", seed: 42, level: 3 },
});
let state = await created.json();
for (const seconds of [30, 30, 30, 6]) {
state = await (
await request.post(`/api/sessions/${state.id}`, {
data: { version: state.version, action: { kind: "tick", seconds } },
})
).json();
}
for (const viewport of [
{ width: 1366, height: 768 },
{ width: 1920, height: 1080 },
]) {
await page.setViewportSize(viewport);
await page.goto(`/demo?session=${state.id}`);
await expect(
page.getByRole("heading", {
name: "가상 훈련이 종료됐습니다.",
exact: true,
}),
).toBeVisible();
await fitsScreen(page);
expect(
(await page.locator(".incident-map svg").boundingBox())!.height,
).toBeGreaterThanOrEqual(180);
for (const name of [
"상황도",
"드론 정찰",
"신고·관찰 기록",
"AI 지휘관",
"임무·명령",
"사건 타임라인 · 복기",
]) {
const bounds = await page
.getByRole("heading", { name, exact: true })
.boundingBox();
expect(bounds!.y + bounds!.height).toBeLessThanOrEqual(viewport.height);
}
await page
.locator(".command-scroll .command-item")
.last()
.scrollIntoViewIfNeeded();
await fitsScreen(page);
}
});
test("스마트폰 상황본부는 화면 전환과 개입 조작을 스크롤 없이 제공한다", async ({
page,
}) => {
for (const viewport of [
{ width: 360, height: 800 },
{ width: 390, height: 844 },
]) {
await page.setViewportSize(viewport);
await page.goto("/demo");
await page.getByRole("button", { name: "시연 실행", exact: true }).click();
await page
.getByRole("button", { name: "개입 · 일시정지", exact: true })
.click();
await page.getByRole("button", { name: "상황도", exact: true }).click();
await expect(page.locator(".incident-map svg")).toBeVisible();
await fitsScreen(page);
await page.getByRole("button", { name: "AI·명령", exact: true }).click();
await expect(
page.getByRole("heading", { name: "임무·명령", exact: true }),
).toBeVisible();
await fitsScreen(page);
await page.getByRole("button", { name: "관찰·드론", exact: true }).click();
await expect(
page.getByRole("heading", { name: "신고·관찰 기록", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "복기", exact: true }).click();
await expect(
page.getByRole("heading", { name: "사건 타임라인 · 복기", exact: true }),
).toBeVisible();
await fitsScreen(page);
const control = await page
.getByRole("button", { name: "진행 재개", exact: true })
.boundingBox();
expect(control!.height).toBeGreaterThanOrEqual(44);
expect(control!.y + control!.height).toBeLessThanOrEqual(viewport.height);
}
});
test("모바일 주 메뉴와 자료 화면은 가로 넘침 없이 접근할 수 있다", async ({
page,
}) => {
await page.setViewportSize({ width: 360, height: 800 });
for (const route of ["/", "/help", "/manuals", "/library", "/station"]) {
await page.goto(route);
await page.getByRole("button", { name: "메뉴 열기", exact: true }).click();
await expect(
page
.getByRole("navigation", { name: "주 메뉴" })
.getByRole("link", { name: "기관용 지휘 지원", exact: true }),
).toBeVisible();
await page.getByRole("button", { name: "메뉴 닫기", exact: true }).click();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
}
});
test("높이가 낮은 스마트폰에서도 지도가 사라지지 않고 조작에 접근할 수 있다", async ({
page,
}) => {
await page.setViewportSize({ width: 360, height: 640 });
await page.goto("/demo");
await page.getByRole("button", { name: "시연 실행", exact: true }).click();
await page
.getByRole("button", { name: "개입 · 일시정지", exact: true })
.click();
await page.getByRole("button", { name: "상황도", exact: true }).click();
expect(
(await page.locator(".incident-map svg").boundingBox())!.height,
).toBeGreaterThanOrEqual(160);
await page
.getByRole("button", { name: "진행 재개", exact: true })
.scrollIntoViewIfNeeded();
await expect(
page.getByRole("button", { name: "진행 재개", exact: true }),
).toBeInViewport();
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
});
tests/e2e/simulation.e2e.ts
import { test, expect } from "@playwright/test";
test("식당 사건 UE 화면은 실제 엔진 연결 대기와 2D 복귀를 제공한다", async ({
page,
request,
}) => {
const state = await (
await request.post("/api/sessions", {
data: { scenarioId: "restaurant-gas", seed: 42, level: 3 },
})
).json();
await page.goto(`/demo?session=${state.id}`);
await page.getByRole("link", { name: "UE5 현장 보기" }).click();
await expect(
page.getByRole("heading", { name: "UE5 식당 화재 현장" }),
).toBeVisible();
await expect(page.getByText("스트리밍 실행 안내")).toBeVisible();
await expect(page.locator("video")).toHaveCount(0);
await expect(page.getByRole("button", { name: "전체 현장" })).toBeDisabled();
expect(
await page.evaluate(
() =>
document.querySelector(".ue-viewer")!.getBoundingClientRect().bottom <=
innerHeight,
),
).toBe(true);
await page.getByRole("link", { name: "2D 상황본부로 복귀" }).click();
await expect(page).toHaveURL(new RegExp(`/demo\\?session=${state.id}`));
});
test("장면 조회 오류 후 명시 재시도로 상태를 다시 읽는다", async ({
page,
request,
}) => {
const state = await (
await request.post("/api/sessions", {
data: { scenarioId: "restaurant-gas", seed: 42, level: 3 },
})
).json();
await page.route(`**/api/sessions/${state.id}/scene`, (route) =>
route.fulfill({ status: 503, body: "" }),
);
await page.goto(`/simulation?session=${state.id}`);
await expect(page.locator(".ue-error[role=alert]")).toContainText(
"사건 상태를 읽을 수 없습니다",
);
await page.unroute(`**/api/sessions/${state.id}/scene`);
await page.getByRole("button", { name: "연결 재시도" }).click();
await expect(page.locator(".ue-error[role=alert]")).toHaveCount(0);
await expect(page.getByText(/사건 진행 중/)).toBeVisible();
});
test("스마트폰 UE 대기는 화면 넘침 없이 44px 카메라 조작을 배치한다", async ({
page,
request,
}) => {
const state = await (
await request.post("/api/sessions", {
data: { scenarioId: "restaurant-gas", seed: 42, level: 3 },
})
).json();
await page.setViewportSize({ width: 390, height: 844 });
await page.goto(`/simulation?session=${state.id}`);
await expect(page.getByText("스트리밍 실행 안내")).toBeVisible();
for (const name of ["전체 현장", "드론 시점", "차량 시점", "대원 시점"]) {
const box = await page.getByRole("button", { name }).boundingBox();
expect(box!.height).toBeGreaterThanOrEqual(44);
}
expect(
await page.evaluate(
() => document.documentElement.scrollWidth <= innerWidth,
),
).toBe(true);
});
test("잘못된 사건과 지원하지 않는 시나리오는 영상 연결 전에 차단한다", async ({
page,
request,
}) => {
await page.goto("/simulation?session=bad-session");
await expect(page.locator(".ue-error[role=alert]")).toContainText(
"올바른 사건 UUID",
);
await page.goto("/simulation?session=00000000-0000-4000-8000-000000000000");
await expect(page.locator(".ue-error[role=alert]")).toContainText(
"사건을 찾을 수 없습니다",
);
const state = await (
await request.post("/api/sessions", {
data: { scenarioId: "warehouse-oil", seed: 42, level: 3 },
})
).json();
// API가 유효한 다른 시나리오를 반환했는지 확인한다.
expect(state.id).toBeTruthy();
await page.goto(`/simulation?session=${state.id}`);
await expect(page.locator(".ue-error[role=alert]")).toContainText(
"식당 화재 DEMO 사건만",
);
await expect(page.locator("video")).toHaveCount(0);
});
tests/e2e/upgrade.e2e.ts
import { test, expect } from "@playwright/test";
test("사용설명서 두 종류와 HTML 다운로드", async ({ page }) => {
await page.goto("/help");
await expect(
page.getByRole("heading", { name: "1. 쉽게 따라하기", exact: true }),
).toBeVisible();
await expect(
page.getByRole("heading", { name: "2. 상세 사용설명서", exact: true }),
).toBeVisible();
const download = page.waitForEvent("download");
await page.getByRole("link", { name: "설명서 HTML 다운로드" }).click();
expect((await download).suggestedFilename()).toBe("manual.html");
await expect(
page.getByRole("link", { name: "구현 로직 HTML 다운로드" }),
).toBeVisible();
});
test("2026 교재 검색·원문·해설·복사", async ({ page }) => {
await page.goto("/library");
await expect(
page.getByRole("heading", { name: "2026 중앙소방학교 교재" }),
).toBeVisible();
await page.getByLabel("본문 검색어").fill("가스");
await page.getByRole("button", { name: "본문 검색", exact: true }).click();
await page.locator(".search-hit").first().click();
await expect(page.getByLabel("페이지 텍스트")).not.toBeEmpty();
await page.getByRole("button", { name: "페이지·출처 복사" }).click();
await expect(page.getByRole("status")).toContainText("복사");
await expect(
page.getByText("자체 작성 해설 · 소방 전문가 검수 전", { exact: false }),
).toBeVisible();
await page.getByRole("tab", { name: "PDF", exact: true }).click();
await expect(page.getByTitle("공식 교재 PDF 원문")).toBeVisible();
});
test("기관 최초 설정부터 승인·대원 보고·종료·감사까지", async ({
page,
browser,
}) => {
test.skip(
!process.env.FIRESTATION_TEST_SETUP_KEY,
"기관 테스트는 npm run test:e2e 격리 실행기를 사용하세요.",
);
const suffix = Date.now().toString(),
password = "Browser-test-password-2026!";
await page.setViewportSize({ width: 1366, height: 768 });
await page.goto("/station");
const forged = await page.request.post("/api/station/auth", {
headers: { Host: "localhost:3110", Origin: "http://localhost:3110" },
data: {
type: "setup",
input: {
stationName: "위조 기관",
username: "forged-admin",
displayName: "위조",
password: "Forged-test-password-2026!",
},
},
});
expect([400, 403]).toContain(forged.status());
expect(
(await (await page.request.get("/api/station/auth")).json()).configured,
).toBe(false);
await page.getByLabel("기관명").fill("브라우저 검증 기관");
await page
.getByLabel("설정 키")
.fill(process.env.FIRESTATION_TEST_SETUP_KEY!);
await page.getByLabel("사용자명", { exact: true }).fill("admin-e2e");
await page.getByLabel("표시 이름").fill("관리자");
await page.getByLabel("암호", { exact: true }).fill(password);
await page.getByRole("button", { name: "최초 관리자 설정" }).click();
await expect(page.getByText("관리자 · 기관 연결됨")).toBeVisible();
await page.getByRole("button", { name: "기관 관리", exact: true }).click();
await page.getByLabel("새 사용자명").fill("crew-e2e");
await page.getByLabel("새 표시 이름").fill("현장 대원");
await page.getByLabel("새 암호").fill(password);
await page.getByLabel("새 역할").selectOption("crew");
await page.getByRole("button", { name: "사용자 등록" }).click();
await expect(page.getByLabel("담당 대원")).toContainText("현장 대원");
await page.getByLabel("자원 이름").fill("검증 펌프1");
await page.getByLabel("자원 종류").fill("펌프차");
await page.getByLabel("담당 대원").selectOption({ label: "현장 대원" });
await page.getByRole("button", { name: "자원 등록" }).click();
await page.getByLabel("근거 교재").selectOption({ index: 1 });
await page.getByLabel("PDF 페이지", { exact: true }).fill("5");
await page.getByLabel("검토자").fill("기관 담당");
await page.getByLabel("검토 메모").fill("검증 환경에서 기관 적용성을 확인함");
await page.getByRole("button", { name: "검토 근거 등록" }).click();
await expect(
page.getByText("기관 적용성을 확인함", { exact: false }),
).toBeVisible();
await page.getByRole("button", { name: "사건 지휘", exact: true }).click();
await page.getByLabel("사건 제목").fill("검증 화재");
await page.getByLabel("위치").fill("가상 검증 주소");
await page.getByLabel("신고 내용").fill("가스 냄새 확인 필요");
await page.getByRole("button", { name: "사건 등록" }).click();
await expect(page.getByRole("heading", { name: "검증 화재" })).toBeVisible();
await page.getByLabel("배정할 자원").selectOption({ label: "검증 펌프1" });
await page.getByRole("button", { name: "자원 배정" }).click();
await page.getByLabel("명령 내용").fill("현장 확인 보고");
await page.getByLabel("대상 검증 펌프1").check();
await page.locator('[name="evidenceIds"]').first().check();
await page.getByRole("button", { name: "명령 초안 저장" }).click();
await page.getByRole("button", { name: "승인·발행" }).click();
await expect(page.locator(".station-command").first()).toContainText("발행");
expect(
await page.evaluate(
() => document.documentElement.scrollHeight <= innerHeight + 1,
),
).toBe(true);
const crewContext = await browser.newContext({
viewport: { width: 390, height: 844 },
}),
crewPage = await crewContext.newPage();
await crewPage.goto(new URL("/station", page.url()).href);
await crewPage.getByLabel("사용자명", { exact: true }).fill("crew-e2e");
await crewPage.getByLabel("암호", { exact: true }).fill(password);
await crewPage.getByRole("button", { name: "로그인", exact: true }).click();
await expect(
crewPage.getByRole("button", { name: "명령·보고", exact: true }),
).toHaveAttribute("aria-pressed", "true");
const receive = await crewPage
.getByRole("button", { name: "수신 확인", exact: true })
.boundingBox();
expect(receive!.height).toBeGreaterThanOrEqual(44);
expect(receive!.y + receive!.height).toBeLessThanOrEqual(844);
expect(
await crewPage.evaluate(
() => document.documentElement.scrollHeight <= innerHeight + 1,
),
).toBe(true);
await crewPage
.getByRole("button", { name: "현장 상황", exact: true })
.click();
await expect(
crewPage.getByRole("heading", { name: "관찰·현장 보고", exact: true }),
).toBeVisible();
await crewPage
.getByRole("button", { name: "명령·보고", exact: true })
.click();
await crewPage.getByRole("button", { name: "수신 확인" }).click();
await crewPage.getByRole("button", { name: "수행 시작" }).click();
await crewPage.getByRole("button", { name: "완료 보고" }).click();
await expect(crewPage.locator(".station-command").first()).toContainText(
"완료",
);
await expect(page.locator(".station-command").first()).toContainText("완료", {
timeout: 7000,
});
await crewPage.route("**/api/station", (route) => route.abort());
await crewPage.getByRole("button", { name: "새로고침", exact: true }).click();
await expect(crewPage.getByRole("status")).toContainText("갱신 실패");
await expect(crewPage.getByRole("status")).toContainText("마지막 성공");
await crewPage.unroute("**/api/station");
await crewPage.getByRole("button", { name: "새로고침", exact: true }).click();
await expect(crewPage.getByRole("status")).toContainText("5초마다 갱신");
await expect(crewPage.getByRole("status")).not.toContainText("갱신 실패");
await page.getByRole("button", { name: "새로고침" }).click();
await page.getByRole("button", { name: "사건 종료" }).click();
await expect(page.getByText("종료", { exact: true }).first()).toBeVisible();
const exported = page.waitForEvent("download");
await page.getByRole("link", { name: "감사·사건 내보내기" }).click();
expect((await exported).suggestedFilename()).toBe("firestation-audit.json");
await crewContext.close();
});
async function loginAdmin(page: import("@playwright/test").Page) {
await page.goto("/station");
const origin = new URL(page.url()).origin,
info = await (await page.request.get("/api/station/auth")).json();
if (!info.configured) {
await page.request.post("/api/station/auth", {
headers: { Origin: origin },
data: {
type: "setup",
input: {
stationName: "회귀 기관",
username: "admin-e2e",
displayName: "관리자",
password: "Browser-test-password-2026!",
setupKey: process.env.FIRESTATION_TEST_SETUP_KEY,
},
},
});
await page.reload();
} else {
await page.getByLabel("사용자명", { exact: true }).fill("admin-e2e");
await page
.getByLabel("암호", { exact: true })
.fill("Browser-test-password-2026!");
await page.getByRole("button", { name: "로그인", exact: true }).click();
}
await expect(page.getByText("관리자 · 기관 연결됨")).toBeVisible();
}
test("사건별 입력을 격리하고 같은 사건의 조회에서는 작성 내용을 보존한다", async ({
page,
}) => {
test.skip(!process.env.FIRESTATION_TEST_SETUP_KEY);
await loginAdmin(page);
const origin = new URL(page.url()).origin,
s = await (await page.request.get("/api/station")).json();
for (const title of ["입력 사건 A", "입력 사건 B"]) {
const r = await page.request.post("/api/station", {
headers: { Origin: origin },
data: {
type: "incident",
input: {
title,
address: "검증 주소",
report: "검증",
commanderId: s.user.id,
},
},
});
expect(r.ok()).toBe(true);
}
await page.getByRole("button", { name: "새로고침" }).click();
await page.getByRole("button", { name: /입력 사건 A 진행/ }).click();
await page.getByLabel("관찰 내용").fill("A 전용 관찰");
await page.getByLabel("명령 내용").fill("A 전용 초안");
await page.getByRole("button", { name: "새로고침" }).click();
await expect(page.getByLabel("관찰 내용")).toHaveValue("A 전용 관찰");
await page.getByRole("button", { name: /입력 사건 B 진행/ }).click();
await expect(page.getByLabel("관찰 내용")).toHaveValue("");
await expect(page.getByLabel("명령 내용")).toHaveValue("");
});
test("로그아웃 후 지연된 조회 응답이 사건 화면을 다시 표시하지 않는다", async ({
page,
}) => {
test.skip(!process.env.FIRESTATION_TEST_SETUP_KEY);
await loginAdmin(page);
let release!: () => void, captured!: () => void, fulfilled!: () => void;
const gate = new Promise<void>((r) => (release = r)),
seen = new Promise<void>((r) => (captured = r)),
done = new Promise<void>((r) => (fulfilled = r));
let held = false;
await page.route("**/api/station", async (route) => {
if (held || route.request().method() !== "GET") {
await route.continue();
return;
}
held = true;
const response = await route.fetch();
captured();
await gate;
await route.fulfill({ response });
fulfilled();
});
await page.getByRole("button", { name: "새로고침" }).click();
await seen;
await page.getByRole("button", { name: "로그아웃" }).click();
await expect(
page.getByRole("button", { name: "로그인", exact: true }),
).toBeVisible();
release();
await done;
await page.evaluate(
() =>
new Promise<void>((resolve) =>
requestAnimationFrame(() => requestAnimationFrame(() => resolve())),
),
);
await expect(
page.getByRole("button", { name: "로그인", exact: true }),
).toBeVisible();
await expect(page.getByText("관리자 · 기관 연결됨")).toHaveCount(0);
});
test("기관 관리에서 계정 회수·암호 재설정·역할 변경으로 기존 세션을 폐기한다", async ({
page,
browser,
}) => {
test.skip(!process.env.FIRESTATION_TEST_SETUP_KEY);
await loginAdmin(page);
const origin = new URL(page.url()).origin;
const created = await page.request.post("/api/station", {
headers: { Origin: origin },
data: {
type: "user",
input: {
username: "revoke-browser",
displayName: "회수 대원",
password: "Original-browser-password-2026!",
role: "crew",
},
},
});
expect(created.ok()).toBe(true);
const context = await browser.newContext({ baseURL: origin });
const session = await context.request.post("/api/station/auth", {
headers: { Origin: origin },
data: {
type: "login",
input: {
username: "revoke-browser",
password: "Original-browser-password-2026!",
},
},
});
expect(session.ok()).toBe(true);
await page.getByRole("button", { name: "새로고침" }).click();
await page.getByRole("button", { name: "기관 관리", exact: true }).click();
await page.locator("summary").filter({ hasText: "회수 대원" }).click();
await page.getByLabel("계정 상태 회수 대원").selectOption("false");
await page.getByRole("button", { name: "회수 대원 계정 변경" }).click();
await expect(
page.locator("summary").filter({ hasText: "회수 대원" }),
).toHaveText("회수 대원 · 대원 · 비활성");
expect((await context.request.get("/api/station")).status()).toBe(401);
await page.locator("summary").filter({ hasText: "회수 대원" }).click();
await page.getByLabel("계정 상태 회수 대원").selectOption("true");
await page
.getByLabel("재설정 암호 회수 대원")
.fill("Replacement-browser-password-2026!");
await page.getByRole("button", { name: "회수 대원 계정 변경" }).click();
await expect(
page.locator("summary").filter({ hasText: "회수 대원" }),
).toHaveText("회수 대원 · 대원 · 활성");
const old = await context.request.post("/api/station/auth", {
headers: { Origin: origin },
data: {
type: "login",
input: {
username: "revoke-browser",
password: "Original-browser-password-2026!",
},
},
});
expect(old.ok()).toBe(false);
const fresh = await context.request.post("/api/station/auth", {
headers: { Origin: origin },
data: {
type: "login",
input: {
username: "revoke-browser",
password: "Replacement-browser-password-2026!",
},
},
});
expect(fresh.ok()).toBe(true);
await page.locator("summary").filter({ hasText: "회수 대원" }).click();
await page.getByLabel("계정 역할 회수 대원").selectOption("dispatcher");
await page.getByRole("button", { name: "회수 대원 계정 변경" }).click();
await expect(
page.locator("summary").filter({ hasText: "회수 대원" }),
).toHaveText("회수 대원 · 상황요원 · 활성");
expect((await context.request.get("/api/station")).status()).toBe(401);
await context.close();
});
tests/e2e/zz-library-upload.e2e.ts
import { test, expect } from "@playwright/test";
import { textPdf } from "../fixtures/pdf";
// 기관 설정 시험 뒤 같은 격리 DB에서 관리자 업로드의 실제 변환·파일 전달을 검증한다.
test("관리자 PDF 업로드·HTML 변환·기관 공유·순서 버전·로그아웃 차단", async ({
page,
browser,
baseURL,
}) => {
test.skip(
!process.env.FIRESTATION_TEST_SETUP_KEY,
"기관 시험은 격리 실행기에서만 실행합니다.",
);
const origin = baseURL!;
const login = await page.request.post("/api/station/auth", {
headers: { Origin: origin },
data: {
type: "login",
input: { username: "admin-e2e", password: "Browser-test-password-2026!" },
},
});
expect(login.ok()).toBe(true);
await page.goto("/library");
await page.locator(".library-upload summary").click();
const bytes = textPdf([
"Training <script>do not execute</script>",
"Gas safety page",
"End of training",
]);
await page.getByLabel("추가할 PDF").setInputFiles({
name: "training.pdf",
mimeType: "application/pdf",
buffer: bytes,
});
await page.getByLabel("교재 제목").fill("기관 훈련 교재");
await page.getByLabel("발행기관").fill("시험 기관");
await page
.getByRole("button", { name: "PDF 추가 · HTML 자동 생성", exact: true })
.click();
await expect(page.locator(".book-row").last().locator("b")).toHaveText(
"기관 훈련 교재",
);
await expect(page.locator(".html-page pre")).toContainText("<script>");
const list = await (await page.request.get("/api/library?catalog=1")).json();
const doc = list.documents.at(-1);
const pdf = await page.request.get(doc.pdfUrl);
expect(await pdf.body()).toEqual(bytes);
const html = await page.request.get(doc.htmlUrl);
expect(await html.text()).toContain("<script>");
const range = await page.request.get(doc.pdfUrl, {
headers: { Range: "bytes=0-9" },
});
expect(range.status()).toBe(206);
expect(await range.body()).toEqual(bytes.subarray(0, 10));
await page
.getByRole("button", { name: "기관 훈련 교재 위로 이동", exact: true })
.click();
await page.reload();
await expect(page.locator(".book-row").nth(13).locator("b")).toHaveText(
"기관 훈련 교재",
);
const conflict = await page.request.post("/api/library/order", {
headers: { Origin: origin },
data: {
ids: list.documents.map((d: { id: string }) => d.id),
version: list.version,
},
});
expect(conflict.status()).toBe(409);
const guest = await browser.newContext({ baseURL: origin });
expect((await guest.request.get(doc.pdfUrl)).status()).toBe(401);
expect((await guest.request.get(doc.htmlUrl)).status()).toBe(401);
expect(
(await (await guest.request.get("/api/library?catalog=1")).json())
.documents,
).toHaveLength(14);
let release!: () => void, received!: () => void, finished!: () => void;
const gate = new Promise<void>((r) => (release = r)),
seen = new Promise<void>((r) => (received = r)),
done = new Promise<void>((r) => (finished = r));
await page.route("**/api/library/upload", async (route) => {
const response = await route.fetch();
received();
await gate;
try {
await route.fulfill({ response });
} catch {
} finally {
finished();
}
});
await page.locator(".library-upload summary").click();
await page
.getByLabel("추가할 PDF")
.setInputFiles({
name: "late.pdf",
mimeType: "application/pdf",
buffer: bytes,
});
await page.getByLabel("교재 제목").fill("늦은 기관 응답");
await page.getByLabel("발행기관").fill("시험 기관");
await page
.getByRole("button", { name: "PDF 추가 · HTML 자동 생성", exact: true })
.click();
await seen;
await page.request.post("/api/station/auth", {
headers: { Origin: origin },
data: { type: "logout" },
});
await page.evaluate(() => window.dispatchEvent(new Event("focus")));
await expect(page.locator(".book-row")).toHaveCount(14);
release();
await done;
await page.waitForTimeout(100);
await expect(page.locator(".book-row")).toHaveCount(14);
await expect(page.locator(".library-reader")).not.toContainText(
"늦은 기관 응답",
);
await expect(
page.getByRole("link", { name: "PDF 추가 · 관리자 로그인" }),
).toBeVisible();
expect((await page.request.get(doc.pdfUrl)).status()).toBe(401);
await guest.close();
});
tests/engine.test.ts
import { describe, expect, it } from "vitest";
import { scenarios } from "../packages/core/scenario";
import {
createSession,
tick,
applyAction,
publicState,
} from "../packages/core/engine";
function run(id = 0, seed = 42) {
let s = createSession(scenarios[id], { seed, mode: "DEMO", level: 3 });
for (let i = 0; i < 120; i++) s = tick(s, 1);
return s;
}
describe("공통 사건 엔진·AI 지휘관", () => {
it("늦은 승인에서도 자원 ETA와 도착은 발행 시각 이후 이동 시간을 반영한다", () => {
let s = createSession(scenarios[0], { seed: 1, mode: "DEMO", level: 2 });
s = tick(tick(s, 30), 30);
s = applyAction(s, { kind: "approve", commandId: s.commands[0].id });
expect(publicState(s).units[0]).toMatchObject({
status: "출동 중",
eta: 40,
});
s = tick(s, 8);
const recon = s.commands.find((c) => c.actionKey === "RECON")!;
s = applyAction(s, { kind: "approve", commandId: recon.id });
expect(publicState(s).drone).toMatchObject({ status: "이동 중", eta: 12 });
s = tick(s, 11);
expect(publicState(s).drone.arrivedAt).toBeNull();
expect(publicState(s).drone.eta).toBe(1);
s = tick(s, 1);
expect(publicState(s).drone).toMatchObject({
status: "외부 정찰",
eta: 0,
arrivedAt: 80,
});
s = tick(s, 19);
expect(publicState(s).units[0]).toMatchObject({
status: "출동 중",
eta: 1,
});
s = tick(s, 1);
expect(publicState(s).units[0]).toMatchObject({
status: "현장 도착",
eta: 0,
});
});
it.each([0, 1, 2])(
"시나리오 %i가 사람 조작 없이 근거 명령·수신·이행·종료까지 진행된다",
(i) => {
const s = run(i);
expect(s.status).toBe("closed");
expect(
s.commands.filter((c) => c.status === "complete").length,
).toBeGreaterThanOrEqual(6);
expect(s.commands.every((c) => c.cardIds.length > 0)).toBe(true);
expect(s.log.some((e) => e.type === "ack")).toBe(true);
expect(s.observations.some((o) => o.key === "close-ready")).toBe(true);
},
);
it("동일 시드·입력은 같은 관찰·화재 상태·명령·복기 결과를 만든다", () => {
expect(publicState(run())).toEqual(publicState(run()));
});
it("발견 전 시간표·비공개 상태·화점 셀을 AI/브라우저 공개 입력에서 제외한다", () => {
const view = publicState(
createSession(scenarios[0], { seed: 42, mode: "DEMO", level: 3 }),
);
expect(view.observations).toHaveLength(1);
expect(view.fireCells).toEqual([]);
expect(view.scenario).not.toHaveProperty("timeline");
expect(JSON.stringify(view)).not.toContain("실제 잔류");
expect(view.observations.some((o) => o.key === "onsite-check")).toBe(false);
});
it("개입하면 시간·화재·자동 명령을 즉시 멈추고 기록한다", () => {
let s = tick(
createSession(scenarios[0], { seed: 1, mode: "DEMO", level: 3 }),
5,
);
s = applyAction(s, { kind: "intervene", reason: "현장 재확인" });
const stopped = publicState(s);
expect(publicState(tick(s, 20))).toEqual(stopped);
expect(s.log.at(-1)?.type).toBe("intervention");
});
it("사람 지휘로 전환 후 재개해도 AI 명령을 추가 발행하지 않는다", () => {
let s = createSession(scenarios[0], { seed: 1, mode: "DEMO", level: 3 });
s = applyAction(s, { kind: "intervene" });
s = applyAction(s, { kind: "level", level: 0 });
s = applyAction(s, { kind: "resume" });
const before = s.commands.length;
expect(tick(s, 30).commands).toHaveLength(before);
});
it.each(["OPERATIONS", "SHADOW"] as const)(
"%s에서 L3를 생성·전환할 수 없다",
(mode) => {
expect(() =>
createSession(scenarios[0], { seed: 1, mode, level: 3 }),
).toThrow(/자동|수준/);
const s = createSession(scenarios[0], { seed: 1, mode, level: 1 });
expect(() => applyAction(s, { kind: "level", level: 3 })).toThrow(
/자동|수준/,
);
},
);
it("L1은 조언만 만들고 L2는 승인 전까지 가상 명령을 발행하지 않는다", () => {
const advice = createSession(scenarios[0], {
seed: 1,
mode: "TRAINING",
level: 1,
});
expect(advice.commands).toEqual([]);
expect(advice.decisions.length).toBeGreaterThan(0);
let approval = createSession(scenarios[0], {
seed: 1,
mode: "TRAINING",
level: 2,
});
expect(approval.commands[0].status).toBe("draft");
approval = applyAction(approval, {
kind: "approve",
commandId: approval.commands[0].id,
});
expect(approval.commands[0].status).toBe("issued");
expect(approval.commands[0].actor).toBe("human");
});
it("같은 명령 승인 재시도는 중복 명령을 만들지 않는다", () => {
let s = createSession(scenarios[0], { seed: 1, mode: "DEMO", level: 2 });
const id = s.commands[0].id;
s = applyAction(s, { kind: "approve", commandId: id });
const count = s.log.length;
s = applyAction(s, { kind: "approve", commandId: id });
expect(s.commands).toHaveLength(1);
expect(s.log).toHaveLength(count);
});
it.each([1, 2] as const)(
"L%i에서 자동 지휘로 전환하면 대기한 판단을 한 번만 발행하고 종료한다",
(level) => {
let s = createSession(scenarios[0], { seed: 1, mode: "DEMO", level });
s = applyAction(s, { kind: "level", level: 3 });
expect(s.commands).toHaveLength(1);
expect(s.commands[0].status).toBe("issued");
for (let i = 0; i < 120; i++) s = tick(s, 1);
expect(s.status).toBe("closed");
expect(s.commands.filter((c) => c.actionKey === "DISPATCH")).toHaveLength(
1,
);
},
);
it("L1 조언에서 L2 승인 모드로 전환하면 기존 판단이 승인 대기 명령으로 연결된다", () => {
let s = createSession(scenarios[0], { seed: 1, mode: "DEMO", level: 1 });
s = applyAction(s, { kind: "level", level: 2 });
expect(s.commands[0]?.status).toBe("draft");
});
it("새 관찰이 들어오면 과거 초안 승인을 막고 재검토한 후에만 발행한다", () => {
let s = createSession(scenarios[0], { seed: 1, mode: "DEMO", level: 2 });
const draft = s.commands[0].id;
s = applyAction(s, {
kind: "manual",
actionKey: "DISPATCH",
target: "펌프1",
text: "가상 준비 확인",
cardIds: ["R-03"],
});
s = tick(s, 10);
expect(() => applyAction(s, { kind: "approve", commandId: draft })).toThrow(
/새 관찰|재검토/,
);
s = applyAction(s, { kind: "review", commandId: draft });
s = applyAction(s, { kind: "approve", commandId: draft });
expect(s.commands.find((c) => c.id === draft)?.status).toBe("issued");
});
it("취소된 명령이 지연된 가상 수신·완료 이벤트로 되살아나지 않는다", () => {
let s = createSession(scenarios[0], { seed: 1, mode: "DEMO", level: 3 });
const id = s.commands[0].id;
s = applyAction(s, { kind: "cancel", commandId: id });
s = tick(s, 30);
expect(s.commands.find((c) => c.id === id)?.status).toBe("cancelled");
});
it("존재하지 않는 근거 카드의 수동 명령을 거부한다", () => {
const s = createSession(scenarios[0], { seed: 1, mode: "DEMO", level: 0 });
expect(() =>
applyAction(s, {
kind: "manual",
actionKey: "VERIFY",
target: "선착대",
cardIds: ["UNKNOWN"],
text: "확인",
}),
).toThrow(/근거/);
});
it("OPERATIONS에서 교육 초안 기반 명령은 사람에게도 발행하지 않는다", () => {
const s = createSession(scenarios[0], {
seed: 1,
mode: "OPERATIONS",
level: 2,
});
expect(() =>
applyAction(s, {
kind: "manual",
actionKey: "VERIFY",
target: "선착대",
cardIds: ["G-01"],
text: "확인",
}),
).toThrow(/교육|운영/);
});
it("서로 다른 시드는 가상 화재 진행에 반영된다", () => {
let a = createSession(scenarios[0], { seed: 1, mode: "DEMO", level: 3 });
let b = createSession(scenarios[0], { seed: 99, mode: "DEMO", level: 3 });
a = tick(a, 30);
b = tick(b, 30);
expect(publicState(a).fireCells).not.toEqual(publicState(b).fireCells);
});
it("유효한 시드 0과 1을 서로 다른 난수 상태로 보존한다", () => {
const a = createSession(scenarios[0], { seed: 0, mode: "DEMO", level: 3 }),
b = createSession(scenarios[0], { seed: 1, mode: "DEMO", level: 3 });
expect(a.rng).not.toBe(b.rng);
});
it("음수·무한대·과도한 시간 이동을 거부하고 원본 상태를 변경하지 않는다", () => {
const s = createSession(scenarios[0], { seed: 1, mode: "DEMO", level: 3 });
expect(() => tick(s, -1)).toThrow();
expect(() => tick(s, Infinity)).toThrow();
expect(() => tick(s, 10000)).toThrow();
tick(s, 10);
expect(s.elapsed).toBe(0);
});
});
tests/fixtures/pdf.ts
// 외부 파일에 의존하지 않는 텍스트 PDF로 실제 변환 경로를 시험한다.
export function textPdf(
texts = ["Training manual", "Gas safety page", "Final page"],
) {
const objects = [
"<< /Type /Catalog /Pages 2 0 R >>",
`<< /Type /Pages /Kids [${texts.map((_, i) => `${4 + i * 2} 0 R`).join(" ")}] /Count ${texts.length} >>`,
"<< /Type /Font /Subtype /Type1 /BaseFont /Helvetica >>",
];
for (const text of texts) {
const content = `BT /F1 14 Tf 50 700 Td (${text.replace(/[()\\]/g, "\\$&")}) Tj ET`;
objects.push(
`<< /Type /Page /Parent 2 0 R /MediaBox [0 0 595 842] /Resources << /Font << /F1 3 0 R >> >> /Contents ${5 + (objects.length - 3)} 0 R >>`,
`<< /Length ${Buffer.byteLength(content)} >>\nstream\n${content}\nendstream`,
);
}
let pdf = "%PDF-1.4\n";
const offsets = [0];
objects.forEach((body, i) => {
offsets.push(Buffer.byteLength(pdf));
pdf += `${i + 1} 0 obj\n${body}\nendobj\n`;
});
const xref = Buffer.byteLength(pdf);
pdf += `xref\n0 ${objects.length + 1}\n0000000000 65535 f \n${offsets
.slice(1)
.map((n) => `${String(n).padStart(10, "0")} 00000 n \n`)
.join(
"",
)}trailer\n<< /Size ${objects.length + 1} /Root 1 0 R >>\nstartxref\n${xref}\n%%EOF\n`;
return Buffer.from(pdf);
}
tests/http.test.ts
import { describe, expect, it } from "vitest";
import { guard, readJson } from "../apps/web/lib/http";
describe("로컬 HTTP 경계", () => {
it("Next 내부 URL과 브라우저 Host가 달라도 동일 사이트 요청을 허용한다", () => {
const request = new Request("http://localhost:3100/api/sessions", {
method: "POST",
headers: {
host: "127.0.0.1:3100",
origin: "http://127.0.0.1:3100",
"content-type": "application/json",
},
body: "{}",
});
expect(() => guard(request)).not.toThrow();
});
it("외부 사이트와 JSON이 아닌 변경 요청을 거부한다", () => {
expect(() =>
guard(
new Request("http://localhost:3100/api/sessions", {
headers: {
host: "localhost:3100",
origin: "https://example.com",
"content-type": "application/json",
},
}),
),
).toThrow(/다른 사이트/);
expect(() =>
guard(new Request("http://localhost:3100/api/sessions")),
).toThrow(/JSON/);
});
it("깨진 JSON은 내부 서버 오류 대신 입력 오류로 반환한다", async () => {
const request = new Request("http://localhost:3100/api/sessions", {
method: "POST",
body: "{",
});
await expect(readJson(request)).rejects.toMatchObject({ code: 400 });
});
});
tests/library-html.test.ts
import { afterEach, describe, expect, it, vi } from "vitest";
import { mkdtempSync, readFileSync } from "node:fs";
import { join } from "node:path";
import { tmpdir } from "node:os";
import { StationDatabase } from "../packages/station/database";
import { StationAuth, type User } from "../packages/station/auth";
import { LibraryStore } from "../packages/library/store";
import { renderLibraryHtml } from "../packages/library/html";
import { catalog } from "../packages/library";
import { textPdf } from "./fixtures/pdf";
import { POST as uploadRoute } from "../apps/web/app/api/library/upload/route";
import { POST as orderRoute } from "../apps/web/app/api/library/order/route";
const databases: StationDatabase[] = [];
afterEach(() => {
databases.splice(0).forEach((db) => db.close());
delete (
globalThis as typeof globalThis & { firestationDatabase?: StationDatabase }
).firestationDatabase;
vi.unstubAllEnvs();
});
function fixture() {
const directory = mkdtempSync(join(tmpdir(), "firestation-library-"));
const db = new StationDatabase(join(directory, "station.db"));
databases.push(db);
const auth = new StationAuth(db);
const admin = auth.bootstrap({
stationName: "교재 시험 기관",
username: "admin_test",
displayName: "관리자",
password: "test-password-123",
setupKey: auth.issueSetupToken(),
}).user;
const crew = auth.createUser(admin, {
username: "crew_test",
displayName: "대원",
password: "test-password-123",
role: "crew",
});
return {
db,
auth,
admin,
crew,
directory,
store: new LibraryStore(db, join(directory, "library")),
};
}
describe("HTML 교재·기관 업로드", () => {
it("업로드 HTTP 경계는 다른 Origin·방문자·대원·큰 본문을 읽기 전에 거부한다", async () => {
const { db, auth, directory } = fixture();
(
globalThis as typeof globalThis & {
firestationDatabase?: StationDatabase;
}
).firestationDatabase = db;
vi.stubEnv("FIRESTATION_DB", join(directory, "station.db"));
vi.stubEnv("FIRESTATION_ORIGIN", "http://localhost:3100");
const request = (origin: string, cookie = "", size = "100") =>
new Request("http://localhost:3100/api/library/upload", {
method: "POST",
headers: {
origin,
cookie,
"Content-Type": "multipart/form-data; boundary=test",
"Content-Length": size,
},
body: "not a multipart PDF",
});
expect((await uploadRoute(request("https://other.example"))).status).toBe(
403,
);
expect((await uploadRoute(request("http://localhost:3100"))).status).toBe(
401,
);
const crew = auth.login({
username: "crew_test",
password: "test-password-123",
});
expect(
(
await uploadRoute(
request("http://localhost:3100", "firestation_session=" + crew.token),
)
).status,
).toBe(403);
const admin = auth.login({
username: "admin_test",
password: "test-password-123",
});
expect(
(
await uploadRoute(
request(
"http://localhost:3100",
"firestation_session=" + admin.token,
String(66 * 1024 * 1024),
),
)
).status,
).toBe(413);
expect(
(
await orderRoute(
new Request("http://localhost:3100/api/library/order", {
method: "POST",
headers: {
Origin: "http://localhost:3100",
"Content-Type": "application/json",
},
body: "{}",
}),
)
).status,
).toBe(401);
});
it("HTML은 원문 문자열을 실행하지 않고 페이지별 앵커와 출처를 제공한다", () => {
const html = renderLibraryHtml(catalog.documents[0], [
{ page: 1, text: '<script>alert("x")</script>\n안전 & 확인' },
]);
expect(html).not.toContain("<script>alert");
expect(html).toContain("<script>");
expect(html).toContain('id="page-1"');
expect(html).toContain("예방실무1");
});
it("PDF 원문·HTML을 저장하고 기관 목록 끝에 추가하며 다른 기관과 방문자는 볼 수 없다", async () => {
const { store, admin, crew, directory, db } = fixture();
const source = textPdf();
const added = await store.upload(
admin,
{
title: "훈련 자료",
publisher: "시험 기관",
licenseStatement: "기관 내부 교육용",
filename: "manual.pdf",
},
source,
);
expect(store.list(admin).documents.at(-1)?.id).toBe(added.id);
expect(store.list(null).documents).toHaveLength(14);
expect(store.page(added.id, 2, crew)).toContain("Gas safety");
expect(store.search("Gas safety", added.id, crew)[0].page).toBe(2);
expect(readFileSync(join(directory, "library", added.id + ".pdf"))).toEqual(
source,
);
expect(
readFileSync(join(directory, "library", added.id + ".html"), "utf8"),
).toContain("Final page");
expect(() => store.page(added.id, 1, null)).toThrow();
db.sql
.prepare("INSERT INTO stations VALUES(?,?)")
.run("other-station", "다른 기관");
expect(() =>
store.page(added.id, 1, { ...crew, stationId: "other-station" } as User),
).toThrow();
expect(() => store.page("../../escape", 1, admin)).toThrow();
expect(() => store.page(added.id, 0, admin)).toThrow();
});
it("순서 변경은 모든 ID와 최신 버전을 요구하고 저장소를 다시 열어도 유지된다", () => {
const { store, admin, db, directory } = fixture();
const original = store.list(admin);
const ids = original.documents.map((d) => d.id).reverse();
const saved = store.reorder(admin, ids, original.version);
expect(saved.documents.map((d) => d.id)).toEqual(ids);
expect(
new LibraryStore(db, join(directory, "library"))
.list(admin)
.documents.map((d) => d.id),
).toEqual(ids);
expect(() => store.reorder(admin, ids, original.version)).toThrow(
/변경|다시/,
);
expect(() =>
store.reorder(admin, [...ids.slice(1), ids[1]], saved.version),
).toThrow();
});
it("대원·잘못된 PDF 업로드가 목록이나 버전을 변경하지 않는다", async () => {
const { store, admin, crew } = fixture();
const metadata = {
title: "업로드",
publisher: "기관",
licenseStatement: "내부 교육",
filename: "a.pdf",
};
await expect(store.upload(crew, metadata, textPdf())).rejects.toThrow();
await expect(
store.upload(admin, metadata, Buffer.from("not pdf")),
).rejects.toThrow();
await expect(
store.upload(admin, metadata, Buffer.from("%PDF-1.4\nbroken")),
).rejects.toThrow();
expect(store.list(admin).documents).toHaveLength(14);
expect(store.list(admin).version).toBe(0);
});
});
tests/library.test.ts
import { describe, it, expect } from "vitest";
import {
catalog,
searchPages,
pageText,
explanation,
} from "../packages/library/index";
describe("공식 교재 자료실", () => {
it("공식 출처·이용 조건·원문 해시·페이지 수를 보존한다", () => {
expect(catalog.documents.length).toBeGreaterThanOrEqual(14);
for (const d of catalog.documents) {
expect(d.year).toBe(2026);
expect(d.postUrl).toMatch(/^https:\/\/www.nfsa.go.kr\//);
expect(d.license).toBe("KOGL-1");
expect(d.sha256).toMatch(/^[a-f0-9]{64}$/);
expect(d.pageCount).toBeGreaterThan(1);
}
});
it("본문 검색 결과를 원문 PDF 페이지와 연결하고 잘못된 페이지를 거부한다", () => {
const results = searchPages("소방");
expect(results.length).toBeGreaterThan(0);
const first = results[0];
expect(first.pdfUrl).toContain("#page=");
expect(pageText(first.documentId, first.page)).toContain("소방");
expect(() => pageText(first.documentId, 0)).toThrow();
expect(() => pageText("../../escape", 1)).toThrow();
expect(() => searchPages("x")).toThrow();
});
it("해설을 공식 원문과 구분한다", () => {
expect(explanation(catalog.documents[0])).toMatch(/자체 작성|검수 전/);
});
});
tests/map-motion.test.ts
import { describe, it, expect } from "vitest";
import {
createSession,
publicState,
tick,
applyAction,
} from "../packages/core/engine";
import { scenarios } from "../packages/core/scenario";
import {
STATION_POSITION,
INCIDENT_POSITION,
PUMP_ROUTE,
pointOnRoute,
mapPositions,
} from "../packages/core/map-motion";
describe("가상 자원 이동 경로", () => {
it("펌프는 모든 구간에서 도로를 따라 움직이고 모서리에서도 이동 거리가 일정하다", () => {
expect(pointOnRoute(PUMP_ROUTE, 0)).toEqual(STATION_POSITION);
expect(pointOnRoute(PUMP_ROUTE, 280 / 509)).toEqual([355, 224]);
expect(pointOnRoute(PUMP_ROUTE, 394 / 509)).toEqual([355, 110]);
expect(pointOnRoute(PUMP_ROUTE, 1)).toEqual([470, 110]);
for (let n = 0; n <= 100; n++) {
const [x, y] = pointOnRoute(PUMP_ROUTE, n / 100);
expect(y === 224 || x === 355 || y === 110).toBe(true);
}
expect(pointOnRoute(PUMP_ROUTE, 0.5)).toEqual([329.5, 224]);
});
it("드론은 소방서에서 출발해 신고 위치까지 최단 직선으로 먼저 도착한다", () => {
let s = createSession(scenarios[0], { seed: 42, mode: "DEMO", level: 3 });
expect(mapPositions(publicState(s)).pump).toEqual(STATION_POSITION);
s = tick(s, 8);
expect(mapPositions(publicState(s)).drone).toEqual(STATION_POSITION);
s = tick(s, 6);
const p = mapPositions(publicState(s));
expect(p.drone).toEqual([272.5, 194]);
s = tick(s, 6);
expect(mapPositions(publicState(s)).drone).toEqual(INCIDENT_POSITION);
expect(publicState(s).units[0].status).toBe("출동 중");
s = tick(s, 20);
expect(mapPositions(publicState(s)).pump).toEqual([470, 110]);
});
it("승인 전에는 출발하지 않고 늦은 드론 승인도 소방서에서 시작한다", () => {
let s = createSession(scenarios[0], { seed: 42, mode: "DEMO", level: 2 });
s = tick(tick(s, 30), 30);
expect(mapPositions(publicState(s)).pump).toEqual(STATION_POSITION);
s = applyAction(s, { kind: "approve", commandId: s.commands[0].id });
expect(mapPositions(publicState(s)).pump).toEqual(STATION_POSITION);
s = tick(s, 8);
s = applyAction(s, {
kind: "approve",
commandId: s.commands.find((c) => c.actionKey === "RECON")!.id,
});
expect(mapPositions(publicState(s)).drone).toEqual(STATION_POSITION);
s = tick(s, 12);
expect(mapPositions(publicState(s)).drone).toEqual(INCIDENT_POSITION);
});
it("경로 진행률이 범위를 벗어나도 출발·도착 지점 밖으로 이동하지 않는다", () => {
expect(pointOnRoute(PUMP_ROUTE, -1)).toEqual(STATION_POSITION);
expect(pointOnRoute(PUMP_ROUTE, 9)).toEqual([470, 110]);
});
});
tests/request-id.test.ts
import { afterEach, describe, expect, it, vi } from "vitest";
import { newRequestId } from "../apps/web/components/request-id";
afterEach(() => vi.unstubAllGlobals());
describe("브라우저 요청 ID", () => {
it("randomUUID가 없는 HTTP LAN 환경에서도 서버 UUID 계약을 유지한다", () => {
const getRandomValues = globalThis.crypto.getRandomValues.bind(
globalThis.crypto,
);
vi.stubGlobal("crypto", { getRandomValues });
expect(typeof globalThis.crypto.randomUUID).toBe("undefined");
const ids = Array.from({ length: 100 }, () => newRequestId());
expect(new Set(ids).size).toBe(ids.length);
for (const id of ids)
expect(id).toMatch(
/^[0-9a-f]{8}-[0-9a-f]{4}-4[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/,
);
});
});
tests/scenario.test.ts
import { describe, expect, it } from "vitest";
import {
scenarios,
parseScenario,
publicScenario,
} from "../packages/core/scenario";
describe("공통 시나리오 계약", () => {
it("대표 시나리오 3개를 검증하고 서로 다른 사건 유형을 제공한다", () => {
expect(scenarios.map((s) => s.id)).toEqual([
"restaurant-gas",
"warehouse-oil",
"house-rescue",
]);
for (const scenario of scenarios)
expect(parseScenario(scenario).id).toBe(scenario.id);
});
it("음수 시각과 역순 사건 시간표를 거부한다", () => {
const base = scenarios[0];
expect(() =>
parseScenario({
...base,
timeline: [{ at: -1, kind: "report", text: "x" }],
}),
).toThrow();
expect(() =>
parseScenario({
...base,
timeline: [
{ at: 20, kind: "report", text: "x" },
{ at: 5, kind: "report", text: "y" },
],
}),
).toThrow();
});
it("허용된 메타데이터만 공개하고 숨겨진 상태·시간표를 노출하지 않는다", () => {
const view = publicScenario(scenarios[0]);
expect(view).toMatchObject({
id: "restaurant-gas",
title: "식당 화재 · 가스 위험 의심",
});
expect(view).not.toHaveProperty("hidden");
expect(view).not.toHaveProperty("timeline");
expect(JSON.stringify(view)).not.toContain("실제 잔류");
});
});
tests/scene.test.ts
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import {
createSession,
publicState,
tick,
applyAction,
} from "../packages/core/engine";
import { scenarios } from "../packages/core/scenario";
import { restaurantScene } from "../packages/simulation/scene";
import { demoService } from "../apps/web/lib/service";
import { GET } from "../apps/web/app/api/sessions/[id]/scene/route";
const initial = () =>
createSession(
scenarios.find((s) => s.id === "restaurant-gas")!,
{ seed: 42, mode: "DEMO", level: 3 },
);
describe("UE 식당 공개 장면", () => {
it("공개 ETA로 펌프 도로와 드론 직선 좌표를 투영한다", () => {
const state = publicState(initial());
state.units[0] = { ...state.units[0], status: "출동 중", eta: 20 };
state.drone = { status: "이동 중", eta: 6, arrivedAt: null };
const scene = restaurantScene(state);
expect(scene.pump.position).toEqual({ x: -610, y: 880, z: 0 });
expect(scene.drone.position).toEqual({ x: -1750, y: 280, z: 900 });
expect(scene.pump.progress).toBe(0.5);
expect(scene.drone.progress).toBe(0.5);
expect(scene.pumpRoute).toEqual([
{ x: -5700, y: 880, z: 0 },
{ x: -100, y: 880, z: 0 },
{ x: -100, y: -1400, z: 0 },
{ x: 2200, y: -1400, z: 0 },
]);
});
it("늦은 승인 전에는 대기하고 정지 상태는 시간과 장면이 고정된다", () => {
let state = createSession(scenarios[0], {
seed: 42,
mode: "DEMO",
level: 2,
});
state = tick(tick(state, 30), 30);
expect(restaurantScene(publicState(state)).pump.progress).toBe(0);
expect(restaurantScene(publicState(state)).drone.position.z).toBe(120);
state = applyAction(state, {
kind: "approve",
commandId: state.commands[0].id,
});
expect(restaurantScene(publicState(state)).pump.progress).toBe(0);
state = tick(state, 10);
state = applyAction(state, { kind: "intervene" });
expect(restaurantScene(publicState(tick(state, 30)))).toEqual(
restaurantScene(publicState(state)),
);
});
it("미공개 화재와 내부 상태를 보내지 않고 공개 화재만 정규화한다", () => {
const state = publicState(initial());
expect(restaurantScene(state).fire).toEqual({
visible: false,
intensity: 0,
cells: [],
});
state.fireCells = [40, 80];
const scene = restaurantScene(state);
expect(scene.fire).toEqual({
visible: true,
intensity: 0.6,
cells: [40, 80],
});
expect(Object.keys(scene).sort()).toEqual(
[
"protocol",
"sessionId",
"version",
"elapsed",
"paused",
"status",
"scenario",
"station",
"incident",
"pumpRoute",
"pump",
"drone",
"fire",
].sort(),
);
expect(scene.scenario).toEqual({
id: "restaurant-gas",
title: state.scenario.title,
});
scene.fire.cells.push(100);
expect(state.fireCells).toEqual([40, 80]);
});
it("진행률과 강도를 제한하고 도착 드론은 사건 위치를 유지한다", () => {
const state = publicState(initial());
state.units[0] = { ...state.units[0], status: "도착", eta: -20 };
state.drone = { status: "정찰 중", eta: 99, arrivedAt: 20 };
state.fireCells = [200];
const scene = restaurantScene(state);
expect(scene.pump.progress).toBe(1);
expect(scene.drone).toMatchObject({
progress: 1,
position: { x: 2200, y: -320, z: 900 },
});
expect(scene.fire.intensity).toBe(1);
state.units[0].eta = 99;
expect(restaurantScene(state).pump.progress).toBe(0);
});
it("다른 시나리오 및 기관 모드는 차단한다", () => {
const state = publicState(initial());
expect(() => restaurantScene({ ...state, mode: "OPERATIONS" })).toThrow();
expect(() =>
restaurantScene({
...state,
scenario: { ...state.scenario, id: "other" },
}),
).toThrow();
});
});
describe("읽기 전용 장면 API", () => {
const originalFile = demoService.file;
let directory: string | undefined;
afterEach(() => {
demoService.file = originalFile;
if (directory) rmSync(directory, { recursive: true, force: true });
});
it("반복 GET은 저장 파일을 바꾸지 않고 성공과 오류 모두 캐시를 금지한다", async () => {
directory = mkdtempSync(join(tmpdir(), "fire-scene-"));
demoService.file = join(directory, "sessions.json");
const state = initial();
const other = createSession(
scenarios.find((s) => s.id !== "restaurant-gas")!,
{ seed: 1, mode: "DEMO", level: 3 },
);
const institution = { ...initial(), id: "institution", mode: "OPERATIONS" };
writeFileSync(
demoService.file,
JSON.stringify({ [state.id]: state, [other.id]: other, institution }),
);
const before = readFileSync(demoService.file, "utf8");
const get = (id: string) =>
GET(new Request(`http://localhost/api/sessions/${id}/scene`), {
params: Promise.resolve({ id }),
});
const first = await get(state.id);
expect(first.status).toBe(200);
expect(first.headers.get("cache-control")).toBe("no-store");
expect(await first.json()).toEqual(await (await get(state.id)).json());
for (const [id, status] of [
[other.id, 409],
["institution", 409],
["missing", 404],
] as const) {
const response = await get(id);
expect(response.status).toBe(status);
expect(response.headers.get("cache-control")).toBe("no-store");
}
expect(readFileSync(demoService.file, "utf8")).toBe(before);
});
});
tests/server.test.ts
import { afterEach, describe, expect, it } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { DemoService } from "../apps/web/lib/service";
const paths: string[] = [];
function service() {
const dir = mkdtempSync(join(tmpdir(), "firestation-test-"));
paths.push(dir);
return new DemoService(join(dir, "sessions.json"));
}
afterEach(() => {
for (const p of paths.splice(0)) rmSync(p, { recursive: true, force: true });
});
describe("데모 서버 경계·저장", () => {
it("프로토타입 이름은 사건으로 해석하지 않는다", () => {
const api = service();
expect(() => api.get("__proto__")).toThrow(/사건/);
expect(() => api.get("constructor")).toThrow(/사건/);
});
it("재시작 후 저장된 사건과 개입 상태를 복구한다", () => {
const api = service();
let s = api.create({ scenarioId: "restaurant-gas", seed: 42 });
s = api.change(s.id, { kind: "intervene" }, s.version);
expect(new DemoService(api.file).get(s.id).paused).toBe(true);
});
it("클라이언트가 실제 모드 또는 비공개 상태를 생성할 수 없다", () => {
const api = service();
expect(() =>
api.create({ scenarioId: "restaurant-gas", seed: 1, mode: "OPERATIONS" }),
).toThrow();
expect(() =>
api.create({
scenarioId: "restaurant-gas",
seed: 1,
hidden: { truth: "공격" },
}),
).toThrow();
const view = api.create({ scenarioId: "restaurant-gas", seed: 1 });
expect(JSON.stringify(view)).not.toContain("실제 잔류");
expect(view.mode).toBe("DEMO");
});
it("오래된 사건 버전의 변경과 임의 모드 전환을 거부한다", () => {
const api = service();
const s = api.create({ scenarioId: "restaurant-gas", seed: 1 });
api.change(s.id, { kind: "tick", seconds: 1 }, s.version);
expect(() =>
api.change(s.id, { kind: "tick", seconds: 1 }, s.version),
).toThrow(/버전/);
expect(() =>
api.change(s.id, { kind: "mode", mode: "OPERATIONS" }, s.version + 1),
).toThrow();
});
it("없는 사건·잘못된 속도·과도한 시드·임의 필드를 거부한다", () => {
const api = service();
expect(() => api.get("missing")).toThrow(/사건/);
expect(() =>
api.create({ scenarioId: "restaurant-gas", seed: Infinity }),
).toThrow();
const s = api.create({ scenarioId: "restaurant-gas", seed: 1 });
expect(() =>
api.change(s.id, { kind: "tick", seconds: 10000 }, s.version),
).toThrow();
expect(() =>
api.change(
s.id,
{ kind: "level", level: 3, mode: "OPERATIONS" },
s.version,
),
).toThrow();
});
});
tests/station.test.ts
import { describe, it, expect, afterEach } from "vitest";
import { mkdtempSync, rmSync } from "node:fs";
import { join, resolve } from "node:path";
import { tmpdir } from "node:os";
import { StationDatabase } from "../packages/station/database";
import { StationAuth } from "../packages/station/auth";
import { StationService } from "../packages/station/service";
const fixtures: { dir: string; db: StationDatabase }[] = [];
afterEach(() => {
for (const f of fixtures.splice(0)) {
f.db.close();
if (!resolve(f.dir).startsWith(resolve(tmpdir()))) throw Error("임시 경로");
rmSync(f.dir, { recursive: true, force: true });
}
});
function setup() {
const dir = mkdtempSync(join(tmpdir(), "station-domain-")),
db = new StationDatabase(join(dir, "s.db")),
auth = new StationAuth(db);
fixtures.push({ dir, db });
const admin = auth.bootstrap({
stationName: "훈련기관",
setupKey: auth.issueSetupToken(),
username: "admin",
displayName: "관리자",
password: "Password-for-test-2026!",
}).user;
const commander = auth.createUser(admin, {
username: "commander",
displayName: "지휘관",
password: "Password-for-test-2026!",
role: "commander",
}),
crew = auth.createUser(admin, {
username: "crew",
displayName: "대원",
password: "Password-for-test-2026!",
role: "crew",
}),
other = auth.createUser(admin, {
username: "other",
displayName: "다른 대원",
password: "Password-for-test-2026!",
role: "crew",
});
const service = new StationService(db, auth, () => ({
id: "doc",
pageCount: 20,
status: "verified",
title: "공식 교재",
}));
const resource = service.addResource(admin, {
name: "펌프1",
kind: "펌프차",
crewUserId: crew.id,
});
const evidence = service.addEvidence(admin, {
documentId: "doc",
page: 3,
reviewer: "기관 검토자",
notes: "기관 적용성 확인",
});
const incident = service.create(admin, {
title: "사건",
address: "서울",
report: "가스 냄새 신고, 확인 필요",
commanderId: commander.id,
});
return {
db,
auth,
service,
admin,
commander,
crew,
other,
resource,
evidence,
incident,
};
}
function action(
f: ReturnType<typeof setup>,
actor: typeof f.admin,
payload: Record<string, unknown>,
) {
const current = f.service.get(actor, f.incident.id);
return f.service.act(actor, f.incident.id, {
version: current.version,
requestId: crypto.randomUUID(),
...payload,
});
}
describe("기관용 지휘 상태와 권한", () => {
it("자원 중복 배정을 거부하고 대원은 배정 사건만 조회한다", () => {
const f = setup();
expect(() => f.service.get(f.crew, f.incident.id)).toThrow(/권한/);
action(f, f.admin, { type: "assign", resourceId: f.resource.id });
expect(f.service.get(f.crew, f.incident.id).id).toBe(f.incident.id);
expect(() => f.service.get(f.other, f.incident.id)).toThrow(/권한/);
const next = f.service.create(f.admin, {
title: "두번째",
address: "주소",
report: "신고",
commanderId: f.commander.id,
});
expect(() =>
f.service.act(f.admin, next.id, {
type: "assign",
resourceId: f.resource.id,
version: next.version,
requestId: crypto.randomUUID(),
}),
).toThrow(/배정/);
});
it("근거와 지휘관 승인·새 관찰 재검토를 강제한다", () => {
const f = setup();
action(f, f.admin, { type: "assign", resourceId: f.resource.id });
expect(() =>
action(f, f.admin, {
type: "draft",
text: "현장 확인",
resourceIds: [f.resource.id],
evidenceIds: [],
}),
).toThrow();
let s = action(f, f.admin, {
type: "draft",
text: "현장 보고 확인",
resourceIds: [f.resource.id],
evidenceIds: [f.evidence.id],
});
const commandId = s.commands[0].id;
expect(() => action(f, f.crew, { type: "approve", commandId })).toThrow(
/권한/,
);
action(f, f.admin, {
type: "observe",
text: "새로운 통전 의심",
source: "대원",
certainty: "suspected",
});
expect(() =>
action(f, f.commander, { type: "approve", commandId }),
).toThrow(/재검토/);
action(f, f.commander, { type: "review", commandId });
s = action(f, f.commander, { type: "approve", commandId });
expect(s.commands[0].status).toBe("issued");
});
it("대상별 완료만 인정하며 취소 뒤 늦은 완료는 거부한다", () => {
const f = setup();
action(f, f.admin, { type: "assign", resourceId: f.resource.id });
let s = action(f, f.admin, {
type: "draft",
text: "인계 보고",
resourceIds: [f.resource.id],
evidenceIds: [f.evidence.id],
});
const commandId = s.commands[0].id;
action(f, f.commander, { type: "approve", commandId });
expect(() => action(f, f.commander, { type: "close" })).toThrow(/미완료/);
expect(() =>
action(f, f.other, { type: "ack", commandId, resourceId: f.resource.id }),
).toThrow();
action(f, f.crew, { type: "ack", commandId, resourceId: f.resource.id });
action(f, f.commander, { type: "cancel", commandId });
expect(() =>
action(f, f.crew, {
type: "complete",
commandId,
resourceId: f.resource.id,
}),
).toThrow(/취소/);
expect(() => action(f, f.commander, { type: "close" })).toThrow();
action(f, f.crew, {
type: "cancelAck",
commandId,
resourceId: f.resource.id,
});
s = action(f, f.commander, { type: "close" });
expect(s.status).toBe("closed");
expect(f.service.resources(f.admin)[0].incidentId).toBeNull();
});
it("중복 키 재시도는 한 번만 적용하고 버전 충돌·인계를 검증한다", () => {
const f = setup();
const input = {
type: "observe",
text: "새 보고",
source: "무전",
certainty: "unconfirmed",
version: 1,
requestId: crypto.randomUUID(),
};
const a = f.service.act(f.admin, f.incident.id, input),
b = f.service.act(f.admin, f.incident.id, input);
expect(a).toEqual(b);
expect(a.observations).toHaveLength(1);
expect(() =>
f.service.act(f.admin, f.incident.id, { ...input, text: "다름" }),
).toThrow(/중복/);
expect(() =>
f.service.act(f.admin, f.incident.id, {
...input,
requestId: crypto.randomUUID(),
}),
).toThrow(/충돌/);
action(f, f.admin, { type: "handover", commanderId: f.admin.id });
expect(() => action(f, f.commander, { type: "close" })).toThrow(/지휘/);
expect(f.db.verifyAudit(f.admin.stationId).valid).toBe(true);
});
it("기관·역할 주입과 자동 지휘 입력을 거부한다", () => {
const f = setup();
expect(() =>
f.service.get({ ...f.admin, stationId: "forged" }, f.incident.id),
).toThrow();
expect(() =>
f.service.act(f.admin, f.incident.id, {
type: "auto",
level: 3,
version: 1,
requestId: crypto.randomUUID(),
}),
).toThrow();
expect(() =>
action(f, f.admin, {
type: "observe",
text: "x",
source: "x",
certainty: "confirmed",
stationId: "forged",
}),
).toThrow();
});
});
tests/unreal-runtime.test.ts
import { describe, expect, it } from "vitest";
import { spawnSync } from "node:child_process";
import { readFileSync } from "node:fs";
const run = (script: string, args: string[]) => spawnSync("powershell.exe", ["-NoProfile", "-File", script, ...args], { encoding: "utf8" });
describe.skipIf(process.platform !== "win32")("UE 실행 입력 경계", () => {
it("잘못된 세션은 실행 전에 거부한다", () => {
const result = run("scripts/unreal.ps1", ["-Action", "run", "-Session", "bad; whoami"]);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("UUID");
});
it("없는 엔진은 성공으로 표시하지 않는다", () => {
const result = run("scripts/unreal.ps1", ["-Action", "build", "-EngineRoot", "C:/missing-ai-firestation-engine"]);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("UE_ENGINE_ROOT");
});
it("신호 서버의 저장소 이탈 경로를 거부한다", () => {
const result = run("scripts/unreal-stream.ps1", ["-Action", "setup", "-InfrastructureRoot", "C:/Windows"]);
expect(result.status).not.toBe(0);
expect(result.stderr).toContain(".unreal-runtime");
});
it("따옴표와 셸 구문을 포함한 엔진 경로를 거부한다", () => {
const result = run("scripts/unreal.ps1", ["-Action", "build", "-EngineRoot", 'C:/invalid\"&whoami']);
expect(result.status).not.toBe(0);
expect(result.stderr).not.toContain("UE_ENGINE_ROOT");
});
});
describe("네이티브 공개 상태와 입력 경계", () => {
it("HTTP는 읽기 요청이고 PixelStreaming 입력은 지정된 카메라에만 연결한다", () => {
const source = readFileSync("unreal/AIFirestation/Source/AIFirestation/RestaurantScene.cpp", "utf8");
expect(source).toContain('SetVerb(TEXT("GET"))');
expect(source).not.toMatch(/SetVerb\(TEXT\("(POST|PUT|DELETE|PATCH)"\)\)/);
expect(source).toContain('Session!=SessionId');
expect(source).toContain('Type!=TEXT("firestation.camera")');
expect(source).toContain('Version<LastVersion');
expect(source).toContain('BindWeakLambda');
expect(source).toContain('CancelRequest()');
expect(source).toContain('if(bPaused||Intensity<=0) return');
});
it("생성 재질에는 서버 정지 시 함께 멈추는 scalar 시간이 있다", () => {
const bootstrap = readFileSync("unreal/AIFirestation/Scripts/bootstrap.py", "utf8");
expect(bootstrap).toContain("'SceneTime'");
expect(bootstrap).toContain("MaterialExpressionNoise");
expect(bootstrap).not.toContain("MaterialExpressionTime");
});
});
describe("UE 자산 생성 실패 처리", () => {
const executeBootstrap = (failure: "connection" | "save") => {
// 실제 엔진 없이 첫 재질의 연결/저장 실패를 주입한다.
const stub = `
import runpy, sys, types
class Asset:
def set_editor_property(self, *args): pass
class AssetTools:
def create_asset(self, *args): return Asset()
class Editing:
@staticmethod
def delete_all_material_expressions(*args): pass
@staticmethod
def create_material_expression(*args): return Asset()
@staticmethod
def connect_material_property(*args): return ${failure === "connection" ? "False" : "True"}
@staticmethod
def connect_material_expressions(*args): return True
@staticmethod
def recompile_material(*args): pass
class Editor:
@staticmethod
def does_directory_exist(*args): return True
@staticmethod
def make_directory(*args): return True
@staticmethod
def load_asset(*args): return None
@staticmethod
def save_loaded_asset(*args): return ${failure === "save" ? "False" : "True"}
u = types.SimpleNamespace(AssetToolsHelpers=types.SimpleNamespace(get_asset_tools=lambda: AssetTools()), EditorAssetLibrary=Editor, MaterialEditingLibrary=Editing, Material=Asset, MaterialFactoryNew=Asset, MaterialExpressionConstant3Vector=Asset, MaterialExpressionConstant=Asset, LinearColor=lambda *a:a, MaterialProperty=types.SimpleNamespace(MP_BASE_COLOR=0, MP_EMISSIVE_COLOR=1, MP_ROUGHNESS=2, MP_METALLIC=3))
sys.modules['unreal'] = u
runpy.run_path('unreal/AIFirestation/Scripts/bootstrap.py', run_name='__main__')
`;
return spawnSync(process.env.PYTHON ?? "python", ["-c", stub], { encoding: "utf8" });
};
it("재질 연결 실패를 성공으로 숨기지 않는다", () => {
const result = executeBootstrap("connection");
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("material connection failed");
});
it("재질 저장 실패를 성공으로 숨기지 않는다", () => {
const result = executeBootstrap("save");
expect(result.status).not.toBe(0);
expect(result.stderr).toContain("material save failed");
});
});
tests/unreal-viewer.test.ts
import { describe, expect, it, vi } from "vitest";
import {
sceneSignalUrl,
validSceneSession,
lockSceneConfig,
} from "../apps/web/components/unreal-viewer-config";
import { ownWindowListeners } from "../apps/web/components/unreal-viewer-lifecycle";
import { monitorScenePlayback } from "../apps/web/components/unreal-viewer-playback";
describe("UE viewer 접속 계약", () => {
it("SDK 재생 요청 후 거부는 연결 완료가 아니며 실제 playing만 완료로 표시한다", () => {
const video = new EventTarget();
let live = false;
const update = vi.fn((state: string) => {
live = state === "playing";
});
const playback = monitorScenePlayback(video, update);
playback.suspend("request");
expect(live).toBe(false);
playback.suspend("rejected");
expect(live).toBe(false);
video.dispatchEvent(new Event("playing"));
expect(live).toBe(true);
for (const event of ["pause", "waiting", "ended", "error"]) {
video.dispatchEvent(new Event(event));
expect(live).toBe(false);
video.dispatchEvent(new Event("playing"));
expect(live).toBe(true);
}
playback.suspend("disconnected");
expect(live).toBe(false);
playback.dispose();
update.mockClear();
video.dispatchEvent(new Event("playing"));
playback.suspend("request");
expect(update).not.toHaveBeenCalled();
expect(live).toBe(false);
});
it("실제 Epic Config의 다른 스트리머 및 입력 설정을 사건 UUID로 고정한다", async () => {
const location = {
search: "?StreamerId=foreign&ss=ws://wrong.test&UseMic=true",
protocol: "http:",
hostname: "example.test",
port: "",
};
vi.stubGlobal("window", { location });
vi.stubGlobal("location", location);
vi.stubGlobal("RTCRtpReceiver", {
getCapabilities: () => ({ codecs: [] }),
});
try {
const { Config } =
await import("@epicgames-ps/lib-pixelstreamingfrontend-ue5.8");
const config = new Config({ useUrlParams: false });
lockSceneConfig(
config,
"00000000-0000-4000-8000-000000000000",
"wss://safe.test?autoSubscribe=false",
);
expect(config.getSettingOption("StreamerId").selected).toBe(
"00000000-0000-4000-8000-000000000000",
);
expect(config.getTextSettingValue("ss")).toBe(
"wss://safe.test?autoSubscribe=false",
);
expect(config.isFlagEnabled("WaitForStreamer")).toBe(true);
for (const key of [
"UseMic",
"UseCamera",
"KeyboardInput",
"MouseInput",
"TouchInput",
"GamepadInput",
"XRControllerInput",
] as const)
expect(config.isFlagEnabled(key)).toBe(false);
expect(config.getNumericSettingValue("MaxReconnectAttempts")).toBe(0);
} finally {
vi.unstubAllGlobals();
}
});
it("UUID 형태만 사건 경로에 사용한다", () => {
expect(validSceneSession("00000000-0000-4000-8000-000000000000")).toBe(
true,
);
expect(validSceneSession("../api/station")).toBe(false);
expect(validSceneSession("")).toBe(false);
});
it("신호 서버의 임의 선구독을 비활성화한다", () => {
expect(
new URL(
sceneSignalUrl("wss://example.test/stream?autoSubscribe=true")!,
).searchParams.get("autoSubscribe"),
).toBe("false");
});
it("휴대폰에서 잘못 연결할 루프백과 잘못된 프로토콜을 배제한다", () => {
for (const url of [
"ws://localhost:80",
"ws://127.0.0.1",
"ws://[::1]",
"ws://0.0.0.0",
"http://example.test",
"wss://u:p@example.test",
"bad",
])
expect(sceneSignalUrl(url)).toBeNull();
expect(sceneSignalUrl(undefined)).toBeNull();
expect(sceneSignalUrl("ws://192.168.1.30:80")).not.toBeNull();
});
it("HTTPS 브라우저에는 WSS만 허용한다", () => {
expect(
sceneSignalUrl("ws://example.test", "https://example.test"),
).toBeNull();
expect(
sceneSignalUrl("wss://example.test", "https://example.test"),
).not.toBeNull();
});
it("플레이어 생성 시 등록한 익명 창 리스너를 동일 옵션으로 해제한다", () => {
const target = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as Window;
const original = target.addEventListener;
const cleanup: (() => void)[] = [];
const resize = () => {};
ownWindowListeners(
target,
() => {
target.addEventListener("resize", resize, true);
return 42;
},
cleanup,
);
expect(target.addEventListener).toBe(original);
expect(target.removeEventListener).not.toHaveBeenCalled();
cleanup.forEach((fn) => fn());
expect(target.removeEventListener).toHaveBeenCalledWith(
"resize",
resize,
true,
);
});
it("생성 실패에도 창 API를 복구하고 부분 등록 리스너를 해제할 수 있다", () => {
const target = {
addEventListener: vi.fn(),
removeEventListener: vi.fn(),
} as unknown as Window;
const original = target.addEventListener;
const cleanup: (() => void)[] = [];
expect(() =>
ownWindowListeners(
target,
() => {
target.addEventListener("resize", () => {});
throw Error("실패");
},
cleanup,
),
).toThrow("실패");
expect(target.addEventListener).toBe(original);
cleanup.forEach((fn) => fn());
expect(target.removeEventListener).toHaveBeenCalledOnce();
});
});
tsconfig.json
{
"compilerOptions": {
"target": "ES2022",
"lib": ["dom", "dom.iterable", "es2022"],
"strict": true,
"noEmit": true,
"esModuleInterop": true,
"module": "esnext",
"moduleResolution": "bundler",
"resolveJsonModule": true,
"jsx": "react-jsx",
"skipLibCheck": true,
"paths": {
"@core/*": ["./packages/core/*"],
"@kb/*": ["./packages/kb/*"],
"@web/*": ["./apps/web/*"]
}
},
"include": [
"packages/**/*.ts",
"apps/web/**/*.ts",
"apps/web/**/*.tsx",
"scripts/**/*.ts",
"tests/**/*.ts",
"vitest.config.ts",
"playwright.config.ts"
],
"exclude": ["node_modules", "apps/web/.next"]
}
unreal/AIFirestation/AIFirestation.uproject
{
"FileVersion": 3,
"EngineAssociation": "5.8",
"Category": "Simulation",
"Description": "공개 상태 기반 식당 화재 교육 시각화",
"Modules": [{"Name": "AIFirestation", "Type": "Runtime", "LoadingPhase": "Default"}],
"Plugins": [{"Name": "PixelStreaming", "Enabled": true}, {"Name": "PythonScriptPlugin", "Enabled": true}, {"Name": "EditorScriptingUtilities", "Enabled": true}]
}
unreal/AIFirestation/Config/DefaultEngine.ini
[/Script/EngineSettings.GameMapsSettings] EditorStartupMap=/Game/Maps/RestaurantFire GameDefaultMap=/Game/Maps/RestaurantFire GlobalDefaultGameMode=/Script/AIFirestation.RestaurantGameMode [/Script/Engine.RendererSettings] r.DefaultFeature.AutoExposure=False r.DefaultFeature.MotionBlur=False r.DynamicGlobalIlluminationMethod=1 r.ReflectionMethod=1 r.GenerateMeshDistanceFields=True [PixelStreaming] AllowPixelStreamingCommands=False [/Script/Engine.Engine] bUseFixedFrameRate=True FixedFrameRate=30.0
unreal/AIFirestation/Config/DefaultGame.ini
[/Script/EngineSettings.GeneralProjectSettings] ProjectID=C424FB8046EF4F3CAE9A4BA11A36218B ProjectName=AI Firestation Restaurant Description=공개 상태 기반 교육용 UE5 식당 화재 장면
unreal/AIFirestation/Scripts/bootstrap.py
"""외부 자산 없이 UE 편집기에서 맵과 재질을 생성한다."""
import unreal
asset_tools = unreal.AssetToolsHelpers.get_asset_tools()
editor = unreal.EditorAssetLibrary
for directory in ('/Game/Maps', '/Game/Materials'):
if not editor.does_directory_exist(directory) and not editor.make_directory(directory):
raise RuntimeError('asset directory creation failed: ' + directory)
def expression(*args):
result = unreal.MaterialEditingLibrary.create_material_expression(*args)
if result is None:
raise RuntimeError('material expression creation failed')
return result
def connect_property(*args):
if not unreal.MaterialEditingLibrary.connect_material_property(*args):
raise RuntimeError('material connection failed: property')
def connect_expressions(*args):
if not unreal.MaterialEditingLibrary.connect_material_expressions(*args):
raise RuntimeError('material connection failed: expression')
def material(name, color, emissive=False, opacity=None):
path = '/Game/Materials/' + name
asset = editor.load_asset(path)
if asset is None:
asset = asset_tools.create_asset(name, '/Game/Materials', unreal.Material, unreal.MaterialFactoryNew())
if asset is None:
raise RuntimeError('material asset creation failed: ' + path)
unreal.MaterialEditingLibrary.delete_all_material_expressions(asset)
rgb = expression(asset, unreal.MaterialExpressionConstant3Vector, -250, 0)
rgb.set_editor_property('constant', unreal.LinearColor(*color, 1))
output = unreal.MaterialProperty.MP_EMISSIVE_COLOR if emissive else unreal.MaterialProperty.MP_BASE_COLOR
connect_property(rgb, '', output)
if emissive:
asset.set_editor_property('shading_model', unreal.MaterialShadingModel.MSM_UNLIT)
if opacity is not None:
asset.set_editor_property('blend_mode', unreal.BlendMode.BLEND_TRANSLUCENT)
asset.set_editor_property('two_sided', True)
alpha = expression(asset, unreal.MaterialExpressionConstant, -250, 150)
alpha.set_editor_property('r', opacity)
# 엔진 Time 노드를 사용하지 않아 서버 정지 중 셰이더도 고정한다.
clock = expression(asset, unreal.MaterialExpressionScalarParameter, -900, 300)
clock.set_editor_property('parameter_name', 'SceneTime')
position = expression(asset, unreal.MaterialExpressionWorldPosition, -900, 450)
offset = expression(asset, unreal.MaterialExpressionAdd, -650, 350)
connect_expressions(clock, '', offset, 'A')
connect_expressions(position, '', offset, 'B')
noise = expression(asset, unreal.MaterialExpressionNoise, -450, 350)
noise.set_editor_property('scale', .018)
connect_expressions(offset, '', noise, 'Position')
fresnel = expression(asset, unreal.MaterialExpressionFresnel, -650, 650)
inverse = expression(asset, unreal.MaterialExpressionOneMinus, -450, 650)
connect_expressions(fresnel, '', inverse, 'Input')
soft = expression(asset, unreal.MaterialExpressionMultiply, -200, 550)
connect_expressions(noise, '', soft, 'A')
connect_expressions(inverse, '', soft, 'B')
strength = expression(asset, unreal.MaterialExpressionMultiply, 0, 350)
connect_expressions(soft, '', strength, 'A')
connect_expressions(alpha, '', strength, 'B')
connect_property(strength, '', unreal.MaterialProperty.MP_OPACITY)
if emissive:
glow = expression(asset, unreal.MaterialExpressionMultiply, 0, 0)
connect_expressions(rgb, '', glow, 'A')
connect_expressions(noise, '', glow, 'B')
connect_property(glow, '', unreal.MaterialProperty.MP_EMISSIVE_COLOR)
else:
rough = expression(asset, unreal.MaterialExpressionConstant, -250, 200)
rough.set_editor_property('r', .24 if name == 'M_Metal' else .8)
connect_property(rough, '', unreal.MaterialProperty.MP_ROUGHNESS)
if name == 'M_Metal':
metal = expression(asset, unreal.MaterialExpressionConstant, -250, 300)
metal.set_editor_property('r', .8)
connect_property(metal, '', unreal.MaterialProperty.MP_METALLIC)
unreal.MaterialEditingLibrary.recompile_material(asset)
if not editor.save_loaded_asset(asset):
raise RuntimeError('material save failed: ' + path)
material('M_Concrete', (.38,.41,.44))
material('M_Road', (.045,.055,.065))
material('M_Red', (.65,.015,.018))
material('M_Metal', (.65,.68,.72))
material('M_Glass', (.04,.24,.34))
material('M_Flame', (8,1.6,.04), True, .82)
material('M_Smoke', (.07,.075,.08), False, .26)
level = unreal.get_editor_subsystem(unreal.LevelEditorSubsystem)
if not level.new_level('/Game/Maps/RestaurantFire'):
raise RuntimeError('RestaurantFire 맵 생성 실패')
world = unreal.get_editor_subsystem(unreal.UnrealEditorSubsystem).get_editor_world()
game_mode = unreal.load_class(None, '/Script/AIFirestation.RestaurantGameMode')
if game_mode is None:
raise RuntimeError('C++ 모듈 빌드 후 다시 실행하십시오')
world.get_world_settings().set_editor_property('default_game_mode', game_mode)
if not level.save_current_level():
raise RuntimeError('RestaurantFire 맵 저장 실패')
unreal.log('RestaurantFire 맵/재질 생성 완료; 실제 렌더링 검수 필요')
unreal/AIFirestation/Source/AIFirestation.Target.cs
using UnrealBuildTool;
using System.Collections.Generic;
public class AIFirestationTarget : TargetRules {
public AIFirestationTarget(TargetInfo Target) : base(Target) {
Type = TargetType.Game; DefaultBuildSettings = BuildSettingsVersion.V6;
ExtraModuleNames.Add("AIFirestation");
}
}
unreal/AIFirestation/Source/AIFirestation/AIFirestation.Build.cs
using UnrealBuildTool;
public class AIFirestation : ModuleRules {
public AIFirestation(ReadOnlyTargetRules Target) : base(Target) {
PCHUsage = PCHUsageMode.UseExplicitOrSharedPCHs;
PublicDependencyModuleNames.AddRange(new string[] { "Core", "CoreUObject", "Engine", "HTTP", "Json", "JsonUtilities", "PixelStreaming" });
}
}
unreal/AIFirestation/Source/AIFirestation/AIFirestation.cpp
#include "Modules/ModuleManager.h" IMPLEMENT_PRIMARY_GAME_MODULE(FDefaultGameModuleImpl, AIFirestation, "AIFirestation");
unreal/AIFirestation/Source/AIFirestation/RestaurantGameMode.cpp
#include "RestaurantGameMode.h"
#include "RestaurantScene.h"
#include "Engine/World.h"
ARestaurantGameMode::ARestaurantGameMode() { DefaultPawnClass = nullptr; }
void ARestaurantGameMode::BeginPlay() {
Super::BeginPlay();
GetWorld()->SpawnActor<ARestaurantScene>();
}
unreal/AIFirestation/Source/AIFirestation/RestaurantGameMode.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/GameModeBase.h"
#include "RestaurantGameMode.generated.h"
UCLASS()
class AIFIRESTATION_API ARestaurantGameMode : public AGameModeBase {
GENERATED_BODY()
public:
ARestaurantGameMode();
virtual void BeginPlay() override;
};
unreal/AIFirestation/Source/AIFirestation/RestaurantScene.cpp
#include "RestaurantScene.h"
#include "PixelStreamingInputComponent.h"
#include "Camera/CameraActor.h"
#include "Components/StaticMeshComponent.h"
#include "Components/PointLightComponent.h"
#include "Components/TextRenderComponent.h"
#include "Engine/DirectionalLight.h"
#include "Engine/SkyLight.h"
#include "Engine/StaticMesh.h"
#include "Engine/World.h"
#include "HttpModule.h"
#include "Interfaces/IHttpResponse.h"
#include "Materials/MaterialInstanceDynamic.h"
#include "Serialization/JsonReader.h"
#include "Serialization/JsonSerializer.h"
#include "Misc/CommandLine.h"
#include "Misc/Parse.h"
#include "Kismet/GameplayStatics.h"
#include "TimerManager.h"
namespace {
bool Number(const TSharedPtr<FJsonObject>& O, const TCHAR* Key, double& Out, double Min, double Max) {
return O.IsValid() && O->TryGetNumberField(Key, Out) && FMath::IsFinite(Out) && Out >= Min && Out <= Max;
}
bool Point(const TSharedPtr<FJsonObject>& O, FVector& Out) {
double X, Y, Z;
if (!Number(O, TEXT("x"), X, -20000, 20000) || !Number(O, TEXT("y"), Y, -20000, 20000) || !Number(O, TEXT("z"), Z, 0, 5000)) return false;
Out = FVector(X,Y,Z); return true;
}
TSharedPtr<FJsonObject> Object(const TSharedPtr<FJsonObject>& O, const TCHAR* Key) {
const TSharedPtr<FJsonObject>* Value = nullptr;
return O.IsValid() && O->TryGetObjectField(Key, Value) ? *Value : nullptr;
}
}
ARestaurantScene::ARestaurantScene() {
PrimaryActorTick.bCanEverTick = true;
RootComponent = CreateDefaultSubobject<USceneComponent>(TEXT("SceneRoot"));
Input = CreateDefaultSubobject<UPixelStreamingInput>(TEXT("CameraOnlyInput"));
}
UStaticMeshComponent* ARestaurantScene::Shape(const FString& Name, const FVector& Position, const FVector& Size, UMaterialInterface* Material, const TCHAR* Mesh, USceneComponent* Parent) {
auto* C = NewObject<UStaticMeshComponent>(this, FName(*Name));
C->SetStaticMesh(LoadObject<UStaticMesh>(nullptr, Mesh));
C->SetMobility(EComponentMobility::Movable);
C->SetupAttachment(Parent ? Parent : GetRootComponent());
const FVector ParentScale = Parent ? Parent->GetComponentScale() : FVector::OneVector;
C->SetRelativeLocation(Position / ParentScale); C->SetRelativeScale3D(Size / 100.0 / ParentScale);
C->SetCollisionEnabled(ECollisionEnabled::NoCollision);
if (Material) C->SetMaterial(0, Material);
AddInstanceComponent(C); C->RegisterComponent(); return C;
}
void ARestaurantScene::BuildScene() {
auto Mat = [](const TCHAR* Name) { return LoadObject<UMaterialInterface>(nullptr, *FString::Printf(TEXT("/Game/Materials/%s.%s"),Name,Name)); };
auto* Concrete=Mat(TEXT("M_Concrete")); auto* Road=Mat(TEXT("M_Road")); auto* Red=Mat(TEXT("M_Red")); auto* Metal=Mat(TEXT("M_Metal")); auto* Glass=Mat(TEXT("M_Glass"));
Shape(TEXT("Ground"), FVector(0,0,-40), FVector(16000,10000,80),Concrete);
Shape(TEXT("MainRoad"), FVector(-2500,880,3),FVector(10000,700,8),Road);
Shape(TEXT("CrossRoad"),FVector(-100,-200,4),FVector(700,4500,8),Road);
Shape(TEXT("KitchenAccessRoad"),FVector(1050,-1400,5),FVector(3000,700,8),Road);
for (int I=0; I<24; ++I) Shape(FString::Printf(TEXT("Lane%d"),I),FVector(-7000+I*400,880,10),FVector(180,15,4),Metal);
// 지도 소방서 (-5700,880), 식당 (2200,-320)와 같은 센티미터 좌표를 사용한다.
Shape(TEXT("Station"),FVector(-5700,1900,250),FVector(1700,1500,500),Red);
Shape(TEXT("StationRoof"),FVector(-5700,1900,520),FVector(1800,1600,50),Metal);
for (int I=0; I<3; ++I) Shape(FString::Printf(TEXT("Garage%d"),I),FVector(-6250+I*550,1135,170),FVector(440,25,320),Metal);
for (int I=0; I<8; ++I) {
const float X=-7000+I*1700;
Shape(FString::Printf(TEXT("CityBlock%d"),I),FVector(X,-3000,400+(I%3)*120),FVector(1000,1200,800+(I%3)*240),Concrete);
for (int J=0; J<3; ++J) Shape(FString::Printf(TEXT("CityWindow%d_%d"),I,J),FVector(X-300+J*300,-2390,450),FVector(180,15,150),Glass);
}
const FVector Kitchen(2200,-320,0);
Shape(TEXT("KitchenFloor"),Kitchen+FVector(0,0,12),FVector(1600,1300,24),Metal);
Shape(TEXT("KitchenBackWall"),Kitchen+FVector(0,640,230),FVector(1600,30,460),Concrete);
Shape(TEXT("KitchenSideWall"),Kitchen+FVector(790,0,230),FVector(30,1300,460),Concrete);
Shape(TEXT("Counter"),Kitchen+FVector(0,400,80),FVector(1100,250,160),Metal);
Shape(TEXT("Stove"),Kitchen+FVector(100,300,180),FVector(400,200,25),Road);
Shape(TEXT("Extractor"),Kitchen+FVector(100,350,370),FVector(500,350,60),Metal);
Shape(TEXT("PrepTable"),Kitchen+FVector(-400,-250,80),FVector(450,300,160),Metal);
Shape(TEXT("Fridge"),Kitchen+FVector(570,380,130),FVector(200,200,260),Glass);
Shape(TEXT("GasCylinder"),Kitchen+FVector(500,170,65),FVector(70,70,130),Red,TEXT("/Engine/BasicShapes/Cylinder.Cylinder"));
auto* KitchenLight=NewObject<UPointLightComponent>(this,TEXT("KitchenLight"));
KitchenLight->SetupAttachment(RootComponent); KitchenLight->SetRelativeLocation(Kitchen+FVector(0,0,400));
KitchenLight->SetIntensity(14000); KitchenLight->SetAttenuationRadius(1800); AddInstanceComponent(KitchenLight); KitchenLight->RegisterComponent();
Pump=Shape(TEXT("PumpTruck"),FVector(-5700,880,100),FVector(560,220,200),Red);
Shape(TEXT("TruckCab"),FVector(180,0,60),FVector(180,220,120),Red,TEXT("/Engine/BasicShapes/Cube.Cube"),Pump);
// 자식 크기는 부모 스케일의 영향을 받으므로 월드 크기로 보정한다.
Shape(TEXT("TruckWindshield"),FVector(235,0,80),FVector(12,180,70),Glass,TEXT("/Engine/BasicShapes/Cube.Cube"),Pump);
Shape(TEXT("TruckBeacon"),FVector(180,0,140),FVector(65,130,25),Red,TEXT("/Engine/BasicShapes/Cube.Cube"),Pump);
for(int I=0;I<4;++I) {
auto* Wheel=Shape(FString::Printf(TEXT("Wheel%d"),I),FVector(I<2?-170:170,I%2?-120:120,-55),FVector(85,35,85),Road,TEXT("/Engine/BasicShapes/Cylinder.Cylinder"));
Wheel->AttachToComponent(Pump,FAttachmentTransformRules::KeepWorldTransform); Wheel->SetRelativeLocation(FVector(I<2?-30:30,I%2?-55:55,-25));
Wheel->SetWorldScale3D(FVector(.85,.85,.35)); Wheel->SetRelativeRotation(FRotator(0,0,90));
}
Drone=Shape(TEXT("Drone"),FVector(-5700,880,120),FVector(100,100,35),Metal);
for(int I=0;I<4;++I) {
auto* Rotor=Shape(FString::Printf(TEXT("Rotor%d"),I),Drone->GetComponentLocation()+FVector(I<2?-90:90,I%2?-90:90,20),FVector(100,100,10),Road,TEXT("/Engine/BasicShapes/Cylinder.Cylinder"));
Rotor->AttachToComponent(Drone,FAttachmentTransformRules::KeepWorldTransform);
}
FireMaterial=UMaterialInstanceDynamic::Create(Mat(TEXT("M_Flame")),this);
SmokeMaterial=UMaterialInstanceDynamic::Create(Mat(TEXT("M_Smoke")),this);
for(int I=0;I<12;++I) {
Flames.Add(Shape(FString::Printf(TEXT("Flame%d"),I),Kitchen+FVector(-200+(I%4)*130,220+(I/4)*90,260),FVector(90,90,220),FireMaterial,TEXT("/Engine/BasicShapes/Cone.Cone")));
Smoke.Add(Shape(FString::Printf(TEXT("Smoke%d"),I),Kitchen+FVector(-250+(I%4)*180,180+(I/4)*100,450+I*35),FVector(230,230,200),SmokeMaterial,TEXT("/Engine/BasicShapes/Sphere.Sphere")));
}
for(const auto& C:Flames) C->SetVisibility(false); for(const auto& C:Smoke) C->SetVisibility(false);
auto* Sun=GetWorld()->SpawnActor<ADirectionalLight>(); Sun->SetActorRotation(FRotator(-50,-35,0));
GetWorld()->SpawnActor<ASkyLight>();
const FVector Positions[]={FVector(-1600,-6600,6500),FVector(1800,-1000,1500),FVector(-6100,200,400),FVector(1400,-1100,180)};
for(int I=0;I<4;++I) {
auto* Camera=GetWorld()->SpawnActor<ACameraActor>(Positions[I],(Kitchen-Positions[I]).Rotation()); Cameras.Add(Camera);
}
if(auto* PC=UGameplayStatics::GetPlayerController(this,0)) PC->SetViewTarget(Cameras[0]);
}
void ARestaurantScene::BeginPlay() {
Super::BeginPlay(); BuildScene();
Input->OnInputEvent.AddDynamic(this,&ARestaurantScene::CameraInput);
FParse::Value(FCommandLine::Get(),TEXT("FireSession="),SessionId);
FGuid Id;
if(!FGuid::ParseExact(SessionId,EGuidFormats::DigitsWithHyphens,Id)) { UE_LOG(LogTemp,Error,TEXT("FireSession UUID missing")); return; }
SessionId=Id.ToString(EGuidFormats::DigitsWithHyphens).ToLower();
FParse::Value(FCommandLine::Get(),TEXT("FirestationBaseUrl="),BaseUrl);
if(!(BaseUrl.StartsWith(TEXT("http://"))||BaseUrl.StartsWith(TEXT("https://")))||BaseUrl.Contains(TEXT("?"))||BaseUrl.Contains(TEXT("#"))) return;
BaseUrl.RemoveFromEnd(TEXT("/"));
GetWorldTimerManager().SetTimer(PollTimer,this,&ARestaurantScene::Poll,0.5f,true); Poll();
}
void ARestaurantScene::Poll() {
if(bEnding||Pending.IsValid()) return;
Pending=FHttpModule::Get().CreateRequest(); Pending->SetURL(BaseUrl+TEXT("/api/sessions/")+SessionId+TEXT("/scene"));
Pending->SetVerb(TEXT("GET")); Pending->SetHeader(TEXT("Accept"),TEXT("application/json")); Pending->SetTimeout(5);
Pending->OnProcessRequestComplete().BindWeakLambda(this,[this](FHttpRequestPtr Request,FHttpResponsePtr Response,bool Success) {
Pending.Reset(); if(bEnding) return;
if(!Success||!Response.IsValid()||Response->GetResponseCode()!=200||!ApplyScene(Response->GetContentAsString())) {
bPaused=true; UE_LOG(LogTemp,Warning,TEXT("Scene update unavailable; visual state frozen"));
}
});
if(!Pending->ProcessRequest()) { Pending.Reset(); bPaused=true; }
}
bool ARestaurantScene::ApplyScene(const FString& Body) {
if(Body.Len()>65536) return false;
TSharedPtr<FJsonObject> O; if(!FJsonSerializer::Deserialize(TJsonReaderFactory<>::Create(Body),O)||!O.IsValid()) return false;
FString Protocol,Session,Scenario,Status; double Version,Elapsed,FireIntensity; bool Paused,Visible;
auto S=Object(O,TEXT("scenario")), P=Object(O,TEXT("pump")),D=Object(O,TEXT("drone")),F=Object(O,TEXT("fire"));
FVector PP,DP,Station,Incident;
if(!O->TryGetStringField(TEXT("protocol"),Protocol)||Protocol!=TEXT("ai-firestation.restaurant.v1")||!O->TryGetStringField(TEXT("sessionId"),Session)||Session!=SessionId||!S.IsValid()||!S->TryGetStringField(TEXT("id"),Scenario)||Scenario!=TEXT("restaurant-gas")) return false;
if(!Number(O,TEXT("version"),Version,0,9007199254740991.0)||Version!=FMath::FloorToDouble(Version)||Version<LastVersion||!Number(O,TEXT("elapsed"),Elapsed,0,1e9)||!O->TryGetBoolField(TEXT("paused"),Paused)||!O->TryGetStringField(TEXT("status"),Status)||(Status!=TEXT("active")&&Status!=TEXT("closed"))) return false;
if(!Point(Object(P,TEXT("position")),PP)||!Point(Object(D,TEXT("position")),DP)||!Point(Object(O,TEXT("station")),Station)||!Point(Object(O,TEXT("incident")),Incident)||!Station.Equals(FVector(-5700,880,0),1)||!Incident.Equals(FVector(2200,-320,0),1)) return false;
double Progress,Eta; FString ResourceStatus;
for(auto Resource:{P,D}) if(!Number(Resource,TEXT("progress"),Progress,0,1)||!Number(Resource,TEXT("eta"),Eta,0,1e9)||!Resource->TryGetStringField(TEXT("status"),ResourceStatus)||ResourceStatus.Len()>64) return false;
const TArray<TSharedPtr<FJsonValue>>* Route=nullptr;
if(!O->TryGetArrayField(TEXT("pumpRoute"),Route)||Route->Num()!=4) return false;
const FVector Expected[]={FVector(-5700,880,0),FVector(-100,880,0),FVector(-100,-1400,0),FVector(2200,-1400,0)};
for(int I=0;I<4;++I) { FVector V; if(!Point((*Route)[I]->AsObject(),V)||!V.Equals(Expected[I],1)) return false; }
const TArray<TSharedPtr<FJsonValue>>* Cells=nullptr;
if(!F.IsValid()||!F->TryGetBoolField(TEXT("visible"),Visible)||!Number(F,TEXT("intensity"),FireIntensity,0,1)||!F->TryGetArrayField(TEXT("cells"),Cells)||Cells->Num()>4096) return false;
for(auto Cell:*Cells) { double N; if(!Cell->TryGetNumber(N)||!FMath::IsFinite(N)||N<0||N>100) return false; }
if(!Visible&&(FireIntensity!=0||Cells->Num()!=0)) return false;
// DTO 전체 검증 후에만 화면 상태를 적용한다. 서버 시간으로 보간/진행하지 않는다.
LastVersion=Version; bPaused=Paused||Status==TEXT("closed"); Intensity=FireIntensity;
const FVector Old=Pump->GetComponentLocation(); Pump->SetWorldLocation(PP+FVector(0,0,100));
if(FVector::DistSquared2D(Old,PP)>1) Pump->SetWorldRotation(FRotator(0,(PP-Old).Rotation().Yaw,0));
Drone->SetWorldLocation(DP);
for(const auto& C:Flames) C->SetVisibility(Visible&&Intensity>0); for(const auto& C:Smoke) C->SetVisibility(Visible&&Intensity>0);
Cameras[1]->SetActorLocation(DP+FVector(-400,-500,200)); Cameras[1]->SetActorRotation((Incident-Cameras[1]->GetActorLocation()).Rotation());
Cameras[2]->SetActorLocation(PP+FVector(-700,-500,400)); Cameras[2]->SetActorRotation((PP+FVector(0,0,100)-Cameras[2]->GetActorLocation()).Rotation());
return true;
}
void ARestaurantScene::Tick(float DeltaSeconds) {
Super::Tick(DeltaSeconds); if(bPaused||Intensity<=0) return;
AnimationTime+=DeltaSeconds;
if(FireMaterial) FireMaterial->SetScalarParameterValue(TEXT("SceneTime"),AnimationTime*90);
if(SmokeMaterial) SmokeMaterial->SetScalarParameterValue(TEXT("SceneTime"),AnimationTime*25);
for(int I=0;I<Flames.Num();++I) {
const float Pulse=.75f+.25f*FMath::Sin(AnimationTime*6+I);
Flames[I]->SetWorldScale3D(FVector(.9,.9,1+Intensity*2*Pulse));
Smoke[I]->SetWorldScale3D(FVector(2+Intensity,2+Intensity,2+Intensity*.5f));
Smoke[I]->SetRelativeLocation(FVector(1950+(I%4)*180,-140+(I/4)*100,450+I*35+FMath::Sin(AnimationTime+I)*50));
}
}
void ARestaurantScene::CameraInput(const FString& Descriptor) {
if(bEnding||Descriptor.Len()>512) return;
TSharedPtr<FJsonObject> O; if(!FJsonSerializer::Deserialize(TJsonReaderFactory<>::Create(Descriptor),O)||!O.IsValid()||O->Values.Num()!=3) return;
FString Type,Session,Camera;
if(!O->TryGetStringField(TEXT("type"),Type)||Type!=TEXT("firestation.camera")||!O->TryGetStringField(TEXT("sessionId"),Session)||Session!=SessionId||!O->TryGetStringField(TEXT("camera"),Camera)) return;
const TArray<FString> Names={TEXT("overview"),TEXT("drone"),TEXT("vehicle"),TEXT("crew")}; const int Index=Names.IndexOfByKey(Camera);
if(Cameras.IsValidIndex(Index)) if(auto* PC=UGameplayStatics::GetPlayerController(this,0)) PC->SetViewTarget(Cameras[Index]);
}
void ARestaurantScene::EndPlay(const EEndPlayReason::Type Reason) {
bEnding=true; GetWorldTimerManager().ClearTimer(PollTimer);
Input->OnInputEvent.RemoveDynamic(this,&ARestaurantScene::CameraInput);
if(Pending.IsValid()) { Pending->OnProcessRequestComplete().Unbind(); Pending->CancelRequest(); Pending.Reset(); }
Super::EndPlay(Reason);
}
unreal/AIFirestation/Source/AIFirestation/RestaurantScene.h
#pragma once
#include "CoreMinimal.h"
#include "GameFramework/Actor.h"
#include "Interfaces/IHttpRequest.h"
#include "RestaurantScene.generated.h"
class UPixelStreamingInput;
class UStaticMeshComponent;
class UMaterialInstanceDynamic;
class ACameraActor;
class UMaterialInterface;
UCLASS()
class AIFIRESTATION_API ARestaurantScene : public AActor {
GENERATED_BODY()
public:
ARestaurantScene();
virtual void BeginPlay() override;
virtual void Tick(float DeltaSeconds) override;
virtual void EndPlay(const EEndPlayReason::Type Reason) override;
private:
UPROPERTY() TObjectPtr<UPixelStreamingInput> Input;
UPROPERTY() TObjectPtr<UStaticMeshComponent> Pump;
UPROPERTY() TObjectPtr<UStaticMeshComponent> Drone;
UPROPERTY() TArray<TObjectPtr<UStaticMeshComponent>> Flames;
UPROPERTY() TArray<TObjectPtr<UStaticMeshComponent>> Smoke;
UPROPERTY() TArray<TObjectPtr<ACameraActor>> Cameras;
UPROPERTY() TObjectPtr<UMaterialInstanceDynamic> FireMaterial;
UPROPERTY() TObjectPtr<UMaterialInstanceDynamic> SmokeMaterial;
FString SessionId, BaseUrl;
FTimerHandle PollTimer;
TSharedPtr<IHttpRequest, ESPMode::ThreadSafe> Pending;
double LastVersion = -1;
float AnimationTime = 0, Intensity = 0;
bool bPaused = true, bEnding = false;
UStaticMeshComponent* Shape(const FString& Name, const FVector& Position, const FVector& Size, UMaterialInterface* Material, const TCHAR* Mesh = TEXT("/Engine/BasicShapes/Cube.Cube"), USceneComponent* Parent = nullptr);
void BuildScene();
void Poll();
bool ApplyScene(const FString& Body);
UFUNCTION() void CameraInput(const FString& Descriptor);
};
unreal/AIFirestation/Source/AIFirestationEditor.Target.cs
using UnrealBuildTool;
using System.Collections.Generic;
public class AIFirestationEditorTarget : TargetRules {
public AIFirestationEditorTarget(TargetInfo Target) : base(Target) {
Type = TargetType.Editor; DefaultBuildSettings = BuildSettingsVersion.V6;
ExtraModuleNames.Add("AIFirestation");
}
}
unreal/README.md
# UE5.8 식당 화재 현장 현재 저장소에는 C++ 프로젝트와 편집기 자산 생성 스크립트가 있다. UE5.8 엔진이 이 개발 환경에 없어 네이티브 컴파일, 맵 생성, 영상 재생은 미검증이다. MSVC Build Tools만으로는 엔진을 대체할 수 없다. Epic Launcher에서 UE5.8과 C++ 개발 도구를 설치하고 다음 명령을 실행한다. ```powershell $env:UE_ENGINE_ROOT = 'C:\Program Files\Epic Games\UE_5.8' powershell -File scripts/unreal.ps1 -Action doctor powershell -File scripts/unreal.ps1 -Action build powershell -File scripts/unreal.ps1 -Action bootstrap powershell -File scripts/unreal-stream.ps1 -Action setup powershell -File scripts/unreal-stream.ps1 -Action run # 별도 터미널: 서버에서 만든 DEMO restaurant-gas 사건 UUID powershell -File scripts/unreal.ps1 -Action run -Session '<UUID>' ``` 프로젝트 기본맵은 `/Game/Maps/RestaurantFire`이며 Python이 재질과 맵을 만들고 C++가 소방서·도로·도시·식당 주방·펌프차·드론·불꽃·연기를 구성한다. 기본 엔진 메시만 사용한다. 불꽃과 연기는 생성 재질/메시로 표현하며 외부 Niagara 자산을 요구하지 않는다. HTTP GET은 0.5초 간격이며 상태를 진행시키거나 명령을 발행하지 않는다. UUID/프로토콜/역전 버전/좌표/공개 화재 DTO 전체를 검증한 뒤 적용한다. 수신 실패와 정지는 애니메이션을 고정한다. Pixel Streaming의 `UPixelStreamingInput`에는 같은 sessionId의 overview/drone/vehicle/crew 카메라 메시지만 허용한다. 한 UE 인스턴스에 접속한 PC와 휴대폰은 같은 카메라를 공유한다. 기관용 자동 명령·가상 수신 완료·실장비 제어는 없다. 공식 [Pixel Streaming Infrastructure UE5.8](https://github.com/EpicGamesExt/PixelStreamingInfrastructure/tree/UE5.8)을 커밋 `e5e243ed038313d3dad4e3fb9087d4393a5df192`으로 고정한다. 기본 브라우저 신호 포트는 8880, UE streamer 포트는 8888이다. 웹 서버의 `FIRESTATION_UE_SIGNAL_URL=ws://<LAN 호스트>:8880`을 설정한다. 휴대폰에 localhost를 전달하지 않는다. 외부 연결은 HTTPS/WSS와 TURN을 별도 구성한다. setup은 프로젝트 `.unreal-runtime` 하위만 허용하며 삭제 작업을 하지 않는다. 실제 검수: build 성공 → bootstrap 성공 → 해당 UUID 스트리머 영상 재생 → PC·휴대폰 동시 접속 → 4개 카메라 → 서버 일시정지에서 펌프/드론/불꽃/연기 고정 → 펌프 도로 경로 → 미공개 화재 숨김 → 연결 중단 후 화면 고정. 이 검수 전에는 실제 UE 영상 완성으로 표시하지 않는다. 신호 서버 setup은 `npm ci` 뒤 `npm audit fix --ignore-scripts`로 호환 범위 보안 패치를 적용한다. `--force`나 메이저 버전 갱신을 사용하지 않는다. 감사 명령의 종료 코드 1은 운영 의존성 JSON 감사에서 high/critical이 모두 0일 때만 허용하며, 네트워크 실패·잘못된 JSON·다른 종료 코드는 실패 처리한다. 2026-10-09 검수 시 운영 의존성 high/critical은 0, moderate는 5개이며 개발 의존성 문제도 남았다. 취약점이 모두 해결된 상태로 표시하지 않는다. 실제 설치 시 감사 결과는 변할 수 있다. API 근거: [UPixelStreamingInput / PixelStreamingInputComponent.h](https://dev.epicgames.com/documentation/unreal-engine/API/Plugins/PixelStreaming/UPixelStreamingInput), [공식 신호 서버 빌드/실행](https://github.com/EpicGamesExt/PixelStreamingInfrastructure/blob/UE5.8/SignallingWebServer/README.md).
vitest.config.ts
import { defineConfig } from "vitest/config";
export default defineConfig({
test: { include: ["tests/**/*.test.ts"], environment: "node" },
});