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 @@ + + + + + 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 @@ + + + + + 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 @@ 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": "登入身分:" },