File size: 10,701 Bytes
1c730d1
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
// Copyright (c) 2025-2026, RTE (https://www.rte-france.com)
// This Source Code Form is subject to the terms of the Mozilla Public License, version 2.0.
// If a copy of the Mozilla Public License, version 2.0 was not distributed with this file,
// you can obtain one at http://mozilla.org/MPL/2.0/.
// SPDX-License-Identifier: MPL-2.0
// This file is part of Co-Study4Grid a Power Grid Study tool Assistant Interface to help solve contigencies for a grid state under study.

import { useCallback, useEffect, useRef, useState } from 'react';
import { api } from '../api';
import { colors, space, text, radius } from '../styles/tokens';
import type { GameLeverStatWire, LeverInteraction } from '../types';
import { gameBridge } from './gameBridge';
import { GAME_HUD_HEIGHT } from './GameHud';
import { buildLeverInteraction } from './solutionLog';
import type { GameStudy } from './types';

/** Single-click is deferred this long so a double-click can pre-empt it.
 *  Mirrors the VL-disk interactions' `VL_SINGLE_CLICK_DELAY_MS`. */
const LEVER_SINGLE_CLICK_DELAY_MS = 250;

/** Simulation state of one lever, driving the inline feedback + re-run block. */
type LeverSimStatus = 'idle' | 'simulating' | 'simulated';

interface GameHintsPanelProps {
  study: GameStudy;
}

/** Display wording for each lever equipment category. */
const LEVER_CATEGORY_LABELS: Record<GameLeverStatWire['category'], string> = {
  voltage_level: 'Voltage level',
  branch: 'Branch',
  generation: 'Generation',
  load: 'Load',
  other: 'Other',
};

const CATEGORY_ICONS: Record<GameLeverStatWire['category'], string> = {
  voltage_level: '🔀',
  branch: '🔌',
  generation: '⚡',
  load: '🏠',
  other: '🔧',
};

/**
 * Beginner-assistance hints — the 5 levers most used across ALL players'
 * retained solutions on the current contingency (shared solution base),
 * each tagged with the equipment family it acts on. Collapsible so the
 * hint never covers the workspace; fetched once per study, best-effort
 * (no data / no backend → the panel stays hidden).
 */
export default function GameHintsPanel({ study }: GameHintsPanelProps) {
  const [levers, setLevers] = useState<GameLeverStatWire[]>([]);
  const [total, setTotal] = useState(0);
  const [open, setOpen] = useState(true);

  // Simulation feedback state. `inFlight` marks a lever whose simulation is
  // running (the "simulating…" chip + re-run block); `localDone` marks
  // coupling maneuvers whose fresh `user_topo_*` id the workspace snapshot
  // can't match back to a lever. Action-id levers (branch disco/reco +
  // injections) are marked "simulated" straight from `simulatedIds`, the set
  // of materialised action ids the App publishes on the game bridge — so a
  // lever also flips to "simulated" when its action arrives through the
  // recommender's suggestions, and a failed run self-clears (its id never
  // enters the set) leaving the lever runnable again.
  const [inFlight, setInFlight] = useState<Set<string>>(() => new Set());
  const [localDone, setLocalDone] = useState<Set<string>>(() => new Set());
  const [simulatedIds, setSimulatedIds] = useState<Set<string>>(
    () => new Set(gameBridge.getSnapshot().simulatedActionIds));
  useEffect(() => {
    setSimulatedIds(new Set(gameBridge.getSnapshot().simulatedActionIds));
    return gameBridge.subscribe((snap) => setSimulatedIds(new Set(snap.simulatedActionIds)));
  }, []);

  const leverStatus = useCallback((lever: GameLeverStatWire): LeverSimStatus => {
    const sig = lever.signature;
    if (inFlight.has(sig)) return 'simulating';
    if (localDone.has(sig)) return 'simulated';
    const actionId = buildLeverInteraction(lever).simulate?.actionId;
    if (actionId && simulatedIds.has(actionId)) return 'simulated';
    return 'idle';
  }, [inFlight, localDone, simulatedIds]);

  // A single-click locates + inspects the lever; a double-click simulates it.
  // The single-click action is deferred so a double-click can pre-empt it —
  // otherwise the first click of a double-click would fire an inspect too.
  const clickTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
  useEffect(() => () => {
    if (clickTimerRef.current !== null) clearTimeout(clickTimerRef.current);
  }, []);

  const handleLeverClick = useCallback((lever: GameLeverStatWire) => {
    if (clickTimerRef.current !== null) clearTimeout(clickTimerRef.current);
    clickTimerRef.current = setTimeout(() => {
      clickTimerRef.current = null;
      void gameBridge.requestLeverInteraction(buildLeverInteraction(lever), 'inspect');
    }, LEVER_SINGLE_CLICK_DELAY_MS);
  }, []);

  const handleLeverDoubleClick = useCallback(async (lever: GameLeverStatWire) => {
    if (clickTimerRef.current !== null) {
      clearTimeout(clickTimerRef.current);
      clickTimerRef.current = null;
    }
    const interaction: LeverInteraction = buildLeverInteraction(lever);
    // Magnitude-free lever (PST / raw gen_p / load_p) → nothing to simulate;
    // let the handler degrade to inspect without any status tracking.
    if (!interaction.simulate) {
      void gameBridge.requestLeverInteraction(interaction, 'simulate');
      return;
    }
    // Already simulating or simulated → ignore, so a second double-click can't
    // fire a duplicate simulation of the same lever.
    if (leverStatus(lever) !== 'idle') return;
    const sig = lever.signature;
    setInFlight((prev) => new Set(prev).add(sig));
    try {
      await gameBridge.requestLeverInteraction(interaction, 'simulate');
      // Coupling maneuvers register under a fresh user_topo_<vl>_<ts> id the
      // snapshot can't map back to this lever, so record completion locally.
      // Action-id levers rely on `simulatedIds` (self-correcting on failure).
      if (!interaction.simulate.actionId) {
        setLocalDone((prev) => new Set(prev).add(sig));
      }
    } finally {
      setInFlight((prev) => {
        const next = new Set(prev);
        next.delete(sig);
        return next;
      });
    }
  }, [leverStatus]);

  // No synchronous state reset here: GameShell keys the panel by study id,
  // so each study mounts a fresh panel with empty initial state.
  useEffect(() => {
    let cancelled = false;
    if (!study.contingencyElementId) return;
    api.getGameLeverStats(study.networkPath, study.contingencyElementId)
      .then((stats) => {
        if (cancelled) return;
        setLevers(stats.levers);
        setTotal(stats.total_retentions);
      })
      .catch(() => {
        // Hints are best-effort: without a reachable base the panel hides.
      });
    return () => { cancelled = true; };
  }, [study.networkPath, study.contingencyElementId]);

  if (!levers.length) return null;

  const pill: React.CSSProperties = {
    position: 'fixed', top: GAME_HUD_HEIGHT + 8, right: space[3], zIndex: 9300,
    boxSizing: 'border-box',
  };

  if (!open) {
    return (
      <button
        onClick={() => setOpen(true)}
        title="Show the community's most-used levers for this contingency"
        style={{
          ...pill, padding: `${space[1]} ${space[2]}`, borderRadius: radius.md,
          background: colors.infoSoft, border: `1px solid ${colors.infoBorder}`,
          color: colors.infoText, fontSize: text.sm, fontWeight: 600, cursor: 'pointer',
        }}
      >
        💡 Hints
      </button>
    );
  }

  return (
    <div
      role="complementary"
      aria-label="Beginner assistance — most-used levers"
      style={{
        ...pill, width: 320, maxWidth: '90vw', padding: space[2],
        borderRadius: radius.lg, background: colors.surfaceRaised,
        border: `1px solid ${colors.infoBorder}`, fontSize: text.sm,
        color: colors.textPrimary,
      }}
    >
      <div style={{ display: 'flex', alignItems: 'center', gap: space[1], marginBottom: space[1] }}>
        <span style={{ fontWeight: 700, color: colors.infoText, flex: 1 }}>
          💡 Most-used levers here
        </span>
        <button
          onClick={() => setOpen(false)}
          title="Collapse hints"
          style={{
            border: 'none', background: 'transparent', cursor: 'pointer',
            color: colors.textTertiary, fontSize: text.sm, fontWeight: 700,
          }}
        >

        </button>
      </div>
      <ol style={{ margin: 0, paddingLeft: space[4] }}>
        {levers.map((lever) => {
          const status = leverStatus(lever);
          const done = status === 'simulated';
          const busy = status === 'simulating';
          return (
            <li key={lever.signature} style={{ marginBottom: space.half }}>
              <button
                onClick={() => handleLeverClick(lever)}
                onDoubleClick={() => handleLeverDoubleClick(lever)}
                title={done
                  ? `${lever.sample_description ?? lever.label} — already simulated; click to locate & inspect`
                  : `${lever.sample_description ?? lever.label} — click to locate & inspect, double-click to simulate`}
                data-testid={`game-lever-${lever.signature}`}
                style={{
                  border: 'none', background: 'transparent', padding: 0,
                  cursor: 'pointer', fontWeight: 600, fontSize: text.sm,
                  color: colors.infoText, textDecoration: 'underline',
                  textUnderlineOffset: 2, opacity: done ? 0.65 : 1,
                }}
              >
                {CATEGORY_ICONS[lever.category]} {lever.label}
              </button>
              <span style={{ color: colors.textTertiary, fontSize: text.xs }}>
                {' '}— {LEVER_CATEGORY_LABELS[lever.category]} · used {lever.count}×
              </span>
              {busy && (
                <span data-testid={`game-lever-status-${lever.signature}`}
                  style={{ color: colors.infoText, fontSize: text.xs, fontWeight: 600, marginLeft: space[1] }}>
                  ⏳ simulating…
                </span>
              )}
              {done && (
                <span data-testid={`game-lever-status-${lever.signature}`}
                  style={{ color: colors.successText, fontSize: text.xs, fontWeight: 600, marginLeft: space[1] }}>
                  ✓ simulated
                </span>
              )}
            </li>
          );
        })}
      </ol>
      <p style={{ margin: `${space[1]} 0 0`, fontSize: text.xs, color: colors.textTertiary }}>
        From {total} retained solution{total === 1 ? '' : 's'} by all players on this
        contingency. Click a lever to locate & inspect it; double-click to simulate it
        (once — a ✓ marks levers already simulated).
      </p>
    </div>
  );
}