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
9 changes: 9 additions & 0 deletions contracts/openapi.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -202,6 +202,15 @@ components:
ended:
type: boolean
description: True if this play ended the game (won, lost, or grid full).
fameScore:
type: integer
minimum: 0
maximum: 100
nullable: true
description: |
Snapshotted fame_score of the entity just resolved (when ok=true).
Lets the client surface a rarity tag on the cell in real time.
Null on wrong answers and on entities without fame data baked in.

AutocompleteResult:
type: object
Expand Down
28 changes: 28 additions & 0 deletions server/src/routes/games.rs
Original file line number Diff line number Diff line change
Expand Up @@ -409,6 +409,12 @@ pub struct PlayResponse {
pub score_delta: i32,
pub mistakes_left: i32,
pub ended: bool,
/// Snapshotted fame_score of the entity that was just resolved, when the
/// answer was correct. Lets the client surface a rarity tag in real time
/// instead of waiting for the EndGameView. Null when the answer was
/// wrong or when the entity has no fame data baked into the grid.
#[serde(skip_serializing_if = "Option::is_none")]
pub fame_score: Option<i32>,
}

pub async fn play(
Expand Down Expand Up @@ -546,14 +552,36 @@ pub async fn play(
}
am.update(db.as_ref()).await?;

let fame_score = if ok {
fame_score_for_entity(&grid.payload, &entity_id)
} else {
None
};

Ok(Json(PlayResponse {
ok,
score_delta,
mistakes_left,
ended,
fame_score,
}))
}

/// Look up `fame_score` for a given `entity_id` inside a grid payload's
/// `entities` array. Returns None if the entity is unknown or wasn't scored
/// at ingestion time (paris-metro stations have fame, rer stations partly).
fn fame_score_for_entity(payload: &serde_json::Value, entity_id: &str) -> Option<i32> {
let entities = payload.get("entities")?.as_array()?;
for ent in entities {
let id = ent.get("id")?.as_str()?;
if id == entity_id {
let fame = ent.get("fame_score")?.as_u64()?;
return i32::try_from(fame.min(100)).ok();
}
}
None
}

/// Look up an entity_id whose canonical or alias name matches the normalised user input.
fn resolve_entity_id(payload: &serde_json::Value, normalised: &str) -> Option<String> {
let entities = payload.get("entities")?.as_array()?;
Expand Down
9 changes: 9 additions & 0 deletions web/messages/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
"site_tagline": "The daily multi-domain puzzle",
"site_lede": "One grid of knowledge, every day. Three rows, three columns, nine cells to fill.",
"grid_today": "Today's grid",
"grid_mode_daily": "Today's grid",
"grid_mode_solo": "Solo grid",
"grid_mode_duel": "Duel",
"grid_title_solo": "Solo",
"grid_title_duel": "Duel",
"grid_domain_paris_metro": "Paris Metro",
"grid_domain_rer": "Île-de-France RER",
"score": "Score",
Expand Down Expand Up @@ -98,6 +103,10 @@
"modal_endgame_share_heading": "Share your result",
"modal_endgame_share_hint": "The block below contains no answers and no grid name — spoiler-safe.",
"originality_label": "Originality",
"rarity_common": "Common",
"rarity_rare": "Rare",
"rarity_epic": "Epic",
"rarity_legendary": "Legendary",
"originality_hint": "The more obscure your picks, the higher this gets. Used as a tiebreaker on the leaderboard.",
"fame_chip_title": "Fame {fame}/100",
"leaderboard_col_originality": "Originality",
Expand Down
9 changes: 9 additions & 0 deletions web/messages/fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,11 @@
"site_tagline": "Le puzzle quotidien multi-domaine",
"site_lede": "Une grille de connaissance, chaque jour. Trois lignes, trois colonnes, neuf cases à remplir.",
"grid_today": "Grille du jour",
"grid_mode_daily": "Grille du jour",
"grid_mode_solo": "Grille solo",
"grid_mode_duel": "Duel",
"grid_title_solo": "Solo",
"grid_title_duel": "Duel",
"grid_domain_paris_metro": "Métro de Paris",
"grid_domain_rer": "RER d'Île-de-France",
"score": "Score",
Expand Down Expand Up @@ -98,6 +103,10 @@
"modal_endgame_share_heading": "Partager votre résultat",
"modal_endgame_share_hint": "Le bloc ci-dessous ne contient ni les réponses, ni le nom de la grille — sans spoil.",
"originality_label": "Originalité",
"rarity_common": "Commun",
"rarity_rare": "Rare",
"rarity_epic": "Épique",
"rarity_legendary": "Légendaire",
"originality_hint": "Plus vous choisissez d'entités peu connues, plus ce score grimpe. Sert à départager les égalités sur le classement.",
"fame_chip_title": "Notoriété {fame}/100",
"leaderboard_col_originality": "Originalité",
Expand Down
72 changes: 69 additions & 3 deletions web/src/lib/components/CellButton.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@
import Check from "lucide-svelte/icons/check";
import Plus from "lucide-svelte/icons/plus";
import * as m from "../../paraglide/messages.js";
import { rarityFor, type Rarity } from "../rarity.js";
import type { CellAnswer } from "../stores/gameStore.svelte.js";

interface Props {
Expand All @@ -26,6 +27,24 @@
: cellLabel + ", " + m.cell_empty(),
);
const shortLabel = $derived(m.cell_short_label({ row: row + 1, col: col + 1 }));

// The persistent rarity badge sits in the top-right corner of a solved
// cell. We only render it when the cell is solved AND the rarity is more
// notable than 'common' — common stations don't need decoration, the
// badge would mostly add noise.
const rarity: Rarity | null = $derived(answer ? rarityFor(answer.fameScore) : null);
const rarityLabel = $derived.by((): string => {
switch (rarity) {
case "rare":
return m.rarity_rare();
case "epic":
return m.rarity_epic();
case "legendary":
return m.rarity_legendary();
default:
return "";
}
});
</script>

<button
Expand All @@ -41,9 +60,25 @@
<span class="cell-tag eyebrow" aria-hidden="true">{shortLabel}</span>

{#if answer}
<span class="cell-check" aria-hidden="true">
<Check size={14} strokeWidth={2.5} />
</span>
{#if rarity && rarity !== "common"}
<!-- Persistent corner badge — replaces the plain check on cells where
the resolved entity is at least 'Rare'. Common cells keep the
understated check so the badge wall doesn't drown the eye. -->
<span
class="cell-rarity"
class:rarity-rare={rarity === "rare"}
class:rarity-epic={rarity === "epic"}
class:rarity-legendary={rarity === "legendary"}
aria-label={rarityLabel}
title={rarityLabel}
>
{rarityLabel}
</span>
{:else}
<span class="cell-check" aria-hidden="true">
<Check size={14} strokeWidth={2.5} />
</span>
{/if}
<span class="cell-answer" title={answer.entityName}>{answer.entityName}</span>
{:else}
<span class="cell-plus" aria-hidden="true">
Expand Down Expand Up @@ -131,6 +166,37 @@
color: var(--color-success);
background: color-mix(in oklab, var(--color-success) 18%, transparent);
}
/* Rarity badge — sits where the check normally lives on solved cells.
Three colour stops keyed off MMO loot ladders (blue / purple / gold).
Common cells fall back to .cell-check above, no badge. */
.cell-rarity {
position: absolute;
top: 0.4rem;
right: 0.45rem;
display: inline-flex;
align-items: center;
justify-content: center;
height: 16px;
padding: 0 6px;
border-radius: 999px;
font-size: 9px;
line-height: 1;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
border: 1px solid currentColor;
background: color-mix(in oklab, currentColor 14%, transparent);
}
.rarity-rare {
color: oklch(0.6 0.16 250);
}
.rarity-epic {
color: oklch(0.58 0.18 305);
}
.rarity-legendary {
color: oklch(0.7 0.16 75);
box-shadow: 0 0 0 1px color-mix(in oklab, currentColor 35%, transparent);
}
.cell-answer {
display: -webkit-box;
-webkit-line-clamp: 3;
Expand Down
Loading
Loading