2026-07-23 17:03:26 +03:00
// Інтеграційний тест: реальний сервер + два ws-клієнти проходять справу до кінця
// новими механіками: обшук місця, запити-форми, ордер у відділку, ручна лабораторія.
const assert = require ( 'assert' ) ;
const fs = require ( 'fs' ) ;
const path = require ( 'path' ) ;
const { spawn } = require ( 'child_process' ) ;
const WebSocket = require ( 'ws' ) ;
2026-08-15 14:07:39 +03:00
const { generateCase , caseSeed } = require ( './casegen' ) ;
2026-07-23 17:03:26 +03:00
const PORT = + process . env . PORT || 3102 ;
const NO _SPAWN = ! ! process . env . NO _SPAWN ; // підключитись до вже запущеного сервера (для дебагу з логами)
const SEED = 'TEST1' ;
const sleep = ms => new Promise ( r => setTimeout ( r , ms ) ) ;
// чекаємо на умову замість фіксованого сну — сервер асинхронний, а після
// «зливу» 16 проходів його черга повідомлень може бути ще не розібрана
const waitFor = async ( fn , ms = 4000 ) => {
for ( const t0 = Date . now ( ) ; Date . now ( ) - t0 < ms ; await sleep ( 40 ) ) if ( fn ( ) ) return true ;
return false ;
} ;
function client ( role ) {
const ws = new WebSocket ( ` ws://127.0.0.1: ${ PORT } ` ) ;
const c = { ws , role , state : null , init : null , ended : null } ;
ws . on ( 'message' , raw => {
const m = JSON . parse ( raw ) ;
if ( m . t === 'init' ) c . init = m ;
if ( m . t === 'state' ) { c . state = m ; c . ended = m . ended ; }
} ) ;
c . open = new Promise ( r => ws . on ( 'open' , ( ) => { ws . send ( JSON . stringify ( { t : 'join' , session : c . session , role } ) ) ; r ( ) ; } ) ) ;
c . send = m => ws . send ( JSON . stringify ( m ) ) ;
return c ;
}
// будинок, чий центр збігається з трьома дугами тріангуляції
function solveTriangulate ( cs , towers ) {
let best = null , bestErr = Infinity ;
for ( const b of cs . district . buildings ) {
const cx = b . pos . x + b . pos . w / 2 , cy = b . pos . y + b . pos . h / 2 ;
const err = towers . reduce ( ( a , t ) => a + Math . abs ( Math . hypot ( t . x - cx , t . y - cy ) - t . dist ) , 0 ) ;
if ( err < bestErr ) { bestErr = err ; best = b . id ; }
}
return best ;
}
async function playToTheEnd ( session , accusation ) {
2026-08-15 14:07:39 +03:00
const cs = generateCase ( caseSeed ( session ) ) ; // сервер сіє хешованим кодом — тест мусить так само
2026-07-23 17:03:26 +03:00
const killer = cs . citizens . find ( c => c . id === cs . finale . killer ) ;
const field = client ( 'field' ) ; field . session = session ;
const analyst = client ( 'analyst' ) ; analyst . session = session ;
await field . open ; await analyst . open ; await sleep ( 300 ) ;
// міні-ігри: тест «грає» їх одразу правильно
const react = c => c . ws . on ( 'message' , raw => {
const m = JSON . parse ( raw ) ;
if ( m . t === 'mg' ) {
if ( m . kind === 'collect' ) c . send ( { t : 'mg_done' , evId : m . evId } ) ;
if ( m . kind === 'triangulate' ) c . send ( { t : 'triangulate_pick' , building : solveTriangulate ( cs , m . towers ) } ) ;
}
} ) ;
react ( field ) ; react ( analyst ) ;
assert . ok ( field . init && analyst . init , 'обидва отримали init' ) ;
assert . strictEqual ( field . init . citizens . length , cs . citizens . length , 'мешканці на місці' ) ;
assert . ok ( field . init . citizens . every ( c =>
! ( 'evening' in c ) && ! ( 'liar' in c ) && ! ( 'excuse' in c ) && ! ( 'regShoe' in c ) && ! ( 'regCoat' in c ) ) ,
'таємниці слідства не течуть у клієнт' ) ;
assert . ok ( field . state . found . length >= 1 , 'брифінг уже на дошці' ) ;
const stn = cs . district . buildings . find ( b => b . id === 'station' ) ;
const realFound = ( ) => analyst . state . found . filter ( f => cs . evidence [ f . id ] ) ;
// ганяємо всі можливі дії по колу, як робили б гравці
for ( let pass = 0 ; pass < 16 ; pass ++ ) {
const found = new Set ( realFound ( ) . map ( f => f . id ) ) ;
for ( const p of cs . puzzles )
analyst . send ( { t : 'puzzle_try' , id : p . id , shift : p . shift , answer : p . answer } ) ;
// ордери: польовий іде у відділок і підкладає доказ, що прямо вказує
field . send ( { t : 'move' , x : stn . pos . x + stn . pos . w / 2 | 0 , y : stn . pos . y + stn . pos . h / 2 | 0 } ) ;
for ( const id of found ) {
const ev = cs . evidence [ id ] ;
if ( ev . gives . pointsTo ) field . send ( { t : 'warrant' , cid : ev . gives . pointsTo , evId : id } ) ;
}
for ( const ev of Object . values ( cs . evidence ) ) {
if ( found . has ( ev . id ) ) continue ;
if ( ev . at . type === 'spot' ) field . send ( { t : 'search' , building : ev . at . building , spot : ev . at . spot } ) ;
if ( ev . at . type === 'unit' ) field . send ( { t : 'search_unit' , unit : ev . at . unit , slot : ev . at . slot } ) ;
if ( ev . at . type === 'person' ) field . send ( { t : 'ask' , evId : ev . id } ) ;
if ( ev . at . type === 'sys' && ev . q ) {
const q = ev . q ;
if ( q . sys === 'cctv' ) {
const cam = cs . district . cameras . find ( c => c . covers . includes ( q . building ) ) ;
const [ h , mi ] = cs . crimeTime . split ( ':' ) . map ( Number ) ;
analyst . send ( { t : 'sys_query' , sys : 'cctv' , camera : cam . id , minutes : h * 60 + mi } ) ;
} else {
analyst . send ( { t : 'sys_query' , sys : q . sys , kind : q . kind || 'search' , input : [ ] . concat ( q . match || [ '' ] ) [ 0 ] } ) ;
}
}
}
// лабораторія: порівняння вручну (тест одразу тицяє правильних власників)
analyst . send ( { t : 'dna_pick' , sampleEv : 'sf_dna' , cid : cs . accomplice || cs . finale . killer } ) ;
if ( cs . dna2 ) analyst . send ( { t : 'dna_pick' , sampleEv : 'sf_dna2' , cid : cs . dna2 . owner } ) ;
analyst . send ( { t : 'print_pick' , cid : cs . finale . killer } ) ;
await sleep ( 250 ) ;
}
// зламане алібі: допит вбивці + архів камери над його «алібі» будить сплячий доказ
const killerClaim = cs . citizens . find ( c => c . id === cs . finale . killer ) . claim ;
const [ ch , cm ] = cs . crimeTime . split ( ':' ) . map ( Number ) ;
field . send ( { t : 'ask_alibi' , cid : cs . finale . killer , minutes : ch * 60 + cm } ) ;
// спершу дочекатись, що алібі вбивці ЗАПИСАНЕ (видно в стані), і лише тоді
// тягнути архів — інакше camera-запит спрацює до запису й нічого не розбудить
await waitFor ( ( ) => analyst . state && analyst . state . alibiClaims && analyst . state . alibiClaims [ cs . finale . killer ] != null ) ;
const claimCam = cs . district . cameras . find ( c => c . covers . includes ( killerClaim ) ) ;
analyst . send ( { t : 'sys_query' , sys : 'cctv' , camera : claimCam . id , minutes : 1230 } ) ;
await waitFor ( ( ) => realFound ( ) . some ( f => f . id === 'alibi_broken' ) ) ;
assert . ok ( realFound ( ) . some ( f => f . id === 'alibi_broken' ) , 'брехня вбивці + камери = доказ' ) ;
assert . strictEqual ( realFound ( ) . length , Object . keys ( cs . evidence ) . length ,
` зібрано все: ${ realFound ( ) . length } / ${ Object . keys ( cs . evidence ) . length } ` ) ;
assert . ok ( analyst . state . identity , 'о с о б а жертви встановлена' ) ;
// брехун ламається і від пред'явленого архіву, не лише від повторного питання:
// заявив, що був там, куди дивиться камера, а в кадрах його нема.
const liar = cs . citizens . find ( c => c . liar && c . id !== cs . finale . killer && c . id !== cs . accomplice &&
cs . district . cameras . some ( cam => cam . covers . includes ( c . claim ) ) ) ;
if ( liar ) {
const liarCam = cs . district . cameras . find ( cam => cam . covers . includes ( liar . claim ) ) ;
field . send ( { t : 'ask_alibi' , cid : liar . id , minutes : ch * 60 + cm } ) ;
await waitFor ( ( ) => analyst . state . alibiClaims [ liar . id ] != null ) ;
analyst . send ( { t : 'sys_query' , sys : 'cctv' , camera : liarCam . id , minutes : 1230 } ) ;
await waitFor ( ( ) => analyst . state . found . some ( f => f . title === ` Архів за вечір: ${ liarCam . name } ` ) ) ;
const arch = analyst . state . found . find ( f => f . title === ` Архів за вечір: ${ liarCam . name } ` ) ;
field . send ( { t : 'show_ev' , cid : liar . id , evId : arch . id } ) ;
await waitFor ( ( ) => ( analyst . state . log [ liar . id ] || [ ] ) . some ( e => e . a . includes ( liar . excuse ) ) ) ;
const said = analyst . state . log [ liar . id ] . at ( - 1 ) . a ;
assert . ok ( said . includes ( liar . excuse ) , 'пред\'явлений архів ламає брехуна так само, як повторний допит' ) ;
}
// допити: алібі, «про людину», пред'явлення доказу — відповіді пишуться в журнал
field . send ( { t : 'ask_about' , cid : cs . herring , about : cs . finale . killer } ) ;
field . send ( { t : 'show_ev' , cid : cs . decoyOwner , evId : 'dec_footprint' } ) ;
await sleep ( 300 ) ;
assert . ok ( ( analyst . state . log [ cs . finale . killer ] || [ ] ) . length >= 1 , 'алібі вбивці в журналі' ) ;
assert . ok ( analyst . state . found . some ( f => f . id . startsWith ( 'x' ) && f . title . toLowerCase ( ) . includes ( 'відбиток' ) ) ,
'фейковий слід закрито карткою на дошці' ) ;
// мітка аналітика видна в стані
analyst . send ( { t : 'marker' , x : 500 , y : 100 } ) ;
await sleep ( 200 ) ;
assert . strictEqual ( analyst . state . markers . length , 1 , 'мітка на мапі' ) ;
for ( const f of analyst . state . found ) field . send ( { t : 'pin' , evId : f . id } ) ;
await sleep ( 300 ) ;
const how = cs . caseType === 'kidnap' ? cs . finale . where : cs . finale . method ;
const acc = accusation === 'right'
? { who : killer . name , how , why : cs . finale . motive }
: { who : cs . citizens . find ( c => c . id === cs . herring ) . name , how , why : cs . finale . motive } ;
field . send ( { t : 'accuse_draft' , ... acc } ) ;
await waitFor ( ( ) => field . state . accuseDraft && field . state . accuseDraft . who === acc . who ) ;
field . send ( { t : 'accuse_confirm' } ) ;
analyst . send ( { t : 'accuse_confirm' } ) ;
await waitFor ( ( ) => analyst . ended ) ;
const ended = analyst . ended ;
assert . ok ( ended , 'г р а завершилась після двох підписів' ) ;
if ( accusation === 'right' ) assert . ok ( ended . solved , 'правильне звинувачення = справу розкрито' ) ;
else { assert . ok ( ended . fired , 'хибне звинувачення = звільнення' ) ; assert . ok ( ended . recap , 'справжня картина показана' ) ; }
field . ws . close ( ) ; analyst . ws . close ( ) ;
return ended ;
}
( async ( ) => {
for ( const f of [ SEED , 'TEST2' , 'TEST3' ] ) { try { fs . unlinkSync ( path . join ( _ _dirname , 'saves' , f + '.json' ) ) ; } catch { } }
const srv = NO _SPAWN ? null : spawn ( 'node' , [ 'server.js' ] , { cwd : _ _dirname , env : { ... process . env , PORT } } ) ;
if ( srv ) await new Promise ( r => srv . stdout . on ( 'data' , d => { if ( String ( d ) . includes ( 'localhost' ) ) r ( ) ; } ) ) ;
else console . error ( '[test] NO_SPAWN: підключаюсь до сервера на PORT=' + PORT ) ;
try {
await playToTheEnd ( SEED , 'right' ) ;
const savePath = path . join ( _ _dirname , 'saves' , SEED + '.json' ) ;
// дочекатись, поки фінальний стан ДОПИСАЛО на диск (жодних відкладених записів),
// і лише тоді видаляти — інакше запис-«хвіст» відтворив би файл після unlink
await waitFor ( ( ) => { try { return ! ! JSON . parse ( fs . readFileSync ( savePath , 'utf8' ) ) . ended ; } catch { return false ; } } ) ;
assert . ok ( fs . existsSync ( savePath ) , 'сейв на диску' ) ;
// видалення сейву з диска = чиста справа після перепідключення
fs . unlinkSync ( savePath ) ;
const fresh = client ( 'field' ) ; fresh . session = SEED ;
await fresh . open ;
await waitFor ( ( ) => fresh . state != null ) ;
2026-07-23 18:10:36 +03:00
assert . ok ( fresh . state && ! fresh . state . ended && fresh . state . found . length <= 3 ,
2026-07-23 17:03:26 +03:00
'видалений сейв = справа з нуля, на дошці лише брифінг: ' + ( fresh . state && fresh . state . found . length ) ) ;
fresh . ws . close ( ) ;
await playToTheEnd ( 'TEST2' , 'wrong' ) ;
// TEST3 — перевіряємо, що і викрадення проходиться (якщо сід дає kidnap — тим краще)
const k = await playToTheEnd ( 'TEST3' , 'right' ) ;
assert . ok ( k . solved , 'третя справа теж розкривається' ) ;
// -- мультіплеєр: до 2 на роль, свій номер кожному, 3-й дістає помилку ліміту
const grab = ws => new Promise ( r => { const o = { } ; ws . on ( 'message' , raw => { const m = JSON . parse ( raw ) ; if ( m . t === 'init' ) { o . num = m . num ; r ( o ) ; } if ( m . t === 'joinerr' ) { o . err = m . txt ; r ( o ) ; } } ) ; } ) ;
const mk = ( ) => { const ws = new WebSocket ( ` ws://127.0.0.1: ${ PORT } ` ) ; const g = grab ( ws ) ; ws . on ( 'open' , ( ) => ws . send ( JSON . stringify ( { t : 'join' , session : 'MPTEST' , role : 'field' } ) ) ) ; return { ws , g } ; } ;
// послідовно: кожен join опрацьовується до наступного, щоб номери були детерміновані
const f1 = mk ( ) ; assert . strictEqual ( ( await f1 . g ) . num , 1 , 'перший польовий — №1' ) ;
const f2 = mk ( ) ; assert . strictEqual ( ( await f2 . g ) . num , 2 , 'другий польовий — №2' ) ;
const f3 = mk ( ) ; const r3 = await f3 . g ;
assert . ok ( r3 . err && ! r3 . num , '3-й польовий дістає помилку ліміту, а не вхід' ) ;
// звільнили слот — новий займає найменший вільний номер
f1 . ws . close ( ) ; await sleep ( 200 ) ;
const f4 = mk ( ) ; assert . strictEqual ( ( await f4 . g ) . num , 1 , 'після виходу №1 звільняється — новий стає №1' ) ;
for ( const x of [ f2 , f4 ] ) x . ws . close ( ) ;
console . log ( '✓ інтеграція: справи розкриваються новими механіками, хибне звинувачення = звільнення, сейви пишуться, мультіплеєр тримає ліміт по 2 на роль.' ) ;
} finally { if ( srv ) srv . kill ( ) ; }
} ) ( ) . catch ( e => { console . error ( '✗' , e . message ) ; process . exit ( 1 ) ; } ) ;