What changed, and why

Issue #31: the contact-year slider sometimes doesn't appear, even when a figure's lifespan is sitting right there on a suggestion chip. The slider renders only when grounding finds a birth year, and a live probe of 24 figures showed the old pipeline losing dates four distinct ways. The big one is intermittent: Wikidata rate-limits a burst of lookups (the suggester verifying candidates is exactly such a burst), the failure was swallowed into a "resolved, no dates" answer, and that answer was cached for 24 hours. The others are structural: the code read claims.P569[0] when Wikidata's community-preferred claim is often not first (Moses, Jesus and Confucius all keep theirs at index 2), [0] can be a dateless somevalue placeholder or a deprecated value, and century-precision dates (Homer) rendered fake-exact.

The fix layers four defenses, all server-side in the grounding pipeline plus one honest line in the picker: read Wikidata by item id and best-ranked claim; cache transient failures for minutes instead of a day; bridge genuinely dateless figures with a small, validated AI estimate marked circa; and say so in the dossier when even that fails. The public GroundedFigure shape only gains an optional circa flag on years, so the store, the prompts and the e2e mocks all keep working untouched.

Notably, every one of the 24 probed figures — Homer, Sun Tzu, Zoroaster, King Arthur included — has recoverable dates once the lookup is robust. The AI bridge is the rare-case net, not the workhorse.

Reading Wikidata the way Wikidata means it

Wikidata stacks several birth/death claims per person: scholarly guesses, deprecated values, "unknown value" placeholders, and one the community marks preferred. The old code took [0] blindly. pickClaimYear now filters to claims that actually carry a date (a somevalue snak has no datavalue), never reads deprecated ones, and takes preferred rank first.

Best-ranked claim with a real value; the live-probed counterexamples are in the docstring.

server/utils/figure-grounding.ts · 282 lines
server/utils/figure-grounding.ts282 lines · TypeScript
⋯ 115 lines hidden (lines 1–115)
1/**
2 * Real-world grounding for historical figures — the backbone of the "timeline RPG"
3 * direction (see docs/ROADMAP.md). Resolves a free-form name to solid facts so the
4 * game can show who someone was, validate *when* you may contact them, and feed the
5 * role-play real dates.
6 *
7 * Pipeline (the wiki APIs are open + key-less):
8 * 1. Wikipedia REST summary — the reliable resolver. It follows redirects and
9 * picks the notable article, giving a canonical title, a one-line description,
10 * a blurb, a portrait, and the Wikidata item id. (Wikidata search alone is NOT
11 * reliable: "Cleopatra" resolves to "female given name", "Genghis Khan" to an
12 * Iron Maiden song.)
13 * 2. Wikidata, looked up by that item id (title as fallback) — structured birth
14 * (P569) / death (P570), taking the best-ranked claim that actually carries a
15 * date, with sub-year precision surfaced honestly as `circa`.
16 * 3. If the record still has no birth year, a small AI estimator bridges the gap
17 * with circa dates (issue #31) — so a real, resolvable figure keeps their
18 * "when" slider even when Wikidata can't date them.
19 *
20 * Anything unresolved returns `{ resolved: false }`, and callers fall back to the
21 * free-form behavior rather than blocking — we never punish reaching for someone.
22 *
23 * Cache honesty: only definitive answers — a real Wikidata reply (dated or not,
24 * with the estimator genuinely consulted), a genuine no-such-article miss, a
25 * disambiguation page — keep the full TTL. A transient failure anywhere on the
26 * path (a rate-limit 429, a timeout, an estimator outage) gets a short TTL, so
27 * one blip never poisons a figure for a day.
28 */
29import { estimateLifespan } from './lifespan-estimator'
30 
31/** A year on the timeline. `signed` is AD-positive / BCE-negative for comparison. */
32export interface FigureYear {
33 year: number
34 bce: boolean
35 signed: number
36 display: string
37 /** Approximate: sub-year Wikidata precision (decade/century/…) or an AI estimate. */
38 circa?: boolean
40 
41export interface GroundedFigure {
42 name: string
43 resolved: boolean
44 description?: string
45 extract?: string
46 born?: FigureYear
47 died?: FigureYear
48 living?: boolean
49 thumbnail?: string
50 wikiUrl?: string
52 
53const WIKI_HEADERS = {
54 accept: 'application/json',
55 'User-Agent': 'Revisionist/1.0 (timeline RPG; grounding lookups)'
57 
58const FULL_TTL_MS = 24 * 60 * 60 * 1000
59const TRANSIENT_TTL_MS = 10 * 60 * 1000
60const WIKIDATA_RETRY_DELAY_MS = 400
61const cache = new Map<string, { value: GroundedFigure; expires: number }>()
62 
63/** Builds a `FigureYear`; the display string owns the "c." honesty marker. */
64export function makeFigureYear(year: number, bce: boolean, circa = false): FigureYear {
65 const base = bce ? `${year} BC` : `${year}`
66 return {
67 year,
68 bce,
69 signed: bce ? -year : year,
70 display: circa ? `c. ${base}` : base,
71 ...(circa ? { circa: true } : {})
72 }
74 
75/**
76 * Parses a Wikidata time literal (e.g. "+1856-07-10T..", "-0069-01-13T..") to a year.
77 * Precision 9 means year-level; anything below (8 decade, 7 century, 6 millennium)
78 * still carries a representative year in the literal, but only approximately —
79 * those parse as `circa` rather than being dropped or displayed as exact.
80 */
81export function parseWikidataYear(time?: string, precision?: number): FigureYear | undefined {
82 if (!time) return undefined
83 const m = /^([+-])0*(\d+)-/.exec(time)
84 if (!m) return undefined
85 const year = parseInt(m[2], 10)
86 if (!year) return undefined
87 const bce = m[1] === '-'
88 return makeFigureYear(year, bce, precision != null && precision < 9)
90 
91/** The slice of the Wikipedia REST summary we actually read. */
92interface WikiSummary {
93 type?: string
94 title?: string
95 description?: string
96 extract?: string
97 thumbnail?: { source?: string }
98 content_urls?: { desktop?: { page?: string } }
99 wikibase_item?: string
101 
102/** The slice of a Wikidata date claim we actually read. */
103interface WikidataClaim {
104 rank?: string
105 mainsnak?: {
106 snaktype?: string
107 datavalue?: { value?: { time?: string; precision?: number } }
108 }
110 
111/** The slice of the Wikidata `wbgetentities` response we actually read. */
112interface WikidataEntities {
113 entities?: Record<string, { claims?: Record<string, WikidataClaim[]> }>
115 
116/**
117 * The best date a claim list actually carries. Wikidata routinely stacks several
118 * birth/death claims (scholarly guesses, deprecated values, "unknown value"
119 * placeholders) and the community-preferred one is often NOT first — Moses, Jesus
120 * and Confucius all keep theirs at index 2. So: only claims whose snak carries a
121 * real time (somevalue/novalue snaks don't), never deprecated, preferred rank first.
122 */
123export function pickClaimYear(claims?: WikidataClaim[]): FigureYear | undefined {
124 const usable = (claims ?? []).filter(c =>
125 c.rank !== 'deprecated' &&
126 c.mainsnak?.snaktype === 'value' &&
127 c.mainsnak.datavalue?.value?.time
128 )
129 const best = usable.find(c => c.rank === 'preferred') ?? usable[0]
130 const value = best?.mainsnak?.datavalue?.value
131 return parseWikidataYear(value?.time, value?.precision)
⋯ 150 lines hidden (lines 133–282)
133 
134/**
135 * A fetch that tells a definitive miss from a transient one: 404 means the thing
136 * genuinely isn't there (cacheable at full TTL), anything else — 429, 5xx, timeout,
137 * network error — is weather to retry soon, not remember for a day.
138 */
139interface FetchOutcome<T> {
140 data: T | null
141 transient: boolean
143 
144async function fetchJson<T>(url: string): Promise<FetchOutcome<T>> {
145 try {
146 const res = await fetch(url, { headers: WIKI_HEADERS, signal: AbortSignal.timeout(6000) })
147 if (!res.ok) return { data: null, transient: res.status !== 404 }
148 return { data: (await res.json()) as T, transient: false }
149 } catch {
150 return { data: null, transient: true }
151 }
153 
154interface WikidataDates {
155 born?: FigureYear
156 died?: FigureYear
157 /** True when we never got a real answer (so the result must not cache long). */
158 transient: boolean
160 
161/**
162 * Birth/death from Wikidata — by item id when the summary supplied one (immune to
163 * title-matching quirks), by sitelink title otherwise. Rate-limit blips are the
164 * dominant way dates go missing in practice (a 429 body carries no `entities`), so
165 * a no-entities response gets one spaced retry before being declared transient.
166 */
167async function wikidataDates(qid: string | undefined, title: string): Promise<WikidataDates> {
168 const subject = qid
169 ? `ids=${encodeURIComponent(qid)}`
170 : `sites=enwiki&titles=${encodeURIComponent(title)}&normalize=1`
171 const url = `https://www.wikidata.org/w/api.php?action=wbgetentities&${subject}&props=claims&languages=en&format=json&origin=*`
172 let outcome = await fetchJson<WikidataEntities>(url)
173 if (!outcome.data?.entities) {
174 await new Promise(resolve => setTimeout(resolve, WIKIDATA_RETRY_DELAY_MS))
175 outcome = await fetchJson<WikidataEntities>(url)
176 }
177 const entities = outcome.data?.entities
178 if (!entities) return { transient: true }
179 const id = Object.keys(entities)[0]
180 const claims = id ? entities[id]?.claims : undefined
181 return { born: pickClaimYear(claims?.P569), died: pickClaimYear(claims?.P570), transient: false }
183 
184/**
185 * Resolves a name to grounded facts (cached). Never throws; returns
186 * `{ resolved: false }` when the figure can't be confidently identified.
187 */
188export async function groundFigure(rawName: string): Promise<GroundedFigure> {
189 const name = (rawName || '').trim()
190 if (!name) return { name: '', resolved: false }
191 
192 const key = name.toLowerCase()
193 const hit = cache.get(key)
194 if (hit && hit.expires > Date.now()) return hit.value
195 
196 const { value, ttlMs } = await resolve(name)
197 cache.set(key, { value, expires: Date.now() + ttlMs })
198 return value
200 
201/**
202 * Keep a URL only if it's a plain http(s) link — drop `javascript:` / `data:` /
203 * relative values. These come from a remote Wikipedia response and end up bound to
204 * `:href` / `:src` in the figure dossier; Vue escapes the value but NOT the scheme,
205 * so a spoofed `javascript:` URL would execute on click. (Surfaced by the
206 * unsafe-render loop.) Exported for the figure-search route, which binds Wikipedia
207 * thumbnails the same way.
208 */
209export function safeUrl(url?: string): string | undefined {
210 if (!url) return undefined
211 try {
212 const { protocol } = new URL(url)
213 return protocol === 'https:' || protocol === 'http:' ? url : undefined
214 } catch {
215 return undefined
216 }
218 
219interface Resolution {
220 value: GroundedFigure
221 ttlMs: number
223 
224async function resolve(name: string): Promise<Resolution> {
225 const summary = await fetchJson<WikiSummary>(
226 `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(name)}`
227 )
228 
229 // No article, or an ambiguous disambiguation page → can't pin a person.
230 const article = summary.data
231 if (!article || article.type === 'disambiguation' || !article.title) {
232 return {
233 value: { name, resolved: false },
234 ttlMs: summary.transient ? TRANSIENT_TTL_MS : FULL_TTL_MS
235 }
236 }
237 
238 const title: string = article.title
239 
240 const dates = await wikidataDates(article.wikibase_item, title)
241 let born = dates.born
242 let died = dates.died
243 let bridgeTransient = false
244 
245 // The AI bridge (issue #31): the record knows WHO they are but not WHEN — either
246 // Wikidata genuinely has no usable birth claim, or the lookup is down right now.
247 // Estimated years arrive marked circa, so the dossier and prompts stay honest.
248 // A recorded death is law: the estimate only fills what the record lacks, and an
249 // estimate that contradicts the record (born after the documented death) is
250 // dropped whole rather than shipped as an incoherent lifespan.
251 if (!born) {
252 const bridge = await estimateLifespan(title, article.description, article.extract, died?.display)
253 bridgeTransient = bridge.transient
254 if (bridge.estimate) {
255 const estBorn = makeFigureYear(bridge.estimate.born.year, bridge.estimate.born.bce, true)
256 if (!died || estBorn.signed <= died.signed) {
257 born = estBorn
258 died = died ?? (bridge.estimate.died
259 ? makeFigureYear(bridge.estimate.died.year, bridge.estimate.died.bce, true)
260 : undefined)
261 }
262 }
263 }
264 
265 return {
266 value: {
267 name: title,
268 resolved: true,
269 description: article.description || undefined,
270 extract: article.extract || undefined,
271 born,
272 died,
273 living: !!born && !died,
274 thumbnail: safeUrl(article.thumbnail?.source),
275 wikiUrl: safeUrl(article.content_urls?.desktop?.page)
276 },
277 // Anything short of a definitive answer keeps the short TTL: a transient
278 // Wikidata miss (even when the estimator papered over it — the next lookup
279 // should get a shot at the real dates) and an estimator outage alike.
280 ttlMs: dates.transient || bridgeTransient ? TRANSIENT_TTL_MS : FULL_TTL_MS
281 }

Each claim's precision field now travels into the year parse. Precision 9 is year-level; below that (decade, century, millennium) the time literal still carries a representative year, and it used to display as if exact. Those now parse as circa, and the display string owns the marker: one place decides what "c. 800 BC" looks like.

makeFigureYear owns the "c." marker; parseWikidataYear maps precision < 9 to circa.

server/utils/figure-grounding.ts · 282 lines
server/utils/figure-grounding.ts282 lines · TypeScript
⋯ 62 lines hidden (lines 1–62)
1/**
2 * Real-world grounding for historical figures — the backbone of the "timeline RPG"
3 * direction (see docs/ROADMAP.md). Resolves a free-form name to solid facts so the
4 * game can show who someone was, validate *when* you may contact them, and feed the
5 * role-play real dates.
6 *
7 * Pipeline (the wiki APIs are open + key-less):
8 * 1. Wikipedia REST summary — the reliable resolver. It follows redirects and
9 * picks the notable article, giving a canonical title, a one-line description,
10 * a blurb, a portrait, and the Wikidata item id. (Wikidata search alone is NOT
11 * reliable: "Cleopatra" resolves to "female given name", "Genghis Khan" to an
12 * Iron Maiden song.)
13 * 2. Wikidata, looked up by that item id (title as fallback) — structured birth
14 * (P569) / death (P570), taking the best-ranked claim that actually carries a
15 * date, with sub-year precision surfaced honestly as `circa`.
16 * 3. If the record still has no birth year, a small AI estimator bridges the gap
17 * with circa dates (issue #31) — so a real, resolvable figure keeps their
18 * "when" slider even when Wikidata can't date them.
19 *
20 * Anything unresolved returns `{ resolved: false }`, and callers fall back to the
21 * free-form behavior rather than blocking — we never punish reaching for someone.
22 *
23 * Cache honesty: only definitive answers — a real Wikidata reply (dated or not,
24 * with the estimator genuinely consulted), a genuine no-such-article miss, a
25 * disambiguation page — keep the full TTL. A transient failure anywhere on the
26 * path (a rate-limit 429, a timeout, an estimator outage) gets a short TTL, so
27 * one blip never poisons a figure for a day.
28 */
29import { estimateLifespan } from './lifespan-estimator'
30 
31/** A year on the timeline. `signed` is AD-positive / BCE-negative for comparison. */
32export interface FigureYear {
33 year: number
34 bce: boolean
35 signed: number
36 display: string
37 /** Approximate: sub-year Wikidata precision (decade/century/…) or an AI estimate. */
38 circa?: boolean
40 
41export interface GroundedFigure {
42 name: string
43 resolved: boolean
44 description?: string
45 extract?: string
46 born?: FigureYear
47 died?: FigureYear
48 living?: boolean
49 thumbnail?: string
50 wikiUrl?: string
52 
53const WIKI_HEADERS = {
54 accept: 'application/json',
55 'User-Agent': 'Revisionist/1.0 (timeline RPG; grounding lookups)'
57 
58const FULL_TTL_MS = 24 * 60 * 60 * 1000
59const TRANSIENT_TTL_MS = 10 * 60 * 1000
60const WIKIDATA_RETRY_DELAY_MS = 400
61const cache = new Map<string, { value: GroundedFigure; expires: number }>()
62 
63/** Builds a `FigureYear`; the display string owns the "c." honesty marker. */
64export function makeFigureYear(year: number, bce: boolean, circa = false): FigureYear {
65 const base = bce ? `${year} BC` : `${year}`
66 return {
67 year,
68 bce,
69 signed: bce ? -year : year,
70 display: circa ? `c. ${base}` : base,
71 ...(circa ? { circa: true } : {})
72 }
74 
75/**
76 * Parses a Wikidata time literal (e.g. "+1856-07-10T..", "-0069-01-13T..") to a year.
77 * Precision 9 means year-level; anything below (8 decade, 7 century, 6 millennium)
78 * still carries a representative year in the literal, but only approximately —
79 * those parse as `circa` rather than being dropped or displayed as exact.
80 */
81export function parseWikidataYear(time?: string, precision?: number): FigureYear | undefined {
82 if (!time) return undefined
83 const m = /^([+-])0*(\d+)-/.exec(time)
84 if (!m) return undefined
85 const year = parseInt(m[2], 10)
86 if (!year) return undefined
87 const bce = m[1] === '-'
88 return makeFigureYear(year, bce, precision != null && precision < 9)
⋯ 193 lines hidden (lines 90–282)
90 
91/** The slice of the Wikipedia REST summary we actually read. */
92interface WikiSummary {
93 type?: string
94 title?: string
95 description?: string
96 extract?: string
97 thumbnail?: { source?: string }
98 content_urls?: { desktop?: { page?: string } }
99 wikibase_item?: string
101 
102/** The slice of a Wikidata date claim we actually read. */
103interface WikidataClaim {
104 rank?: string
105 mainsnak?: {
106 snaktype?: string
107 datavalue?: { value?: { time?: string; precision?: number } }
108 }
110 
111/** The slice of the Wikidata `wbgetentities` response we actually read. */
112interface WikidataEntities {
113 entities?: Record<string, { claims?: Record<string, WikidataClaim[]> }>
115 
116/**
117 * The best date a claim list actually carries. Wikidata routinely stacks several
118 * birth/death claims (scholarly guesses, deprecated values, "unknown value"
119 * placeholders) and the community-preferred one is often NOT first — Moses, Jesus
120 * and Confucius all keep theirs at index 2. So: only claims whose snak carries a
121 * real time (somevalue/novalue snaks don't), never deprecated, preferred rank first.
122 */
123export function pickClaimYear(claims?: WikidataClaim[]): FigureYear | undefined {
124 const usable = (claims ?? []).filter(c =>
125 c.rank !== 'deprecated' &&
126 c.mainsnak?.snaktype === 'value' &&
127 c.mainsnak.datavalue?.value?.time
128 )
129 const best = usable.find(c => c.rank === 'preferred') ?? usable[0]
130 const value = best?.mainsnak?.datavalue?.value
131 return parseWikidataYear(value?.time, value?.precision)
133 
134/**
135 * A fetch that tells a definitive miss from a transient one: 404 means the thing
136 * genuinely isn't there (cacheable at full TTL), anything else — 429, 5xx, timeout,
137 * network error — is weather to retry soon, not remember for a day.
138 */
139interface FetchOutcome<T> {
140 data: T | null
141 transient: boolean
143 
144async function fetchJson<T>(url: string): Promise<FetchOutcome<T>> {
145 try {
146 const res = await fetch(url, { headers: WIKI_HEADERS, signal: AbortSignal.timeout(6000) })
147 if (!res.ok) return { data: null, transient: res.status !== 404 }
148 return { data: (await res.json()) as T, transient: false }
149 } catch {
150 return { data: null, transient: true }
151 }
153 
154interface WikidataDates {
155 born?: FigureYear
156 died?: FigureYear
157 /** True when we never got a real answer (so the result must not cache long). */
158 transient: boolean
160 
161/**
162 * Birth/death from Wikidata — by item id when the summary supplied one (immune to
163 * title-matching quirks), by sitelink title otherwise. Rate-limit blips are the
164 * dominant way dates go missing in practice (a 429 body carries no `entities`), so
165 * a no-entities response gets one spaced retry before being declared transient.
166 */
167async function wikidataDates(qid: string | undefined, title: string): Promise<WikidataDates> {
168 const subject = qid
169 ? `ids=${encodeURIComponent(qid)}`
170 : `sites=enwiki&titles=${encodeURIComponent(title)}&normalize=1`
171 const url = `https://www.wikidata.org/w/api.php?action=wbgetentities&${subject}&props=claims&languages=en&format=json&origin=*`
172 let outcome = await fetchJson<WikidataEntities>(url)
173 if (!outcome.data?.entities) {
174 await new Promise(resolve => setTimeout(resolve, WIKIDATA_RETRY_DELAY_MS))
175 outcome = await fetchJson<WikidataEntities>(url)
176 }
177 const entities = outcome.data?.entities
178 if (!entities) return { transient: true }
179 const id = Object.keys(entities)[0]
180 const claims = id ? entities[id]?.claims : undefined
181 return { born: pickClaimYear(claims?.P569), died: pickClaimYear(claims?.P570), transient: false }
183 
184/**
185 * Resolves a name to grounded facts (cached). Never throws; returns
186 * `{ resolved: false }` when the figure can't be confidently identified.
187 */
188export async function groundFigure(rawName: string): Promise<GroundedFigure> {
189 const name = (rawName || '').trim()
190 if (!name) return { name: '', resolved: false }
191 
192 const key = name.toLowerCase()
193 const hit = cache.get(key)
194 if (hit && hit.expires > Date.now()) return hit.value
195 
196 const { value, ttlMs } = await resolve(name)
197 cache.set(key, { value, expires: Date.now() + ttlMs })
198 return value
200 
201/**
202 * Keep a URL only if it's a plain http(s) link — drop `javascript:` / `data:` /
203 * relative values. These come from a remote Wikipedia response and end up bound to
204 * `:href` / `:src` in the figure dossier; Vue escapes the value but NOT the scheme,
205 * so a spoofed `javascript:` URL would execute on click. (Surfaced by the
206 * unsafe-render loop.) Exported for the figure-search route, which binds Wikipedia
207 * thumbnails the same way.
208 */
209export function safeUrl(url?: string): string | undefined {
210 if (!url) return undefined
211 try {
212 const { protocol } = new URL(url)
213 return protocol === 'https:' || protocol === 'http:' ? url : undefined
214 } catch {
215 return undefined
216 }
218 
219interface Resolution {
220 value: GroundedFigure
221 ttlMs: number
223 
224async function resolve(name: string): Promise<Resolution> {
225 const summary = await fetchJson<WikiSummary>(
226 `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(name)}`
227 )
228 
229 // No article, or an ambiguous disambiguation page → can't pin a person.
230 const article = summary.data
231 if (!article || article.type === 'disambiguation' || !article.title) {
232 return {
233 value: { name, resolved: false },
234 ttlMs: summary.transient ? TRANSIENT_TTL_MS : FULL_TTL_MS
235 }
236 }
237 
238 const title: string = article.title
239 
240 const dates = await wikidataDates(article.wikibase_item, title)
241 let born = dates.born
242 let died = dates.died
243 let bridgeTransient = false
244 
245 // The AI bridge (issue #31): the record knows WHO they are but not WHEN — either
246 // Wikidata genuinely has no usable birth claim, or the lookup is down right now.
247 // Estimated years arrive marked circa, so the dossier and prompts stay honest.
248 // A recorded death is law: the estimate only fills what the record lacks, and an
249 // estimate that contradicts the record (born after the documented death) is
250 // dropped whole rather than shipped as an incoherent lifespan.
251 if (!born) {
252 const bridge = await estimateLifespan(title, article.description, article.extract, died?.display)
253 bridgeTransient = bridge.transient
254 if (bridge.estimate) {
255 const estBorn = makeFigureYear(bridge.estimate.born.year, bridge.estimate.born.bce, true)
256 if (!died || estBorn.signed <= died.signed) {
257 born = estBorn
258 died = died ?? (bridge.estimate.died
259 ? makeFigureYear(bridge.estimate.died.year, bridge.estimate.died.bce, true)
260 : undefined)
261 }
262 }
263 }
264 
265 return {
266 value: {
267 name: title,
268 resolved: true,
269 description: article.description || undefined,
270 extract: article.extract || undefined,
271 born,
272 died,
273 living: !!born && !died,
274 thumbnail: safeUrl(article.thumbnail?.source),
275 wikiUrl: safeUrl(article.content_urls?.desktop?.page)
276 },
277 // Anything short of a definitive answer keeps the short TTL: a transient
278 // Wikidata miss (even when the estimator papered over it — the next lookup
279 // should get a shot at the real dates) and an estimator outage alike.
280 ttlMs: dates.transient || bridgeTransient ? TRANSIENT_TTL_MS : FULL_TTL_MS
281 }

The lookup: by id, with one spaced retry

The Wikipedia REST summary already names the Wikidata entity (wikibase_item, present on every probed figure), so the dates lookup now goes by id — sidestepping the whole class of by-title sitelink mismatches. The title arm survives as a fallback, with normalize=1 for good measure.

Rate limiting is the dominant real-world failure: a 429 body carries no entities key. The lookup retries once, 400ms later, before declaring the answer transient.

fetchJson separates a definitive 404 from retry-worthy weather; wikidataDates goes id-first and retries once.

server/utils/figure-grounding.ts · 282 lines
server/utils/figure-grounding.ts282 lines · TypeScript
⋯ 133 lines hidden (lines 1–133)
1/**
2 * Real-world grounding for historical figures — the backbone of the "timeline RPG"
3 * direction (see docs/ROADMAP.md). Resolves a free-form name to solid facts so the
4 * game can show who someone was, validate *when* you may contact them, and feed the
5 * role-play real dates.
6 *
7 * Pipeline (the wiki APIs are open + key-less):
8 * 1. Wikipedia REST summary — the reliable resolver. It follows redirects and
9 * picks the notable article, giving a canonical title, a one-line description,
10 * a blurb, a portrait, and the Wikidata item id. (Wikidata search alone is NOT
11 * reliable: "Cleopatra" resolves to "female given name", "Genghis Khan" to an
12 * Iron Maiden song.)
13 * 2. Wikidata, looked up by that item id (title as fallback) — structured birth
14 * (P569) / death (P570), taking the best-ranked claim that actually carries a
15 * date, with sub-year precision surfaced honestly as `circa`.
16 * 3. If the record still has no birth year, a small AI estimator bridges the gap
17 * with circa dates (issue #31) — so a real, resolvable figure keeps their
18 * "when" slider even when Wikidata can't date them.
19 *
20 * Anything unresolved returns `{ resolved: false }`, and callers fall back to the
21 * free-form behavior rather than blocking — we never punish reaching for someone.
22 *
23 * Cache honesty: only definitive answers — a real Wikidata reply (dated or not,
24 * with the estimator genuinely consulted), a genuine no-such-article miss, a
25 * disambiguation page — keep the full TTL. A transient failure anywhere on the
26 * path (a rate-limit 429, a timeout, an estimator outage) gets a short TTL, so
27 * one blip never poisons a figure for a day.
28 */
29import { estimateLifespan } from './lifespan-estimator'
30 
31/** A year on the timeline. `signed` is AD-positive / BCE-negative for comparison. */
32export interface FigureYear {
33 year: number
34 bce: boolean
35 signed: number
36 display: string
37 /** Approximate: sub-year Wikidata precision (decade/century/…) or an AI estimate. */
38 circa?: boolean
40 
41export interface GroundedFigure {
42 name: string
43 resolved: boolean
44 description?: string
45 extract?: string
46 born?: FigureYear
47 died?: FigureYear
48 living?: boolean
49 thumbnail?: string
50 wikiUrl?: string
52 
53const WIKI_HEADERS = {
54 accept: 'application/json',
55 'User-Agent': 'Revisionist/1.0 (timeline RPG; grounding lookups)'
57 
58const FULL_TTL_MS = 24 * 60 * 60 * 1000
59const TRANSIENT_TTL_MS = 10 * 60 * 1000
60const WIKIDATA_RETRY_DELAY_MS = 400
61const cache = new Map<string, { value: GroundedFigure; expires: number }>()
62 
63/** Builds a `FigureYear`; the display string owns the "c." honesty marker. */
64export function makeFigureYear(year: number, bce: boolean, circa = false): FigureYear {
65 const base = bce ? `${year} BC` : `${year}`
66 return {
67 year,
68 bce,
69 signed: bce ? -year : year,
70 display: circa ? `c. ${base}` : base,
71 ...(circa ? { circa: true } : {})
72 }
74 
75/**
76 * Parses a Wikidata time literal (e.g. "+1856-07-10T..", "-0069-01-13T..") to a year.
77 * Precision 9 means year-level; anything below (8 decade, 7 century, 6 millennium)
78 * still carries a representative year in the literal, but only approximately —
79 * those parse as `circa` rather than being dropped or displayed as exact.
80 */
81export function parseWikidataYear(time?: string, precision?: number): FigureYear | undefined {
82 if (!time) return undefined
83 const m = /^([+-])0*(\d+)-/.exec(time)
84 if (!m) return undefined
85 const year = parseInt(m[2], 10)
86 if (!year) return undefined
87 const bce = m[1] === '-'
88 return makeFigureYear(year, bce, precision != null && precision < 9)
90 
91/** The slice of the Wikipedia REST summary we actually read. */
92interface WikiSummary {
93 type?: string
94 title?: string
95 description?: string
96 extract?: string
97 thumbnail?: { source?: string }
98 content_urls?: { desktop?: { page?: string } }
99 wikibase_item?: string
101 
102/** The slice of a Wikidata date claim we actually read. */
103interface WikidataClaim {
104 rank?: string
105 mainsnak?: {
106 snaktype?: string
107 datavalue?: { value?: { time?: string; precision?: number } }
108 }
110 
111/** The slice of the Wikidata `wbgetentities` response we actually read. */
112interface WikidataEntities {
113 entities?: Record<string, { claims?: Record<string, WikidataClaim[]> }>
115 
116/**
117 * The best date a claim list actually carries. Wikidata routinely stacks several
118 * birth/death claims (scholarly guesses, deprecated values, "unknown value"
119 * placeholders) and the community-preferred one is often NOT first — Moses, Jesus
120 * and Confucius all keep theirs at index 2. So: only claims whose snak carries a
121 * real time (somevalue/novalue snaks don't), never deprecated, preferred rank first.
122 */
123export function pickClaimYear(claims?: WikidataClaim[]): FigureYear | undefined {
124 const usable = (claims ?? []).filter(c =>
125 c.rank !== 'deprecated' &&
126 c.mainsnak?.snaktype === 'value' &&
127 c.mainsnak.datavalue?.value?.time
128 )
129 const best = usable.find(c => c.rank === 'preferred') ?? usable[0]
130 const value = best?.mainsnak?.datavalue?.value
131 return parseWikidataYear(value?.time, value?.precision)
133 
134/**
135 * A fetch that tells a definitive miss from a transient one: 404 means the thing
136 * genuinely isn't there (cacheable at full TTL), anything else — 429, 5xx, timeout,
137 * network error — is weather to retry soon, not remember for a day.
138 */
139interface FetchOutcome<T> {
140 data: T | null
141 transient: boolean
143 
144async function fetchJson<T>(url: string): Promise<FetchOutcome<T>> {
145 try {
146 const res = await fetch(url, { headers: WIKI_HEADERS, signal: AbortSignal.timeout(6000) })
147 if (!res.ok) return { data: null, transient: res.status !== 404 }
148 return { data: (await res.json()) as T, transient: false }
149 } catch {
150 return { data: null, transient: true }
151 }
⋯ 8 lines hidden (lines 153–160)
153 
154interface WikidataDates {
155 born?: FigureYear
156 died?: FigureYear
157 /** True when we never got a real answer (so the result must not cache long). */
158 transient: boolean
160 
161/**
162 * Birth/death from Wikidata — by item id when the summary supplied one (immune to
163 * title-matching quirks), by sitelink title otherwise. Rate-limit blips are the
164 * dominant way dates go missing in practice (a 429 body carries no `entities`), so
165 * a no-entities response gets one spaced retry before being declared transient.
166 */
167async function wikidataDates(qid: string | undefined, title: string): Promise<WikidataDates> {
168 const subject = qid
169 ? `ids=${encodeURIComponent(qid)}`
170 : `sites=enwiki&titles=${encodeURIComponent(title)}&normalize=1`
171 const url = `https://www.wikidata.org/w/api.php?action=wbgetentities&${subject}&props=claims&languages=en&format=json&origin=*`
172 let outcome = await fetchJson<WikidataEntities>(url)
173 if (!outcome.data?.entities) {
174 await new Promise(resolve => setTimeout(resolve, WIKIDATA_RETRY_DELAY_MS))
175 outcome = await fetchJson<WikidataEntities>(url)
176 }
177 const entities = outcome.data?.entities
178 if (!entities) return { transient: true }
179 const id = Object.keys(entities)[0]
180 const claims = id ? entities[id]?.claims : undefined
181 return { born: pickClaimYear(claims?.P569), died: pickClaimYear(claims?.P570), transient: false }
⋯ 100 lines hidden (lines 183–282)
183 
184/**
185 * Resolves a name to grounded facts (cached). Never throws; returns
186 * `{ resolved: false }` when the figure can't be confidently identified.
187 */
188export async function groundFigure(rawName: string): Promise<GroundedFigure> {
189 const name = (rawName || '').trim()
190 if (!name) return { name: '', resolved: false }
191 
192 const key = name.toLowerCase()
193 const hit = cache.get(key)
194 if (hit && hit.expires > Date.now()) return hit.value
195 
196 const { value, ttlMs } = await resolve(name)
197 cache.set(key, { value, expires: Date.now() + ttlMs })
198 return value
200 
201/**
202 * Keep a URL only if it's a plain http(s) link — drop `javascript:` / `data:` /
203 * relative values. These come from a remote Wikipedia response and end up bound to
204 * `:href` / `:src` in the figure dossier; Vue escapes the value but NOT the scheme,
205 * so a spoofed `javascript:` URL would execute on click. (Surfaced by the
206 * unsafe-render loop.) Exported for the figure-search route, which binds Wikipedia
207 * thumbnails the same way.
208 */
209export function safeUrl(url?: string): string | undefined {
210 if (!url) return undefined
211 try {
212 const { protocol } = new URL(url)
213 return protocol === 'https:' || protocol === 'http:' ? url : undefined
214 } catch {
215 return undefined
216 }
218 
219interface Resolution {
220 value: GroundedFigure
221 ttlMs: number
223 
224async function resolve(name: string): Promise<Resolution> {
225 const summary = await fetchJson<WikiSummary>(
226 `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(name)}`
227 )
228 
229 // No article, or an ambiguous disambiguation page → can't pin a person.
230 const article = summary.data
231 if (!article || article.type === 'disambiguation' || !article.title) {
232 return {
233 value: { name, resolved: false },
234 ttlMs: summary.transient ? TRANSIENT_TTL_MS : FULL_TTL_MS
235 }
236 }
237 
238 const title: string = article.title
239 
240 const dates = await wikidataDates(article.wikibase_item, title)
241 let born = dates.born
242 let died = dates.died
243 let bridgeTransient = false
244 
245 // The AI bridge (issue #31): the record knows WHO they are but not WHEN — either
246 // Wikidata genuinely has no usable birth claim, or the lookup is down right now.
247 // Estimated years arrive marked circa, so the dossier and prompts stay honest.
248 // A recorded death is law: the estimate only fills what the record lacks, and an
249 // estimate that contradicts the record (born after the documented death) is
250 // dropped whole rather than shipped as an incoherent lifespan.
251 if (!born) {
252 const bridge = await estimateLifespan(title, article.description, article.extract, died?.display)
253 bridgeTransient = bridge.transient
254 if (bridge.estimate) {
255 const estBorn = makeFigureYear(bridge.estimate.born.year, bridge.estimate.born.bce, true)
256 if (!died || estBorn.signed <= died.signed) {
257 born = estBorn
258 died = died ?? (bridge.estimate.died
259 ? makeFigureYear(bridge.estimate.died.year, bridge.estimate.died.bce, true)
260 : undefined)
261 }
262 }
263 }
264 
265 return {
266 value: {
267 name: title,
268 resolved: true,
269 description: article.description || undefined,
270 extract: article.extract || undefined,
271 born,
272 died,
273 living: !!born && !died,
274 thumbnail: safeUrl(article.thumbnail?.source),
275 wikiUrl: safeUrl(article.content_urls?.desktop?.page)
276 },
277 // Anything short of a definitive answer keeps the short TTL: a transient
278 // Wikidata miss (even when the estimator papered over it — the next lookup
279 // should get a shot at the real dates) and an estimator outage alike.
280 ttlMs: dates.transient || bridgeTransient ? TRANSIENT_TTL_MS : FULL_TTL_MS
281 }

Cache honesty: definitive answers keep the day, blips get minutes

The old cache stored every outcome for 24 hours — including "Wikidata was down for six seconds", which is how one blip branded a figure dateless for a day and produced issue #31's maddening sometimes it works. Resolution now returns a TTL with the value: full for definitive answers (a dated figure, a genuine 404, a disambiguation page, a confident "this can't be dated"), ten minutes for anything transient on the path — a wiki 429 or timeout, or an estimator outage.

The two TTLs, the cache write, and the decision: any transient leg keeps the short TTL — even when the bridge papered over it, so the next lookup gets a shot at the real dates.

server/utils/figure-grounding.ts · 282 lines
server/utils/figure-grounding.ts282 lines · TypeScript
⋯ 57 lines hidden (lines 1–57)
1/**
2 * Real-world grounding for historical figures — the backbone of the "timeline RPG"
3 * direction (see docs/ROADMAP.md). Resolves a free-form name to solid facts so the
4 * game can show who someone was, validate *when* you may contact them, and feed the
5 * role-play real dates.
6 *
7 * Pipeline (the wiki APIs are open + key-less):
8 * 1. Wikipedia REST summary — the reliable resolver. It follows redirects and
9 * picks the notable article, giving a canonical title, a one-line description,
10 * a blurb, a portrait, and the Wikidata item id. (Wikidata search alone is NOT
11 * reliable: "Cleopatra" resolves to "female given name", "Genghis Khan" to an
12 * Iron Maiden song.)
13 * 2. Wikidata, looked up by that item id (title as fallback) — structured birth
14 * (P569) / death (P570), taking the best-ranked claim that actually carries a
15 * date, with sub-year precision surfaced honestly as `circa`.
16 * 3. If the record still has no birth year, a small AI estimator bridges the gap
17 * with circa dates (issue #31) — so a real, resolvable figure keeps their
18 * "when" slider even when Wikidata can't date them.
19 *
20 * Anything unresolved returns `{ resolved: false }`, and callers fall back to the
21 * free-form behavior rather than blocking — we never punish reaching for someone.
22 *
23 * Cache honesty: only definitive answers — a real Wikidata reply (dated or not,
24 * with the estimator genuinely consulted), a genuine no-such-article miss, a
25 * disambiguation page — keep the full TTL. A transient failure anywhere on the
26 * path (a rate-limit 429, a timeout, an estimator outage) gets a short TTL, so
27 * one blip never poisons a figure for a day.
28 */
29import { estimateLifespan } from './lifespan-estimator'
30 
31/** A year on the timeline. `signed` is AD-positive / BCE-negative for comparison. */
32export interface FigureYear {
33 year: number
34 bce: boolean
35 signed: number
36 display: string
37 /** Approximate: sub-year Wikidata precision (decade/century/…) or an AI estimate. */
38 circa?: boolean
40 
41export interface GroundedFigure {
42 name: string
43 resolved: boolean
44 description?: string
45 extract?: string
46 born?: FigureYear
47 died?: FigureYear
48 living?: boolean
49 thumbnail?: string
50 wikiUrl?: string
52 
53const WIKI_HEADERS = {
54 accept: 'application/json',
55 'User-Agent': 'Revisionist/1.0 (timeline RPG; grounding lookups)'
57 
58const FULL_TTL_MS = 24 * 60 * 60 * 1000
59const TRANSIENT_TTL_MS = 10 * 60 * 1000
60const WIKIDATA_RETRY_DELAY_MS = 400
61const cache = new Map<string, { value: GroundedFigure; expires: number }>()
⋯ 126 lines hidden (lines 62–187)
62 
63/** Builds a `FigureYear`; the display string owns the "c." honesty marker. */
64export function makeFigureYear(year: number, bce: boolean, circa = false): FigureYear {
65 const base = bce ? `${year} BC` : `${year}`
66 return {
67 year,
68 bce,
69 signed: bce ? -year : year,
70 display: circa ? `c. ${base}` : base,
71 ...(circa ? { circa: true } : {})
72 }
74 
75/**
76 * Parses a Wikidata time literal (e.g. "+1856-07-10T..", "-0069-01-13T..") to a year.
77 * Precision 9 means year-level; anything below (8 decade, 7 century, 6 millennium)
78 * still carries a representative year in the literal, but only approximately —
79 * those parse as `circa` rather than being dropped or displayed as exact.
80 */
81export function parseWikidataYear(time?: string, precision?: number): FigureYear | undefined {
82 if (!time) return undefined
83 const m = /^([+-])0*(\d+)-/.exec(time)
84 if (!m) return undefined
85 const year = parseInt(m[2], 10)
86 if (!year) return undefined
87 const bce = m[1] === '-'
88 return makeFigureYear(year, bce, precision != null && precision < 9)
90 
91/** The slice of the Wikipedia REST summary we actually read. */
92interface WikiSummary {
93 type?: string
94 title?: string
95 description?: string
96 extract?: string
97 thumbnail?: { source?: string }
98 content_urls?: { desktop?: { page?: string } }
99 wikibase_item?: string
101 
102/** The slice of a Wikidata date claim we actually read. */
103interface WikidataClaim {
104 rank?: string
105 mainsnak?: {
106 snaktype?: string
107 datavalue?: { value?: { time?: string; precision?: number } }
108 }
110 
111/** The slice of the Wikidata `wbgetentities` response we actually read. */
112interface WikidataEntities {
113 entities?: Record<string, { claims?: Record<string, WikidataClaim[]> }>
115 
116/**
117 * The best date a claim list actually carries. Wikidata routinely stacks several
118 * birth/death claims (scholarly guesses, deprecated values, "unknown value"
119 * placeholders) and the community-preferred one is often NOT first — Moses, Jesus
120 * and Confucius all keep theirs at index 2. So: only claims whose snak carries a
121 * real time (somevalue/novalue snaks don't), never deprecated, preferred rank first.
122 */
123export function pickClaimYear(claims?: WikidataClaim[]): FigureYear | undefined {
124 const usable = (claims ?? []).filter(c =>
125 c.rank !== 'deprecated' &&
126 c.mainsnak?.snaktype === 'value' &&
127 c.mainsnak.datavalue?.value?.time
128 )
129 const best = usable.find(c => c.rank === 'preferred') ?? usable[0]
130 const value = best?.mainsnak?.datavalue?.value
131 return parseWikidataYear(value?.time, value?.precision)
133 
134/**
135 * A fetch that tells a definitive miss from a transient one: 404 means the thing
136 * genuinely isn't there (cacheable at full TTL), anything else — 429, 5xx, timeout,
137 * network error — is weather to retry soon, not remember for a day.
138 */
139interface FetchOutcome<T> {
140 data: T | null
141 transient: boolean
143 
144async function fetchJson<T>(url: string): Promise<FetchOutcome<T>> {
145 try {
146 const res = await fetch(url, { headers: WIKI_HEADERS, signal: AbortSignal.timeout(6000) })
147 if (!res.ok) return { data: null, transient: res.status !== 404 }
148 return { data: (await res.json()) as T, transient: false }
149 } catch {
150 return { data: null, transient: true }
151 }
153 
154interface WikidataDates {
155 born?: FigureYear
156 died?: FigureYear
157 /** True when we never got a real answer (so the result must not cache long). */
158 transient: boolean
160 
161/**
162 * Birth/death from Wikidata — by item id when the summary supplied one (immune to
163 * title-matching quirks), by sitelink title otherwise. Rate-limit blips are the
164 * dominant way dates go missing in practice (a 429 body carries no `entities`), so
165 * a no-entities response gets one spaced retry before being declared transient.
166 */
167async function wikidataDates(qid: string | undefined, title: string): Promise<WikidataDates> {
168 const subject = qid
169 ? `ids=${encodeURIComponent(qid)}`
170 : `sites=enwiki&titles=${encodeURIComponent(title)}&normalize=1`
171 const url = `https://www.wikidata.org/w/api.php?action=wbgetentities&${subject}&props=claims&languages=en&format=json&origin=*`
172 let outcome = await fetchJson<WikidataEntities>(url)
173 if (!outcome.data?.entities) {
174 await new Promise(resolve => setTimeout(resolve, WIKIDATA_RETRY_DELAY_MS))
175 outcome = await fetchJson<WikidataEntities>(url)
176 }
177 const entities = outcome.data?.entities
178 if (!entities) return { transient: true }
179 const id = Object.keys(entities)[0]
180 const claims = id ? entities[id]?.claims : undefined
181 return { born: pickClaimYear(claims?.P569), died: pickClaimYear(claims?.P570), transient: false }
183 
184/**
185 * Resolves a name to grounded facts (cached). Never throws; returns
186 * `{ resolved: false }` when the figure can't be confidently identified.
187 */
188export async function groundFigure(rawName: string): Promise<GroundedFigure> {
189 const name = (rawName || '').trim()
190 if (!name) return { name: '', resolved: false }
191 
192 const key = name.toLowerCase()
193 const hit = cache.get(key)
194 if (hit && hit.expires > Date.now()) return hit.value
195 
196 const { value, ttlMs } = await resolve(name)
197 cache.set(key, { value, expires: Date.now() + ttlMs })
198 return value
⋯ 66 lines hidden (lines 199–264)
200 
201/**
202 * Keep a URL only if it's a plain http(s) link — drop `javascript:` / `data:` /
203 * relative values. These come from a remote Wikipedia response and end up bound to
204 * `:href` / `:src` in the figure dossier; Vue escapes the value but NOT the scheme,
205 * so a spoofed `javascript:` URL would execute on click. (Surfaced by the
206 * unsafe-render loop.) Exported for the figure-search route, which binds Wikipedia
207 * thumbnails the same way.
208 */
209export function safeUrl(url?: string): string | undefined {
210 if (!url) return undefined
211 try {
212 const { protocol } = new URL(url)
213 return protocol === 'https:' || protocol === 'http:' ? url : undefined
214 } catch {
215 return undefined
216 }
218 
219interface Resolution {
220 value: GroundedFigure
221 ttlMs: number
223 
224async function resolve(name: string): Promise<Resolution> {
225 const summary = await fetchJson<WikiSummary>(
226 `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(name)}`
227 )
228 
229 // No article, or an ambiguous disambiguation page → can't pin a person.
230 const article = summary.data
231 if (!article || article.type === 'disambiguation' || !article.title) {
232 return {
233 value: { name, resolved: false },
234 ttlMs: summary.transient ? TRANSIENT_TTL_MS : FULL_TTL_MS
235 }
236 }
237 
238 const title: string = article.title
239 
240 const dates = await wikidataDates(article.wikibase_item, title)
241 let born = dates.born
242 let died = dates.died
243 let bridgeTransient = false
244 
245 // The AI bridge (issue #31): the record knows WHO they are but not WHEN — either
246 // Wikidata genuinely has no usable birth claim, or the lookup is down right now.
247 // Estimated years arrive marked circa, so the dossier and prompts stay honest.
248 // A recorded death is law: the estimate only fills what the record lacks, and an
249 // estimate that contradicts the record (born after the documented death) is
250 // dropped whole rather than shipped as an incoherent lifespan.
251 if (!born) {
252 const bridge = await estimateLifespan(title, article.description, article.extract, died?.display)
253 bridgeTransient = bridge.transient
254 if (bridge.estimate) {
255 const estBorn = makeFigureYear(bridge.estimate.born.year, bridge.estimate.born.bce, true)
256 if (!died || estBorn.signed <= died.signed) {
257 born = estBorn
258 died = died ?? (bridge.estimate.died
259 ? makeFigureYear(bridge.estimate.died.year, bridge.estimate.died.bce, true)
260 : undefined)
261 }
262 }
263 }
264 
265 return {
266 value: {
267 name: title,
268 resolved: true,
269 description: article.description || undefined,
270 extract: article.extract || undefined,
271 born,
272 died,
273 living: !!born && !died,
274 thumbnail: safeUrl(article.thumbnail?.source),
275 wikiUrl: safeUrl(article.content_urls?.desktop?.page)
276 },
277 // Anything short of a definitive answer keeps the short TTL: a transient
278 // Wikidata miss (even when the estimator papered over it — the next lookup
279 // should get a shot at the real dates) and an estimator outage alike.
280 ttlMs: dates.transient || bridgeTransient ? TRANSIENT_TTL_MS : FULL_TTL_MS
281 }
⋯ 1 line hidden (lines 282–282)

The AI bridge: circa dates for figures the record can't pin

When a resolved figure still has no birth year, a small structured gpt-5.5 call estimates the lifespan — the issue's own suggestion. Three things keep it honest. First, the prompt is anchored to the figure's own Wikipedia description and extract, so it dates the right person, not a namesake. Second, the wire answer passes mapLifespanWire, a pure validator that refuses unconfident answers, death-before-birth, future dates, and the "2800-year-old contemporary" shape (a still-living sentinel is only coherent for a birth within 120 years). Third, every estimated year is consumed as circa — the dossier and the character prompt say "c. 800 BC", never a fake-precise date.

The outcome type keeps outages distinguishable from refusals; the validator is exported pure and tested guard by guard.

server/utils/lifespan-estimator.ts · 152 lines
server/utils/lifespan-estimator.ts152 lines · TypeScript
⋯ 27 lines hidden (lines 1–27)
1/**
2 * AI lifespan bridge (issue #31). Some figures resolve to a real Wikipedia article
3 * but Wikidata can't date them — no usable birth claim, or the lookup is down —
4 * and the picker would silently lose its "when" slider. This small structured
5 * gpt-5.5 call estimates the lifespan instead, anchored to the article's own
6 * description so it dates the RIGHT person, not a namesake. Every estimated year
7 * is consumed as `circa`, so the dossier and prompts say "c. 800 BC", never a
8 * fake-precise date.
9 *
10 * Refusal-friendly by design: the model may answer "not confident" (a group, a
11 * place, a myth with no plausible era), and callers then degrade exactly as
12 * before — no slider, no year plumbing, free-form contact. Never throws.
13 */
14import { getOpenAIClient, MODEL } from './openai'
15 
16/** An estimated year, era split out — the grounding layer builds the FigureYear. */
17export interface EstimatedYear {
18 year: number
19 bce: boolean
21 
22export interface EstimatedLifespan {
23 born: EstimatedYear
24 /** Absent only for someone plausibly still alive. */
25 died?: EstimatedYear
27 
28/**
29 * The estimator's answer, with the failure mode kept distinguishable: a transient
30 * outcome means the model couldn't be consulted (outage, timeout, empty reply) and
31 * the question deserves a retry soon; a definitive null estimate means the model
32 * answered and the figure genuinely can't be dated. Callers cache accordingly.
33 */
34export interface LifespanEstimateOutcome {
35 estimate: EstimatedLifespan | null
36 transient: boolean
38 
39/**
40 * What the model returns under the strict schema (every field always present):
41 * era strings are a closed enum, and `diedYear: 0` is the "still living" sentinel.
42 */
43interface LifespanWire {
44 confident: boolean
45 bornYear: number
46 bornEra: 'BC' | 'AD'
47 diedYear: number
48 diedEra: 'BC' | 'AD'
50 
51const SYSTEM = `You estimate when a person was born and died, for a timeline game. You are given a name and the short description from their Wikipedia article — date THAT person, not anyone else who shares the name.
52 
53Rules:
54- Estimate even when the record is rough: era- or century-level accuracy is fine (e.g. Homer → born around 800 BC). Legendary figures with a traditional era (King Arthur, Laozi) still get dated.
55- Anyone born more than about 110 years ago is dead — estimate a death year for them, even roughly.
56- Set diedYear to 0 only for a person plausibly still alive today.
57- If a documented death year is given, it is fact: your birth estimate must precede it.
58- Set confident to false when the subject is not a datable person at all (a group, a place, a concept, a myth with no plausible era) or the description doesn't tell you who this is.`
⋯ 11 lines hidden (lines 59–69)
59 
60/** Sanity caps shared with the API boundary (whenSigned clamps to ±12000 there). */
61const MAX_YEAR = 12000
62/** A birth older than this with no death year is incoherent, not "still living". */
63const MAX_LIVING_AGE_YEARS = 120
64 
65/**
66 * Turns the wire shape into a validated estimate, or null when the answer is
67 * unconfident or incoherent (death before birth, future dates, ancient "living").
68 * Exported pure so the guards are directly testable.
69 */
70export function mapLifespanWire(wire: unknown): EstimatedLifespan | null {
71 const w = wire as Partial<LifespanWire> | null
72 if (!w || w.confident !== true) return null
73 
74 if (typeof w.bornYear !== 'number' || !Number.isInteger(w.bornYear)) return null
75 if (w.bornYear <= 0 || w.bornYear > MAX_YEAR) return null
76 if (w.bornEra !== 'BC' && w.bornEra !== 'AD') return null
77 const bornBce = w.bornEra === 'BC'
78 const bornSigned = bornBce ? -w.bornYear : w.bornYear
79 
80 const nowYear = new Date().getFullYear()
81 if (bornSigned > nowYear) return null
82 
83 if (typeof w.diedYear !== 'number' || !Number.isInteger(w.diedYear) || w.diedYear < 0) return null
84 if (w.diedYear === 0) {
85 // "Still living" only coheres for a recent birth.
86 if (bornSigned < nowYear - MAX_LIVING_AGE_YEARS) return null
87 return { born: { year: w.bornYear, bce: bornBce } }
88 }
89 
90 if (w.diedYear > MAX_YEAR) return null
91 if (w.diedEra !== 'BC' && w.diedEra !== 'AD') return null
92 const diedBce = w.diedEra === 'BC'
93 const diedSigned = diedBce ? -w.diedYear : w.diedYear
94 if (diedSigned < bornSigned || diedSigned > nowYear) return null
95 
96 return { born: { year: w.bornYear, bce: bornBce }, died: { year: w.diedYear, bce: diedBce } }
⋯ 55 lines hidden (lines 98–152)
98 
99/**
100 * Estimates a resolved figure's lifespan from their name + Wikipedia description.
101 * A documented death year (when the record has one without a birth) is passed as
102 * an anchor the estimate must respect. A null estimate means "no dates" to the
103 * caller either way; `transient` tells it whether to ask again soon.
104 */
105export async function estimateLifespan(
106 name: string,
107 description?: string,
108 extract?: string,
109 documentedDeath?: string
110): Promise<LifespanEstimateOutcome> {
111 try {
112 const client = getOpenAIClient()
113 const completion = await client.chat.completions.create({
114 model: MODEL,
115 messages: [
116 { role: 'system', content: SYSTEM },
117 {
118 role: 'user',
119 content: `Name: ${name}\nDescription: ${description || '—'}${extract ? `\nAbout them: ${extract.slice(0, 600)}` : ''}${documentedDeath ? `\nDocumented death: ${documentedDeath}` : ''}`
120 }
121 ],
122 reasoning_effort: 'low',
123 max_completion_tokens: 1200,
124 response_format: {
125 type: 'json_schema',
126 json_schema: {
127 name: 'lifespan_estimate',
128 strict: true,
129 schema: {
130 type: 'object',
131 properties: {
132 confident: { type: 'boolean', description: 'False when the subject is not a datable person or cannot be identified' },
133 bornYear: { type: 'integer', description: 'Estimated birth year as a positive number, e.g. 800' },
134 bornEra: { type: 'string', enum: ['BC', 'AD'], description: 'Era of the birth year' },
135 diedYear: { type: 'integer', description: 'Estimated death year as a positive number; 0 if still living' },
136 diedEra: { type: 'string', enum: ['BC', 'AD'], description: 'Era of the death year (ignored when diedYear is 0)' }
137 },
138 required: ['confident', 'bornYear', 'bornEra', 'diedYear', 'diedEra'],
139 additionalProperties: false
140 }
141 }
142 }
143 })
144 
145 const content = completion.choices?.[0]?.message?.content?.trim()
146 if (!content) return { estimate: null, transient: true }
147 return { estimate: mapLifespanWire(JSON.parse(content)), transient: false }
148 } catch (error) {
149 console.error('Lifespan estimator error:', error)
150 return { estimate: null, transient: true }
151 }

Back in the grounding layer, the merge treats the record as law. Wikidata commonly knows a death without a birth (medieval figures, privacy-redacted births). The estimate fills only the missing side: a documented death survives verbatim, anchors the estimator's prompt, and an estimate whose birth lands after it is dropped whole rather than shipped as an incoherent lifespan.

The bridge fires only without a birth year; the recorded death outranks the estimate.

server/utils/figure-grounding.ts · 282 lines
server/utils/figure-grounding.ts282 lines · TypeScript
⋯ 239 lines hidden (lines 1–239)
1/**
2 * Real-world grounding for historical figures — the backbone of the "timeline RPG"
3 * direction (see docs/ROADMAP.md). Resolves a free-form name to solid facts so the
4 * game can show who someone was, validate *when* you may contact them, and feed the
5 * role-play real dates.
6 *
7 * Pipeline (the wiki APIs are open + key-less):
8 * 1. Wikipedia REST summary — the reliable resolver. It follows redirects and
9 * picks the notable article, giving a canonical title, a one-line description,
10 * a blurb, a portrait, and the Wikidata item id. (Wikidata search alone is NOT
11 * reliable: "Cleopatra" resolves to "female given name", "Genghis Khan" to an
12 * Iron Maiden song.)
13 * 2. Wikidata, looked up by that item id (title as fallback) — structured birth
14 * (P569) / death (P570), taking the best-ranked claim that actually carries a
15 * date, with sub-year precision surfaced honestly as `circa`.
16 * 3. If the record still has no birth year, a small AI estimator bridges the gap
17 * with circa dates (issue #31) — so a real, resolvable figure keeps their
18 * "when" slider even when Wikidata can't date them.
19 *
20 * Anything unresolved returns `{ resolved: false }`, and callers fall back to the
21 * free-form behavior rather than blocking — we never punish reaching for someone.
22 *
23 * Cache honesty: only definitive answers — a real Wikidata reply (dated or not,
24 * with the estimator genuinely consulted), a genuine no-such-article miss, a
25 * disambiguation page — keep the full TTL. A transient failure anywhere on the
26 * path (a rate-limit 429, a timeout, an estimator outage) gets a short TTL, so
27 * one blip never poisons a figure for a day.
28 */
29import { estimateLifespan } from './lifespan-estimator'
30 
31/** A year on the timeline. `signed` is AD-positive / BCE-negative for comparison. */
32export interface FigureYear {
33 year: number
34 bce: boolean
35 signed: number
36 display: string
37 /** Approximate: sub-year Wikidata precision (decade/century/…) or an AI estimate. */
38 circa?: boolean
40 
41export interface GroundedFigure {
42 name: string
43 resolved: boolean
44 description?: string
45 extract?: string
46 born?: FigureYear
47 died?: FigureYear
48 living?: boolean
49 thumbnail?: string
50 wikiUrl?: string
52 
53const WIKI_HEADERS = {
54 accept: 'application/json',
55 'User-Agent': 'Revisionist/1.0 (timeline RPG; grounding lookups)'
57 
58const FULL_TTL_MS = 24 * 60 * 60 * 1000
59const TRANSIENT_TTL_MS = 10 * 60 * 1000
60const WIKIDATA_RETRY_DELAY_MS = 400
61const cache = new Map<string, { value: GroundedFigure; expires: number }>()
62 
63/** Builds a `FigureYear`; the display string owns the "c." honesty marker. */
64export function makeFigureYear(year: number, bce: boolean, circa = false): FigureYear {
65 const base = bce ? `${year} BC` : `${year}`
66 return {
67 year,
68 bce,
69 signed: bce ? -year : year,
70 display: circa ? `c. ${base}` : base,
71 ...(circa ? { circa: true } : {})
72 }
74 
75/**
76 * Parses a Wikidata time literal (e.g. "+1856-07-10T..", "-0069-01-13T..") to a year.
77 * Precision 9 means year-level; anything below (8 decade, 7 century, 6 millennium)
78 * still carries a representative year in the literal, but only approximately —
79 * those parse as `circa` rather than being dropped or displayed as exact.
80 */
81export function parseWikidataYear(time?: string, precision?: number): FigureYear | undefined {
82 if (!time) return undefined
83 const m = /^([+-])0*(\d+)-/.exec(time)
84 if (!m) return undefined
85 const year = parseInt(m[2], 10)
86 if (!year) return undefined
87 const bce = m[1] === '-'
88 return makeFigureYear(year, bce, precision != null && precision < 9)
90 
91/** The slice of the Wikipedia REST summary we actually read. */
92interface WikiSummary {
93 type?: string
94 title?: string
95 description?: string
96 extract?: string
97 thumbnail?: { source?: string }
98 content_urls?: { desktop?: { page?: string } }
99 wikibase_item?: string
101 
102/** The slice of a Wikidata date claim we actually read. */
103interface WikidataClaim {
104 rank?: string
105 mainsnak?: {
106 snaktype?: string
107 datavalue?: { value?: { time?: string; precision?: number } }
108 }
110 
111/** The slice of the Wikidata `wbgetentities` response we actually read. */
112interface WikidataEntities {
113 entities?: Record<string, { claims?: Record<string, WikidataClaim[]> }>
115 
116/**
117 * The best date a claim list actually carries. Wikidata routinely stacks several
118 * birth/death claims (scholarly guesses, deprecated values, "unknown value"
119 * placeholders) and the community-preferred one is often NOT first — Moses, Jesus
120 * and Confucius all keep theirs at index 2. So: only claims whose snak carries a
121 * real time (somevalue/novalue snaks don't), never deprecated, preferred rank first.
122 */
123export function pickClaimYear(claims?: WikidataClaim[]): FigureYear | undefined {
124 const usable = (claims ?? []).filter(c =>
125 c.rank !== 'deprecated' &&
126 c.mainsnak?.snaktype === 'value' &&
127 c.mainsnak.datavalue?.value?.time
128 )
129 const best = usable.find(c => c.rank === 'preferred') ?? usable[0]
130 const value = best?.mainsnak?.datavalue?.value
131 return parseWikidataYear(value?.time, value?.precision)
133 
134/**
135 * A fetch that tells a definitive miss from a transient one: 404 means the thing
136 * genuinely isn't there (cacheable at full TTL), anything else — 429, 5xx, timeout,
137 * network error — is weather to retry soon, not remember for a day.
138 */
139interface FetchOutcome<T> {
140 data: T | null
141 transient: boolean
143 
144async function fetchJson<T>(url: string): Promise<FetchOutcome<T>> {
145 try {
146 const res = await fetch(url, { headers: WIKI_HEADERS, signal: AbortSignal.timeout(6000) })
147 if (!res.ok) return { data: null, transient: res.status !== 404 }
148 return { data: (await res.json()) as T, transient: false }
149 } catch {
150 return { data: null, transient: true }
151 }
153 
154interface WikidataDates {
155 born?: FigureYear
156 died?: FigureYear
157 /** True when we never got a real answer (so the result must not cache long). */
158 transient: boolean
160 
161/**
162 * Birth/death from Wikidata — by item id when the summary supplied one (immune to
163 * title-matching quirks), by sitelink title otherwise. Rate-limit blips are the
164 * dominant way dates go missing in practice (a 429 body carries no `entities`), so
165 * a no-entities response gets one spaced retry before being declared transient.
166 */
167async function wikidataDates(qid: string | undefined, title: string): Promise<WikidataDates> {
168 const subject = qid
169 ? `ids=${encodeURIComponent(qid)}`
170 : `sites=enwiki&titles=${encodeURIComponent(title)}&normalize=1`
171 const url = `https://www.wikidata.org/w/api.php?action=wbgetentities&${subject}&props=claims&languages=en&format=json&origin=*`
172 let outcome = await fetchJson<WikidataEntities>(url)
173 if (!outcome.data?.entities) {
174 await new Promise(resolve => setTimeout(resolve, WIKIDATA_RETRY_DELAY_MS))
175 outcome = await fetchJson<WikidataEntities>(url)
176 }
177 const entities = outcome.data?.entities
178 if (!entities) return { transient: true }
179 const id = Object.keys(entities)[0]
180 const claims = id ? entities[id]?.claims : undefined
181 return { born: pickClaimYear(claims?.P569), died: pickClaimYear(claims?.P570), transient: false }
183 
184/**
185 * Resolves a name to grounded facts (cached). Never throws; returns
186 * `{ resolved: false }` when the figure can't be confidently identified.
187 */
188export async function groundFigure(rawName: string): Promise<GroundedFigure> {
189 const name = (rawName || '').trim()
190 if (!name) return { name: '', resolved: false }
191 
192 const key = name.toLowerCase()
193 const hit = cache.get(key)
194 if (hit && hit.expires > Date.now()) return hit.value
195 
196 const { value, ttlMs } = await resolve(name)
197 cache.set(key, { value, expires: Date.now() + ttlMs })
198 return value
200 
201/**
202 * Keep a URL only if it's a plain http(s) link — drop `javascript:` / `data:` /
203 * relative values. These come from a remote Wikipedia response and end up bound to
204 * `:href` / `:src` in the figure dossier; Vue escapes the value but NOT the scheme,
205 * so a spoofed `javascript:` URL would execute on click. (Surfaced by the
206 * unsafe-render loop.) Exported for the figure-search route, which binds Wikipedia
207 * thumbnails the same way.
208 */
209export function safeUrl(url?: string): string | undefined {
210 if (!url) return undefined
211 try {
212 const { protocol } = new URL(url)
213 return protocol === 'https:' || protocol === 'http:' ? url : undefined
214 } catch {
215 return undefined
216 }
218 
219interface Resolution {
220 value: GroundedFigure
221 ttlMs: number
223 
224async function resolve(name: string): Promise<Resolution> {
225 const summary = await fetchJson<WikiSummary>(
226 `https://en.wikipedia.org/api/rest_v1/page/summary/${encodeURIComponent(name)}`
227 )
228 
229 // No article, or an ambiguous disambiguation page → can't pin a person.
230 const article = summary.data
231 if (!article || article.type === 'disambiguation' || !article.title) {
232 return {
233 value: { name, resolved: false },
234 ttlMs: summary.transient ? TRANSIENT_TTL_MS : FULL_TTL_MS
235 }
236 }
237 
238 const title: string = article.title
239 
240 const dates = await wikidataDates(article.wikibase_item, title)
241 let born = dates.born
242 let died = dates.died
243 let bridgeTransient = false
244 
245 // The AI bridge (issue #31): the record knows WHO they are but not WHEN — either
246 // Wikidata genuinely has no usable birth claim, or the lookup is down right now.
247 // Estimated years arrive marked circa, so the dossier and prompts stay honest.
248 // A recorded death is law: the estimate only fills what the record lacks, and an
249 // estimate that contradicts the record (born after the documented death) is
250 // dropped whole rather than shipped as an incoherent lifespan.
251 if (!born) {
252 const bridge = await estimateLifespan(title, article.description, article.extract, died?.display)
253 bridgeTransient = bridge.transient
254 if (bridge.estimate) {
255 const estBorn = makeFigureYear(bridge.estimate.born.year, bridge.estimate.born.bce, true)
256 if (!died || estBorn.signed <= died.signed) {
257 born = estBorn
258 died = died ?? (bridge.estimate.died
259 ? makeFigureYear(bridge.estimate.died.year, bridge.estimate.died.bce, true)
260 : undefined)
261 }
262 }
263 }
⋯ 19 lines hidden (lines 264–282)
264 
265 return {
266 value: {
267 name: title,
268 resolved: true,
269 description: article.description || undefined,
270 extract: article.extract || undefined,
271 born,
272 died,
273 living: !!born && !died,
274 thumbnail: safeUrl(article.thumbnail?.source),
275 wikiUrl: safeUrl(article.content_urls?.desktop?.page)
276 },
277 // Anything short of a definitive answer keeps the short TTL: a transient
278 // Wikidata miss (even when the estimator papered over it — the next lookup
279 // should get a shot at the real dates) and an estimator outage alike.
280 ttlMs: dates.transient || bridgeTransient ? TRANSIENT_TTL_MS : FULL_TTL_MS
281 }

The dossier says so when nobody can date them

If Wikidata has nothing and the bridge refuses, the figure stays resolved and contactable — free-form, exactly as before. New: the dossier says why the slider is missing instead of leaving a mysteriously absent control. The copy claims only the lookup outcome ("No dates could be found"), not a property of the record, because the same state can be a ten-minute outage window.

The when-control as before, and the honest v-else note — the only component change in this PR.

components/FigurePicker.vue · 379 lines
components/FigurePicker.vue379 lines · Vue
⋯ 52 lines hidden (lines 1–52)
1<template>
2 <div data-testid="figure-picker" class="space-y-3">
3 <div ref="searchBoxRef" class="relative" @focusout="onSearchFocusOut">
4 <label for="figure-input" class="rv-label block mb-1">Who in history will you reach?</label>
5 <input id="figure-input" v-model="figure" data-testid="figure-input" type="text"
6 class="rv-field rv-mono text-sm" placeholder="Anyone, any era — Cleopatra, Tesla, Genghis Khan…"
7 :disabled="disabled"
8 role="combobox" aria-autocomplete="list" :aria-expanded="searchOpen ? 'true' : 'false'"
9 :aria-controls="searchOpen ? 'figure-search-listbox' : undefined"
10 :aria-activedescendant="activeIndex >= 0 ? `figure-search-option-${activeIndex}` : undefined"
11 autocomplete="off" @keydown="onSearchKeydown" />
12 <span class="sr-only" role="status">{{ searchStatus }}</span>
13 <!-- Name autocomplete: Wikipedia title search, the description line telling
14 the pharaoh from the 1963 film. Picking one fills the input; grounding
15 fires exactly as if it had been typed. -->
16 <ul v-if="searchOpen" id="figure-search-listbox" data-testid="figure-search-listbox" role="listbox"
17 aria-label="Matching names from the record"
18 class="search-pop absolute left-0 right-0 top-full mt-1 z-20 border rv-line rounded-sm rv-bg max-h-64 overflow-y-auto">
19 <li v-for="(r, i) in searchResults" :id="`figure-search-option-${i}`" :key="r.name" role="option"
20 :aria-selected="i === activeIndex ? 'true' : 'false'" data-testid="figure-search-option"
21 class="flex items-center gap-2 px-2.5 py-1.5 cursor-pointer text-sm"
22 :class="i === activeIndex ? 'rv-tint' : 'rv-hover'"
23 @mousedown.prevent @click="choose(r.name)" @mousemove="activeIndex = i">
24 <img v-if="r.thumbnail" :src="r.thumbnail" alt="" class="h-7 w-7 shrink-0 rounded object-cover" aria-hidden="true" />
25 <span v-else class="h-7 w-7 shrink-0 rounded rv-tint" aria-hidden="true" />
26 <span class="min-w-0">
27 <span class="rv-fg font-medium block truncate">{{ r.name }}</span>
28 <span v-if="r.description" class="rv-faint text-[11px] block truncate">{{ r.description }}</span>
29 </span>
30 </li>
31 </ul>
32 </div>
33 
34 <!-- Grounding: who they were, and when you reach them -->
35 <div v-if="groundingLoading" data-testid="grounding-loading" class="flex items-center gap-2 text-xs rv-faint">
36 <span class="rv-spinner" aria-hidden="true" />
37 Consulting the records…
38 </div>
39 
40 <div v-else-if="grounding?.resolved" data-testid="figure-dossier" class="rv-card p-3">
41 <div class="flex gap-3">
42 <img v-if="grounding.thumbnail" :src="grounding.thumbnail" :alt="grounding.name"
43 class="h-12 w-12 shrink-0 rounded object-cover" />
44 <div class="min-w-0 flex-1">
45 <div class="flex flex-wrap items-center gap-x-2 gap-y-0.5">
46 <span data-testid="dossier-name" class="rv-fg font-semibold text-sm">{{ grounding.name }}</span>
47 <span v-if="lifespan" data-testid="dossier-lifespan" class="rv-mono text-[11px] rv-faint">{{ lifespan }}</span>
48 <a v-if="grounding.wikiUrl" :href="grounding.wikiUrl" target="_blank" rel="noopener noreferrer"
49 class="rv-accent text-[11px]">Wikipedia ↗</a>
50 </div>
51 <p v-if="grounding.description" class="rv-muted text-xs mt-0.5">{{ grounding.description }}</p>
52 
53 <!-- When do you reach them? A promoted, lifetime-bounded playhead: the
54 chosen year + live age lead, big and in the accent, so the moment you're
55 writing into is the loudest thing in the dossier (not a buried micro-row). -->
56 <div v-if="hasLifespan" data-testid="when-control" class="mt-2.5 border-t rv-line pt-2.5">
57 <div class="flex items-baseline justify-between gap-2">
58 <span id="when-label" class="rv-label">Reach them in</span>
59 <span data-testid="when-display" class="rv-mono rv-accent text-lg font-bold leading-none">
60 {{ whenDisplay }}<span v-if="contactAge != null" data-testid="when-age" class="rv-faint text-xs font-normal"> · age {{ contactAge }}</span>
61 </span>
62 </div>
63 <input type="range" data-testid="when-slider" class="w-full mt-2" :style="{ accentColor: 'var(--rv-accent)' }"
64 :min="range.min" :max="range.max" :value="contactWhen ?? range.min" :disabled="disabled"
65 aria-labelledby="when-label" :aria-valuetext="whenValueText" @input="onWhenInput" />
66 <div class="flex items-center justify-between text-[10px] rv-faint rv-mono">
67 <span>{{ grounding.born?.display }} · born</span>
68 <span>{{ grounding.died?.display ? grounding.died.display + ' · died' : 'present' }}</span>
69 </div>
70 </div>
71 
72 <!-- Even the AI bridge couldn't date them: say so honestly instead of a
73 mysteriously absent control. The copy claims only the lookup outcome
74 (it may be a transient outage, not a dateless record). No manual year
75 here — the contact year prices the anachronism wager, so it stays
76 grounded or unset. -->
77 <p v-else data-testid="when-unknown" class="mt-2.5 border-t rv-line pt-2.5 text-[11px] italic rv-faint">
78 No dates could be found for them — your message will find them in their own time.
79 </p>
⋯ 300 lines hidden (lines 80–379)
80 
81 <!-- The Archive: study who you're reaching, at the chosen moment -->
82 <div data-testid="archive" class="mt-2 border-t rv-line pt-2">
83 <button v-if="!studyShown" type="button" data-testid="study-button"
84 class="rv-accent text-[11px] font-medium hover:underline disabled:opacity-50"
85 :disabled="studyLoading || disabled" @click="studyThem">
86 <span aria-hidden="true">🔎</span> {{ studyLoading ? 'The Archivist consults the record…' : studyLabel }}
87 </button>
88 
89 <div v-else data-testid="figure-study" class="space-y-1 text-[11px] leading-snug rv-muted">
90 <p class="italic rv-fg">{{ figureStudy?.atThisMoment }}</p>
91 <p v-if="figureStudy?.grasp.length"><span class="rv-faint">grasps</span> {{ figureStudy?.grasp.join(' · ') }}</p>
92 <p v-if="figureStudy?.reaching.length"><span class="rv-faint">reaching</span> {{ figureStudy?.reaching.join(' · ') }}</p>
93 <p v-if="figureStudy?.cannotYetKnow" class="rv-warn"><span class="rv-faint">beyond them</span> {{ figureStudy?.cannotYetKnow }}</p>
94 <button v-if="yearMoved" type="button" data-testid="restudy-button"
95 class="rv-accent font-medium hover:underline disabled:opacity-50" :disabled="studyLoading || disabled" @click="studyThem">
96 <span aria-hidden="true">🔎</span> {{ studyLoading ? 'Consulting…' : restudyLabel }}
97 </button>
98 </div>
99 </div>
100 </div>
101 </div>
102 </div>
103 
104 <div v-else-if="grounding && !grounding.resolved" data-testid="figure-unresolved" class="text-xs italic rv-faint">
105 No record found for "{{ activeName }}" — you can still send a message into the unknown.
106 </div>
107 
108 <!-- Figures already contacted this run -->
109 <div v-if="contacted.length" class="flex flex-wrap items-center gap-1.5">
110 <span class="rv-label">contacts</span>
111 <button v-for="f in contacted" :key="f.name" type="button" data-testid="contact-chip"
112 class="rv-press text-xs px-2 py-0.5 border rv-line rounded-sm inline-flex items-center gap-1.5" :class="chipClass(f.name)" @click="select(f.name)">
113 <span class="rv-dot" :class="f.name === figure ? 'rv-dot--accent' : 'rv-dot--hollow'" aria-hidden="true" />{{ f.name }}
114 </button>
115 </div>
116 
117 <!-- Era-relevant figures for this objective (the on-ramp); you can still type anyone. -->
118 <div class="space-y-1.5">
119 <span class="rv-label block" aria-live="polite">{{ suggestionsLabel }}</span>
120 <!-- While the era-aware agent works: quiet skeleton chips — never clickable
121 generic defaults masquerading as objective-relevant picks. The famous-
122 names fallback appears only AFTER the agent has genuinely come up empty,
123 labeled honestly as what it is. -->
124 <div v-if="suggestionsPending" data-testid="suggestion-skeletons" class="flex flex-col gap-1" aria-hidden="true">
125 <div v-for="i in 3" :key="i" class="border rv-line rounded-sm px-2.5 py-2">
126 <span class="skeleton-line w-32" />
127 <span class="skeleton-line w-48 mt-1.5" />
128 </div>
129 </div>
130 <div v-else class="flex flex-col gap-1">
131 <button v-for="s in displaySuggestions" :key="s.name" type="button" data-testid="suggestion-chip"
132 class="rv-press border rv-line rounded-sm px-2.5 py-1.5 text-left" :class="suggestionClass(s.name)" @click="select(s.name)">
133 <span class="text-sm rv-fg font-medium">{{ s.name }}</span>
134 <span v-if="s.lifespan" class="ml-1.5 rv-mono text-[10px] rv-faint">{{ s.lifespan }}</span>
135 <span v-if="s.reason" data-testid="suggestion-reason" class="block text-[11px] leading-snug rv-faint">{{ s.reason }}</span>
136 </button>
137 </div>
138 </div>
139 </div>
140</template>
141 
142<script setup lang="ts">
143/**
144 * FigurePicker — choose whom (free-form name, suggestion, or prior contact) and, once
145 * grounded, when to reach them (a lifetime-bounded slider with a live age). The
146 * Archive's "study them" brief discloses who they are at that moment. Re-skin only —
147 * every store binding (grounding, contactWhen, study, suggestions) is unchanged.
148 */
149import { computed, watch, onMounted, onUnmounted } from 'vue'
150import { useGameStore, formatContactYear } from '~/stores/game'
151import type { FigureSuggestion } from '~/server/utils/figure-suggester'
152 
153const figure = defineModel<string>({ default: '' })
154defineProps<{ disabled?: boolean }>()
155 
156const gameStore = useGameStore()
157const contacted = computed(() => gameStore.figures)
158const figureSuggestions = computed(() => gameStore.figureSuggestions)
159const suggestionsLoading = computed(() => gameStore.suggestionsLoading)
160const grounding = computed(() => gameStore.figureGrounding)
161const groundingLoading = computed(() => gameStore.groundingLoading)
162const contactWhen = computed(() => gameStore.contactWhen)
163const contactAge = computed(() => gameStore.contactAge)
164const activeName = computed(() => figure.value.trim())
165 
166const hasLifespan = computed(() => !!(grounding.value?.resolved && grounding.value.born))
167const lifespan = computed(() => {
168 const g = grounding.value
169 if (!g?.born) return ''
170 if (g.died) return `${g.born.display}${g.died.display}`
171 return g.living ? `${g.born.display} – present` : g.born.display
172})
173const range = computed(() => ({
174 min: grounding.value?.born?.signed ?? 0,
175 max: grounding.value?.died?.signed ?? new Date().getFullYear()
176}))
177const whenDisplay = computed(() => (contactWhen.value != null ? formatContactYear(contactWhen.value) : ''))
178// What AT announces for the slider: the human year + live age (e.g. "44 BC · age 25"),
179// not the raw signed integer the value attribute carries.
180const whenValueText = computed(() =>
181 contactWhen.value == null ? '' : whenDisplay.value + (contactAge.value != null ? ` · age ${contactAge.value}` : '')
183 
184function onWhenInput(e: Event) {
185 gameStore.setContactWhen(Number((e.target as HTMLInputElement).value))
187 
188// The Archive: study the grounded figure in-game, at the chosen moment.
189const figureStudy = computed(() => gameStore.figureStudy)
190const studyLoading = computed(() => gameStore.studyLoading)
191const studyShown = computed(() => !!figureStudy.value && gameStore.studyFor === grounding.value?.name)
192const yearMoved = computed(() => studyShown.value && gameStore.studyWhen !== contactWhen.value)
193const studyLabel = computed(() => (contactWhen.value != null ? `Study them in ${whenDisplay.value}` : 'Study them'))
194const restudyLabel = computed(() => (contactWhen.value != null ? `Re-study in ${whenDisplay.value}` : 'Re-study'))
195function studyThem() { gameStore.studyActiveFigure() }
196 
197const LOCAL_DEFAULT: FigureSuggestion[] = [
198 { name: 'Cleopatra', reason: '' },
199 { name: 'Nikola Tesla', reason: '' },
200 { name: 'Genghis Khan', reason: '' },
201 { name: 'Marie Curie', reason: '' },
202 { name: 'Leonardo da Vinci', reason: '' }
204const displaySuggestions = computed(() => (figureSuggestions.value.length ? figureSuggestions.value : LOCAL_DEFAULT))
205// Pending = in flight, OR simply not yet asked for this objective — the latter
206// covers the first painted frame (loadSuggestions fires onMounted, after render),
207// which would otherwise flash the clickable famous-names fallback for one frame.
208// The store records suggestionsFor even on failure, so a genuine miss still
209// settles into the honest fallback rather than skeletons forever.
210const suggestionsPending = computed(() =>
211 !figureSuggestions.value.length && (
212 suggestionsLoading.value ||
213 (!!gameStore.currentObjective && gameStore.suggestionsFor !== gameStore.currentObjective.title)
214 )
216const suggestionsLabel = computed(() =>
217 suggestionsPending.value
218 ? 'finding figures who matter here…'
219 : figureSuggestions.value.length
220 ? 'figures who might matter here'
221 : 'some famous names to start with'
223 
224function select(name: string) {
225 choose(name)
227 
228// --- Name autocomplete (a combobox over Wikipedia title search) ---
229interface SearchResult { name: string; description?: string; thumbnail?: string }
230const searchBoxRef = ref<HTMLElement | null>(null)
231const searchResults = ref<SearchResult[]>([])
232const searchOpen = ref(false)
233const activeIndex = ref(-1)
234let searchDebounce: ReturnType<typeof setTimeout> | null = null
235let searchSeq = 0
236// The name most recently CHOSEN (dropdown option, suggestion chip, contact chip):
237// the model change it causes must not reopen the dropdown over the fresh dossier.
238let lastChosen = ''
239 
240/** Closing also INVALIDATES any response still in flight: bumping the seq is what
241 * keeps a slow fetch from reopening a stale dropdown over a chosen figure's
242 * dossier, an emptied input, or a disabled one mid-send. */
243function closeSearch() {
244 searchSeq++
245 searchResults.value = []
246 searchOpen.value = false
247 activeIndex.value = -1
249 
250/** Deliberate selection from any surface: fill the input and stand down. */
251function choose(name: string) {
252 lastChosen = name
253 figure.value = name
254 closeSearch()
256 
257/** Fire the actual lookup for the CURRENT seq; results land only if still current. */
258async function runSearch(q: string, seq: number) {
259 try {
260 const res = await $fetch('/api/figure-search', { params: { q } }) as { results?: SearchResult[] }
261 if (seq !== searchSeq) return // closed, or a newer keystroke took over
262 searchResults.value = res?.results ?? []
263 searchOpen.value = searchResults.value.length > 0
264 activeIndex.value = -1
265 } catch {
266 if (seq === searchSeq) closeSearch()
267 }
269 
270watch(figure, (name) => {
271 if (searchDebounce) clearTimeout(searchDebounce)
272 const q = (name || '').trim()
273 if (q.length < 2 || q === lastChosen) {
274 closeSearch()
275 // One-shot suppression: it exists to swallow the single model echo a
276 // choice causes. A later deliberate retype of the same name searches again.
277 if (q === lastChosen) lastChosen = ''
278 return
279 }
280 lastChosen = ''
281 const seq = ++searchSeq
282 searchDebounce = setTimeout(() => { void runSearch(q, seq) }, 250)
283})
284 
285function onSearchKeydown(e: KeyboardEvent) {
286 if (e.isComposing) return // IME candidate navigation owns these keys
287 if (!searchOpen.value || !searchResults.value.length) {
288 // APG editable-combobox: Down Arrow on a closed combobox reopens the popup
289 // (the only recovery after Escape that doesn't require editing the text).
290 if (e.key === 'ArrowDown') {
291 const q = (figure.value || '').trim()
292 if (q.length >= 2) {
293 e.preventDefault()
294 void runSearch(q, ++searchSeq)
295 }
296 }
297 return
298 }
299 if (e.key === 'ArrowDown') {
300 e.preventDefault()
301 activeIndex.value = (activeIndex.value + 1) % searchResults.value.length
302 } else if (e.key === 'ArrowUp') {
303 e.preventDefault()
304 activeIndex.value = activeIndex.value <= 0 ? searchResults.value.length - 1 : activeIndex.value - 1
305 } else if (e.key === 'Enter') {
306 if (activeIndex.value >= 0) {
307 e.preventDefault()
308 choose(searchResults.value[activeIndex.value].name)
309 }
310 } else if (e.key === 'Escape') {
311 closeSearch()
312 }
314 
315/** Close when focus genuinely leaves the combobox (not when it moves within it). */
316function onSearchFocusOut(e: FocusEvent) {
317 const next = e.relatedTarget as Node | null
318 if (next && searchBoxRef.value?.contains(next)) return
319 closeSearch()
321 
322/** Spoken result count — aria-expanded alone is not reliably announced while typing. */
323const searchStatus = computed(() =>
324 searchOpen.value
325 ? `${searchResults.value.length} ${searchResults.value.length === 1 ? 'match' : 'matches'} — up and down arrows to review, Enter to choose`
326 : ''
328 
329function chipClass(name: string) {
330 return name === figure.value ? 'rv-tint rv-fg' : 'rv-muted rv-hover'
332function suggestionClass(name: string) {
333 return name === figure.value ? 'rv-tint' : 'rv-hover'
335 
336// Ground the chosen figure, debounced so typing doesn't spam the lookup. The
337// PREVIOUS dossier is cleared synchronously the instant the name changes: a send
338// fired during the debounce/fetch window must go out clean (free-form), never
339// wearing the old figure's facts, year, or liveness gate.
340let debounce: ReturnType<typeof setTimeout> | null = null
341watch(figure, (name) => {
342 if (debounce) clearTimeout(debounce)
343 gameStore.clearGrounding()
344 const trimmed = (name || '').trim()
345 if (!trimmed) return
346 debounce = setTimeout(() => gameStore.groundActiveFigure(trimmed), 350)
347})
348onUnmounted(() => {
349 if (debounce) clearTimeout(debounce)
350 if (searchDebounce) clearTimeout(searchDebounce)
351})
352 
353// Load era-relevant suggestions for the current objective when the board appears.
354onMounted(() => gameStore.loadSuggestions())
355</script>
356 
357<style scoped>
358/* Quiet placeholder bars while the suggester works — letterpress tint, a gentle
359 pulse, stilled under reduced motion. Never a clickable fake. */
360.skeleton-line {
361 display: block;
362 height: 10px;
363 border-radius: 2px;
364 background: var(--rv-tint);
365 animation: skeleton-pulse 1.6s var(--rv-ease) infinite;
367@keyframes skeleton-pulse {
368 0%, 100% { opacity: .55; }
369 50% { opacity: 1; }
371@media (prefers-reduced-motion: reduce) {
372 .skeleton-line { animation: none; }
374 
375/* The autocomplete pop floats over the dossier on warm paper, not chrome. */
376.search-pop {
377 box-shadow: 0 6px 18px color-mix(in srgb, var(--rv-fg) 10%, transparent);
379</style>

Verification

npx vitest run green (346 tests), npx nuxt typecheck clean, 12/12 Playwright e2e. The new tests are discriminators, not padding — the TTL and prompt-anchoring tests were verified by mutation to fail on the unfixed code:

BehaviorPinned by
Preferred-rank over [0], somevalue skipped, deprecated never readpickClaimYear suite (figure-grounding.spec.ts:72)
Sub-year precision → circa display; year/day precision stays exactparseWikidataYear suite (:23)
Lookup by ids=Q…; title+normalize=1 fallback armgroundFigure suite (:160, :171)
Recorded death survives the bridge; contradicting estimate dropped whole:202, :222, :237
429 retry recovers dates (fake timers, no real sleep):263
Transient wiki / estimator-outage answers expire in minutes; definitive ones hold:277, :301, :325, :350
Estimator guards (unconfident, incoherent, junk) and prompt anchoring (name, description, 600-char extract cap, documented death)lifespan-estimator.spec.ts:29, :83
No-dates note renders; circa displays pass through verbatim, never on the chosen yearFigurePicker.spec.ts dossier suite
Where each new behavior is pinned.