feat(看板娘): 做对的题越多,她说话越软、也越耐烦;点她多少次不算
Deploy / deploy (push) Successful in 57s

好感按一共做对几道分五档(不到 10 / 10 / 25 / 40 / 100 道,含比赛题,和成就同一个数),
档位不显示。门槛按最近 60 天交过题的 378 个学生定的:前四档大约各占四分之一,最高一档是前一成。
- 每档多一组自己的话,越往上越软,高档也抽得到低档的;只掺进闲聊、夸人、空白题目页、
  题目列表这几类情况,现有台词不改。
- 越熟越耐烦:最低档点到第 7 次背过身,最高档到第 10 次。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
xuyueandClaude Opus 5.5 committed 2026-11-04 18:26:56 +08:00
1 parent 3987b83220
commit 47c0b5464d
5 files changed
+93 -8

No files matched your search

+7 -1
View File
@@ -51,11 +51,16 @@ mascotRoutes.get('/mascot/profile', requireAuth, async (c) => {
const week = weekStart() const week = weekStart()
const e = schema.mascotEvent 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 db
.select({ value: count() }) .select({ value: count() })
.from(schema.mascotEvent) .from(schema.mascotEvent)
.where(and(eq(schema.mascotEvent.userId, userId), eq(schema.mascotEvent.kind, 'open'))), .where(and(eq(schema.mascotEvent.userId, userId), eq(schema.mascotEvent.kind, 'open'))),
// 成就每次判完都重数一遍存进 user_stat,直接读,不再按提交现数
db
.select({ value: sql<number>`coalesce((${schema.userStat.metrics}->>'accepted_count')::int, 0)` })
.from(schema.userStat)
.where(eq(schema.userStat.userId, userId)),
db db
.select({ value: countDistinct(s.problemId) }) .select({ value: countDistinct(s.problemId) })
.from(s) .from(s)
@@ -94,6 +99,7 @@ mascotRoutes.get('/mascot/profile', requireAuth, async (c) => {
return success(c, { return success(c, {
pokes: pokes[0]?.value ?? 0, pokes: pokes[0]?.value ?? 0,
solved: solved[0]?.value ?? 0,
todaySolved: today[0]?.value ?? 0, todaySolved: today[0]?.value ?? 0,
tooHard: tooHard[0]?.value ?? 0, tooHard: tooHard[0]?.value ?? 0,
week: { week: {
+10 -7
View File
@@ -11,7 +11,8 @@
* - 按下去像玩偶一样压扁(底边不动),松手弹回。 * - 按下去像玩偶一样压扁(底边不动),松手弹回。
* - 点一下冒气泡:一句话加最多三个按钮,按页面、按情况变(页面经 shared/mascot/guide.ts 告诉她), * - 点一下冒气泡:一句话加最多三个按钮,按页面、按情况变(页面经 shared/mascot/guide.ts 告诉她),
* 台词在 shared/mascot/lines.ts。比赛里点她不弹气泡。再点一下、点别处、换页面都收起。 * 台词在 shared/mascot/lines.ts。比赛里点她不弹气泡。再点一下、点别处、换页面都收起。
* - 对学生按行为挑话(常犯的错、今天做了几道、熟不熟,见 shared/mascot/behavior.ts); * - 对学生按行为挑话(常犯的错、今天做了几道、熟不熟,见 shared/mascot/behavior.ts)。好感按一共做对几道分五档,
* 高档多几组软一点的话、也更耐烦(档位不显示);
* 题目页上有几件事她会主动开口,频率上限在下面 SPEAK_GAP_MS。 * 题目页上有几件事她会主动开口,频率上限在下面 SPEAK_GAP_MS。
* - 互动都记进 mascot_event(点开、点按钮、主动开口、摸头、嫌烦、被拖走)。 * - 互动都记进 mascot_event(点开、点按钮、主动开口、摸头、嫌烦、被拖走)。
* - 一句一句地刷(两次点开隔不到 BURST_GAP_MS):第 3 次起不耐烦、第 5 次起炸毛、第 7 次背过身去, * - 一句一句地刷(两次点开隔不到 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 patUrl from 'shared/assets/mascot/whale-pat.gif'
import { MASCOT_LINES, type MascotSituation } from 'shared/mascot/lines' import { MASCOT_LINES, type MascotSituation } from 'shared/mascot/lines'
import { currentMascotGuide, fillLine, mascotBox, type MascotAction, type MascotGuide } from 'shared/mascot/guide' 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 { useAuthModalStore } from 'shared/store/authModal'
import { useCollabStore } from 'shared/store/collab' import { useCollabStore } from 'shared/store/collab'
import { useUserStore } from 'shared/store/user' import { useUserStore } from 'shared/store/user'
@@ -55,11 +56,12 @@ const OVERCLICK = 8 // 一轮连点到这么多下还没摸到头,她就嫌烦
const QUIET_BUBBLE_MS = 5000 // 没有按钮的气泡(只是一句话)过这么久自己收起 const QUIET_BUBBLE_MS = 5000 // 没有按钮的气泡(只是一句话)过这么久自己收起
const SPOKEN_BUBBLE_MS = 10_000 // 她主动说的话,有按钮也过这么久收起:没人点她,不该一直挡着 const SPOKEN_BUBBLE_MS = 10_000 // 她主动说的话,有按钮也过这么久收起:没人点她,不该一直挡着
const SPEAK_GAP_MS = 3 * 60_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 BURST_GAP_MS = 20_000
const PESTER_MILD = 3 const PESTER_MILD = 3
const PESTER_ANGRY = 5 const PESTER_ANGRY = [5, 5, 6, 7, 8] as const
const SULK_AT = 7 const SULK_AT = [7, 7, 8, 9, 10] as const
const SULK_MS = 30_000 const SULK_MS = 30_000
type HAnchor = 'left' | 'right' | null type HAnchor = 'left' | 'right' | null
@@ -290,8 +292,9 @@ let sulkTimer: ReturnType<typeof setTimeout> | undefined
/** 按这一串点到第几次换成不耐烦、炸毛的话;页面的按钮照给,真有事的还能办。背过身去就不给了 */ /** 按这一串点到第几次换成不耐烦、炸毛的话;页面的按钮照给,真有事的还能办。背过身去就不给了 */
function pester(guide: MascotGuide): MascotGuide { function pester(guide: MascotGuide): MascotGuide {
if (burst >= SULK_AT) return { situation: 'sulk' } const tier = affinityTier()
const situation = burst >= PESTER_ANGRY ? 'pester.angry' : burst >= PESTER_MILD ? 'pester.mild' : null 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 return situation ? { situation, actions: guide.actions } : guide
} }
+38
View File
@@ -101,6 +101,40 @@ const TODAY_MIN = 3
const GREETINGS = new Set<MascotSituation>(['greet', 'announcement', 'achievement', 'profile']) const GREETINGS = new Set<MascotSituation>(['greet', 'announcement', 'achievement', 'profile'])
const PRAISE = new Set<MascotSituation>(['problem.accepted', 'problem.firstTry', 'problem.finally', 'home.done', 'home.assigned', 'home.free']) const PRAISE = new Set<MascotSituation>(['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<MascotSituation>([
'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 { export function personalize(guide: MascotGuide): MascotGuide {
const profile = mascotProfile.value 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) { if (PRAISE.has(guide.situation) && profile.todaySolved >= TODAY_MIN && Math.random() < TODAY_CHANCE) {
return { ...guide, situation: 'today.count', n: profile.todaySolved } 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) { if (GREETINGS.has(guide.situation) && profile.pokes >= FAMILIAR_POKES && Math.random() < FAMILIAR_CHANCE) {
return { ...guide, situation: 'familiar' } return { ...guide, situation: 'familiar' }
} }
+33
View File
@@ -146,6 +146,39 @@ export const MASCOT_LINES = {
// 今天和熟悉程度 // 今天和熟悉程度
'today.count': ['今天第 {n} 道了……还行', '{n} 道了,别骄傲', '今天挺能做嘛,{n} 道', '才 {n} 道,再来'], 'today.count': ['今天第 {n} 道了……还行', '{n} 道了,别骄傲', '今天挺能做嘛,{n} 道', '才 {n} 道,再来'],
'first.meet': ['初次见面。……才不是特意等你', '你是新来的?', '第一次戳我?', '叫我小鲸鱼就行,别叫大肥鱼'], 'first.meet': ['初次见面。……才不是特意等你', '你是新来的?', '第一次戳我?', '叫我小鲸鱼就行,别叫大肥鱼'],
// 好感:按一共做对几道分档(behavior.ts 的 AFFINITY_TIERS),档位不显示给学生。
// 每档多一组,越往上口气越软;高档也抽得到低档的。只掺进闲聊类的情况,现有台词不改
'affinity.1': ['最近来得挺勤啊', '又见面了', '题做得不少了嘛', '还算有点眼熟', '你好像没那么菜了', '哼,又是你', '今天也来做题?', '记住你了……才怪'],
'affinity.2': [
'你来了我就不无聊了……才怪',
'你写代码的样子,还挺认真',
'有不会的可以问我……虽然我也不会',
'今天也加油……随口说的',
'跟你说话不算浪费时间',
'别人戳我我才不理',
'你比刚来那会儿强多了',
'做这么多了,还挺能坚持',
],
'affinity.3': [
'……你来得正好',
'我给你留了一道好题……骗你的',
'累了就歇会儿',
'你做题,我放心',
'我才没有一直等你',
'错了也没关系,你能行',
'跟你熟了,不怼你了……偶尔',
'今天也一起做题吧',
],
'affinity.4': [
'……其实你挺厉害的',
'一百多道都做下来了,哼,服了',
'别告诉别人我夸过你',
'有你在,我挺安心的',
'再难的题你也做得出来',
'我就知道是你',
'下次比赛,我押你',
'你是我最熟的同学……之一',
],
familiar: ['又是你', '你今天戳了几次了', '习惯了,戳吧', '老熟人了', '又来找我?'], familiar: ['又是你', '你今天戳了几次了', '习惯了,戳吧', '老熟人了', '又来找我?'],
// ---- 接进来的其他数据 ---- // ---- 接进来的其他数据 ----
+5
View File
@@ -39,6 +39,11 @@ export type MascotEventInput = z.infer<typeof mascotEventSchema>
export const mascotProfileSchema = z.object({ export const mascotProfileSchema = z.object({
/** 一共点开过她几次(open 事件数),按账号算 */ /** 一共点开过她几次(open 事件数),按账号算 */
pokes: z.number().int(), pokes: z.number().int(),
/**
* 一共做对了几道不同的题,含比赛题(就是成就的 accepted_count,user_stat 里存着的那个数)。
* 看板娘的好感按它分档,点她多少次不算
*/
solved: z.number().int(),
/** 今天(东八区)做对了几道不同的题 */ /** 今天(东八区)做对了几道不同的题 */
todaySolved: z.number().int(), todaySolved: z.number().int(),
/** 一共点过几次「太难了」(做对后的点评) */ /** 一共点过几次「太难了」(做对后的点评) */