articulate(app): port single-device word game from partiverse + tests

中英猜词派对 — 选主题 / 难度 / 词数 → 大字模式描述给队友猜,无 room / 无 ws。
15 个 preset 主题(wordlists 已在 scaffold 时就位)+ 3 档难度 + 已看词跨场
记忆(localStorage,cap 5000)+ Enter/Space/Esc 键盘。pickWords 优先未看过
再 fallback 见过的。logic 层 24 个 vitest(解析 / 抽词 / 确定性 rng)。
This commit is contained in:
Fam Zheng
2026-05-14 15:32:15 +01:00
parent 0b22691b3d
commit 78f84d4225
11 changed files with 3869 additions and 0 deletions
@@ -0,0 +1,65 @@
import type { Word } from './wordlist'
export interface GameConfig {
topic: string | null
difficulty: 1 | 2 | 3
totalWords: number
}
export interface GameState {
config: GameConfig
queue: Word[]
currentIndex: number
correctCount: number
passCount: number
}
interface PersistedState {
game: GameState | null
seenWords: string[] // wordKey list
}
const KEY = 'articulate:v1'
const MAX_SEEN = 5000
function isBrowser(): boolean {
return typeof window !== 'undefined' && typeof window.localStorage !== 'undefined'
}
export function loadState(): PersistedState {
if (!isBrowser()) return { game: null, seenWords: [] }
try {
const raw = window.localStorage.getItem(KEY)
if (!raw) return { game: null, seenWords: [] }
const parsed = JSON.parse(raw) as Partial<PersistedState>
return {
game: parsed.game ?? null,
seenWords: parsed.seenWords ?? [],
}
} catch {
return { game: null, seenWords: [] }
}
}
export function saveState(state: PersistedState): void {
if (!isBrowser()) return
try {
// 限制 seen list 大小
const seen = state.seenWords.slice(-MAX_SEEN)
window.localStorage.setItem(KEY, JSON.stringify({ ...state, seenWords: seen }))
} catch {
// ignore
}
}
export function addSeen(seen: string[], keys: string[]): string[] {
const set = new Set(seen)
const out = [...seen]
for (const k of keys) {
if (!set.has(k)) {
set.add(k)
out.push(k)
}
}
return out.slice(-MAX_SEEN)
}
@@ -0,0 +1,28 @@
// preset topic 列表与 wordlist 文件路径。
export interface PresetTopic {
value: string
label: string
}
export const PRESET_TOPICS: PresetTopic[] = [
{ value: 'animals', label: '动物' },
{ value: 'food', label: '食物' },
{ value: 'places', label: '地点' },
{ value: 'objects', label: '物品' },
{ value: 'actions', label: '动作' },
{ value: 'colors', label: '颜色' },
{ value: 'emotions', label: '情感' },
{ value: 'sports', label: '运动' },
{ value: 'professions', label: '职业' },
{ value: 'nature', label: '自然' },
{ value: 'body', label: '身体' },
{ value: 'clothing', label: '服装' },
{ value: 'vehicles', label: '交通工具' },
{ value: 'music', label: '音乐' },
{ value: 'technology', label: '科技' },
]
export function wordlistUrl(topic: string): string {
return `/wordlists/${encodeURIComponent(topic)}.txt`
}
@@ -0,0 +1,85 @@
// Wordlist 解析 + 抽词。纯函数,可单测。
export interface Word {
difficulty: 'easy' | 'medium' | 'hard'
english: string
chinese: string
}
export interface Rng {
next(): number
}
export const defaultRng: Rng = { next: () => Math.random() }
/** 解析一行 "easy - run - 跑"。空行或格式错误返回 null。 */
export function parseWordlistLine(line: string): Word | null {
const s = line.trim()
if (!s) return null
const parts = s.split(' - ').map((p) => p.trim())
if (parts.length !== 3) return null
const [difficulty, english, chinese] = parts
if (difficulty !== 'easy' && difficulty !== 'medium' && difficulty !== 'hard') return null
if (!english || !chinese) return null
return { difficulty, english, chinese }
}
/** 解析一整个 wordlist 文件文本。跳过空行 / 格式错误行。 */
export function parseWordlist(text: string): Word[] {
return text.split(/\r?\n/).map(parseWordlistLine).filter((w): w is Word => w !== null)
}
/** difficulty 数字 → 包含的难度等级。 */
export function difficultyLevels(d: 1 | 2 | 3): Word['difficulty'][] {
if (d === 1) return ['easy']
if (d === 2) return ['easy', 'medium']
return ['easy', 'medium', 'hard']
}
function wordKey(w: Word): string {
return `${w.english}|${w.chinese}`
}
function shuffle<T>(arr: T[], rng: Rng): T[] {
const a = [...arr]
for (let i = a.length - 1; i > 0; i--) {
const j = Math.floor(rng.next() * (i + 1))
;[a[i], a[j]] = [a[j], a[i]]
}
return a
}
/**
* 从 pool 抽 count 个词,优先未见过的。
* 如果未见过的不够,用见过的补足,避免凑不齐。
*/
export function pickWords(
pool: Word[],
difficulty: 1 | 2 | 3,
count: number,
seen: Set<string>,
rng: Rng = defaultRng,
): Word[] {
const levels = new Set(difficultyLevels(difficulty))
const filtered = pool.filter((w) => levels.has(w.difficulty))
const unseen = filtered.filter((w) => !seen.has(wordKey(w)))
const seenShuffled = shuffle(
filtered.filter((w) => seen.has(wordKey(w))),
rng,
)
const unseenShuffled = shuffle(unseen, rng)
const picked: Word[] = []
for (const w of unseenShuffled) {
if (picked.length >= count) break
picked.push(w)
}
for (const w of seenShuffled) {
if (picked.length >= count) break
picked.push(w)
}
return picked
}
export function wordKeyOf(w: Word): string {
return wordKey(w)
}