From aa8a3b8bfc91bf05c4cf4e0e2c7573408e5cde7f Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Wed, 4 Nov 2026 19:00:40 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=9C=8B=E6=9D=BF=E5=A8=98):=20=E5=A5=B9?= =?UTF-8?q?=E4=BC=9A=E8=AE=B0=E5=BE=97=E5=89=8D=E5=87=A0=E5=A4=A9=E8=A2=AB?= =?UTF-8?q?=E4=BD=A0=E6=83=B9=E7=94=9F=E6=B0=94=E3=80=81=E8=A2=AB=E6=91=B8?= =?UTF-8?q?=E8=BF=87=E5=A4=B4=EF=BC=8C=E4=BB=8A=E5=A4=A9=E7=AC=AC=E4=B8=80?= =?UTF-8?q?=E6=AC=A1=E7=82=B9=E5=A5=B9=E6=97=B6=E7=BF=BB=E4=B8=80=E6=AC=A1?= =?UTF-8?q?=E6=97=A7=E8=B4=A6=EF=BC=9B=E5=A5=BD=E6=84=9F=E5=8D=87=E6=A1=A3?= =?UTF-8?q?=E9=82=A3=E5=A4=A9=E8=AF=B4=E4=B8=80=E5=8F=A5=E3=80=8C=E7=86=9F?= =?UTF-8?q?=E4=BA=86=E4=B8=80=E7=82=B9=E3=80=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 只看 7 天内、今天以前的事,同一件事只翻一次(之后说过那句就不再提);顺序是升档 > 惹生气 > 摸头。 - 只在闲聊类情况(空白题目页、题目列表、打招呼、夸人)翻,错了、卡住时照常说正事。 - 好感档位照样不显示,「熟了一点」是唯一的暗示。 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/mascot.ts | 59 ++++++++++++++++++++++- apps/web/src/shared/components/Mascot.vue | 5 +- apps/web/src/shared/mascot/behavior.ts | 33 ++++++++++++- apps/web/src/shared/mascot/lines.ts | 6 +++ packages/contract/src/mascot.ts | 14 ++++++ 5 files changed, 112 insertions(+), 5 deletions(-) diff --git a/apps/api/src/routes/mascot.ts b/apps/api/src/routes/mascot.ts index 8bb45e3..6a8036b 100644 --- a/apps/api/src/routes/mascot.ts +++ b/apps/api/src/routes/mascot.ts @@ -5,7 +5,8 @@ import { Hono } from 'hono' import { requireAuth, type AppEnv } from '../auth/middleware' import { db, schema } from '../db' import { parseBody, success } from '../http' -import { dayStart, weekStart } from '../time' +import { JudgeStatus } from '../judge/status' +import { calendarDay, dayNumber, dayStart, weekStart } from '../time' export const mascotRoutes = new Hono() @@ -41,6 +42,58 @@ mascotRoutes.post('/mascot/events', requireAuth, async (c) => { return success(c, null) }) +/** 跨天记得的事只翻这么多天以内的 */ +const MEMORY_DAYS = 7 + +/** 几个东八区日历日以前;不在 MEMORY_DAYS 以内、或之后已经说过那句了,就是 null */ +function daysAgo(at: string | null, saidAt: string | null) { + if (!at || (saidAt && saidAt > at)) return null + const days = dayNumber(calendarDay()) - dayNumber(calendarDay(at)) + return days >= 1 && days <= MEMORY_DAYS ? days : null +} + +/** 跨天记得的事(契约 mascotProfileSchema.memory),一条语句查完 */ +async function memoryOf(userId: number) { + const today = dayStart() + const since = new Date(Date.parse(today) - MEMORY_DAYS * 86_400_000).toISOString() + const e = schema.mascotEvent + /** 7 天内最后一次。`beforeToday`:今天的事不算「记得」,只取今天以前的 */ + const last = (kind: string, situation: string | null, beforeToday: boolean) => sql`( + select max(${e.createdAt}) from ${e} + where ${e.userId} = ${userId} and ${e.kind} = ${kind} + ${situation ? sql`and ${e.situation} = ${situation}` : sql``} + and ${e.createdAt} >= ${since} + ${beforeToday ? sql`and ${e.createdAt} < ${today}` : sql``} + )` + const s = schema.submission + const [row] = await db.execute<{ + sulk_at: string | null + grudge_at: string | null + pat_at: string | null + pat_said_at: string | null + closer_at: string | null + solved_before: number + }>(sql` + select + ${last('open', 'sulk', true)} as sulk_at, + ${last('open', 'memory.grudge', false)} as grudge_at, + ${last('pat', null, true)} as pat_at, + ${last('open', 'memory.pat', false)} as pat_said_at, + ${last('open', 'memory.closer', false)} as closer_at, + ( + select count(distinct ${s.problemId})::int from ${s} + where ${s.userId} = ${userId} and ${s.result} in (${JudgeStatus.ACCEPTED}, ${JudgeStatus.AST_CHECK_FAILED}) + and ${s.createTime} < ${today} + ) as solved_before + `) + return { + grudgeDaysAgo: daysAgo(row?.sulk_at ?? null, row?.grudge_at ?? null), + patDaysAgo: daysAgo(row?.pat_at ?? null, row?.pat_said_at ?? null), + solvedBeforeToday: row?.solved_before ?? 0, + closerSaidToday: !!row?.closer_at && row.closer_at >= today, + } +} + /** 最近多少次题库提交拿来看学生常犯哪类错 */ const RECENT_SUBMISSIONS = 20 @@ -51,7 +104,7 @@ mascotRoutes.get('/mascot/profile', requireAuth, async (c) => { const week = weekStart() const e = schema.mascotEvent - const [pokes, solved, today, recent, tooHard, weekSubmits, weekEvents, weekHints] = await Promise.all([ + const [pokes, solved, memory, today, recent, tooHard, weekSubmits, weekEvents, weekHints] = await Promise.all([ db .select({ value: count() }) .from(schema.mascotEvent) @@ -61,6 +114,7 @@ mascotRoutes.get('/mascot/profile', requireAuth, async (c) => { .select({ value: sql`coalesce((${schema.userStat.metrics}->>'accepted_count')::int, 0)` }) .from(schema.userStat) .where(eq(schema.userStat.userId, userId)), + memoryOf(userId), db .select({ value: countDistinct(s.problemId) }) .from(s) @@ -100,6 +154,7 @@ mascotRoutes.get('/mascot/profile', requireAuth, async (c) => { return success(c, { pokes: pokes[0]?.value ?? 0, solved: solved[0]?.value ?? 0, + memory, todaySolved: today[0]?.value ?? 0, tooHard: tooHard[0]?.value ?? 0, week: { diff --git a/apps/web/src/shared/components/Mascot.vue b/apps/web/src/shared/components/Mascot.vue index 7dc70e4..bbdcb0a 100644 --- a/apps/web/src/shared/components/Mascot.vue +++ b/apps/web/src/shared/components/Mascot.vue @@ -12,7 +12,8 @@ * - 点一下冒气泡:一句话加最多三个按钮,按页面、按情况变(页面经 shared/mascot/guide.ts 告诉她), * 台词在 shared/mascot/lines.ts。比赛里点她不弹气泡。再点一下、点别处、换页面都收起。 * - 对学生按行为挑话(常犯的错、今天做了几道、熟不熟,见 shared/mascot/behavior.ts)。好感按一共做对几道分五档, - * 高档多几组软一点的话、也更耐烦(档位不显示); + * 高档多几组软一点的话、也更耐烦(档位不显示)。跨天记得几件事(前几天惹她生气、摸过头、今天升了档), + * 今天第一次闲聊时翻一次旧账; * 题目页上有几件事她会主动开口,频率上限在下面 SPEAK_GAP_MS。 * - 互动都记进 mascot_event(点开、点按钮、主动开口、摸头、嫌烦、被拖走)。 * - 一句一句地刷(两次点开隔不到 BURST_GAP_MS):第 3 次起不耐烦、第 5 次起炸毛、第 7 次背过身去, @@ -329,7 +330,7 @@ const TIME_CHANCE = 0.15 function pickFor(guide: MascotGuide) { let situation = guide.situation - if (!SERIOUS.has(situation) && !PESTERED.has(situation) && guide.n === undefined && !guide.vars) { + if (!SERIOUS.has(situation) && !PESTERED.has(situation) && situation !== 'memory.closer' && guide.n === undefined && !guide.vars) { const timely = timeSituation() if (Math.random() < RARE_CHANCE) situation = 'rare' else if (timely && !userStore.isTeacherOrAbove && Math.random() < TIME_CHANCE) situation = timely diff --git a/apps/web/src/shared/mascot/behavior.ts b/apps/web/src/shared/mascot/behavior.ts index 714fbfb..3a22942 100644 --- a/apps/web/src/shared/mascot/behavior.ts +++ b/apps/web/src/shared/mascot/behavior.ts @@ -109,7 +109,9 @@ const PRAISE = new Set(['problem.accepted', 'problem.firstTry', const AFFINITY_TIERS = [10, 25, 40, 100] as const /** 0 是还不熟(没有额外的话),1~4 对应 lines.ts 的 affinity.1~4。老师、没拉到数都是 0 */ export function affinityTier() { - const solved = mascotProfile.value?.solved ?? 0 + return tierOf(mascotProfile.value?.solved ?? 0) +} +function tierOf(solved: number) { return AFFINITY_TIERS.filter((min) => solved >= min).length } const AFFINITY_CHANCE = 0.25 @@ -135,12 +137,41 @@ function affinitySituation(tier: number): MascotSituation { return `affinity.${level}` as MascotSituation } +/** 这一页会话里翻过的旧账:说完到下次拉回新数之间别再说一遍 */ +const memorySaid = new Set<'closer' | 'grudge' | 'pat'>() + +function whenText(days: number) { + return days === 1 ? '昨天' : days === 2 ? '前天' : '前几天' +} + +/** 跨天记得的事,有就挑一件:今天升档 > 被惹生气 > 被摸头。每件只翻一次(后端看说没说过) */ +function memoryGuide(profile: MascotProfile): Pick | null { + const memory = profile.memory + if (!memory.closerSaidToday && !memorySaid.has('closer') && tierOf(memory.solvedBeforeToday) < tierOf(profile.solved)) { + memorySaid.add('closer') + return { situation: 'memory.closer' } + } + if (memory.grudgeDaysAgo !== null && !memorySaid.has('grudge')) { + memorySaid.add('grudge') + return { situation: 'memory.grudge', vars: { when: whenText(memory.grudgeDaysAgo) } } + } + if (memory.patDaysAgo !== null && !memorySaid.has('pat')) { + memorySaid.add('pat') + return { situation: 'memory.pat', vars: { when: whenText(memory.patDaysAgo) } } + } + return null +} + /** 点她的时候:按行为把页面给的情况换成更贴这个学生的那一组(按钮不变) */ export function personalize(guide: MascotGuide): MascotGuide { const profile = mascotProfile.value if (!profile) return guide if (profile.pokes + sessionPokes === 0) return { ...guide, situation: 'first.meet', n: undefined, vars: undefined } const situation = guide.situation + if (CHATTY.has(situation)) { + const memory = memoryGuide(profile) + if (memory) return { ...guide, ...memory, n: undefined } + } // 常点「太难了」的,错了先软着说,不翻老毛病 if (FAILURES.has(situation) && profile.tooHard >= GENTLE_TOO_HARD && Math.random() < GENTLE_CHANCE) return { ...guide, situation: 'gentle.wrong' } const week = profile.week diff --git a/apps/web/src/shared/mascot/lines.ts b/apps/web/src/shared/mascot/lines.ts index 35aec53..6defd28 100644 --- a/apps/web/src/shared/mascot/lines.ts +++ b/apps/web/src/shared/mascot/lines.ts @@ -179,6 +179,12 @@ export const MASCOT_LINES = { '下次比赛,我押你', '你是我最熟的同学……之一', ], + // 跨天记得的事(behavior.ts 的 memoryGuide):今天第一次在闲聊类情况点她时翻一次旧账,同一件事只翻一次。 + // {when} 是「昨天」「前天」「前几天」 + 'memory.grudge': ['{when}把我惹生气的是你吧', '{when}的账还没算呢', '哼,{when}的事我还记着', '{when}戳到我背过身,忘了?'], + 'memory.pat': ['{when}摸我头的账还没算', '别以为{when}摸头的事我忘了', '{when}是谁摸我头来着', '{when}头发被你弄乱了'], + // 今天好感升了一档:档位不显示,这句是唯一的暗示 + 'memory.closer': ['……最近跟你熟了一点', '你最近做了不少题嘛……我才没注意', '感觉跟你熟了点……错觉吧', '今天的你,好像比昨天顺眼'], familiar: ['又是你', '你今天戳了几次了', '习惯了,戳吧', '老熟人了', '又来找我?'], // ---- 接进来的其他数据 ---- diff --git a/packages/contract/src/mascot.ts b/packages/contract/src/mascot.ts index 2f54479..8fe72e4 100644 --- a/packages/contract/src/mascot.ts +++ b/packages/contract/src/mascot.ts @@ -55,6 +55,20 @@ export const mascotProfileSchema = z.object({ helps: z.number().int(), aiHints: z.number().int(), }), + /** + * 跨天记得的事。「几天前」是东八区日历日之差,只看 7 天内、今天以前的; + * 那件事之后已经说过对应那句(situation = memory.grudge / memory.pat)就是 null,同一件事只翻一次旧账 + */ + memory: z.object({ + /** 最近一次被点到背过身(situation = sulk 的那次点开)是几天前 */ + grudgeDaysAgo: z.number().int().nullable(), + /** 最近一次被摸头是几天前 */ + patDaysAgo: z.number().int().nullable(), + /** 今天以前做对了几道(和 solved 同一个口径):和 solved 一比就知道今天升没升好感档 */ + solvedBeforeToday: z.number().int(), + /** 今天说过「熟了一点」(memory.closer)没有 */ + closerSaidToday: z.boolean(), + }), recent: z.object({ total: z.number().int(), wrongAnswer: z.number().int(),