diff --git a/src/entities/spot/sim-stream-types.ts b/src/entities/spot/sim-stream-types.ts index abc9c60..c53c016 100644 --- a/src/entities/spot/sim-stream-types.ts +++ b/src/entities/spot/sim-stream-types.ts @@ -16,6 +16,25 @@ import type { PersonaArchetype, PersonaIntent } from '@/entities/persona/types'; export type PlaceType = 'region' | 'spot'; +export type SimMapAnchor = { + type?: string; + lat: number; + lng: number; + region_id?: string; + category?: string; + confidence?: number; + match_reason?: string; +}; + +export type SimHotspotSignal = { + signal_type?: 'teach_spot' | string; + state?: 'forming' | 'recruiting' | 'active' | string; + host_persona_type?: string; + participant_target_count?: number; + reason_tags?: string[]; + region_character?: Record; +}; + export type PlaceGeometry = { place_id: string; place_type: PlaceType; @@ -30,6 +49,8 @@ export type PlaceGeometry = { intent?: PersonaIntent; /** spot 일 때 UI 카드 제목(template 기반 합성). */ title?: string; + /** simulator 가 제공한 public/proxy map anchor. spot 좌표보다 우선 사용한다. */ + map_anchor?: SimMapAnchor; }; // ─── Agent ─────────────────────────────────────────────────────────────────── @@ -69,8 +90,14 @@ export type SimManifest = { approved_spot_count: number; /** 향후 'all' 옵션 대비. 현재는 항상 'published_only'. */ filter_kind: SimRunFilterKind; - /** 전체 tick 수(예: 48). 재생 길이 표시용. */ + /** 한 재생 사이클의 tick 수. loop_period_ticks 가 없으면 이 값을 loop period 로 사용한다. */ total_ticks: number; + /** total_ticks 이후에도 이전 사이클 잔상/종료 상태를 유지할 때의 loop period. */ + loop_period_ticks?: number; + /** loop 이후 이전 사이클을 겹쳐 보여줄 tail 길이. */ + projection_tail_ticks?: number; + /** tail 데이터 로드를 허용하는 최대 raw tick. inclusive. */ + max_projected_tick?: number; /** 클라이언트 디폴트 1 tick 길이. 사용자가 prop 으로 오버라이드 가능. */ tick_duration_ms_default: number; /** prefetch 청크 크기(tick). */ @@ -114,14 +141,23 @@ export type LifecycleEventType = | 'SPOT_CONFIRMED' | 'SPOT_STARTED' | 'SPOT_COMPLETED' - | 'NO_SHOW'; + | 'NO_SHOW' + | 'PERSONA_LEAVE_SPOT' + | 'PERSONA_RETURN_HOME'; export type LifecycleEvent = { tick: number; event_type: LifecycleEventType; spot_id: string; - /** NO_SHOW 등 agent 가 특정될 때만. */ + /** NO_SHOW / PERSONA_* 등 agent 가 특정될 때만. */ agent_id?: string; + payload?: Record; + scheduled_tick?: number; + schedule_lead_ticks?: number; + duration_ticks?: number; + expected_closed_at_tick?: number; + map_anchor?: SimMapAnchor; + hotspot_signal?: SimHotspotSignal; }; export type LifecycleChunk = { diff --git a/src/features/map/client/MapClient.tsx b/src/features/map/client/MapClient.tsx index 191d983..a842080 100644 --- a/src/features/map/client/MapClient.tsx +++ b/src/features/map/client/MapClient.tsx @@ -338,6 +338,7 @@ export function MapClient() { manifest: sim.manifest, isReady: sim.isReady, currentTick: sim.currentTick, + currentCycle: sim.currentCycle, subscribe: sim.subscribe, positionsRef: sim.positionsRef, playbackStartMsRef: sim.playbackStartMsRef, diff --git a/src/features/map/model/types.ts b/src/features/map/model/types.ts index 466c4f1..419b58a 100644 --- a/src/features/map/model/types.ts +++ b/src/features/map/model/types.ts @@ -1,3 +1,4 @@ +import type { SimHotspotSignal } from '@/entities/spot/sim-stream-types'; import type { GeoCoord } from '@/entities/spot/types'; export type PersonaRef = { @@ -29,6 +30,8 @@ export type ActivityCluster = { variant?: 'discovery' | 'ai-feed' | 'user-feed' | 'feed-group' | 'mine'; /** 변형에 따른 추가 라벨(예: "내 모임"). */ variantLabel?: string; + /** simulator-origin hotspot 신호. AI 피드 본문이 아니라 지도 발견/형성 상태용 메타데이터. */ + hotspotSignal?: SimHotspotSignal; }; export type ClusterInput = { diff --git a/src/features/simulation/model/sim-clock.ts b/src/features/simulation/model/sim-clock.ts index 02e1b67..5452ce6 100644 --- a/src/features/simulation/model/sim-clock.ts +++ b/src/features/simulation/model/sim-clock.ts @@ -62,6 +62,14 @@ export function findNextMovement( return timeline[lo]; } +function placeCoord(place: { + lat: number; + lng: number; + map_anchor?: GeoCoord; +}): GeoCoord { + return place.map_anchor ?? { lat: place.lat, lng: place.lng }; +} + type PositionAtOptions = { fromCoord?: GeoCoord; }; @@ -82,19 +90,20 @@ export function positionAt( const to = placeMap.get(m.to_place_id); if (!from || !to) return null; - const fromCoord = options?.fromCoord ?? { lat: from.lat, lng: from.lng }; + const fromCoord = options?.fromCoord ?? placeCoord(from); + const toCoord = placeCoord(to); if (tFloat <= m.depart_tick) { return fromCoord; } if (tFloat >= m.arrive_tick) { - return { lat: to.lat, lng: to.lng }; + return toCoord; } const span = m.arrive_tick - m.depart_tick; const p = span <= 0 ? 1 : (tFloat - m.depart_tick) / span; return { - lat: lerp(fromCoord.lat, to.lat, p), - lng: lerp(fromCoord.lng, to.lng, p), + lat: lerp(fromCoord.lat, toCoord.lat, p), + lng: lerp(fromCoord.lng, toCoord.lng, p), }; } diff --git a/src/features/simulation/model/sim-domain-adapter.ts b/src/features/simulation/model/sim-domain-adapter.ts index e5d976a..8429317 100644 --- a/src/features/simulation/model/sim-domain-adapter.ts +++ b/src/features/simulation/model/sim-domain-adapter.ts @@ -18,6 +18,7 @@ import type { GeoCoord } from '@/entities/spot/types'; import type { LifecycleEvent, Movement, + SimHotspotSignal, SimManifest, } from '@/entities/spot/sim-stream-types'; import type { ActivityCluster, PersonaRef } from '@/features/map/model/types'; @@ -31,27 +32,40 @@ import type { // 화면이 한 점에 뭉치는 것 + 휴면 dot 의미 없음 → Persona 변환에서 제외한다. // (region 통계 같은 비시각 용도가 필요해지면 별도 export 함수에서 다룬다.) -export function simAgentsToPersonas(manifest: SimManifest): Persona[] { +function renderId(cycle: number, id: string): string { + return `${cycle}:${id}`; +} + +function loopPeriodTicks(manifest: SimManifest): number { + return Math.max(1, manifest.loop_period_ticks ?? manifest.total_ticks); +} + +export function simAgentsToPersonas( + manifest: SimManifest, + cycles: number[] = [0], +): Persona[] { const placeMap = new Map( manifest.places.map((p) => [p.place_id, p] as const), ); - return manifest.agents - .filter((a) => a.agent_role === 'protagonist') - .map((a) => { - const home = placeMap.get(a.home_region_id); - const initialCoord: GeoCoord = home - ? { lat: home.lat, lng: home.lng } - : { lat: 0, lng: 0 }; - return { - id: a.agent_id, - emoji: a.emoji, - name: a.name, - archetype: a.archetype, - initialCoord, - category: a.category, - intent: a.intent, - }; - }); + return cycles.flatMap((cycle) => + manifest.agents + .filter((a) => a.agent_role === 'protagonist') + .map((a) => { + const home = placeMap.get(a.home_region_id); + const initialCoord: GeoCoord = home + ? { lat: home.lat, lng: home.lng } + : { lat: 0, lng: 0 }; + return { + id: renderId(cycle, a.agent_id), + emoji: a.emoji, + name: a.name, + archetype: a.archetype, + initialCoord, + category: a.category, + intent: a.intent, + }; + }), + ); } // ─── lifecycle 이벤트 + movement 청크 → SpotLifecycle[] ──────────────────── @@ -66,7 +80,9 @@ type SpotLifecycleSeed = { createdTick: number; matchedTick: number | null; closedTick: number; - /** participants(host + joiner). joinedAtMs 는 movement.depart_tick 기준. leftAtMs 는 NO_SHOW 시 closed 직전. */ + /** simulator-origin hotspot 신호. AI feed copy 가 아니라 지도 형성/모집 상태 메타데이터다. */ + hotspotSignal?: SimHotspotSignal; + /** participants(host + joiner). joinedAtMs 는 movement.depart_tick 기준. leftAtMs 는 NO_SHOW/leave 시점. */ participants: Array<{ personaId: string; joinedTick: number; @@ -98,10 +114,13 @@ export function buildSpotLifecycleSeeds( if (existing) return existing; const place = placeMap.get(spotId); if (!place || place.place_type !== 'spot') return null; + const anchor = place.map_anchor; const tracker: Tracker = { seed: { spotId, - location: { lat: place.lat, lng: place.lng }, + location: anchor + ? { lat: anchor.lat, lng: anchor.lng } + : { lat: place.lat, lng: place.lng }, category: place.category ?? '운동', intent: place.intent ?? 'offer', title: place.title ?? spotId, @@ -122,6 +141,27 @@ export function buildSpotLifecycleSeeds( switch (ev.event_type) { case 'SPOT_CREATED': t.seed.createdTick = ev.tick; + if ( + ev.scheduled_tick !== undefined && + ev.scheduled_tick !== null + ) { + t.seed.matchedTick = ev.scheduled_tick; + } + if ( + ev.expected_closed_at_tick !== undefined && + ev.expected_closed_at_tick !== null + ) { + t.seed.closedTick = ev.expected_closed_at_tick; + } + if (ev.map_anchor) { + t.seed.location = { + lat: ev.map_anchor.lat, + lng: ev.map_anchor.lng, + }; + } + if (ev.hotspot_signal) { + t.seed.hotspotSignal = ev.hotspot_signal; + } break; case 'SPOT_MATCHED': t.seed.matchedTick = ev.tick; @@ -130,7 +170,7 @@ export function buildSpotLifecycleSeeds( t.startedTick = ev.tick; break; case 'SPOT_COMPLETED': - t.seed.closedTick = ev.tick; + t.seed.closedTick = ev.expected_closed_at_tick ?? ev.tick; break; // SPOT_CONFIRMED 는 seed 에 별도 필드 없음(matched 와 close 사이의 진행 단계). // NO_SHOW 는 movement 단계 후 처리. @@ -159,11 +199,21 @@ export function buildSpotLifecycleSeeds( } // NO_SHOW: agent 가 시작 직전에 이탈한 것으로 표현. + // PERSONA_LEAVE_SPOT: 완료 후 linger/travel 시작 tick 에 cluster 참여에서 빠진다. for (const ev of lifecycle) { - if (ev.event_type !== 'NO_SHOW' || !ev.agent_id) continue; + if ( + ev.event_type !== 'NO_SHOW' && + ev.event_type !== 'PERSONA_LEAVE_SPOT' + ) { + continue; + } + if (!ev.agent_id) continue; const t = map.get(ev.spot_id); if (!t) continue; - const cutoff = (t.startedTick ?? ev.tick) - 0.01; + const cutoff = + ev.event_type === 'NO_SHOW' + ? (t.startedTick ?? ev.tick) - 0.01 + : ev.tick; for (const p of t.seed.participants) { if (p.personaId === ev.agent_id && p.leftTick === null) { p.leftTick = cutoff; @@ -177,6 +227,41 @@ export function buildSpotLifecycleSeeds( .filter((s) => s.participants.length > 0); } +function shiftSeedForCycle( + seed: SpotLifecycleSeed, + cycle: number, + loopPeriod: number, +): SpotLifecycleSeed { + const offset = cycle * loopPeriod; + return { + ...seed, + spotId: renderId(cycle, seed.spotId), + createdTick: seed.createdTick + offset, + matchedTick: + seed.matchedTick === null ? null : seed.matchedTick + offset, + closedTick: seed.closedTick + offset, + hotspotSignal: seed.hotspotSignal, + participants: seed.participants.map((p) => ({ + personaId: renderId(cycle, p.personaId), + joinedTick: p.joinedTick + offset, + leftTick: p.leftTick === null ? null : p.leftTick + offset, + })), + }; +} + +function loopedSpotLifecycleSeeds( + manifest: SimManifest, + baseSeeds: SpotLifecycleSeed[], + currentCycle: number, +): SpotLifecycleSeed[] { + const loopPeriod = loopPeriodTicks(manifest); + const cycles = [...new Set([Math.max(0, currentCycle - 1), currentCycle])]; + const loopSeeds = baseSeeds.filter((seed) => seed.createdTick < loopPeriod); + return cycles.flatMap((cycle) => + loopSeeds.map((seed) => shiftSeedForCycle(seed, cycle, loopPeriod)), + ); +} + // ─── seed (tick 단위) → SpotLifecycle (ms 단위) ──────────────────────────── export function seedToSpotLifecycle( @@ -226,6 +311,7 @@ type UseSimDomainOptions = { manifest: SimManifest | null; isReady: boolean; currentTick: number; + currentCycle: number; /** rAF emit 마다 호출되는 subscribe — 좌표 ref 갱신 통지. */ subscribe: (cb: () => void) => () => void; positionsRef: React.RefObject>; @@ -251,6 +337,7 @@ export function useSimDomain(options: UseSimDomainOptions): SimDomainResult { manifest, isReady, currentTick, + currentCycle, subscribe, positionsRef, playbackStartMsRef, @@ -261,23 +348,29 @@ export function useSimDomain(options: UseSimDomainOptions): SimDomainResult { snapshotThrottleMs = 800, } = options; + const renderCycles = useMemo( + () => [...new Set([Math.max(0, currentCycle - 1), currentCycle])], + [currentCycle], + ); + const personas = useMemo( - () => (manifest ? simAgentsToPersonas(manifest) : []), - [manifest], + () => (manifest ? simAgentsToPersonas(manifest, renderCycles) : []), + [manifest, renderCycles], ); // useSimRun 버퍼에 도착한 청크만 기존 도메인 seed 로 변환한다. // 여기서 API를 직접 호출하지 않아야 재생 pacing 과 무관한 eager full-run drain 이 생기지 않는다. const seeds = useMemo(() => { if (!manifest || !isReady) return []; - return buildSpotLifecycleSeeds( + const baseSeeds = buildSpotLifecycleSeeds( manifest, bufferedLifecycleEventsRef.current, bufferedMovementsRef.current, ); + return loopedSpotLifecycleSeeds(manifest, baseSeeds, currentCycle); // refs 자체는 안정적이므로 새 청크 도착 신호인 bufferedChunkVersion 으로 재계산한다. // eslint-disable-next-line react-hooks/exhaustive-deps - }, [manifest, isReady, bufferedChunkVersion]); + }, [manifest, isReady, bufferedChunkVersion, currentCycle]); // currentTick + positionsRef 기반 cluster/lifecycle 빌드. // 매 emit 마다 호출되도록 subscribe 후크에서 트리거. @@ -322,6 +415,7 @@ export function useSimDomain(options: UseSimDomainOptions): SimDomainResult { const arrivedParticipantIds = new Set(); const clusters: ActivityCluster[] = []; const positions = positionsRef.current; + const seedById = new Map(seeds.map((seed) => [seed.spotId, seed])); for (const lc of lifecycles) { if (now < lc.createdAtMs) continue; @@ -381,6 +475,9 @@ export function useSimDomain(options: UseSimDomainOptions): SimDomainResult { arrivedCount: idsToRender.filter((id) => arrivedParticipantIds.has(id), ).length, + variant: 'discovery', + variantLabel: lc.intent === 'offer' ? '형성 중' : '모집 중', + hotspotSignal: seedById.get(lc.spotId)?.hotspotSignal, }); // ageMs 만 쓰면 lifespan 미사용 경고 — explicit void. void lifespan; @@ -389,7 +486,7 @@ export function useSimDomain(options: UseSimDomainOptions): SimDomainResult { const signature = `${lifecycles.length}:${clusters .map( (cluster) => - `${cluster.id}:${cluster.personas.length}:${cluster.arrivedCount ?? 0}:${cluster.isDying ? 1 : 0}`, + `${cluster.id}:${cluster.centerCoord.lat.toFixed(6)},${cluster.centerCoord.lng.toFixed(6)}:${cluster.personas.length}:${cluster.arrivedCount ?? 0}:${cluster.isDying ? 1 : 0}:${JSON.stringify(cluster.hotspotSignal ?? null)}`, ) .join('|')}:${arrivedParticipantIds.size}`; if (signature === lastSnapshotSignatureRef.current) return; @@ -409,6 +506,7 @@ export function useSimDomain(options: UseSimDomainOptions): SimDomainResult { playbackStartMsRef, tickDurationMsRef, currentTick, + currentCycle, snapshotThrottleMs, ]); diff --git a/src/features/simulation/model/use-sim-run.ts b/src/features/simulation/model/use-sim-run.ts index 0acb360..0afac9c 100644 --- a/src/features/simulation/model/use-sim-run.ts +++ b/src/features/simulation/model/use-sim-run.ts @@ -70,6 +70,8 @@ export type UseSimRunResult = { subscribe: (cb: () => void) => () => void; /** 현재 tick (정수). UI 라벨 용. */ currentTick: number; + /** 현재 재생 cycle. cycle 이 바뀌면 같은 agent 도 별도 render instance 로 취급한다. */ + currentCycle: number; /** 현재 tick 에 발화한 lifecycle 이벤트들. 다음 emit 까지 동일. */ currentLifecycleEvents: LifecycleEvent[]; /** Play / Pause 토글. */ @@ -100,6 +102,25 @@ const DEFAULT_DWELL_JITTER_M = 20; const DEFAULT_SPAWN_SCATTER_M = 180; const MIN_EMPTY_SKIP_TICKS = 3; +function loopPeriodTicks(manifest: SimManifest): number { + return Math.max(1, manifest.loop_period_ticks ?? manifest.total_ticks); +} + +function dataWindowTicks(manifest: SimManifest): number { + return Math.max( + manifest.total_ticks, + (manifest.max_projected_tick ?? manifest.total_ticks - 1) + 1, + ); +} + +function cycleForTick(tFloat: number, period: number): number { + return Math.max(0, Math.floor(tFloat / period)); +} + +function tickInCycle(tFloat: number, period: number): number { + return ((tFloat % period) + period) % period; +} + export function useSimRun(options: UseSimRunOptions = {}): UseSimRunResult { const { runId = DEMO_RUN_ID, @@ -119,6 +140,7 @@ export function useSimRun(options: UseSimRunOptions = {}): UseSimRunResult { const [currentLifecycleEvents, setCurrentLifecycleEvents] = useState< LifecycleEvent[] >([]); + const [currentCycle, setCurrentCycle] = useState(0); const [isPlaying, setIsPlaying] = useState(false); // ── refs (rAF 루프가 직접 참조) ──────────────────────────────────────── @@ -198,7 +220,7 @@ export function useSimRun(options: UseSimRunOptions = {}): UseSimRunResult { // 첫 청크 await loadChunk( 0, - Math.min(m.chunk_size_ticks, m.total_ticks), + Math.min(m.chunk_size_ticks, dataWindowTicks(m)), runId, ); if (!mounted) return; @@ -279,13 +301,14 @@ export function useSimRun(options: UseSimRunOptions = {}): UseSimRunResult { function maybePrefetch(tFloat: number, m: SimManifest, rid: string): void { const w = loadedWindowRef.current; + const dataWindow = dataWindowTicks(m); if (!w) return; - if (w.to >= m.total_ticks) return; + if (w.to >= dataWindow) return; if (inflightChunkRef.current) return; if (tFloat < w.to - prefetchAheadTicks) return; const nextFrom = w.to; - const nextTo = Math.min(nextFrom + m.chunk_size_ticks, m.total_ticks); + const nextTo = Math.min(nextFrom + m.chunk_size_ticks, dataWindow); inflightChunkRef.current = loadChunk(nextFrom, nextTo, rid) .catch((e) => { setError(e instanceof Error ? e : new Error(String(e))); @@ -311,6 +334,7 @@ export function useSimRun(options: UseSimRunOptions = {}): UseSimRunResult { function findNextActivityTick(tFloat: number): number | null { const w = loadedWindowRef.current; + const loopPeriod = manifest ? loopPeriodTicks(manifest) : null; if (!w) return null; let nextTick = Number.POSITIVE_INFINITY; @@ -326,12 +350,14 @@ export function useSimRun(options: UseSimRunOptions = {}): UseSimRunResult { } if (!Number.isFinite(nextTick)) return null; + if (loopPeriod !== null && nextTick >= loopPeriod) return null; // 로드된 범위 안에서만 안전하게 건너뛴다. 다음 청크 내용은 아직 모르므로 추측하지 않는다. return nextTick <= w.to ? nextTick : null; } function maybeSkipEmptyEventTicks(tFloat: number, now: number): number { if (!skipEmptyEventTicks) return tFloat; + if (manifest && tFloat >= loopPeriodTicks(manifest)) return tFloat; const tickInt = Math.floor(tFloat); if (lifecycleByTickRef.current.get(tickInt)?.length) return tFloat; if (hasActiveMovement(tFloat)) return tFloat; @@ -343,7 +369,7 @@ export function useSimRun(options: UseSimRunOptions = {}): UseSimRunResult { const skippedTick = Math.min( nextTick, - manifest?.total_ticks ?? nextTick, + manifest ? loopPeriodTicks(manifest) : nextTick, ); pausedAtTickRef.current = skippedTick; playbackStartMsRef.current = @@ -364,24 +390,17 @@ export function useSimRun(options: UseSimRunOptions = {}): UseSimRunResult { (now - playbackStartMsRef.current) / Math.max(1, tickDurationMsRef.current); const effectiveTFloat = maybeSkipEmptyEventTicks(tFloat, now); - const clamped = Math.min( - effectiveTFloat, - manifest.total_ticks - 0.0001, - ); if (now - lastEmitMs >= emitThrottleMs) { lastEmitMs = now; - emitFrame(clamped); + emitFrame(effectiveTFloat); } - maybePrefetch(clamped, manifest, runId); - - // 재생 종료 - if (effectiveTFloat >= manifest.total_ticks) { - pausedAtTickRef.current = manifest.total_ticks; - setIsPlaying(false); - return; - } + maybePrefetch( + Math.min(effectiveTFloat, dataWindowTicks(manifest)), + manifest, + runId, + ); rafId = requestAnimationFrame(loop); }; @@ -395,63 +414,94 @@ export function useSimRun(options: UseSimRunOptions = {}): UseSimRunResult { const timelines = timelinesRef.current; const agents = agentsRef.current; const next = positionsRef.current; + const m = manifest; + const loopPeriod = m ? loopPeriodTicks(m) : Number.POSITIVE_INFINITY; + const dataWindow = m ? dataWindowTicks(m) : loopPeriod; + const currentCycleValue = Number.isFinite(loopPeriod) + ? cycleForTick(tFloat, loopPeriod) + : 0; + const cycleTicks = Number.isFinite(loopPeriod) + ? [Math.max(0, currentCycleValue - 1), currentCycleValue] + : [0]; + const activeKeys = new Set(); + + for (const cycle of cycleTicks) { + const rawTick = tFloat - cycle * loopPeriod; + if (rawTick < 0 || rawTick >= dataWindow) continue; + + for (const a of agents) { + if (a.agent_role === 'background') { + // 서버 movement 가 없는 background 는 시각화에서 제외(어댑터에서 hide). + continue; + } + const tl = timelines.get(a.agent_id); + const renderAgentId = `${cycle}:${a.agent_id}`; + activeKeys.add(renderAgentId); + + // 대기 상태 정의: + // - timeline 비었음 → 항상 대기 + // - 첫 movement 전 (rawTick < first.depart_tick) → 대기 + // - 마지막 movement 가 go_home 이고 도착 완료 → 다시 대기(귀가) + // 대기 중인 agent 는 positionsRef 에서 좌표를 제거해 마커 자체를 hide. + const isIdle = + !tl || + tl.length === 0 || + rawTick < tl[0].depart_tick || + (tl[tl.length - 1].reason === 'go_home' && + rawTick >= tl[tl.length - 1].arrive_tick); + + if (isIdle) { + next.delete(renderAgentId); + continue; + } - for (const a of agents) { - if (a.agent_role === 'background') { - // 서버 movement 가 없는 background 는 시각화에서 제외(어댑터에서 hide). - continue; - } - const tl = timelines.get(a.agent_id); - - // 대기 상태 정의: - // - timeline 비었음 → 항상 대기 - // - 첫 movement 전 (tFloat < first.depart_tick) → 대기 - // - 마지막 movement 가 go_home 이고 도착 완료 → 다시 대기(귀가) - // 대기 중인 agent 는 positionsRef 에서 좌표를 제거해 마커 자체를 hide. - const isIdle = - !tl || - tl.length === 0 || - tFloat < tl[0].depart_tick || - (tl[tl.length - 1].reason === 'go_home' && - tFloat >= tl[tl.length - 1].arrive_tick); - - if (isIdle) { - next.delete(a.agent_id); - continue; - } + const recentMovement = tl + ? findRecentMovement(tl, rawTick) + : null; + const hasReturnedHome = + recentMovement?.reason === 'go_home' && + recentMovement.arrive_tick <= rawTick; + if (hasReturnedHome) { + next.delete(renderAgentId); + continue; + } - const recentMovement = tl ? findRecentMovement(tl, tFloat) : null; - const hasReturnedHome = - recentMovement?.reason === 'go_home' && - recentMovement.arrive_tick <= tFloat; - if (hasReturnedHome) { - next.delete(a.agent_id); - continue; + const pos = resolveAgentPosition( + a, + timelines, + rawTick, + placeMap, + { + spawnScatterM, + }, + ); + if (!pos) continue; + // spot 도착 후 dwell 은 잔잔한 jitter 만(움직이지 않는 모임 멤버 표현). + const isDwell = + recentMovement !== null && + recentMovement.arrive_tick <= rawTick; + next.set( + renderAgentId, + isDwell && dwellJitterM > 0 + ? jitterAround( + pos, + renderAgentId + ':' + Math.floor(rawTick), + dwellJitterM, + ) + : pos, + ); } + } - const pos = resolveAgentPosition(a, timelines, tFloat, placeMap, { - spawnScatterM, - }); - if (!pos) continue; - // spot 도착 후 dwell 은 잔잔한 jitter 만(움직이지 않는 모임 멤버 표현). - const isDwell = - recentMovement !== null && recentMovement.arrive_tick <= tFloat; - next.set( - a.agent_id, - isDwell && dwellJitterM > 0 - ? jitterAround( - pos, - a.agent_id + ':' + Math.floor(tFloat), - dwellJitterM, - ) - : pos, - ); + for (const key of [...next.keys()]) { + if (!activeKeys.has(key)) next.delete(key); } - const tickInt = Math.floor(tFloat); + const tickInt = Math.floor(tickInCycle(tFloat, loopPeriod)); if (tickInt !== lastEmittedTickRef.current) { lastEmittedTickRef.current = tickInt; setCurrentTick(tickInt); + setCurrentCycle(currentCycleValue); const lc = lifecycleByTickRef.current.get(tickInt) ?? []; setCurrentLifecycleEvents(lc); } @@ -478,7 +528,10 @@ export function useSimRun(options: UseSimRunOptions = {}): UseSimRunResult { const seek = useCallback( (tick: number) => { if (!manifest) return; - const clamped = Math.max(0, Math.min(tick, manifest.total_ticks)); + const clamped = Math.max( + 0, + Math.min(tick, loopPeriodTicks(manifest)), + ); pausedAtTickRef.current = clamped; playbackStartMsRef.current = performance.now() - clamped * tickDurationMsRef.current; @@ -499,6 +552,7 @@ export function useSimRun(options: UseSimRunOptions = {}): UseSimRunResult { positionsRef, subscribe, currentTick, + currentCycle, currentLifecycleEvents, isPlaying, play, @@ -516,6 +570,7 @@ export function useSimRun(options: UseSimRunOptions = {}): UseSimRunResult { error, subscribe, currentTick, + currentCycle, currentLifecycleEvents, isPlaying, play,