From ccc7f7e16b86acbbab0e785d0ff9af42e387c796 Mon Sep 17 00:00:00 2001
From: Disy920
Date: Sun, 13 Sep 2026 20:31:54 +0800
Subject: [PATCH] feat: add Starlight Mine Chromatic Realms easter egg
---
.../easteregg/color-mine/ColorMineAvatar.vue | 93 ++++
.../easteregg/color-mine/ColorMineBoard.vue | 323 ++++++++++++
.../easteregg/color-mine/ColorMineModal.vue | 482 ++++++++++++++++++
.../ui/easteregg/color-mine/engine.test.ts | 110 ++++
.../ui/easteregg/color-mine/engine.ts | 275 ++++++++++
.../easteregg/color-mine/generator.worker.ts | 9 +
.../ui/easteregg/color-mine/messages.ts | 93 ++++
.../ui/easteregg/color-mine/palette.css | 13 +
.../ui/easteregg/color-mine/storage.test.ts | 98 ++++
.../ui/easteregg/color-mine/storage.ts | 93 ++++
.../ui/onboarding/OnboardingWelcome.vue | 3 +-
.../components/ui/settings/AboutSettings.vue | 118 +++--
.../app-frontend/src/locales/en-US/index.json | 105 ++++
.../app-frontend/src/locales/zh-CN/index.json | 105 ++++
.../app-frontend/src/locales/zh-TW/index.json | 105 ++++
15 files changed, 1983 insertions(+), 42 deletions(-)
create mode 100644 apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineAvatar.vue
create mode 100644 apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineBoard.vue
create mode 100644 apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineModal.vue
create mode 100644 apps/app-frontend/src/components/ui/easteregg/color-mine/engine.test.ts
create mode 100644 apps/app-frontend/src/components/ui/easteregg/color-mine/engine.ts
create mode 100644 apps/app-frontend/src/components/ui/easteregg/color-mine/generator.worker.ts
create mode 100644 apps/app-frontend/src/components/ui/easteregg/color-mine/messages.ts
create mode 100644 apps/app-frontend/src/components/ui/easteregg/color-mine/palette.css
create mode 100644 apps/app-frontend/src/components/ui/easteregg/color-mine/storage.test.ts
create mode 100644 apps/app-frontend/src/components/ui/easteregg/color-mine/storage.ts
diff --git a/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineAvatar.vue b/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineAvatar.vue
new file mode 100644
index 0000000..1a9014d
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineAvatar.vue
@@ -0,0 +1,93 @@
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineBoard.vue b/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineBoard.vue
new file mode 100644
index 0000000..9ccb771
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineBoard.vue
@@ -0,0 +1,323 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {{ Math.round(zoom * 100) }}%
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineModal.vue b/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineModal.vue
new file mode 100644
index 0000000..60163aa
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/easteregg/color-mine/ColorMineModal.vue
@@ -0,0 +1,482 @@
+
+
+
+
+
+
+
+ {{
+ formatMessage(confirmation === 'leave' ? messages.leaveTitle : messages.replaceTitle)
+ }}
+
+
+ {{ formatMessage(confirmation === 'leave' ? messages.leaveText : messages.replaceText) }}
+
+
+
+
+
+
+ {{ formatMessage(messages.progress, { count, total: revealed.length }) }}
+ {{
+ formatMessage(messages.best, { time: timeText(best) })
+ }}
+
+
{{ formatMessage(messages.review) }}
+
{{ formatMessage(messages.recordError) }}
+
+ {{ formatMessage(messages.rulesTitle) }}
+ {{ formatMessage(messages.rules) }}
+
+
+
{{ formatMessage(messages.loading) }}
+
+
{{ formatMessage(messages.savedTitle) }}
+
+ {{ formatMessage(messages[saved.difficulty]) }} · {{ timeText(saved.elapsed) }}
+
+
+
{{ formatMessage(messages.generateError) }}
+
{{ formatMessage(messages.storageError) }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/apps/app-frontend/src/components/ui/easteregg/color-mine/engine.test.ts b/apps/app-frontend/src/components/ui/easteregg/color-mine/engine.test.ts
new file mode 100644
index 0000000..9947a65
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/easteregg/color-mine/engine.test.ts
@@ -0,0 +1,110 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import { type Board, createEngine, type Difficulty, generate, LEVELS } from './engine.ts'
+
+function connected(board: Board) {
+ const { cross } = createEngine(board.size, board.colors)
+ for (let color = 0; color < board.colors; color++) {
+ const source = board.answer.indexOf(color)
+ if (source < 0) return false
+ const visited = new Set([source])
+ const pending = [source]
+ while (pending.length) {
+ for (const neighbor of cross[pending.pop()!]) {
+ if (board.answer[neighbor] === color && !visited.has(neighbor)) {
+ visited.add(neighbor)
+ pending.push(neighbor)
+ }
+ }
+ }
+ if (visited.size !== board.answer.filter((c) => c === color).length) return false
+ }
+ return true
+}
+
+for (const difficulty of Object.keys(LEVELS) as Difficulty[]) {
+ test(`${difficulty}: 20 seeds have connected colors, correct numbers and a complete no-guess path`, () => {
+ for (let seed = 1; seed <= 20; seed++) {
+ const puzzle = generate(seed, difficulty)
+ const engine = createEngine(puzzle.size, puzzle.colors)
+ assert.ok(connected(puzzle))
+ assert.ok(puzzle.clues.some((v) => !v))
+ assert.ok(
+ puzzle.clues.filter(Boolean).length >=
+ Math.ceil(puzzle.size ** 2 * LEVELS[difficulty].density),
+ )
+ for (let color = 0; color < puzzle.colors; color++) {
+ assert.ok(puzzle.clues.some((v, i) => v && puzzle.answer[i] === color))
+ }
+ assert.deepEqual(
+ puzzle.numbers,
+ puzzle.answer.map(
+ (color, i) => engine.around[i].filter((j) => puzzle.answer[j] === color).length,
+ ),
+ )
+ assert.ok(engine.solve(puzzle, puzzle.clues).every(Boolean))
+ if (difficulty === 'hard') {
+ for (let i = 0; i < puzzle.clues.length; i++) {
+ if (
+ !puzzle.clues[i] ||
+ puzzle.clues.filter((v, j) => v && puzzle.answer[j] === puzzle.answer[i]).length <= 1
+ )
+ continue
+ const fewer = puzzle.clues.slice()
+ fewer[i] = false
+ assert.ok(
+ engine.solve(puzzle, fewer).some((v) => !v),
+ `Redundant clue at ${i}, seed ${seed}`,
+ )
+ }
+ }
+ }
+ })
+}
+
+test('seeded generation is deterministic for saved games', () => {
+ assert.deepEqual(generate(4294967295, 'normal'), generate(4294967295, 'normal'))
+})
+
+test('deductions agree with exhaustive valid 3 × 3 boards; unrevealed numbers never leak', () => {
+ const engine = createEngine(3, 2)
+ const boards: Board[] = []
+ for (let bits = 1; bits < 511; bits++) {
+ const answer = Array.from({ length: 9 }, (_, i) => (bits >> i) & 1)
+ const board = {
+ size: 3,
+ colors: 2,
+ answer,
+ numbers: answer.map((c, i) => engine.around[i].filter((j) => answer[j] === c).length),
+ }
+ if (connected(board)) boards.push(board)
+ }
+ for (const board of boards) {
+ const revealed = board.answer.map((color, i) => i === board.answer.indexOf(color))
+ const masks = engine.infer(board, revealed)
+ const candidates = boards.filter((candidate) =>
+ revealed.every(
+ (open, i) =>
+ !open ||
+ (candidate.answer[i] === board.answer[i] && candidate.numbers[i] === board.numbers[i]),
+ ),
+ )
+ for (const candidate of candidates) {
+ candidate.answer.forEach((color, i) => assert.ok(masks[i] & (1 << color)))
+ }
+ const hiddenScrambled = {
+ ...board,
+ answer: board.answer.map((c, i) => (revealed[i] ? c : 1 - c)),
+ numbers: board.numbers.map((n, i) => (revealed[i] ? n : 99)),
+ }
+ assert.deepEqual(engine.infer(hiddenScrambled, revealed), masks)
+ }
+})
+
+test('diagonal contact does not connect a color', () => {
+ assert.equal(
+ connected({ size: 2, colors: 2, answer: [0, 1, 1, 0], numbers: [1, 1, 1, 1] }),
+ false,
+ )
+})
diff --git a/apps/app-frontend/src/components/ui/easteregg/color-mine/engine.ts b/apps/app-frontend/src/components/ui/easteregg/color-mine/engine.ts
new file mode 100644
index 0000000..ad8e486
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/easteregg/color-mine/engine.ts
@@ -0,0 +1,275 @@
+export const LEVELS = {
+ easy: { size: 10, colors: 4, density: 0.35 },
+ normal: { size: 25, colors: 6, density: 0.15 },
+ hard: { size: 60, colors: 10, density: 0 },
+} as const
+export type Difficulty = keyof typeof LEVELS
+export interface Board {
+ size: number
+ colors: number
+ answer: number[]
+ numbers: number[]
+}
+export interface Puzzle extends Board {
+ clues: boolean[]
+ seed: number
+ difficulty: Difficulty
+}
+
+const singleton = (mask: number) => mask > 0 && (mask & (mask - 1)) === 0
+export const colorOf = (mask: number) => 31 - Math.clz32(mask)
+
+function randomSource(seed: number) {
+ return () => {
+ seed = (seed + 0x6d2b79f5) | 0
+ let t = Math.imul(seed ^ (seed >>> 15), 1 | seed)
+ t = (t + Math.imul(t ^ (t >>> 7), 61 | t)) ^ t
+ return ((t ^ (t >>> 14)) >>> 0) / 4294967296
+ }
+}
+
+function shuffle(items: T[], random: () => number) {
+ for (let i = items.length - 1; i > 0; i--) {
+ const j = Math.floor(random() * (i + 1))
+ ;[items[i], items[j]] = [items[j], items[i]]
+ }
+ return items
+}
+
+export function createEngine(size: number, colors: number) {
+ const count = size * size
+ const all = (1 << colors) - 1
+ const indices = Array.from({ length: count }, (_, i) => i)
+ const around = indices.map((i) => {
+ const neighbors: number[] = []
+ for (let dy = -1; dy <= 1; dy++) {
+ for (let dx = -1; dx <= 1; dx++) {
+ const x = (i % size) + dx
+ const y = Math.floor(i / size) + dy
+ if ((dx || dy) && x >= 0 && x < size && y >= 0 && y < size) {
+ neighbors.push(y * size + x)
+ }
+ }
+ }
+ return neighbors
+ })
+ const distance = (a: number, b: number) =>
+ Math.abs((a % size) - (b % size)) + Math.abs(Math.floor(a / size) - Math.floor(b / size))
+ const cross = around.map((neighbors, i) => neighbors.filter((j) => distance(i, j) === 1))
+
+ function grow(random: () => number): Board {
+ const answer: number[] = Array(count).fill(-1)
+ const areas: number[] = Array(colors).fill(0)
+ const seeds: number[] = []
+ for (let color = 0; color < colors; color++) {
+ const candidates = shuffle(
+ indices.filter((i) => answer[i] < 0),
+ random,
+ ).slice(0, 80)
+ if (color) {
+ candidates.sort(
+ (a, b) =>
+ Math.min(...seeds.map((s) => distance(b, s))) -
+ Math.min(...seeds.map((s) => distance(a, s))),
+ )
+ }
+ const seed = candidates[0]
+ answer[seed] = color
+ areas[color]++
+ seeds.push(seed)
+ }
+ const frontier: number[][] = Array.from({ length: colors }, () => [])
+ const queued = Array.from({ length: colors }, () => new Uint8Array(count))
+ function extend(i: number, color: number) {
+ for (const j of cross[i]) {
+ if (answer[j] < 0 && !queued[color][j]) {
+ frontier[color].push(j)
+ queued[color][j] = 1
+ }
+ }
+ }
+ seeds.forEach(extend)
+ let left = count - colors
+ while (left) {
+ const available = areas.map((_, c) => c).filter((c) => frontier[c].length)
+ available.sort((a, b) => areas[a] - areas[b])
+ if (!available.length) throw new Error('Incomplete region growth')
+ const color = available[Math.floor(random() * Math.min(2, available.length))]
+ const list = frontier[color]
+ const position = Math.floor(random() * list.length)
+ const i = list[position]
+ list[position] = list[list.length - 1]
+ list.pop()
+ if (answer[i] >= 0) continue
+ answer[i] = color
+ areas[color]++
+ left--
+ extend(i, color)
+ }
+ return {
+ size,
+ colors,
+ answer,
+ numbers: answer.map((color, i) => around[i].filter((j) => answer[j] === color).length),
+ }
+ }
+
+ // Only revealed cells supply numbers. Deduced cells become new clues after a safe paint.
+ function propagate(board: Board, revealed: boolean[], masks: number[]) {
+ let changed = true
+ while (changed) {
+ changed = false
+ for (const i of indices) {
+ if (!revealed[i]) continue
+ const bit = 1 << board.answer[i]
+ const target = board.numbers[i]
+ let sure = 0
+ const optional: number[] = []
+ for (const j of around[i]) {
+ if (masks[j] === bit) sure++
+ else if (masks[j] & bit) optional.push(j)
+ }
+ if (sure > target || sure + optional.length < target) throw new Error('Contradictory clue')
+ if (sure === target || sure + optional.length === target) {
+ for (const j of optional) {
+ masks[j] = sure === target ? masks[j] & ~bit : bit
+ changed = true
+ }
+ }
+ }
+ }
+ }
+
+ function connectivity(masks: number[]) {
+ let changed = false
+ for (let color = 0; color < colors; color++) {
+ const bit = 1 << color
+ const source = masks.indexOf(bit)
+ if (source < 0) continue
+ // Iterative Tarjan traversal avoids a recursive stack overflow on the 60 × 60 board.
+ const order = new Int32Array(count)
+ const low = new Int32Array(count)
+ const parent = new Int32Array(count).fill(-1)
+ const terminals = new Int32Array(count)
+ const edge = new Uint8Array(count)
+ const stack = [source]
+ let time = 1
+ order[source] = low[source] = terminals[source] = 1
+ while (stack.length) {
+ const u = stack[stack.length - 1]
+ if (edge[u] < cross[u].length) {
+ const v = cross[u][edge[u]++]
+ if (!(masks[v] & bit)) continue
+ if (!order[v]) {
+ parent[v] = u
+ order[v] = low[v] = ++time
+ terminals[v] = masks[v] === bit ? 1 : 0
+ stack.push(v)
+ } else if (v !== parent[u]) low[u] = Math.min(low[u], order[v])
+ } else {
+ stack.pop()
+ const p = parent[u]
+ if (p < 0) continue
+ low[p] = Math.min(low[p], low[u])
+ terminals[p] += terminals[u]
+ // A bridge must be this color if its removal separates two known same-color cells.
+ if (p !== source && low[u] >= order[p] && terminals[u] > 0 && masks[p] !== bit) {
+ masks[p] = bit
+ changed = true
+ }
+ }
+ }
+ for (const i of indices) {
+ if (masks[i] & bit && !order[i]) {
+ masks[i] &= ~bit
+ changed = true
+ }
+ }
+ }
+ if (masks.some((mask) => mask === 0)) throw new Error('Empty color domain')
+ return changed
+ }
+
+ function infer(board: Board, revealed: boolean[]) {
+ const masks = board.answer.map((color, i) => (revealed[i] ? 1 << color : all))
+ do {
+ propagate(board, revealed, masks)
+ } while (connectivity(masks))
+ return masks
+ }
+
+ function solve(board: Board, clues: boolean[]) {
+ const revealed = clues.slice()
+ const masks = board.answer.map((color, i) => (revealed[i] ? 1 << color : all))
+ while (true) {
+ propagate(board, revealed, masks)
+ const next = indices.filter((i) => !revealed[i] && singleton(masks[i]))
+ if (!next.length) {
+ if (!connectivity(masks)) return revealed
+ continue
+ }
+ for (const i of next) {
+ if (colorOf(masks[i]) !== board.answer[i]) throw new Error('Unsound deduction')
+ revealed[i] = true
+ }
+ }
+ }
+ return { around, cross, grow, infer, solve }
+}
+
+export function generate(seed: number, difficulty: Difficulty): Puzzle {
+ const level = LEVELS[difficulty]
+ const random = randomSource(seed)
+ const engine = createEngine(level.size, level.colors)
+ const board = engine.grow(random)
+ const indices = board.answer.map((_, i) => i)
+ const clues: boolean[] = indices.map(() => false)
+ for (let color = 0; color < level.colors; color++) {
+ const cells = shuffle(
+ indices.filter((i) => board.answer[i] === color),
+ random,
+ )
+ cells.sort((a, b) => board.numbers[b] - board.numbers[a])
+ clues[cells[0]] = true
+ }
+ let solved = engine.solve(board, clues)
+ while (solved.some((value) => !value)) {
+ const cells = shuffle(
+ indices.filter((i) => !solved[i]),
+ random,
+ )
+ cells.sort(
+ (a, b) =>
+ engine.around[b].filter((j) => !solved[j]).length -
+ engine.around[a].filter((j) => !solved[j]).length,
+ )
+ clues[cells[0]] = true
+ solved = engine.solve(board, clues)
+ }
+ // Greedy irredundancy under these deduction rules, not a claim of global minimum clue count.
+ for (const i of shuffle(
+ indices.filter((i) => clues[i]),
+ random,
+ )) {
+ if (indices.filter((j) => clues[j] && board.answer[j] === board.answer[i]).length <= 1) continue
+ clues[i] = false
+ if (engine.solve(board, clues).some((value) => !value)) clues[i] = true
+ }
+ const target = Math.ceil(indices.length * level.density)
+ const pools = Array.from({ length: level.colors }, (_, color) =>
+ shuffle(
+ indices.filter((i) => board.answer[i] === color && !clues[i]),
+ random,
+ ),
+ )
+ let total = clues.filter(Boolean).length
+ while (total < target) {
+ for (const pool of pools) {
+ if (pool.length && total < target) {
+ clues[pool.pop()!] = true
+ total++
+ }
+ }
+ }
+ return { ...board, clues, seed, difficulty }
+}
diff --git a/apps/app-frontend/src/components/ui/easteregg/color-mine/generator.worker.ts b/apps/app-frontend/src/components/ui/easteregg/color-mine/generator.worker.ts
new file mode 100644
index 0000000..94f67b4
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/easteregg/color-mine/generator.worker.ts
@@ -0,0 +1,9 @@
+import { type Difficulty, generate } from './engine'
+
+self.onmessage = (event: MessageEvent<{ seed: number; difficulty: Difficulty }>) => {
+ try {
+ self.postMessage({ puzzle: generate(event.data.seed, event.data.difficulty) })
+ } catch {
+ self.postMessage({ error: true })
+ }
+}
diff --git a/apps/app-frontend/src/components/ui/easteregg/color-mine/messages.ts b/apps/app-frontend/src/components/ui/easteregg/color-mine/messages.ts
new file mode 100644
index 0000000..88a251c
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/easteregg/color-mine/messages.ts
@@ -0,0 +1,93 @@
+import { defineMessages } from '@modrinth/ui'
+
+export const messages = defineMessages({
+ title: { id: 'app.easteregg.color-mine.title', defaultMessage: 'Starlight Mine: Chromatic Realms' },
+ easy: { id: 'app.easteregg.color-mine.easy', defaultMessage: 'Easy' },
+ normal: { id: 'app.easteregg.color-mine.normal', defaultMessage: 'Normal' },
+ hard: { id: 'app.easteregg.color-mine.hard', defaultMessage: 'Hard' },
+ newGame: { id: 'app.easteregg.color-mine.newGame', defaultMessage: 'New board' },
+ close: { id: 'app.easteregg.color-mine.close', defaultMessage: 'Close' },
+ choose: {
+ id: 'app.easteregg.color-mine.choose',
+ defaultMessage: 'Click a revealed tile to sample its color, then paint a gray tile.',
+ },
+ selected: {
+ id: 'app.easteregg.color-mine.selected',
+ defaultMessage: 'Color {color} selected · Click a colored tile to change your brush.',
+ },
+ rulesTitle: { id: 'app.easteregg.color-mine.rulesTitle', defaultMessage: 'How to play' },
+ rules: {
+ id: 'app.easteregg.color-mine.rules',
+ defaultMessage:
+ 'Numbers count same-color tiles in the eight surrounding spaces, excluding the tile itself. Each color forms one connected region using only horizontal and vertical edges. Sample a revealed tile to select a brush; a correct paint reveals a new number, but one wrong paint ends the game. Every board has a step-by-step deduction path without guessing.',
+ },
+ loading: {
+ id: 'app.easteregg.color-mine.loading',
+ defaultMessage: 'Generating regions and checking the deduction path…',
+ },
+ generateError: {
+ id: 'app.easteregg.color-mine.generateError',
+ defaultMessage: 'Could not generate this board. Try again.',
+ },
+ retry: { id: 'app.easteregg.color-mine.retry', defaultMessage: 'Try again' },
+ progress: {
+ id: 'app.easteregg.color-mine.progress',
+ defaultMessage: '{count} / {total} revealed',
+ },
+ best: { id: 'app.easteregg.color-mine.best', defaultMessage: 'Best · {time}' },
+ won: { id: 'app.easteregg.color-mine.won', defaultMessage: 'All colors restored!' },
+ lost: {
+ id: 'app.easteregg.color-mine.lost',
+ defaultMessage:
+ 'Wrong color at row {row}, column {column}: selected {selected}, correct {correct}. Game over.',
+ },
+ review: {
+ id: 'app.easteregg.color-mine.review',
+ defaultMessage: 'Unrevealed answers are now shown faded for review.',
+ },
+ leaveTitle: { id: 'app.easteregg.color-mine.leaveTitle', defaultMessage: 'Leave this board?' },
+ leaveText: {
+ id: 'app.easteregg.color-mine.leaveText',
+ defaultMessage: 'Save to continue later, or discard this unfinished board.',
+ },
+ saveLeave: { id: 'app.easteregg.color-mine.saveLeave', defaultMessage: 'Save and leave' },
+ discard: { id: 'app.easteregg.color-mine.discard', defaultMessage: 'Discard' },
+ continue: { id: 'app.easteregg.color-mine.continue', defaultMessage: 'Keep playing' },
+ replaceTitle: {
+ id: 'app.easteregg.color-mine.replaceTitle',
+ defaultMessage: 'Start a new board?',
+ },
+ replaceText: {
+ id: 'app.easteregg.color-mine.replaceText',
+ defaultMessage: 'The current board will be discarded.',
+ },
+ savedTitle: {
+ id: 'app.easteregg.color-mine.savedTitle',
+ defaultMessage: 'An unfinished board is saved',
+ },
+ resume: { id: 'app.easteregg.color-mine.resume', defaultMessage: 'Resume saved game' },
+ storageError: {
+ id: 'app.easteregg.color-mine.storageError',
+ defaultMessage:
+ 'Could not read or write the saved game. Your current board has been kept. Retry or explicitly discard it.',
+ },
+ recordError: {
+ id: 'app.easteregg.color-mine.recordError',
+ defaultMessage: 'Finished, but the best time could not be saved.',
+ },
+ zoomIn: { id: 'app.easteregg.color-mine.zoomIn', defaultMessage: 'Zoom in' },
+ zoomOut: { id: 'app.easteregg.color-mine.zoomOut', defaultMessage: 'Zoom out' },
+ map: { id: 'app.easteregg.color-mine.map', defaultMessage: 'Board overview. Click to navigate.' },
+ board: {
+ id: 'app.easteregg.color-mine.board',
+ defaultMessage: '{size} by {size} color deduction board',
+ },
+ hidden: {
+ id: 'app.easteregg.color-mine.hidden',
+ defaultMessage: 'Row {row}, column {column}, unrevealed',
+ },
+ revealed: {
+ id: 'app.easteregg.color-mine.revealed',
+ defaultMessage: 'Row {row}, column {column}, color {color}, {number} same-color neighbors',
+ },
+})
diff --git a/apps/app-frontend/src/components/ui/easteregg/color-mine/palette.css b/apps/app-frontend/src/components/ui/easteregg/color-mine/palette.css
new file mode 100644
index 0000000..a1b2435
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/easteregg/color-mine/palette.css
@@ -0,0 +1,13 @@
+/* Semantic colors stay identifiable when the launcher's theme/accent changes. */
+.color-mine {
+ --mine-color-0: var(--color-red);
+ --mine-color-1: #d7ac37;
+ --mine-color-2: var(--color-green);
+ --mine-color-3: var(--color-blue);
+ --mine-color-4: var(--color-purple);
+ --mine-color-5: var(--color-orange);
+ --mine-color-6: #97ba35;
+ --mine-color-7: #22b6b8;
+ --mine-color-8: var(--color-pink);
+ --mine-color-9: #8999ac;
+}
diff --git a/apps/app-frontend/src/components/ui/easteregg/color-mine/storage.test.ts b/apps/app-frontend/src/components/ui/easteregg/color-mine/storage.test.ts
new file mode 100644
index 0000000..66233c7
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/easteregg/color-mine/storage.test.ts
@@ -0,0 +1,98 @@
+import assert from 'node:assert/strict'
+import test from 'node:test'
+
+import { generate } from './engine.ts'
+import {
+ parseSave,
+ readBest,
+ recordBest,
+ SAVE_KEY,
+ type SavedGame,
+ saveGame,
+ validateResume,
+} from './storage.ts'
+
+const puzzle = generate(123, 'easy')
+const saved: SavedGame = {
+ version: 1,
+ seed: puzzle.seed,
+ difficulty: puzzle.difficulty,
+ revealed: puzzle.clues.slice(),
+ selected: -1,
+ elapsed: 4321,
+ zoom: 1,
+ left: 12,
+ top: 34,
+}
+function memoryStorage() {
+ const values = new Map()
+ return {
+ getItem: (key: string) => values.get(key) ?? null,
+ setItem: (key: string, value: string) => {
+ values.set(key, value)
+ },
+ removeItem: (key: string) => {
+ values.delete(key)
+ },
+ }
+}
+
+test('save round trip keeps board progress, brush, timer and viewport', () => {
+ const storage = memoryStorage()
+ saveGame(storage, saved)
+ assert.deepEqual(parseSave(storage.getItem(SAVE_KEY)), saved)
+ validateResume(saved, puzzle)
+ assert.equal(parseSave(null), null)
+})
+
+test('malformed and incompatible saves are rejected without overwriting data', () => {
+ for (const bad of [
+ null,
+ {},
+ { ...saved, version: 2 },
+ { ...saved, difficulty: '__proto__' },
+ { ...saved, revealed: [true] },
+ { ...saved, elapsed: -1 },
+ { ...saved, selected: 15 },
+ { ...saved, zoom: 5 },
+ { ...saved, seed: 1.2 },
+ { ...saved, top: -1 },
+ { ...saved, revealed: saved.revealed.map(() => true) },
+ ]) {
+ assert.throws(() => parseSave(JSON.stringify(bad)))
+ }
+ assert.throws(() => parseSave('{invalid'))
+ assert.throws(() => validateResume({ ...saved, seed: 9 }, puzzle))
+ assert.throws(() =>
+ validateResume({ ...saved, revealed: saved.revealed.map(() => false) }, puzzle),
+ )
+ const storage = memoryStorage()
+ saveGame(storage, saved)
+ assert.throws(() => saveGame(storage, { ...saved, elapsed: -1 }))
+ assert.deepEqual(parseSave(storage.getItem(SAVE_KEY)), saved)
+})
+
+test('storage failures are surfaced so the UI can keep the active board', () => {
+ assert.throws(() =>
+ saveGame(
+ {
+ ...memoryStorage(),
+ setItem: () => {
+ throw new Error('Quota exceeded')
+ },
+ },
+ saved,
+ ),
+ )
+})
+
+test('best times are independent per difficulty and only improve', () => {
+ const storage = memoryStorage()
+ assert.equal(readBest(storage, 'easy'), null)
+ assert.equal(recordBest(storage, 'easy', 1000), 1000)
+ assert.equal(recordBest(storage, 'easy', 2000), 1000)
+ assert.equal(recordBest(storage, 'easy', 500), 500)
+ assert.equal(recordBest(storage, 'hard', 9000), 9000)
+ assert.equal(readBest(storage, 'easy'), 500)
+ assert.throws(() => recordBest(storage, 'easy', -1))
+})
diff --git a/apps/app-frontend/src/components/ui/easteregg/color-mine/storage.ts b/apps/app-frontend/src/components/ui/easteregg/color-mine/storage.ts
new file mode 100644
index 0000000..44cc246
--- /dev/null
+++ b/apps/app-frontend/src/components/ui/easteregg/color-mine/storage.ts
@@ -0,0 +1,93 @@
+import { type Difficulty, LEVELS, type Puzzle } from './engine.ts'
+
+export const SAVE_KEY = 'starlight.color-mine.save.v1'
+const BEST_KEY = 'starlight.color-mine.best.v1'
+export interface SavedGame {
+ version: 1
+ seed: number
+ difficulty: Difficulty
+ revealed: boolean[]
+ selected: number
+ elapsed: number
+ zoom: number
+ left: number
+ top: number
+}
+type StoragePort = Pick
+const finite = (value: unknown): value is number =>
+ typeof value === 'number' && Number.isFinite(value)
+export function parseSave(raw: string | null): SavedGame | null {
+ if (raw === null) return null
+ const value = JSON.parse(raw) as Partial | null
+ if (
+ !value ||
+ value.version !== 1 ||
+ !value.difficulty ||
+ !Object.keys(LEVELS).includes(value.difficulty)
+ ) {
+ throw new Error('Invalid saved game')
+ }
+ const level = LEVELS[value.difficulty]
+ if (
+ !finite(value.seed) ||
+ !Number.isInteger(value.seed) ||
+ value.seed < 0 ||
+ value.seed > 0xffffffff ||
+ !Array.isArray(value.revealed) ||
+ value.revealed.length !== level.size ** 2 ||
+ value.revealed.some((v) => typeof v !== 'boolean') ||
+ value.revealed.every(Boolean) ||
+ !finite(value.selected) ||
+ !Number.isInteger(value.selected) ||
+ value.selected < -1 ||
+ value.selected >= level.colors ||
+ !finite(value.elapsed) ||
+ value.elapsed < 0 ||
+ !finite(value.zoom) ||
+ value.zoom < 0.7 ||
+ value.zoom > 1.6 ||
+ !finite(value.left) ||
+ value.left < 0 ||
+ !finite(value.top) ||
+ value.top < 0
+ )
+ throw new Error('Invalid saved game')
+ return value as SavedGame
+}
+
+export function validateResume(saved: SavedGame, puzzle: Puzzle) {
+ if (
+ saved.seed !== puzzle.seed ||
+ saved.difficulty !== puzzle.difficulty ||
+ puzzle.clues.some((clue, i) => clue && !saved.revealed[i]) ||
+ (saved.selected >= 0 &&
+ !saved.revealed.some((open, i) => open && puzzle.answer[i] === saved.selected))
+ ) {
+ throw new Error('Saved game does not match puzzle')
+ }
+}
+
+export function saveGame(storage: StoragePort, saved: SavedGame) {
+ const raw = JSON.stringify(saved)
+ parseSave(raw)
+ storage.setItem(SAVE_KEY, raw)
+}
+
+export function readBest(storage: StoragePort, difficulty: Difficulty): number | null {
+ const raw = storage.getItem(BEST_KEY)
+ if (!raw) return null
+ const value: unknown = JSON.parse(raw)
+ if (!value || typeof value !== 'object') return null
+ const time = (value as Record)[difficulty]
+ return finite(time) && time >= 0 ? time : null
+}
+
+export function recordBest(storage: StoragePort, difficulty: Difficulty, elapsed: number) {
+ if (!finite(elapsed) || elapsed < 0) throw new Error('Invalid time')
+ const best = Object.fromEntries(
+ Object.keys(LEVELS).map((key) => [key, readBest(storage, key as Difficulty)]),
+ )
+ best[difficulty] = Math.min(best[difficulty] ?? Infinity, elapsed)
+ storage.setItem(BEST_KEY, JSON.stringify(best))
+ return best[difficulty]
+}
diff --git a/apps/app-frontend/src/components/ui/onboarding/OnboardingWelcome.vue b/apps/app-frontend/src/components/ui/onboarding/OnboardingWelcome.vue
index eb977bb..d2628bf 100644
--- a/apps/app-frontend/src/components/ui/onboarding/OnboardingWelcome.vue
+++ b/apps/app-frontend/src/components/ui/onboarding/OnboardingWelcome.vue
@@ -110,7 +110,8 @@ const { formatMessage } = useVIntl()
color: var(--color-contrast);
font-size: 4.5rem;
font-weight: 800;
- line-height: 1;
+ // Keep descenders inside the line box clipped by the wordmark reveal animation.
+ line-height: normal;
letter-spacing: 0;
white-space: nowrap;
animation: onboarding-welcome-wordmark-reveal 1050ms 900ms cubic-bezier(0.16, 1, 0.3, 1) both;
diff --git a/apps/app-frontend/src/components/ui/settings/AboutSettings.vue b/apps/app-frontend/src/components/ui/settings/AboutSettings.vue
index e8845dc..cc89b8c 100644
--- a/apps/app-frontend/src/components/ui/settings/AboutSettings.vue
+++ b/apps/app-frontend/src/components/ui/settings/AboutSettings.vue
@@ -1,20 +1,15 @@
@@ -237,7 +250,6 @@ const messages = defineMessages({
-
@@ -288,33 +300,56 @@ const messages = defineMessages({
{{ formatMessage(messages.developmentTeam) }}
@@ -360,6 +395,7 @@ const messages = defineMessages({
+
diff --git a/apps/app-frontend/src/locales/en-US/index.json b/apps/app-frontend/src/locales/en-US/index.json
index 6dce6b6..6ad79df 100644
--- a/apps/app-frontend/src/locales/en-US/index.json
+++ b/apps/app-frontend/src/locales/en-US/index.json
@@ -1,4 +1,109 @@
{
+ "app.easteregg.color-mine.title": {
+ "message": "Starlight Mine: Chromatic Realms"
+ },
+ "app.easteregg.color-mine.easy": {
+ "message": "Easy"
+ },
+ "app.easteregg.color-mine.normal": {
+ "message": "Normal"
+ },
+ "app.easteregg.color-mine.hard": {
+ "message": "Hard"
+ },
+ "app.easteregg.color-mine.newGame": {
+ "message": "New board"
+ },
+ "app.easteregg.color-mine.close": {
+ "message": "Close"
+ },
+ "app.easteregg.color-mine.choose": {
+ "message": "Click a revealed tile to sample its color, then paint a gray tile."
+ },
+ "app.easteregg.color-mine.selected": {
+ "message": "Color {color} selected · Click a colored tile to change your brush."
+ },
+ "app.easteregg.color-mine.rulesTitle": {
+ "message": "How to play"
+ },
+ "app.easteregg.color-mine.rules": {
+ "message": "Numbers count same-color tiles in the eight surrounding spaces, excluding the tile itself. Each color forms one connected region using only horizontal and vertical edges. Sample a revealed tile to select a brush; a correct paint reveals a new number, but one wrong paint ends the game. Every board has a step-by-step deduction path without guessing."
+ },
+ "app.easteregg.color-mine.loading": {
+ "message": "Generating regions and checking the deduction path…"
+ },
+ "app.easteregg.color-mine.generateError": {
+ "message": "Could not generate this board. Try again."
+ },
+ "app.easteregg.color-mine.retry": {
+ "message": "Try again"
+ },
+ "app.easteregg.color-mine.progress": {
+ "message": "{count} / {total} revealed"
+ },
+ "app.easteregg.color-mine.best": {
+ "message": "Best · {time}"
+ },
+ "app.easteregg.color-mine.won": {
+ "message": "All colors restored!"
+ },
+ "app.easteregg.color-mine.lost": {
+ "message": "Wrong color at row {row}, column {column}: selected {selected}, correct {correct}. Game over."
+ },
+ "app.easteregg.color-mine.review": {
+ "message": "Unrevealed answers are now shown faded for review."
+ },
+ "app.easteregg.color-mine.leaveTitle": {
+ "message": "Leave this board?"
+ },
+ "app.easteregg.color-mine.leaveText": {
+ "message": "Save to continue later, or discard this unfinished board."
+ },
+ "app.easteregg.color-mine.saveLeave": {
+ "message": "Save and leave"
+ },
+ "app.easteregg.color-mine.discard": {
+ "message": "Discard"
+ },
+ "app.easteregg.color-mine.continue": {
+ "message": "Keep playing"
+ },
+ "app.easteregg.color-mine.replaceTitle": {
+ "message": "Start a new board?"
+ },
+ "app.easteregg.color-mine.replaceText": {
+ "message": "The current board will be discarded."
+ },
+ "app.easteregg.color-mine.savedTitle": {
+ "message": "An unfinished board is saved"
+ },
+ "app.easteregg.color-mine.resume": {
+ "message": "Resume saved game"
+ },
+ "app.easteregg.color-mine.storageError": {
+ "message": "Could not read or write the saved game. Your current board has been kept. Retry or explicitly discard it."
+ },
+ "app.easteregg.color-mine.recordError": {
+ "message": "Finished, but the best time could not be saved."
+ },
+ "app.easteregg.color-mine.zoomIn": {
+ "message": "Zoom in"
+ },
+ "app.easteregg.color-mine.zoomOut": {
+ "message": "Zoom out"
+ },
+ "app.easteregg.color-mine.map": {
+ "message": "Board overview. Click to navigate."
+ },
+ "app.easteregg.color-mine.board": {
+ "message": "{size} by {size} color deduction board"
+ },
+ "app.easteregg.color-mine.hidden": {
+ "message": "Row {row}, column {column}, unrevealed"
+ },
+ "app.easteregg.color-mine.revealed": {
+ "message": "Row {row}, column {column}, color {color}, {number} same-color neighbors"
+ },
"app.account.signed-in-as": {
"message": "Signed in as"
},
diff --git a/apps/app-frontend/src/locales/zh-CN/index.json b/apps/app-frontend/src/locales/zh-CN/index.json
index 93d1519..cfd65ef 100644
--- a/apps/app-frontend/src/locales/zh-CN/index.json
+++ b/apps/app-frontend/src/locales/zh-CN/index.json
@@ -1,4 +1,109 @@
{
+ "app.easteregg.color-mine.title": {
+ "message": "星光矿井:彩域"
+ },
+ "app.easteregg.color-mine.easy": {
+ "message": "简单"
+ },
+ "app.easteregg.color-mine.normal": {
+ "message": "普通"
+ },
+ "app.easteregg.color-mine.hard": {
+ "message": "困难"
+ },
+ "app.easteregg.color-mine.newGame": {
+ "message": "新的一局"
+ },
+ "app.easteregg.color-mine.close": {
+ "message": "关闭"
+ },
+ "app.easteregg.color-mine.choose": {
+ "message": "先点击已揭示的色块取色,再给灰格染色。"
+ },
+ "app.easteregg.color-mine.selected": {
+ "message": "已取色 {color} · 点击其他色块切换画笔。"
+ },
+ "app.easteregg.color-mine.rulesTitle": {
+ "message": "玩法说明"
+ },
+ "app.easteregg.color-mine.rules": {
+ "message": "数字表示周围八格中同色格的数量,不含自身。每种颜色必须组成一个上下左右连通的区域,斜角不算连接。点击已揭示的格子取色,染对会显示新数字,染错则本局结束。每局均经过逐步推理验证,无需猜测。"
+ },
+ "app.easteregg.color-mine.loading": {
+ "message": "正在生成色域并校验推理路径…"
+ },
+ "app.easteregg.color-mine.generateError": {
+ "message": "棋盘生成失败,请重试。"
+ },
+ "app.easteregg.color-mine.retry": {
+ "message": "重试"
+ },
+ "app.easteregg.color-mine.progress": {
+ "message": "已揭示 {count} / {total}"
+ },
+ "app.easteregg.color-mine.best": {
+ "message": "最佳 · {time}"
+ },
+ "app.easteregg.color-mine.won": {
+ "message": "全部色域已还原!"
+ },
+ "app.easteregg.color-mine.lost": {
+ "message": "第 {row} 行、第 {column} 列染错了:选择了 {selected},正确颜色为 {correct}。本局结束。"
+ },
+ "app.easteregg.color-mine.review": {
+ "message": "未揭示的答案已淡色显示,可查看棋盘复盘。"
+ },
+ "app.easteregg.color-mine.leaveTitle": {
+ "message": "要离开当前棋盘吗?"
+ },
+ "app.easteregg.color-mine.leaveText": {
+ "message": "可以保存,下次从这里继续;也可以放弃本局。"
+ },
+ "app.easteregg.color-mine.saveLeave": {
+ "message": "保存并离开"
+ },
+ "app.easteregg.color-mine.discard": {
+ "message": "放弃本局"
+ },
+ "app.easteregg.color-mine.continue": {
+ "message": "继续这局"
+ },
+ "app.easteregg.color-mine.replaceTitle": {
+ "message": "开始新的一局?"
+ },
+ "app.easteregg.color-mine.replaceText": {
+ "message": "当前这局的进度将被放弃。"
+ },
+ "app.easteregg.color-mine.savedTitle": {
+ "message": "有一局尚未完成的棋盘"
+ },
+ "app.easteregg.color-mine.resume": {
+ "message": "继续上次的棋盘"
+ },
+ "app.easteregg.color-mine.storageError": {
+ "message": "无法读取或保存存档,当前棋盘已保留。可重试,或选择放弃存档。"
+ },
+ "app.easteregg.color-mine.recordError": {
+ "message": "已完成,但最佳成绩未能保存。"
+ },
+ "app.easteregg.color-mine.zoomIn": {
+ "message": "放大"
+ },
+ "app.easteregg.color-mine.zoomOut": {
+ "message": "缩小"
+ },
+ "app.easteregg.color-mine.map": {
+ "message": "棋盘总览,点击定位"
+ },
+ "app.easteregg.color-mine.board": {
+ "message": "{size} × {size} 彩色推理棋盘"
+ },
+ "app.easteregg.color-mine.hidden": {
+ "message": "第 {row} 行,第 {column} 列,未揭示"
+ },
+ "app.easteregg.color-mine.revealed": {
+ "message": "第 {row} 行,第 {column} 列,颜色 {color},周围同色 {number} 格"
+ },
"app.settings.developer.announcement-preview": { "message": "公告样式预览" },
"app.settings.developer.announcement-preview-description": {
"message": "使用本地示例预览真实公告组件,不请求远端公告,也不改变真实公告的已读状态。"
diff --git a/apps/app-frontend/src/locales/zh-TW/index.json b/apps/app-frontend/src/locales/zh-TW/index.json
index 7ede2f3..77af608 100644
--- a/apps/app-frontend/src/locales/zh-TW/index.json
+++ b/apps/app-frontend/src/locales/zh-TW/index.json
@@ -1,4 +1,109 @@
{
+ "app.easteregg.color-mine.title": {
+ "message": "星光礦井:彩域"
+ },
+ "app.easteregg.color-mine.easy": {
+ "message": "簡單"
+ },
+ "app.easteregg.color-mine.normal": {
+ "message": "普通"
+ },
+ "app.easteregg.color-mine.hard": {
+ "message": "困難"
+ },
+ "app.easteregg.color-mine.newGame": {
+ "message": "新的一局"
+ },
+ "app.easteregg.color-mine.close": {
+ "message": "關閉"
+ },
+ "app.easteregg.color-mine.choose": {
+ "message": "先點擊已揭示的色塊取色,再為灰格染色。"
+ },
+ "app.easteregg.color-mine.selected": {
+ "message": "已取色 {color} · 點擊其他色塊切換畫筆。"
+ },
+ "app.easteregg.color-mine.rulesTitle": {
+ "message": "玩法說明"
+ },
+ "app.easteregg.color-mine.rules": {
+ "message": "數字表示周圍八格中同色格的數量,不含自身。每種顏色必須組成一個上下左右連通的區域,斜角不算連接。點擊已揭示的格子取色,染對會顯示新數字,染錯則本局結束。每局均經過逐步推理驗證,無需猜測。"
+ },
+ "app.easteregg.color-mine.loading": {
+ "message": "正在生成色域並校驗推理路徑…"
+ },
+ "app.easteregg.color-mine.generateError": {
+ "message": "棋盤生成失敗,請重試。"
+ },
+ "app.easteregg.color-mine.retry": {
+ "message": "重試"
+ },
+ "app.easteregg.color-mine.progress": {
+ "message": "已揭示 {count} / {total}"
+ },
+ "app.easteregg.color-mine.best": {
+ "message": "最佳 · {time}"
+ },
+ "app.easteregg.color-mine.won": {
+ "message": "全部色域已還原!"
+ },
+ "app.easteregg.color-mine.lost": {
+ "message": "第 {row} 行、第 {column} 列染錯了:選擇了 {selected},正確顏色為 {correct}。本局結束。"
+ },
+ "app.easteregg.color-mine.review": {
+ "message": "未揭示的答案已淡色顯示,可查看棋盤複盤。"
+ },
+ "app.easteregg.color-mine.leaveTitle": {
+ "message": "要離開目前棋盤嗎?"
+ },
+ "app.easteregg.color-mine.leaveText": {
+ "message": "可以儲存,下次從這裡繼續;也可以放棄本局。"
+ },
+ "app.easteregg.color-mine.saveLeave": {
+ "message": "儲存並離開"
+ },
+ "app.easteregg.color-mine.discard": {
+ "message": "放棄本局"
+ },
+ "app.easteregg.color-mine.continue": {
+ "message": "繼續這局"
+ },
+ "app.easteregg.color-mine.replaceTitle": {
+ "message": "開始新的一局?"
+ },
+ "app.easteregg.color-mine.replaceText": {
+ "message": "目前這局的進度將被放棄。"
+ },
+ "app.easteregg.color-mine.savedTitle": {
+ "message": "有一局尚未完成的棋盤"
+ },
+ "app.easteregg.color-mine.resume": {
+ "message": "繼續上次的棋盤"
+ },
+ "app.easteregg.color-mine.storageError": {
+ "message": "無法讀取或儲存存檔,目前棋盤已保留。可重試,或選擇放棄存檔。"
+ },
+ "app.easteregg.color-mine.recordError": {
+ "message": "已完成,但最佳成績未能儲存。"
+ },
+ "app.easteregg.color-mine.zoomIn": {
+ "message": "放大"
+ },
+ "app.easteregg.color-mine.zoomOut": {
+ "message": "縮小"
+ },
+ "app.easteregg.color-mine.map": {
+ "message": "棋盤總覽,點擊定位"
+ },
+ "app.easteregg.color-mine.board": {
+ "message": "{size} × {size} 彩色推理棋盤"
+ },
+ "app.easteregg.color-mine.hidden": {
+ "message": "第 {row} 行,第 {column} 列,未揭示"
+ },
+ "app.easteregg.color-mine.revealed": {
+ "message": "第 {row} 行,第 {column} 列,顏色 {color},周圍同色 {number} 格"
+ },
"app.account.signed-in-as": {
"message": "登入身分:"
},