好感按一共做对几道分五档(不到 10 / 10 / 25 / 40 / 100 道,含比赛题,和成就同一个数), 档位不显示。门槛按最近 60 天交过题的 378 个学生定的:前四档大约各占四分之一,最高一档是前一成。 - 每档多一组自己的话,越往上越软,高档也抽得到低档的;只掺进闲聊、夸人、空白题目页、 题目列表这几类情况,现有台词不改。 - 越熟越耐烦:最低档点到第 7 次背过身,最高档到第 10 次。 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
3987b83220
commit
47c0b5464d
5 files changed
+93
-8
No files matched your search
@@ -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<number>`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: {
|
||||
|
||||
@@ -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<typeof setTimeout> | 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
|
||||
}
|
||||
|
||||
|
||||
@@ -101,6 +101,40 @@ const TODAY_MIN = 3
|
||||
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'])
|
||||
|
||||
/**
|
||||
* 好感档位:一共做对几道(含比赛题,和成就一个数)。点她多少次不算,脚本刷不上去。
|
||||
* 门槛按生产库定的(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 {
|
||||
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' }
|
||||
}
|
||||
|
||||
@@ -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: ['又是你', '你今天戳了几次了', '习惯了,戳吧', '老熟人了', '又来找我?'],
|
||||
|
||||
// ---- 接进来的其他数据 ----
|
||||
|
||||
Reference in new issue
Block a user