Files
OJ2/apps/web/src/shared/mascot/behavior.ts
T
xuyueandClaude Opus 5.5 aa8a3b8bfc feat(看板娘): 她会记得前几天被你惹生气、被摸过头,今天第一次点她时翻一次旧账;好感升档那天说一句「熟了一点」
- 只看 7 天内、今天以前的事,同一件事只翻一次(之后说过那句就不再提);顺序是升档 > 惹生气 > 摸头。
- 只在闲聊类情况(空白题目页、题目列表、打招呼、夸人)翻,错了、卡住时照常说正事。
- 好感档位照样不显示,「熟了一点」是唯一的暗示。

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-11-04 19:00:40 +08:00

215 lines
9.2 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import type { MascotEventInput, MascotProfile } from '@oj2/contract'
import api from 'utils/api'
import { useUserStore } from 'shared/store/user'
import type { MascotGuide } from './guide'
import type { MascotSituation } from './lines'
/**
* 看板娘按学生的行为挑话:常犯哪类错、今天做了几道、和她熟不熟。数从 GET /mascot/profile 来,
* 那边按库里的提交和互动记录算;互动本身经 logMascotEvent 记进 mascot_event。
*
* 只对学生。老师的话不跟着行为变。
*/
/** 记一条互动。只记登录了的,失败就算了:前台不等它、也不提示 */
export function logMascotEvent(event: Omit<MascotEventInput, 'path'>) {
if (!useUserStore().isAuthed) return
const body: MascotEventInput = { ...event, path: location.pathname.slice(0, 200) }
if (body.line) body.line = body.line.slice(0, 100)
api.post('mascot/events', body).catch(() => {})
}
export const mascotProfile = shallowRef<MascotProfile | null>(null)
let loadedAt = 0
let loading: Promise<void> | null = null
/** 进页面、交完一次都想要新的数,但别每次都打一遍 */
const STALE_MS = 30_000
export function refreshMascotProfile(force = false) {
const userStore = useUserStore()
if (!userStore.isAuthed || userStore.isTeacherOrAbove) {
mascotProfile.value = null
return null
}
if (loading) return loading
if (!force && mascotProfile.value && Date.now() - loadedAt < STALE_MS) return null
loading = api
.get<MascotProfile>('mascot/profile')
.then((profile) => {
mascotProfile.value = profile
loadedAt = Date.now()
})
.catch(() => {})
.finally(() => {
loading = null
})
return loading
}
/** 这一页会话里点开过几次:第一次见面那句只说一回,刚点过的也算熟了一点 */
let sessionPokes = 0
export function notePoke() {
sessionPokes++
}
// 门槛按本机生产快照定的(2026-11,最近 120 天交过 10 次以上的 266 个学生):最近 20 次里
// 运行出错 ≥3 的 87 人、答案错误 ≥5 的 82 人,各占三成;编译错误有提交前语法检查,≥3 的只有 7 人
const HABIT_MIN = { wrongAnswer: 5, compileError: 3, runtimeError: 3, timeout: 2 } as const
/** 有专门几句的运行错误类型,别的走 habit.runtimeError */
const NAMED_RUNTIME_ERRORS = new Set(['NameError', 'TypeError', 'ValueError', 'EOFError'])
/**
* 这一类错是不是这个学生的老毛病:是就给对应的那组话,不是返回 null。
* 题目页点她、交完错了她主动开口都走这里
*/
export function habitSituation(failure: MascotSituation): MascotSituation | null {
const recent = mascotProfile.value?.recent
if (!recent) return null
switch (failure) {
case 'problem.wrong':
return recent.wrongAnswer >= HABIT_MIN.wrongAnswer ? 'habit.wrongAnswer' : null
case 'problem.compileError':
return recent.compileError >= HABIT_MIN.compileError ? 'habit.compileError' : null
case 'problem.runtimeError': {
if (recent.runtimeError < HABIT_MIN.runtimeError) return null
const type = recent.runtimeErrorType
return type && NAMED_RUNTIME_ERRORS.has(type) ? (`habit.${type}` as MascotSituation) : 'habit.runtimeError'
}
case 'problem.timeout':
return recent.timeout >= HABIT_MIN.timeout ? 'habit.timeout' : null
default:
return null
}
}
const HABIT_CHANCE = 0.6
const GENTLE_CHANCE = 0.5
const WEEK_CHANCE = 0.4
/** 点过这么多次「太难了」,错了就别怼了 */
const GENTLE_TOO_HARD = 3
/** 这一周交了这么多次、例子却跑得不到五分之一,才说她「从来不跑例子」 */
const WEEK_SUBMITS_MIN = 10
/** 这一周要了这么多次 AI 提示,就劝先自己想 */
const WEEK_HINTS_MANY = 5
const FAILURES = new Set<MascotSituation>(['problem.wrong', 'problem.compileError', 'problem.runtimeError', 'problem.timeout'])
const TODAY_CHANCE = 0.4
const FAMILIAR_CHANCE = 0.2
/** 点开过这么多次算老熟人 */
const FAMILIAR_POKES = 50
/** 今天做到这么多道才拿出来说 */
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() {
return tierOf(mascotProfile.value?.solved ?? 0)
}
function tierOf(solved: number) {
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
}
/** 这一页会话里翻过的旧账:说完到下次拉回新数之间别再说一遍 */
const memorySaid = new Set<'closer' | 'grudge' | 'pat'>()
function whenText(days: number) {
return days === 1 ? '昨天' : days === 2 ? '前天' : '前几天'
}
/** 跨天记得的事,有就挑一件:今天升档 > 被惹生气 > 被摸头。每件只翻一次(后端看说没说过) */
function memoryGuide(profile: MascotProfile): Pick<MascotGuide, 'situation' | 'vars'> | 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
if (situation === 'problem.stuck' && week.helps === 0 && Math.random() < WEEK_CHANCE) return { ...guide, situation: 'habit.neverHelp' }
if (situation === 'problem.hintReady' && week.aiHints >= WEEK_HINTS_MANY) return { ...guide, situation: 'habit.aiHintMany', n: week.aiHints }
if (
(situation === 'problem.unsubmitted' || situation === 'problem.samplesFailed') &&
week.submits >= WEEK_SUBMITS_MIN &&
week.trials * 5 < week.submits &&
Math.random() < WEEK_CHANCE
) {
return { ...guide, situation: 'habit.weekNoSamples', n: week.submits }
}
const habit = habitSituation(guide.situation)
if (habit && Math.random() < HABIT_CHANCE) return { ...guide, situation: habit }
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' }
}
return guide
}
// ---- 主动开口 ----
export interface MascotSpeech extends MascotGuide {
/** 同一个 key 一次页面会话里只说一次(比如「这道题卡住了」每道题只说一回) */
key: string
}
/** 页面请她主动说一句;说不说、什么时候说由 Mascot.vue 按频率上限定 */
export const speechRequest = shallowRef<MascotSpeech | null>(null)
export function requestSpeech(speech: MascotSpeech) {
speechRequest.value = speech
}