- 题目页:这节课同班做对的人多就说「全班已经 N 个人做对了」、都卡着就安慰、第一个做对的夸一句; 通过率不到三成的题错了说「本来就难」、高于七成的说「再看看题」;AI 提示开了没用就提一句; 做对了还有挑战就问敢不敢;流程图分低、拿 S 各有话 - 常点「太难了」的学生错了换软话;这周交得多、例子跑得少的念一句;卡住却从不举手的劝举手; 这周要提示多的劝先自己想 - 首页(这节课没要做的时)每次戳随机说一件:知识点差几道升级、周榜差几道超过前一名、 今天明天的比赛、三天内的新公告;两周以上没来说「好久不见」,只说一回 - 排名页第一名、差前一名几道;成就页差一点的;自学没全对的练一练;专题差最后一道拿奖章 - 运行例子、自己输入、举手也记进 mascot_event(trial、help),GET /mascot/profile 多给「太难了」次数和这一周交、跑例子、举手、要提示的次数 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
81ef4dca6b
commit
aa7e0a6eb8
12 files changed
+296
-25
No files matched your search
@@ -1,11 +1,11 @@
|
||||
import { mascotEventSchema, type MascotProfile } from '@oj2/contract'
|
||||
import { and, count, countDistinct, desc, eq, gte, isNull, sql } from 'drizzle-orm'
|
||||
import { and, count, countDistinct, desc, eq, gte, inArray, isNull, sql } from 'drizzle-orm'
|
||||
import { Hono } from 'hono'
|
||||
|
||||
import { requireAuth, type AppEnv } from '../auth/middleware'
|
||||
import { db, schema } from '../db'
|
||||
import { parseBody, success } from '../http'
|
||||
import { dayStart } from '../time'
|
||||
import { dayStart, weekStart } from '../time'
|
||||
|
||||
export const mascotRoutes = new Hono<AppEnv>()
|
||||
|
||||
@@ -49,7 +49,9 @@ mascotRoutes.get('/mascot/profile', requireAuth, async (c) => {
|
||||
const userId = c.get('user')!.id
|
||||
const s = schema.submission
|
||||
|
||||
const [pokes, today, recent] = await Promise.all([
|
||||
const week = weekStart()
|
||||
const e = schema.mascotEvent
|
||||
const [pokes, today, recent, tooHard, weekSubmits, weekEvents, weekHints] = await Promise.all([
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.mascotEvent)
|
||||
@@ -64,7 +66,26 @@ mascotRoutes.get('/mascot/profile', requireAuth, async (c) => {
|
||||
.where(and(eq(s.userId, userId), isNull(s.contestId)))
|
||||
.orderBy(desc(s.createTime))
|
||||
.limit(RECENT_SUBMISSIONS),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.reaction)
|
||||
.where(and(eq(schema.reaction.userId, userId), eq(schema.reaction.type, 'too_hard'))),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(s)
|
||||
.where(and(eq(s.userId, userId), isNull(s.contestId), gte(s.createTime, week))),
|
||||
db
|
||||
.select({ kind: e.kind, value: count() })
|
||||
.from(e)
|
||||
.where(and(eq(e.userId, userId), inArray(e.kind, ['trial', 'help']), gte(e.createdAt, week)))
|
||||
.groupBy(e.kind),
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.aiHint)
|
||||
.innerJoin(s, eq(s.id, schema.aiHint.submissionId))
|
||||
.where(and(eq(s.userId, userId), gte(schema.aiHint.createTime, week))),
|
||||
])
|
||||
const eventCount = (kind: string) => weekEvents.find((row) => row.kind === kind)?.value ?? 0
|
||||
|
||||
const types = new Map<string, number>()
|
||||
for (const row of recent) if (row.result === 4 && row.errorType) types.set(row.errorType, (types.get(row.errorType) ?? 0) + 1)
|
||||
@@ -74,6 +95,13 @@ mascotRoutes.get('/mascot/profile', requireAuth, async (c) => {
|
||||
return success(c, {
|
||||
pokes: pokes[0]?.value ?? 0,
|
||||
todaySolved: today[0]?.value ?? 0,
|
||||
tooHard: tooHard[0]?.value ?? 0,
|
||||
week: {
|
||||
submits: weekSubmits[0]?.value ?? 0,
|
||||
trials: eventCount('trial'),
|
||||
helps: eventCount('help'),
|
||||
aiHints: weekHints[0]?.value ?? 0,
|
||||
},
|
||||
recent: {
|
||||
total: recent.length,
|
||||
wrongAnswer: tally(-1),
|
||||
|
||||
@@ -61,8 +61,14 @@ async function load() {
|
||||
onMounted(load)
|
||||
watch(name, load)
|
||||
|
||||
// 看板娘:只在看自己的成就时说话
|
||||
useMascotGuide(() => (data.value && !other.value && !userStore.isTeacherOrAbove ? { situation: 'achievement' } : null))
|
||||
// 看板娘:只在看自己的成就时说话。「快拿到了」里最近的一个能说成「再做对 3 道」的,报它的名字;
|
||||
// 「还差 3 次」「一天做对 5 道」这种前面加不了「再」,往后找
|
||||
useMascotGuide(() => {
|
||||
if (!data.value || other.value || userStore.isTeacherOrAbove) return null
|
||||
const item = near.value.find((it) => it.remain.startsWith('再'))
|
||||
if (!item) return { situation: 'achievement' }
|
||||
return { situation: 'achievement.near', vars: { name: item.achievement.name, need: item.remain.slice(1) } }
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -5,13 +5,13 @@ import { Icon } from '@iconify/vue'
|
||||
import { getAnnouncementList, getKnowledgeMap, getContestList, getLastVisit, getLearnProgress, getSubmissions, getTutorials, getWeeklyRank } from 'oj/api'
|
||||
import { getClassActivity } from 'shared/api'
|
||||
import SubmissionResultTag from 'shared/components/SubmissionResultTag.vue'
|
||||
import { pickHeadline, type Headline } from 'oj/user/knowledge'
|
||||
import { levelName, MAX_LEVEL, pickHeadline, remainingToNext, tagLink, type Headline } from 'oj/user/knowledge'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
import { activityMode } from 'shared/store/lesson'
|
||||
import { focusJumpAction, goAction, randomProblemAction, useMascotGuide } from 'shared/mascot/guide'
|
||||
import { focusJumpAction, goAction, randomProblemAction, useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
|
||||
import { ContestStatus, CONTEST_STATUS, JUDGE_STATUS, LANGUAGE_SHOW_VALUE } from 'utils/constants'
|
||||
import { duration, parseTime, zonedParts, zonedYear } from 'utils/functions'
|
||||
import type { AnnouncementListItem, ClassActivity, ClassActivityProblem, Contest, LastVisit, SubmissionListItem, WeeklyRank } from 'utils/types'
|
||||
import type { AnnouncementListItem, ClassActivity, ClassActivityProblem, Contest, KnowledgeMap, LastVisit, SubmissionListItem, WeeklyRank } from 'utils/types'
|
||||
|
||||
interface Track {
|
||||
type: TutorialType
|
||||
@@ -37,6 +37,8 @@ const classActivity = ref<ClassActivity | null>(null)
|
||||
const lastVisit = ref<LastVisit | null>(null)
|
||||
/** 知识点地图挑出来的那一句(本周升级 / 再做几道升档 / 去点亮一个),见 oj/user/knowledge.ts */
|
||||
const knowledgeHeadline = ref<Headline | null>(null)
|
||||
/** 地图原样留着给看板娘:她要知识点名、档位名、还差几道,不是拼好的那句话 */
|
||||
const knowledge = ref<KnowledgeMap | null>(null)
|
||||
const loaded = ref(false)
|
||||
|
||||
/** 头一回登录、还没交过:问候换成「欢迎」,告诉他题在哪找 */
|
||||
@@ -124,7 +126,8 @@ async function loadAnnouncements() {
|
||||
}
|
||||
|
||||
async function loadKnowledge() {
|
||||
knowledgeHeadline.value = pickHeadline(await getKnowledgeMap())
|
||||
knowledge.value = await getKnowledgeMap()
|
||||
knowledgeHeadline.value = pickHeadline(knowledge.value)
|
||||
}
|
||||
|
||||
async function loadClassActivity() {
|
||||
@@ -246,16 +249,85 @@ watch(
|
||||
)
|
||||
|
||||
// 看板娘:今天有课(和「这节课」那张卡同一个条件)就催今天的题,没课就随便做一道
|
||||
let greetedBack = false
|
||||
useMascotGuide(() => {
|
||||
if (!loaded.value) return null
|
||||
const random = randomProblemAction(router)
|
||||
const learn = goAction(router, '自学', '/learn')
|
||||
if (!classToday.value || !classActivity.value?.problems.length) return { situation: 'home.free', actions: [focusJumpAction(), random, learn] }
|
||||
const lesson = classToday.value && !!classActivity.value?.problems.length
|
||||
const next = nextProblem.value
|
||||
if (!next) return { situation: 'home.done', actions: [random, learn] }
|
||||
return { situation: 'home.assigned', actions: [goAction(router, '今天的题', `/problem/${next.problemDisplayId}`), focusJumpAction(), random] }
|
||||
if (lesson && next)
|
||||
return { situation: 'home.assigned', actions: [goAction(router, '今天的题', `/problem/${next.problemDisplayId}`), focusJumpAction(), random] }
|
||||
const base: MascotGuide = lesson
|
||||
? { situation: 'home.done', actions: [random, learn] }
|
||||
: { situation: 'home.free', actions: [focusJumpAction(), random, learn] }
|
||||
// 好久没来:只在这次进来后第一次点她时说,之后照常
|
||||
if (awayDays.value >= 14 && !greetedBack) {
|
||||
greetedBack = true
|
||||
return { ...base, situation: 'home.back', n: awayDays.value }
|
||||
}
|
||||
// 这节课做完了或者没课:从别的数据里挑能说的,每点一次随机说一件,点几下听到的不一样
|
||||
const extras = extraGuides(random)
|
||||
return extras.length ? extras[Math.floor(Math.random() * extras.length)]! : base
|
||||
})
|
||||
|
||||
const DAY_MS = 864e5
|
||||
|
||||
function extraGuides(random: MascotAction): MascotGuide[] {
|
||||
const list: MascotGuide[] = []
|
||||
|
||||
// 比赛:进行中,或者 24 小时内开始(那就只可能是今天或明天)
|
||||
const now = Date.now()
|
||||
const contest = contests.value.find((it) => it.status === ContestStatus.underway || Date.parse(it.startTime) - now < DAY_MS)
|
||||
if (contest) {
|
||||
const when = contest.status === ContestStatus.underway ? '现在' : dayKey(contest.startTime) === dayKey(new Date()) ? '今天' : '明天'
|
||||
list.push({ situation: 'home.contestSoon', vars: { when }, actions: [goAction(router, '去看看', `/contest/${contest.id}`), random] })
|
||||
}
|
||||
|
||||
// 公告:三天内发的,挑最新那条
|
||||
const fresh = announcements.value.filter((it) => now - Date.parse(it.createTime) < 3 * DAY_MS).sort((a, b) => b.createTime.localeCompare(a.createTime))[0]
|
||||
if (fresh) {
|
||||
list.push({
|
||||
situation: 'home.newAnnouncement',
|
||||
actions: [{ label: '去看看', run: () => router.push({ path: '/announcement', query: { id: fresh.id } }) }, random],
|
||||
})
|
||||
}
|
||||
|
||||
// 知识点:和首页那一行同一个先后(本周升级 > 离下一档最近 > 班里在学、自己还没碰过),只说一件
|
||||
const map = knowledge.value
|
||||
if (map) {
|
||||
const touched = new Set(map.classTouched)
|
||||
const go = (name: string) => ({ label: '去做', run: () => router.push(tagLink(name)) })
|
||||
const upgraded = map.tags.filter((tag) => tag.level > tag.levelAtWeekStart).sort((a, b) => b.level - a.level)[0]
|
||||
const learning = map.tags
|
||||
.filter((tag) => tag.level >= 1 && tag.level < MAX_LEVEL)
|
||||
.sort((a, b) => Number(touched.has(b.name)) - Number(touched.has(a.name)) || remainingToNext(a)! - remainingToNext(b)!)[0]
|
||||
const untouched = map.tags.find((tag) => tag.level === 0 && touched.has(tag.name))
|
||||
if (upgraded)
|
||||
list.push({ situation: 'home.knowledgeUp', vars: { tag: upgraded.name, level: levelName(upgraded.level) }, actions: [go(upgraded.name), random] })
|
||||
else if (learning)
|
||||
list.push({
|
||||
situation: 'home.knowledgeNext',
|
||||
n: remainingToNext(learning)!,
|
||||
vars: { tag: learning.name, level: levelName(learning.level + 1) },
|
||||
actions: [go(learning.name), random],
|
||||
})
|
||||
else if (untouched) list.push({ situation: 'home.knowledgeFresh', vars: { tag: untouched.name }, actions: [go(untouched.name), random] })
|
||||
}
|
||||
|
||||
// 本周进步榜:第一就说第一;前一名在榜面上(只回前几名)、差得不多就报还要几道。
|
||||
// 同分时提交少的在前,追平不一定超得过,所以按多做 1 道算
|
||||
const me = weekly.value?.me
|
||||
if (me) {
|
||||
const rank = goAction(router, '去排名', '/rank?period=week')
|
||||
const ahead = weekly.value!.results.find((row) => row.rank === me.rank - 1)
|
||||
if (me.rank === 1) list.push({ situation: 'rank.top', actions: [rank, random] })
|
||||
else if (ahead && ahead.solvedCount - me.solvedCount <= 5)
|
||||
list.push({ situation: 'rank.gap', n: ahead.solvedCount - me.solvedCount + 1, actions: [rank, random] })
|
||||
}
|
||||
return list
|
||||
}
|
||||
|
||||
function learnLink(track: Track) {
|
||||
return tutorialPath(track.type, track.step)
|
||||
}
|
||||
|
||||
@@ -99,7 +99,16 @@ function doneIn(course: Course, ids: number[]) {
|
||||
useMascotGuide(() => {
|
||||
if (!loaded.value || userStore.isTeacherOrAbove) return null
|
||||
const it = resume.value
|
||||
if (it) return { situation: 'learn.continue', n: it.step, actions: [goAction(router, '接着学', tutorialPath(it.course.type, it.step))] }
|
||||
if (it) {
|
||||
const next = goAction(router, '接着学', tutorialPath(it.course.type, it.step))
|
||||
// 接着学的那课之前,离它最近的一课打开过、练一练却没全对(「在学」那一态):先叫回去看看
|
||||
const { titles, progress } = it.course
|
||||
for (let step = it.step - 1; step >= 1; step--) {
|
||||
if (tutorialState(progress[titles[step - 1]!.id]) !== 'started') continue
|
||||
return { situation: 'learn.wrongExercise', n: step, actions: [goAction(router, '去看看', tutorialPath(it.course.type, step)), next] }
|
||||
}
|
||||
return { situation: 'learn.continue', n: it.step, actions: [next] }
|
||||
}
|
||||
return { situation: 'learn.fresh', actions: [goAction(router, '第 1 课', tutorialPath(tab.value, 1))] }
|
||||
})
|
||||
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { useBreakpoints } from 'shared/composables/breakpoints'
|
||||
import { useMascotGuide, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
|
||||
import { habitSituation, refreshMascotProfile, requestSpeech } from 'shared/mascot/behavior'
|
||||
import { HINT_MIN_FAILURES, isFlowchartPass } from '@oj2/contract'
|
||||
import { habitSituation, logMascotEvent, refreshMascotProfile, requestSpeech } from 'shared/mascot/behavior'
|
||||
import { useChallengeStore } from 'oj/store/challenge'
|
||||
import { useFlowchartStore } from 'oj/store/flowchart'
|
||||
import type { MascotSituation } from 'shared/mascot/lines'
|
||||
import { useCollabStore } from 'shared/store/collab'
|
||||
import { useLessonStore } from 'shared/store/lesson'
|
||||
@@ -33,6 +36,19 @@ const RESULT_SITUATION: Partial<Record<SubmissionStatus, MascotSituation>> = {
|
||||
const IDLE_MS = 15 * 60_000
|
||||
/** 连着这么多次没跑例子就交,而且错了,她就念一句 */
|
||||
const UNSAMPLED_SUBMITS = 3
|
||||
/** 同班做对这么多人,就拿出来说「全班已经 N 个人做对了」 */
|
||||
const CLASS_MANY = 5
|
||||
/** 同班交过这么多人、做对的不超过 CLASS_FEW 个,算「全班都卡着」 */
|
||||
const CLASS_TRIED = 5
|
||||
const CLASS_FEW = 2
|
||||
/** 全站交过这么多次,通过率才有意义 */
|
||||
const RATE_MIN_SUBMISSIONS = 30
|
||||
const HARD_RATE = 0.3
|
||||
const EASY_RATE = 0.7
|
||||
|
||||
/** 几种「换个说法」各自的机会:不是每次都说同一件事 */
|
||||
const CHANCE = { classMany: 0.3, classFew: 0.5, rate: 0.4, hint: 0.5, challenge: 0.4 } as const
|
||||
const roll = (chance: number) => Math.random() < chance
|
||||
|
||||
const FINISHED = (result: number | undefined) =>
|
||||
result !== undefined && result !== SubmissionStatus.pending && result !== SubmissionStatus.judging && result !== SubmissionStatus.submitting
|
||||
@@ -57,6 +73,8 @@ export function useProblemMascotGuide(showResult: (segment: ResultSegment) => vo
|
||||
const ctx = useProblemPageContext()
|
||||
const teacherCollab = useTeacherCollab()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
const flowchartStore = useFlowchartStore()
|
||||
const challengeStore = useChallengeStore()
|
||||
|
||||
/** 学生在这道题上能用的几个按钮,点她和她主动开口共用 */
|
||||
function studentActions(problem: ProblemDetail) {
|
||||
@@ -100,7 +118,13 @@ export function useProblemMascotGuide(showResult: (segment: ResultSegment) => vo
|
||||
const entry = ctx.value
|
||||
const { runSamples, help, why, whySamples, submit } = studentActions(problem)
|
||||
|
||||
if (codeStore.code.language === 'Flowchart') return guide('problem.flowchart', help)
|
||||
if (codeStore.code.language === 'Flowchart') {
|
||||
// 画过、评过分了:分低就说分低,拿了 S 夸一句
|
||||
const grade = flowchartStore.latestRating.grade
|
||||
if (grade === 'S') return guide('flowchart.good', help)
|
||||
if (grade && !isFlowchartPass(grade)) return guide('flowchart.low', help)
|
||||
return guide('problem.flowchart', help)
|
||||
}
|
||||
// 正在判、正在跑例子:没什么好说的,打个招呼
|
||||
if (submissionStore.judging || submissionStore.pending || submissionStore.trial.samplesRunning.value) return null
|
||||
|
||||
@@ -115,18 +139,45 @@ export function useProblemMascotGuide(showResult: (segment: ResultSegment) => vo
|
||||
}
|
||||
|
||||
const result = submissionStore.submission?.result
|
||||
const lesson = lessonStore.find(problem._id)
|
||||
if (result === SubmissionStatus.accepted) {
|
||||
const next = nextAction()
|
||||
// 课堂条的数是拉的时候的,刚做对重拉回来可能已经把自己算进去了
|
||||
if (lesson && lesson.acceptedCount <= (lesson.myStatus === 'accepted' ? 1 : 0)) return guide('class.firstSolver', next)
|
||||
if (entry.challenges && challengeStore.current?.challenges.length && roll(CHANCE.challenge)) return guide('problem.challenge', next)
|
||||
const fails = problemStore.failCount
|
||||
return guide(fails === 0 ? 'problem.firstTry' : fails >= FINALLY_FAILS ? 'problem.finally' : 'problem.accepted', next)
|
||||
}
|
||||
const failed = result === undefined ? undefined : RESULT_SITUATION[result as SubmissionStatus]
|
||||
if (failed) return stuck ? guide('problem.stuck', help, why) : guide(failed, why, failed === 'problem.wrong' ? runSamples : null, help)
|
||||
if (failed) {
|
||||
if (stuck) return guide('problem.stuck', help, why)
|
||||
const actions = [why, failed === 'problem.wrong' ? runSamples : null, help]
|
||||
// AI 提示开了还没要:提一句,按钮带去结果里(提示在那儿)
|
||||
const hintOpen = !problem.contestId && problemStore.failCount >= HINT_MIN_FAILURES && !submissionStore.hint.hintContent.value
|
||||
if (hintOpen && roll(CHANCE.hint)) return guide('problem.hintReady', ...actions)
|
||||
const classGuide = classSituation(lesson)
|
||||
if (classGuide) return { ...guide(classGuide.situation, ...actions), n: classGuide.n }
|
||||
const rate = problem.submissionNumber >= RATE_MIN_SUBMISSIONS ? problem.acceptedNumber / problem.submissionNumber : null
|
||||
if (rate !== null && rate < HARD_RATE && roll(CHANCE.rate)) return guide('problem.hardWrong', ...actions)
|
||||
if (rate !== null && rate > EASY_RATE && failed === 'problem.wrong' && roll(CHANCE.rate)) return guide('problem.easyWrong', ...actions)
|
||||
return guide(failed, ...actions)
|
||||
}
|
||||
|
||||
if (problem.myStatus === SubmissionStatus.accepted) return guide('problem.solvedBefore', nextAction())
|
||||
if (codeStore.code.value.trim().length < 10) return guide(entry.entry === 'problemset' ? 'problem.inSet' : 'problem.blank', runSamples, help)
|
||||
const classGuide = classSituation(lesson)
|
||||
if (classGuide?.situation === 'class.manyDone') return { ...guide('class.manyDone', runSamples, help), n: classGuide.n }
|
||||
return guide('problem.unsubmitted', runSamples, help)
|
||||
|
||||
/** 这节课的题:同班多少人做对了(课堂条那份数),不在这节课里就没有 */
|
||||
function classSituation(item: typeof lesson) {
|
||||
if (!item || item.myStatus === 'accepted') return null
|
||||
if (item.acceptedCount >= CLASS_MANY && roll(CHANCE.classMany)) return { situation: 'class.manyDone' as const, n: item.acceptedCount }
|
||||
if (item.userCount >= CLASS_TRIED && item.acceptedCount <= CLASS_FEW && roll(CHANCE.classFew))
|
||||
return { situation: 'class.fewDone' as const, n: item.acceptedCount }
|
||||
return null
|
||||
}
|
||||
|
||||
/** 做对之后去哪:专题里回专题,这节课里去下一道还没做的 */
|
||||
function nextAction(): MascotAction | null {
|
||||
if (entry.entry === 'problemset') return { label: '回专题', run: () => router.push(`/problemset/${entry.problemSetId}`) }
|
||||
@@ -159,6 +210,22 @@ export function useProblemMascotGuide(showResult: (segment: ResultSegment) => vo
|
||||
if (!at) return
|
||||
sampledSinceSubmit = true
|
||||
lastActivity = Date.now()
|
||||
if (student()) logMascotEvent({ kind: 'trial', action: 'samples' })
|
||||
},
|
||||
)
|
||||
// 「运行例子」「自己输入」「举手」别处都不存,记下来给她看一周的习惯(behavior.ts)
|
||||
watch(
|
||||
() => submissionStore.trial.customRunning.value,
|
||||
(running) => {
|
||||
if (!running) return
|
||||
lastActivity = Date.now()
|
||||
if (student()) logMascotEvent({ kind: 'trial', action: 'custom' })
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => collabStore.helpStatus,
|
||||
(status, previous) => {
|
||||
if (status === 'pending' && previous === 'idle' && student()) logMascotEvent({ kind: 'help' })
|
||||
},
|
||||
)
|
||||
watch(
|
||||
|
||||
@@ -131,7 +131,10 @@ useMascotGuide(() => {
|
||||
const todo = problems.value.filter((item) => item.isRequired && !item.isCompleted)
|
||||
const next = todo[0]
|
||||
if (progress.value?.isCompleted || !next) return { situation: 'problemset.done', actions: [goAction(router, '专题列表', '/problemset')] }
|
||||
return { situation: 'problemset.unfinished', n: todo.length, actions: [{ label: '去做下一道', run: () => router.push(problemLink(next)) }] }
|
||||
const actions = [{ label: '去做下一道', run: () => router.push(problemLink(next)) }]
|
||||
// 只剩最后一道、做完还有奖章没拿:说「就差最后 1 道」比「还差 1 道」更想让人做完
|
||||
if (todo.length === 1 && set.value.badges.some((badge) => !badge.isEarned)) return { situation: 'problemset.lastOne', actions }
|
||||
return { situation: 'problemset.unfinished', n: todo.length, actions }
|
||||
})
|
||||
|
||||
/** 编辑只给出题人和超管(后台按归属校验,别人点进去是 404) */
|
||||
|
||||
@@ -280,7 +280,13 @@ useMascotGuide(() => {
|
||||
const el = document.querySelector('.track-card .entry.me, .track-card .prow.me, .track-card .tag.me')
|
||||
el?.scrollIntoView({ behavior: 'smooth', block: 'center' })
|
||||
}
|
||||
return { situation: 'rank', actions: listed ? [{ label: '我在哪', run: findMe }] : [] }
|
||||
const actions = listed ? [{ label: '我在哪', run: findMe }] : []
|
||||
const { me: mine, ahead } = board.value
|
||||
if (mine?.solved && mine.rank === 1) return { situation: 'rank.top', actions }
|
||||
// 同分按先做到排,追平还在后面,所以要比前一名多做 1 道
|
||||
const gap = mine && ahead ? ahead.solved - mine.solved : null
|
||||
if (gap !== null && gap <= 5) return { situation: 'rank.gap', n: gap + 1, actions }
|
||||
return { situation: 'rank', actions }
|
||||
})
|
||||
|
||||
function open(username: string) {
|
||||
|
||||
@@ -23,7 +23,7 @@ import { TIME_ZONE_OFFSET_MINUTES } from '@oj2/contract'
|
||||
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, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
|
||||
import { currentMascotGuide, fillLine, type MascotAction, type MascotGuide } from 'shared/mascot/guide'
|
||||
import { logMascotEvent, notePoke, personalize, refreshMascotProfile, speechRequest } from 'shared/mascot/behavior'
|
||||
import { useAuthModalStore } from 'shared/store/authModal'
|
||||
import { useCollabStore } from 'shared/store/collab'
|
||||
@@ -273,12 +273,12 @@ const TIME_CHANCE = 0.15
|
||||
|
||||
function pickFor(guide: MascotGuide) {
|
||||
let situation = guide.situation
|
||||
if (!SERIOUS.has(situation) && guide.n === undefined) {
|
||||
if (!SERIOUS.has(situation) && 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
|
||||
}
|
||||
return pick(situation).replace('{n}', String(guide.n ?? ''))
|
||||
return fillLine(pick(situation), guide)
|
||||
}
|
||||
|
||||
/** 同一组里不连着抽到同一句 */
|
||||
@@ -328,7 +328,7 @@ watch(speechRequest, (speech) => {
|
||||
if (performance.now() - lastSpokeAt < SPEAK_GAP_MS) return
|
||||
lastSpokeAt = performance.now()
|
||||
spokenKeys.add(speech.key)
|
||||
const text = pick(speech.situation).replace('{n}', String(speech.n ?? ''))
|
||||
const text = fillLine(pick(speech.situation), speech)
|
||||
say({ text, actions: speech.actions?.slice(0, 3), situation: speech.situation }, SPOKEN_BUBBLE_MS)
|
||||
logMascotEvent({ kind: 'speak', situation: speech.situation, line: text })
|
||||
})
|
||||
|
||||
@@ -83,6 +83,15 @@ export function habitSituation(failure: MascotSituation): MascotSituation | 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
|
||||
/** 点开过这么多次算老熟人 */
|
||||
@@ -96,7 +105,21 @@ const PRAISE = new Set<MascotSituation>(['problem.accepted', 'problem.firstTry',
|
||||
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 }
|
||||
if (profile.pokes + sessionPokes === 0) return { ...guide, situation: 'first.meet', n: undefined, vars: undefined }
|
||||
const situation = guide.situation
|
||||
// 常点「太难了」的,错了先软着说,不翻老毛病
|
||||
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) {
|
||||
|
||||
@@ -19,6 +19,8 @@ export interface MascotGuide {
|
||||
situation: MascotSituation
|
||||
/** 台词里 `{n}` 换成它:还差几道、第几课、几个人举手 */
|
||||
n?: number
|
||||
/** 别的占位:`{tag}` 换成 vars.tag(知识点名、成就名、「明天」……) */
|
||||
vars?: Record<string, string | number>
|
||||
/** 最多三个,第一个是主按钮 */
|
||||
actions?: MascotAction[]
|
||||
}
|
||||
@@ -46,6 +48,15 @@ export function currentMascotGuide(): MascotGuide | null {
|
||||
return null
|
||||
}
|
||||
|
||||
/** 把台词里的 `{n}` 和 `{xxx}` 换成真的数和字 */
|
||||
export function fillLine(line: string, guide: Pick<MascotGuide, 'n' | 'vars'>) {
|
||||
return line.replace(/\{(\w+)\}/g, (all, key: string) => {
|
||||
if (key === 'n' && guide.n !== undefined) return String(guide.n)
|
||||
const value = guide.vars?.[key]
|
||||
return value === undefined ? all : String(value)
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 几个页面都用得上的按钮 ----
|
||||
|
||||
/** 顶栏「输入题号直达」框:看板娘点一下就把光标放进去。ProblemJumpBox 盯着这个数 */
|
||||
|
||||
@@ -148,6 +148,39 @@ export const MASCOT_LINES = {
|
||||
'first.meet': ['初次见面。……才不是特意等你', '你是新来的?', '第一次戳我?', '叫我小鲸鱼就行,别叫大肥鱼'],
|
||||
familiar: ['又是你', '你今天戳了几次了', '习惯了,戳吧', '老熟人了', '又来找我?'],
|
||||
|
||||
// ---- 接进来的其他数据 ----
|
||||
// 这节课的题:同班多少人做对了(课堂条那份数)
|
||||
'class.manyDone': ['全班已经 {n} 个人做对了', '{n} 个人都对了,你呢', '就差你们几个了……没说你', '别人都对了,加把劲'],
|
||||
'class.fewDone': ['这题全班才 {n} 个做对,别慌', '难的不只你一个', '大家都卡着呢', '全班都在跟它较劲'],
|
||||
'class.firstSolver': ['全班第一个?……还行吧', '比别人都快,别得意', '第一个做对的,记你一功'],
|
||||
// 题目难度、全站通过率
|
||||
'problem.hardWrong': ['这题本来就难', '通过率不到三成,慢慢来', '难题错几次正常'],
|
||||
'problem.easyWrong': ['这题挺简单的……再看看题', '大多数人一次就过了,看看细节', '简单题也别大意'],
|
||||
// AI 提示、挑战、流程图
|
||||
'problem.hintReady': ['卡这么久,要个 AI 提示?', 'AI 提示能用了,不丢人', '提示又不扣分'],
|
||||
'problem.challenge': ['做对了?还有挑战,敢不敢', '挑战等着你呢', '光做对还不算厉害'],
|
||||
'flowchart.low': ['流程图分不高啊', '判断框再看看', '箭头都连对了吗', '再画一版'],
|
||||
'flowchart.good': ['S?……还行', '图画得不错嘛', '这图可以'],
|
||||
// 常点「太难了」的学生,错了换成软一点的
|
||||
'gentle.wrong': ['慢慢来,又不赶', '错了就改,没什么', '一步一步来', '不急,我等你'],
|
||||
// 这一周的习惯
|
||||
'habit.weekNoSamples': ['这周交了 {n} 次,例子没跑几回', '你是不是从来不跑例子', '这周的例子都白给了'],
|
||||
'habit.aiHintMany': ['这周要了 {n} 次提示了,先自己想想', 'AI 又不是你同桌', '先想五分钟再要提示'],
|
||||
'habit.neverHelp': ['从来不举手,硬撑?', '举个手又不会怎样', '卡这么久还不举手'],
|
||||
// 首页:知识点、排名、比赛、公告、好久没来
|
||||
'home.knowledgeNext': ['{tag}再做 {n} 道就到「{level}」', '{tag}快升级了,就差 {n} 道', '{tag},再来 {n} 道'],
|
||||
'home.knowledgeUp': ['本周{tag}升级了……还行', '{tag}升到「{level}」了,别骄傲'],
|
||||
'home.knowledgeFresh': ['班里在学{tag},你还没碰过', '去点亮{tag}', '{tag}还是空的'],
|
||||
'rank.gap': ['再做 {n} 道就超过前一名了', '前一名就多你 {n} 道', '差 {n} 道,够得着'],
|
||||
'rank.top': ['第一?……别得意', '榜首了,守住', '这周你最能做,哼'],
|
||||
'home.contestSoon': ['{when}有比赛', '比赛快开始了,准备好没', '{when}比赛,别迟到'],
|
||||
'home.newAnnouncement': ['有新公告,没看吧', '公告更新了', '看一眼公告'],
|
||||
'home.back': ['{n} 天没来了,还认得我吗', '好久不见……才没有想你', '回来了?'],
|
||||
// 成就、自学、专题
|
||||
'achievement.near': ['再{need}就拿「{name}」了', '「{name}」快到手了', '离「{name}」不远了'],
|
||||
'learn.wrongExercise': ['第 {n} 课的练一练还没全对', '上次的练一练错了几道,回去看看?', '练一练没做完的,记着呢'],
|
||||
'problemset.lastOne': ['奖章就差最后 1 道', '最后一道了,别停', '就剩一道,冲'],
|
||||
|
||||
// ---- 招呼与彩蛋 ----
|
||||
greet: [
|
||||
'干嘛',
|
||||
|
||||
Reference in new issue
Block a user