File size: 3,307 Bytes
54db442
d0c158f
 
54db442
d0c158f
 
 
54db442
 
d0c158f
 
54db442
d0c158f
 
 
54db442
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d0c158f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54db442
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
d0c158f
 
 
 
 
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
import { useEffect, useMemo, useRef } from "react";

import { eventIcon, severityClass } from "../eventDecor";
import type { CountrySnapshot, EntitySnapshot, GameLogEventSnapshot } from "../types";

type EventFeedPanelProps = {
  events: GameLogEventSnapshot[];
  countries?: CountrySnapshot[];
  entities?: EntitySnapshot[];
};

export function EventFeedPanel({ events, countries = [], entities = [] }: EventFeedPanelProps) {
  const listRef = useRef<HTMLDivElement>(null);
  const isPinnedToBottomRef = useRef(true);

  // Resolve a speaker's nation colour (matching the scoreboard: Nemotron blue,
  // Qwen purple) so chronicle speech lines are tinted by who is talking.
  const colorByCountry = useMemo(
    () => new Map(countries.map((country) => [country.id, country.color])),
    [countries],
  );
  const countryByActor = useMemo(
    () => new Map(entities.map((entity) => [entity.id, entity.country_id ?? null])),
    [entities],
  );

  const speechColor = (event: GameLogEventSnapshot): string | undefined => {
    if (event.type !== "speech" || !event.actor_id) {
      return undefined;
    }
    // Fall back to the id prefix when the speaker has since died/left the snapshot.
    let countryId = countryByActor.get(event.actor_id) ?? null;
    if (!countryId) {
      if (event.actor_id.startsWith("qwen")) countryId = "qwen";
      else if (event.actor_id.startsWith("npc")) countryId = "nemotron";
    }
    return countryId ? colorByCountry.get(countryId) : undefined;
  };

  useEffect(() => {
    const list = listRef.current;
    if (list && isPinnedToBottomRef.current) {
      list.scrollTop = list.scrollHeight;
    }
  }, [events]);

  return (
    <aside className="eventFeed" aria-label="Event feed">
      <div className="sidePanelHeader">
        <span className="sidePanelTitle">📰 Village Chronicle</span>
        <span className="sidePanelHint">{events.length} events</span>
      </div>
      <div
        className="eventList"
        ref={listRef}
        onScroll={() => {
          const list = listRef.current;
          if (list) {
            isPinnedToBottomRef.current =
              list.scrollHeight - list.scrollTop - list.clientHeight < 24;
          }
        }}
      >
        {events.length === 0 ? (
          <p className="eventEmpty">The village is quiet... for now.</p>
        ) : (
          events.map((event, index) => {
            const color = speechColor(event);
            return (
              <div
                className={`eventLine ${severityClass(event.severity)}`}
                key={`${event.tick}-${index}-${event.type}`}
                // Tint a speaker's line with their nation colour as a small
                // highlight (left bar + faint wash); the text itself stays light.
                style={
                  color
                    ? { borderLeftColor: color, background: `${color}14` }
                    : undefined
                }
              >
                <span className="eventTick">{event.tick}</span>
                <span className="eventIcon" aria-hidden="true">
                  {eventIcon(event.type)}
                </span>
                <span className="eventText">{event.summary}</span>
              </div>
            );
          })
        )}
      </div>
    </aside>
  );
}