Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion domains/paris-metro/entities.json
Original file line number Diff line number Diff line change
Expand Up @@ -6930,7 +6930,7 @@
"num": 6
}
},
"fame_score": 1
"fame_score": 80
},
{
"id": "segur",
Expand Down
9 changes: 9 additions & 0 deletions domains/paris-metro/fame_overrides.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
{
"_doc": "Manual fame_score overrides for paris-metro. Applied AFTER the percentile-rank pass in scripts/ingest/fame_score_paris_metro.py, so re-running the ingest preserves these tweaks. Keys are entity ids, values are integers 0..=100.",
"_rationale": {
"saint-sulpice": "Wikipedia article 'Saint-Sulpice (métro de Paris)' is eclipsed in the topic-keyword filter by the church article. The station itself is a M4 hub, fame should be ~80 not 1."
},
"overrides": {
"saint-sulpice": 80
}
}
21 changes: 14 additions & 7 deletions domains/rer/entities.json
Original file line number Diff line number Diff line change
Expand Up @@ -89,7 +89,8 @@
"zone": {
"num": 0.0
}
}
},
"fame_score": 95
},
{
"id": "antony",
Expand All @@ -112,7 +113,8 @@
"zone": {
"num": 0.0
}
}
},
"fame_score": 85
},
{
"id": "arcueil-cachan",
Expand Down Expand Up @@ -204,7 +206,8 @@
"zone": {
"num": 0.0
}
}
},
"fame_score": 92
},
{
"id": "aulnay-sous-bois",
Expand Down Expand Up @@ -773,7 +776,8 @@
"zone": {
"num": 0.0
}
}
},
"fame_score": 75
},
{
"id": "cergy-saint-christophe",
Expand Down Expand Up @@ -1932,7 +1936,8 @@
"zone": {
"num": 0.0
}
}
},
"fame_score": 70
},
{
"id": "issy-val-de-seine",
Expand All @@ -1955,7 +1960,8 @@
"zone": {
"num": 0.0
}
}
},
"fame_score": 60
},
{
"id": "ivry-sur-seine",
Expand Down Expand Up @@ -4508,7 +4514,8 @@
"zone": {
"num": 0.0
}
}
},
"fame_score": 95
},
{
"id": "saint-michel-sur-orge",
Expand Down
24 changes: 24 additions & 0 deletions domains/rer/fame_overrides.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
{
"_doc": "Manual fame_score overrides for rer. Applied AFTER the percentile-rank pass in scripts/ingest/fame_score_rer.py, so re-running the ingest preserves these tweaks. Keys are entity ids, values are integers 0..=100.",
"_rationale": {
"auber": "Major RER A station (Opera area) — frwiki summary fell outside the topic-keyword filter so the auto-ingest left it null; manual pick ~92.",
"aeroport-charles-de-gaulle-1": "RER B terminus at CDG airport — universally known among Paris commuters and travellers.",
"antony": "RER B + Orlyval interchange. Commune label was rejected by the topic filter.",
"cergy-prefecture": "Major RER A terminus in the west. Fairly known.",
"issy": "Issy → Issy-les-Moulineaux article was rejected (too commune-flavoured). Station is on RER C, worth ~70.",
"issy-val-de-seine": "Same area as above.",
"luxembourg": "Already auto-scored very high (jardin du Luxembourg) — no override.",
"stade-de-france-saint-denis": "Massive Wikipedia traffic via 'Stade de France' — already scored but worth confirming on next re-ingest.",
"musee-d-orsay": "Already at fame=100 — no override.",
"saint-michel-notre-dame": "Major RER B+C interchange in the heart of Paris."
},
"overrides": {
"auber": 92,
"aeroport-charles-de-gaulle-1": 95,
"antony": 85,
"cergy-prefecture": 75,
"issy": 70,
"issy-val-de-seine": 60,
"saint-michel-notre-dame": 95
}
}
23 changes: 23 additions & 0 deletions scripts/ingest/fame_score_paris_metro.py
Original file line number Diff line number Diff line change
Expand Up @@ -424,8 +424,31 @@ def main() -> int:
time.sleep(0.15)

fame_by_id = percentile_rank(pv_by_id)

# Apply manual overrides last so a re-ingest doesn't undo curator
# decisions. Used to patch outliers where Wikipedia returns a misleading
# match (Saint-Sulpice → église) or where the topic-filter is too
# conservative.
overrides_path = domain_root / "fame_overrides.json"
overrides: dict[str, int] = {}
if overrides_path.is_file():
try:
data = json.loads(overrides_path.read_text())
raw = data.get("overrides", {}) if isinstance(data, dict) else {}
for k, v in raw.items():
if isinstance(v, int) and 0 <= v <= 100:
overrides[k] = v
except (json.JSONDecodeError, OSError) as e:
log(f" WARN: ignoring malformed fame_overrides.json: {e}")
if overrides:
log(f" applying {len(overrides)} manual override(s)")
for entity_id, score in overrides.items():
fame_by_id[entity_id] = score

for r in audit:
r["fame_score"] = fame_by_id.get(r["id"])
if r["id"] in overrides:
r["override"] = True

matched_count = sum(1 for r in audit if r["match"] is not None)
log(f"coverage: {matched_count}/{len(entities)} entities scored")
Expand Down
22 changes: 22 additions & 0 deletions scripts/ingest/fame_score_rer.py
Original file line number Diff line number Diff line change
Expand Up @@ -296,8 +296,30 @@ def main() -> int:
time.sleep(0.15)

fame_by_id = percentile_rank(pv_by_id)

# Apply manual overrides last so a re-ingest doesn't undo curator
# decisions. Used to patch outliers where the auto-resolver fell short
# (Auber, CDG terminus, Antony / Issy commune redirects, etc.).
overrides_path = domain_root / "fame_overrides.json"
overrides: dict[str, int] = {}
if overrides_path.is_file():
try:
data = json.loads(overrides_path.read_text())
raw = data.get("overrides", {}) if isinstance(data, dict) else {}
for k, v in raw.items():
if isinstance(v, int) and 0 <= v <= 100:
overrides[k] = v
except (json.JSONDecodeError, OSError) as e:
log(f" WARN: ignoring malformed fame_overrides.json: {e}")
if overrides:
log(f" applying {len(overrides)} manual override(s)")
for entity_id, score in overrides.items():
fame_by_id[entity_id] = score

for r in audit:
r["fame_score"] = fame_by_id.get(r["id"])
if r["id"] in overrides:
r["override"] = True

log(f"coverage: {matched}/{len(entities)} entities scored")

Expand Down
1 change: 1 addition & 0 deletions web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
"duel_create_subtitle": "Generate a random grid and share the link with a friend — they'll play the same grid as you.",
"duel_create_button": "Create a grid to share",
"duel_create_share_hint": "The link expires in 30 days.",
"duel_create_pick_domain": "Pick a universe",
"modal_rules_title": "How to play",
"modal_rules_intro": "Find the entity that satisfies both the row and column.",
"modal_rules_step_1": "Tap an empty cell.",
Expand Down
1 change: 1 addition & 0 deletions web/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,7 @@
"duel_create_subtitle": "Générez une grille aléatoire et partagez le lien à un ami : il jouera la même grille que vous.",
"duel_create_button": "Créer une grille à partager",
"duel_create_share_hint": "Le lien expire dans 30 jours.",
"duel_create_pick_domain": "Choisir l'univers",
"modal_rules_title": "Comment jouer",
"modal_rules_intro": "Trouvez l'entité qui satisfait à la fois la ligne et la colonne.",
"modal_rules_step_1": "Touchez une case vide.",
Expand Down
86 changes: 84 additions & 2 deletions web/src/lib/components/DuelView.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
import Swords from "lucide-svelte/icons/swords";
import * as m from "../../paraglide/messages.js";
import { api, ApiError } from "../api/client.js";
import { DOMAINS, findDomain } from "../domains.js";
import { copyToClipboard, shareNative } from "../share.js";
import Grid from "./Grid.svelte";

Expand All @@ -32,6 +33,11 @@
type Screen = "landing" | "view" | "playing";

interface Props {
/**
* Default domain when the user lands on /duel without a query string.
* The picker on the landing screen lets them override it before
* creating a share link.
*/
domain: string;
}

Expand All @@ -44,6 +50,21 @@
let loading = $state(true);
let error = $state<string | null>(null);

// Domain to use when *creating* a duel from /duel without a query string.
// Initialised from the page prop (a one-shot snapshot, intentional — the
// page is a single mount and the picker below mutates this state from
// there). eslint-disable because the Svelte rule can't see that the
// parent never re-renders the prop here.
// eslint-disable-next-line svelte/valid-compile
let createDomain = $state<string>(domain);

// Domain the *resolved* duel actually belongs to. The view + playing
// screens use this — never the page prop — so a player who lands on a
// RER duel link from a paris-metro page still plays the right grid.
// Same one-shot capture from the prop; replaced by fetchDuel.
// eslint-disable-next-line svelte/valid-compile
let viewDomain = $state<string>(domain);

// Share-link creation state for the landing screen.
let creating = $state(false);
let createError = $state<string | null>(null);
Expand All @@ -64,6 +85,12 @@
gridId = r.gridId;
expiresAt = r.expiresAt;
players = r.players;
// Use the duel's own domain — the page prop is just a default.
// Without this a RER duel shared with a friend on /paris-metro/ would
// mount Grid in paris-metro mode and clobber its localStorage key.
if (r.domain && findDomain(r.domain)) {
viewDomain = r.domain;
}
screen = "view";
} catch (err) {
if (err instanceof ApiError) {
Expand Down Expand Up @@ -100,7 +127,7 @@
createdShareUrl = null;
createdCopied = false;
try {
const r = await api.post("/api/duels", undefined, { domain });
const r = await api.post("/api/duels", undefined, { domain: createDomain });
createdShareUrl = r.shareUrl;
const shared = await shareNative(r.shareUrl, m.duel_share_title());
if (!shared) {
Expand Down Expand Up @@ -158,6 +185,29 @@
{/if}

{#if createdShareUrl === null}
<!-- Domain picker: lets the player pick which universe the duel is in
before they generate the share link. Default = the page prop
(current /duel?domain context). Hidden when only one domain is
configured — solo selection adds noise without value. -->
{#if DOMAINS.length > 1}
<fieldset class="kd-domain-picker" aria-label={m.duel_create_pick_domain()}>
<legend class="eyebrow text-fg-muted">{m.duel_create_pick_domain()}</legend>
<div class="flex flex-wrap gap-2 pt-1.5">
{#each DOMAINS as d (d.id)}
{@const active = createDomain === d.id}
<button
type="button"
class="kd-domain-pick"
class:active
aria-pressed={active}
onclick={() => (createDomain = d.id)}
>
{d.nameFr}
</button>
{/each}
</div>
</fieldset>
{/if}
<button
type="button"
class="kd-cta inline-flex items-center justify-center gap-2 self-start rounded-lg px-4 py-2.5 text-sm font-semibold"
Expand Down Expand Up @@ -259,7 +309,7 @@
<span>{m.duel_play_button()}</span>
</button>
{:else if screen === "playing" && gridId}
<Grid {domain} mode="duel" duelGridId={gridId} />
<Grid domain={viewDomain} mode="duel" duelGridId={gridId} />
{/if}
</section>

Expand Down Expand Up @@ -322,4 +372,36 @@
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
/* Domain picker on the create-duel landing screen — same look as the
header pill row, scoped here so the component stays self-contained. */
.kd-domain-picker {
border: 0;
padding: 0;
margin: 0;
}
.kd-domain-pick {
padding: 0.4rem 0.85rem;
font-size: 0.78rem;
font-weight: 600;
color: var(--color-fg-muted);
background: var(--color-bg-subtle);
border: 1px solid var(--color-border);
border-radius: 999px;
transition:
color 140ms var(--ease-out),
background-color 140ms var(--ease-out),
border-color 140ms var(--ease-out);
}
.kd-domain-pick:hover {
color: var(--color-fg);
}
.kd-domain-pick.active {
color: var(--color-accent-fg);
background: var(--color-accent);
border-color: var(--color-accent);
}
.kd-domain-pick:focus-visible {
outline: 2px solid var(--color-accent);
outline-offset: 2px;
}
</style>
Loading