From 47c0b5464dd0d6f123c71aa6e96130f3ad7fa772 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Wed, 4 Nov 2026 18:26:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E7=9C=8B=E6=9D=BF=E5=A8=98):=20=E5=81=9A?= =?UTF-8?q?=E5=AF=B9=E7=9A=84=E9=A2=98=E8=B6=8A=E5=A4=9A=EF=BC=8C=E5=A5=B9?= =?UTF-8?q?=E8=AF=B4=E8=AF=9D=E8=B6=8A=E8=BD=AF=E3=80=81=E4=B9=9F=E8=B6=8A?= =?UTF-8?q?=E8=80=90=E7=83=A6=EF=BC=9B=E7=82=B9=E5=A5=B9=E5=A4=9A=E5=B0=91?= =?UTF-8?q?=E6=AC=A1=E4=B8=8D=E7=AE=97?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 好感按一共做对几道分五档(不到 10 / 10 / 25 / 40 / 100 道,含比赛题,和成就同一个数), 档位不显示。门槛按最近 60 天交过题的 378 个学生定的:前四档大约各占四分之一,最高一档是前一成。 - 每档多一组自己的话,越往上越软,高档也抽得到低档的;只掺进闲聊、夸人、空白题目页、 题目列表这几类情况,现有台词不改。 - 越熟越耐烦:最低档点到第 7 次背过身,最高档到第 10 次。 Co-Authored-By: Claude Opus 5.5 --- apps/api/src/routes/mascot.ts | 8 ++++- apps/web/src/shared/components/Mascot.vue | 17 +++++----- apps/web/src/shared/mascot/behavior.ts | 38 +++++++++++++++++++++++ apps/web/src/shared/mascot/lines.ts | 33 ++++++++++++++++++++ packages/contract/src/mascot.ts | 5 +++ 5 files changed, 93 insertions(+), 8 deletions(-) diff --git a/apps/api/src/routes/mascot.ts b/apps/api/src/routes/mascot.ts index c7428d1..8bb45e3 100644 --- a/apps/api/src/routes/mascot.ts +++ b/apps/api/src/routes/mascot.ts @@ -51,11 +51,16 @@ mascotRoutes.get('/mascot/profile', requireAuth, async (c) => { const week = weekStart() const e = schema.mascotEvent - const [pokes, today, recent, tooHard, weekSubmits, weekEvents, weekHints] = await Promise.all([ + const [pokes, solved, today, recent, tooHard, weekSubmits, weekEvents, weekHints] = await Promise.all([ db .select({ value: count() }) .from(schema.mascotEvent) .where(and(eq(schema.mascotEvent.userId, userId), eq(schema.mascotEvent.kind, 'open'))), + // 成就每次判完都重数一遍存进 user_stat,直接读,不再按提交现数 + db + .select({ value: sql`coalesce((${schema.userStat.metrics}->>'accepted_count')::int, 0)` }) + .from(schema.userStat) + .where(eq(schema.userStat.userId, userId)), db .select({ value: countDistinct(s.problemId) }) .from(s) @@ -94,6 +99,7 @@ mascotRoutes.get('/mascot/profile', requireAuth, async (c) => { return success(c, { pokes: pokes[0]?.value ?? 0, + solved: solved[0]?.value ?? 0, 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 c982b3b..7dc70e4 100644 --- a/apps/web/src/shared/components/Mascot.vue +++ b/apps/web/src/shared/components/Mascot.vue @@ -11,7 +11,8 @@ * - 按下去像玩偶一样压扁(底边不动),松手弹回。 * - 点一下冒气泡:一句话加最多三个按钮,按页面、按情况变(页面经 shared/mascot/guide.ts 告诉她), * 台词在 shared/mascot/lines.ts。比赛里点她不弹气泡。再点一下、点别处、换页面都收起。 - * - 对学生按行为挑话(常犯的错、今天做了几道、熟不熟,见 shared/mascot/behavior.ts); + * - 对学生按行为挑话(常犯的错、今天做了几道、熟不熟,见 shared/mascot/behavior.ts)。好感按一共做对几道分五档, + * 高档多几组软一点的话、也更耐烦(档位不显示); * 题目页上有几件事她会主动开口,频率上限在下面 SPEAK_GAP_MS。 * - 互动都记进 mascot_event(点开、点按钮、主动开口、摸头、嫌烦、被拖走)。 * - 一句一句地刷(两次点开隔不到 BURST_GAP_MS):第 3 次起不耐烦、第 5 次起炸毛、第 7 次背过身去, @@ -27,7 +28,7 @@ import imageUrl from 'shared/assets/mascot/whale.webp' import patUrl from 'shared/assets/mascot/whale-pat.gif' import { MASCOT_LINES, type MascotSituation } from 'shared/mascot/lines' import { currentMascotGuide, fillLine, mascotBox, type MascotAction, type MascotGuide } from 'shared/mascot/guide' -import { logMascotEvent, notePoke, personalize, refreshMascotProfile, speechRequest } from 'shared/mascot/behavior' +import { affinityTier, logMascotEvent, notePoke, personalize, refreshMascotProfile, speechRequest } from 'shared/mascot/behavior' import { useAuthModalStore } from 'shared/store/authModal' import { useCollabStore } from 'shared/store/collab' import { useUserStore } from 'shared/store/user' @@ -55,11 +56,12 @@ const OVERCLICK = 8 // 一轮连点到这么多下还没摸到头,她就嫌烦 const QUIET_BUBBLE_MS = 5000 // 没有按钮的气泡(只是一句话)过这么久自己收起 const SPOKEN_BUBBLE_MS = 10_000 // 她主动说的话,有按钮也过这么久收起:没人点她,不该一直挡着 const SPEAK_GAP_MS = 3 * 60_000 // 主动开口至少隔这么久,课上别老打岔 -// 连着刷台词。门槛按上线第一天的数定的:隔不到 20 秒算一串的话,到 3 次的串有 129 串、到 5 次的 50、到 7 次的 23 +// 连着刷台词。门槛按上线第一天的数定的:隔不到 20 秒算一串的话,到 3 次的串有 129 串、到 5 次的 50、到 7 次的 23。 +// 好感越高越耐烦(按档位 0~4 取,见 behavior.ts 的 affinityTier):最熟的点到第 10 次才背过身 const BURST_GAP_MS = 20_000 const PESTER_MILD = 3 -const PESTER_ANGRY = 5 -const SULK_AT = 7 +const PESTER_ANGRY = [5, 5, 6, 7, 8] as const +const SULK_AT = [7, 7, 8, 9, 10] as const const SULK_MS = 30_000 type HAnchor = 'left' | 'right' | null @@ -290,8 +292,9 @@ let sulkTimer: ReturnType | undefined /** 按这一串点到第几次换成不耐烦、炸毛的话;页面的按钮照给,真有事的还能办。背过身去就不给了 */ function pester(guide: MascotGuide): MascotGuide { - if (burst >= SULK_AT) return { situation: 'sulk' } - const situation = burst >= PESTER_ANGRY ? 'pester.angry' : burst >= PESTER_MILD ? 'pester.mild' : null + const tier = affinityTier() + if (burst >= SULK_AT[tier]!) return { situation: 'sulk' } + const situation = burst >= PESTER_ANGRY[tier]! ? 'pester.angry' : burst >= PESTER_MILD ? 'pester.mild' : null return situation ? { situation, actions: guide.actions } : guide } diff --git a/apps/web/src/shared/mascot/behavior.ts b/apps/web/src/shared/mascot/behavior.ts index 63f7a3c..714fbfb 100644 --- a/apps/web/src/shared/mascot/behavior.ts +++ b/apps/web/src/shared/mascot/behavior.ts @@ -101,6 +101,40 @@ const TODAY_MIN = 3 const GREETINGS = new Set(['greet', 'announcement', 'achievement', 'profile']) const PRAISE = new Set(['problem.accepted', 'problem.firstTry', 'problem.finally', 'home.done', 'home.assigned', 'home.free']) +/** + * 好感档位:一共做对几道(含比赛题,和成就一个数)。点她多少次不算,脚本刷不上去。 + * 门槛按生产库定的(2026-11,最近 60 天交过题的 378 个学生):做对 p25 是 14 道、中位 25、p75 37、p90 108, + * 所以前四档大约各占四分之一,最高一档是前一成。档位不显示,学生自己感觉出来 + */ +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 AFFINITY_TIERS.filter((min) => solved >= min).length +} +const AFFINITY_CHANCE = 0.25 +/** 掺好感台词的情况:闲聊、夸人,加上学生最常点她的空白题目页、题目列表(第一天点开的四成在这两处) */ +const CHATTY = new Set([ + 'greet', + 'announcement', + 'achievement', + 'profile', + 'problem.accepted', + 'problem.firstTry', + 'problem.finally', + 'problem.solvedBefore', + 'problem.blank', + 'problems', + 'home.done', + 'home.free', +]) + +/** 抽哪一档的话:自己那一档占一半,剩下一半在低几档里均分 */ +function affinitySituation(tier: number): MascotSituation { + const level = tier === 1 || Math.random() < 0.5 ? tier : 1 + Math.floor(Math.random() * (tier - 1)) + return `affinity.${level}` as MascotSituation +} + /** 点她的时候:按行为把页面给的情况换成更贴这个学生的那一组(按钮不变) */ export function personalize(guide: MascotGuide): MascotGuide { const profile = mascotProfile.value @@ -125,6 +159,10 @@ export function personalize(guide: MascotGuide): MascotGuide { if (PRAISE.has(guide.situation) && profile.todaySolved >= TODAY_MIN && Math.random() < TODAY_CHANCE) { return { ...guide, situation: 'today.count', n: profile.todaySolved } } + const tier = affinityTier() + if (tier > 0 && CHATTY.has(guide.situation) && guide.n === undefined && !guide.vars && Math.random() < AFFINITY_CHANCE) { + return { ...guide, situation: affinitySituation(tier) } + } if (GREETINGS.has(guide.situation) && profile.pokes >= FAMILIAR_POKES && Math.random() < FAMILIAR_CHANCE) { return { ...guide, situation: 'familiar' } } diff --git a/apps/web/src/shared/mascot/lines.ts b/apps/web/src/shared/mascot/lines.ts index 7ace27f..35aec53 100644 --- a/apps/web/src/shared/mascot/lines.ts +++ b/apps/web/src/shared/mascot/lines.ts @@ -146,6 +146,39 @@ export const MASCOT_LINES = { // 今天和熟悉程度 'today.count': ['今天第 {n} 道了……还行', '{n} 道了,别骄傲', '今天挺能做嘛,{n} 道', '才 {n} 道,再来'], 'first.meet': ['初次见面。……才不是特意等你', '你是新来的?', '第一次戳我?', '叫我小鲸鱼就行,别叫大肥鱼'], + // 好感:按一共做对几道分档(behavior.ts 的 AFFINITY_TIERS),档位不显示给学生。 + // 每档多一组,越往上口气越软;高档也抽得到低档的。只掺进闲聊类的情况,现有台词不改 + 'affinity.1': ['最近来得挺勤啊', '又见面了', '题做得不少了嘛', '还算有点眼熟', '你好像没那么菜了', '哼,又是你', '今天也来做题?', '记住你了……才怪'], + 'affinity.2': [ + '你来了我就不无聊了……才怪', + '你写代码的样子,还挺认真', + '有不会的可以问我……虽然我也不会', + '今天也加油……随口说的', + '跟你说话不算浪费时间', + '别人戳我我才不理', + '你比刚来那会儿强多了', + '做这么多了,还挺能坚持', + ], + 'affinity.3': [ + '……你来得正好', + '我给你留了一道好题……骗你的', + '累了就歇会儿', + '你做题,我放心', + '我才没有一直等你', + '错了也没关系,你能行', + '跟你熟了,不怼你了……偶尔', + '今天也一起做题吧', + ], + 'affinity.4': [ + '……其实你挺厉害的', + '一百多道都做下来了,哼,服了', + '别告诉别人我夸过你', + '有你在,我挺安心的', + '再难的题你也做得出来', + '我就知道是你', + '下次比赛,我押你', + '你是我最熟的同学……之一', + ], familiar: ['又是你', '你今天戳了几次了', '习惯了,戳吧', '老熟人了', '又来找我?'], // ---- 接进来的其他数据 ---- diff --git a/packages/contract/src/mascot.ts b/packages/contract/src/mascot.ts index 3cf84e3..2f54479 100644 --- a/packages/contract/src/mascot.ts +++ b/packages/contract/src/mascot.ts @@ -39,6 +39,11 @@ export type MascotEventInput = z.infer export const mascotProfileSchema = z.object({ /** 一共点开过她几次(open 事件数),按账号算 */ pokes: z.number().int(), + /** + * 一共做对了几道不同的题,含比赛题(就是成就的 accepted_count,user_stat 里存着的那个数)。 + * 看板娘的好感按它分档,点她多少次不算 + */ + solved: z.number().int(), /** 今天(东八区)做对了几道不同的题 */ todaySolved: z.number().int(), /** 一共点过几次「太难了」(做对后的点评) */