File size: 3,658 Bytes
e7a9f02
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
/* REST + WebSocket client.
 *
 * The dashboard never polls for frames. It asks for state once when a session
 * starts and then consumes the push stream; REST is only used for commands and
 * for things that are not per-frame.
 */

const BASE = '/api';

async function request(path, options = {}) {
  const res = await fetch(BASE + path, {
    headers: options.body ? { 'content-type': 'application/json' } : undefined,
    ...options,
  });
  if (!res.ok) {
    let detail = res.statusText;
    try {
      const body = await res.json();
      detail = body.detail || body.error || detail;
    } catch { /* non-JSON error body */ }
    const err = new Error(typeof detail === 'string' ? detail : JSON.stringify(detail));
    err.status = res.status;
    throw err;
  }
  return res.status === 204 ? null : res.json();
}

export const api = {
  meta:       () => request('/meta'),
  venues:     () => request('/venues'),
  venue:      id => request(`/venues/${id}`),
  scenarios:  () => request('/scenarios'),
  benchmarks: () => request('/benchmarks'),

  start: payload => request('/simulation/start', {
    method: 'POST', body: JSON.stringify(payload),
  }),
  state: (id, agents = true) => request(`/simulation/${id}/state?agents=${agents}`),
  control: (id, payload) => request(`/simulation/${id}/control`, {
    method: 'POST', body: JSON.stringify(payload),
  }),
  stop: id => request(`/simulation/${id}`, { method: 'DELETE' }),

  simulateStrategies: (id, payload = {}) => request(`/simulation/${id}/strategy/simulate`, {
    method: 'POST', body: JSON.stringify(payload),
  }),
  applyStrategy: (id, strategyId) => request(`/simulation/${id}/strategy/apply`, {
    method: 'POST', body: JSON.stringify({ strategy_id: strategyId }),
  }),

  perceptionStatus: () => request('/perception/status'),
  perceptionAnalyze: (formData) =>
    fetch(`${BASE}/perception/analyze`, { method: 'POST', body: formData })
      .then(async r => {
        const body = await r.json().catch(() => ({}));
        if (!r.ok) throw new Error(body.detail || 'perception failed');
        return body;
      }),
};

/** Auto-reconnecting frame stream. */
export class FrameStream {
  constructor(sessionId, handlers) {
    this.sessionId = sessionId;
    this.handlers = handlers;
    this.ws = null;
    this.closed = false;
    this.retries = 0;
    this._connect();
  }

  _connect() {
    if (this.closed) return;
    const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
    const url = `${proto}//${location.host}${BASE}/ws/simulation/${this.sessionId}`;
    let ws;
    try {
      ws = new WebSocket(url);
    } catch {
      this._scheduleRetry();
      return;
    }
    this.ws = ws;

    ws.onopen = () => {
      this.retries = 0;
      this.handlers.onStatus?.('live');
    };
    ws.onmessage = ev => {
      let msg;
      try { msg = JSON.parse(ev.data); } catch { return; }
      if (msg.type === 'frame') this.handlers.onFrame?.(msg);
      else if (msg.type === 'strategy') this.handlers.onStrategy?.(msg.payload);
      else if (msg.type === 'error') this.handlers.onError?.(msg.detail);
    };
    ws.onclose = () => {
      this.handlers.onStatus?.(this.closed ? 'closed' : 'reconnecting');
      this._scheduleRetry();
    };
    ws.onerror = () => { /* onclose handles recovery */ };
  }

  _scheduleRetry() {
    if (this.closed) return;
    this.retries += 1;
    if (this.retries > 8) {
      this.handlers.onStatus?.('offline');
      return;
    }
    setTimeout(() => this._connect(), Math.min(600 * this.retries, 4000));
  }

  close() {
    this.closed = true;
    try { this.ws?.close(); } catch { /* already gone */ }
  }
}