- 按学生的行为挑话:最近 20 次提交里某类错特别多(运行出错细到 NameError、TypeError、 ValueError、EOFError),再错这一类时点名说;今天做对第 3 道起偶尔数一数; 第一次戳她有见面的话,戳得多了说「又是你」 - 题目页上主动开口:同一题错到第 5 次、连着 3 次没跑例子就交还错了、又犯老毛病、 15 分钟没交没跑。比赛里不说,至少隔 3 分钟,同一件事一题一次,10 秒后收起 - 互动记进新表 mascot_event(0043):点开、点按钮、主动开口、摸头、点烦、被拖走; 只记看板娘这边的事,每人每分钟最多 30 条 - GET /mascot/profile 给最近的错类、今天做对几道、点过几次;门槛按本机生产快照定 (266 人里运行出错 ≥3 的 87 人、答案错误 ≥5 的 82 人) - 编辑过程(粘贴)数据学生侧不用,免得冤枉人 Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
ef3c53c671
commit
81ef4dca6b
12 files changed
+4630
-29
No files matched your search
@@ -0,0 +1,14 @@
|
||||
CREATE TABLE "mascot_event" (
|
||||
"id" integer PRIMARY KEY GENERATED BY DEFAULT AS IDENTITY (sequence name "mascot_event_id_seq" INCREMENT BY 1 MINVALUE 1 MAXVALUE 2147483647 START WITH 1 CACHE 1),
|
||||
"user_id" integer NOT NULL,
|
||||
"kind" text NOT NULL,
|
||||
"situation" text,
|
||||
"line" text,
|
||||
"action" text,
|
||||
"path" text NOT NULL,
|
||||
"created_at" timestamp with time zone DEFAULT now() NOT NULL
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE "mascot_event" ADD CONSTRAINT "mascot_event_user_id_fk_user_id" FOREIGN KEY ("user_id") REFERENCES "public"."user"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint
|
||||
CREATE INDEX "mascot_event_user_kind_idx" ON "mascot_event" USING btree ("user_id","kind");--> statement-breakpoint
|
||||
CREATE INDEX "mascot_event_created_at_idx" ON "mascot_event" USING btree ("created_at");
|
||||
File diff suppressed because it is too large.
Load diff
@@ -302,6 +302,13 @@
|
||||
"when": 1793632543220,
|
||||
"tag": "0042_class_name_cleanup",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 43,
|
||||
"version": "7",
|
||||
"when": 1793693911394,
|
||||
"tag": "0043_mascot_event",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
JudgeStatus,
|
||||
LessonLanguage,
|
||||
LessonMode,
|
||||
MascotEventKind,
|
||||
ProblemChallenges,
|
||||
ProblemDifficulty,
|
||||
ProblemLanguage,
|
||||
@@ -1411,3 +1412,45 @@ export const challengeSuccess = pgTable(
|
||||
}).onDelete('cascade'),
|
||||
],
|
||||
)
|
||||
|
||||
/**
|
||||
* 看板娘的互动记录:点开气泡、点气泡里的按钮、她主动开口、摸头彩蛋、被拖走(种类见契约
|
||||
* mascotEventSchema)。拿来看她说的话、给的按钮学生用不用,以后按学生的行为调整她也读这里;
|
||||
* 「点过几次」(熟悉程度)直接按 user_id 数 open。
|
||||
*
|
||||
* 不是「学生做过什么」的证据,账号没了就没意义,user 外键 CASCADE(外键分档见 CLAUDE.md「数据库」)。
|
||||
*/
|
||||
export const mascotEvent = pgTable(
|
||||
'mascot_event',
|
||||
{
|
||||
id: integer().primaryKey().generatedByDefaultAsIdentity({
|
||||
name: 'mascot_event_id_seq',
|
||||
startWith: 1,
|
||||
increment: 1,
|
||||
minValue: 1,
|
||||
maxValue: 2147483647,
|
||||
cache: 1,
|
||||
}),
|
||||
userId: integer('user_id').notNull(),
|
||||
kind: text().$type<MascotEventKind>().notNull(),
|
||||
situation: text(),
|
||||
line: text(),
|
||||
action: text(),
|
||||
path: text().notNull(),
|
||||
createdAt: timestamp('created_at', {
|
||||
withTimezone: true,
|
||||
mode: 'string',
|
||||
})
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(table) => [
|
||||
index('mascot_event_user_kind_idx').on(table.userId, table.kind),
|
||||
index('mascot_event_created_at_idx').on(table.createdAt),
|
||||
foreignKey({
|
||||
columns: [table.userId],
|
||||
foreignColumns: [user.id],
|
||||
name: 'mascot_event_user_id_fk_user_id',
|
||||
}).onDelete('cascade'),
|
||||
],
|
||||
)
|
||||
@@ -21,6 +21,7 @@ import { problemRoutes } from './routes/problem'
|
||||
import { problemStatsRoutes } from './routes/problem-stats'
|
||||
import { submissionRoutes } from './routes/submission'
|
||||
import { trialRunRoutes } from './routes/trial-run'
|
||||
import { mascotRoutes } from './routes/mascot'
|
||||
import { siteRoutes } from './routes/site'
|
||||
import { scanPendingChallenges } from './services/challenge'
|
||||
import { bridgeSubmissionEvents, isAllowedWebSocketOrigin, startSessionSweep, submissionWebSocketHandler, type SubmissionSocketData } from './websocket'
|
||||
@@ -44,6 +45,7 @@ app.route('/api', problemRoutes)
|
||||
app.route('/api', problemStatsRoutes)
|
||||
app.route('/api', submissionRoutes)
|
||||
app.route('/api', trialRunRoutes)
|
||||
app.route('/api', mascotRoutes)
|
||||
app.route('/api/admin', adminRoutes)
|
||||
|
||||
// 没有这条的话,打错的接口地址回的是 Hono 默认的纯文本「404 Not Found」,前端 utils/api.ts 读不出
|
||||
|
||||
@@ -0,0 +1,86 @@
|
||||
import { mascotEventSchema, type MascotProfile } from '@oj2/contract'
|
||||
import { and, count, countDistinct, desc, eq, gte, 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'
|
||||
|
||||
export const mascotRoutes = new Hono<AppEnv>()
|
||||
|
||||
/**
|
||||
* 每人每分钟最多记这么多条。正常点她一分钟到不了十条(连点只记开头、彩蛋和嫌烦那几下),
|
||||
* 拿脚本刷的才会撞上;撞上了就静默丢掉,不报错 —— 前台本来也不等这个结果
|
||||
*/
|
||||
const EVENTS_PER_MINUTE = 30
|
||||
const windows = new Map<number, { start: number; count: number }>()
|
||||
|
||||
function allow(userId: number) {
|
||||
const now = Date.now()
|
||||
const entry = windows.get(userId)
|
||||
if (!entry || now - entry.start >= 60_000) {
|
||||
windows.set(userId, { start: now, count: 1 })
|
||||
// 顺手清掉过期的,免得 Map 跟着在线人数一直涨
|
||||
if (windows.size > 2000) for (const [id, it] of windows) if (now - it.start >= 60_000) windows.delete(id)
|
||||
return true
|
||||
}
|
||||
entry.count++
|
||||
return entry.count <= EVENTS_PER_MINUTE
|
||||
}
|
||||
|
||||
/** 记一条看板娘互动(种类见契约 mascotEventSchema) */
|
||||
mascotRoutes.post('/mascot/events', requireAuth, async (c) => {
|
||||
const parsed = await parseBody(c, mascotEventSchema, '看板娘记录的格式不对')
|
||||
if (!parsed.success) return parsed.response
|
||||
const user = c.get('user')!
|
||||
if (allow(user.id)) {
|
||||
const { kind, situation, line, action, path } = parsed.data
|
||||
await db.insert(schema.mascotEvent).values({ userId: user.id, kind, situation, line, action, path })
|
||||
}
|
||||
return success(c, null)
|
||||
})
|
||||
|
||||
/** 最近多少次题库提交拿来看学生常犯哪类错 */
|
||||
const RECENT_SUBMISSIONS = 20
|
||||
|
||||
/** 看板娘按学生的行为挑话用的那几个数(契约 mascotProfileSchema) */
|
||||
mascotRoutes.get('/mascot/profile', requireAuth, async (c) => {
|
||||
const userId = c.get('user')!.id
|
||||
const s = schema.submission
|
||||
|
||||
const [pokes, today, recent] = await Promise.all([
|
||||
db
|
||||
.select({ value: count() })
|
||||
.from(schema.mascotEvent)
|
||||
.where(and(eq(schema.mascotEvent.userId, userId), eq(schema.mascotEvent.kind, 'open'))),
|
||||
db
|
||||
.select({ value: countDistinct(s.problemId) })
|
||||
.from(s)
|
||||
.where(and(eq(s.userId, userId), eq(s.result, 0), gte(s.createTime, dayStart()))),
|
||||
db
|
||||
.select({ result: s.result, errorType: sql<string | null>`${s.statisticInfo}->'runtime_error'->>'type'` })
|
||||
.from(s)
|
||||
.where(and(eq(s.userId, userId), isNull(s.contestId)))
|
||||
.orderBy(desc(s.createTime))
|
||||
.limit(RECENT_SUBMISSIONS),
|
||||
])
|
||||
|
||||
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)
|
||||
const topType = [...types.entries()].sort((a, b) => b[1] - a[1])[0]?.[0] ?? null
|
||||
const tally = (...results: number[]) => recent.filter((row) => results.includes(row.result)).length
|
||||
|
||||
return success(c, {
|
||||
pokes: pokes[0]?.value ?? 0,
|
||||
todaySolved: today[0]?.value ?? 0,
|
||||
recent: {
|
||||
total: recent.length,
|
||||
wrongAnswer: tally(-1),
|
||||
compileError: tally(-2),
|
||||
runtimeError: tally(4, 3),
|
||||
timeout: tally(1, 2),
|
||||
runtimeErrorType: topType,
|
||||
},
|
||||
} satisfies MascotProfile)
|
||||
})
|
||||
@@ -1,5 +1,6 @@
|
||||
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 type { MascotSituation } from 'shared/mascot/lines'
|
||||
import { useCollabStore } from 'shared/store/collab'
|
||||
import { useLessonStore } from 'shared/store/lesson'
|
||||
@@ -8,6 +9,7 @@ import { useCodeStore } from 'oj/store/code'
|
||||
import { useProblemStore } from 'oj/store/problem'
|
||||
import { useSubmissionStore, type ResultSegment } from 'oj/store/submission'
|
||||
import { SubmissionStatus } from 'utils/constants'
|
||||
import type { ProblemDetail } from 'utils/types'
|
||||
import { useProblemPageContext } from './problemPageContext'
|
||||
import { useTeacherCollab } from './teacherCollab'
|
||||
|
||||
@@ -27,10 +29,21 @@ const RESULT_SITUATION: Partial<Record<SubmissionStatus, MascotSituation>> = {
|
||||
[SubmissionStatus.real_time_limit_exceeded]: 'problem.timeout',
|
||||
}
|
||||
|
||||
/** 一道题上这么久没交、没跑例子,她就来问一句(主动开口) */
|
||||
const IDLE_MS = 15 * 60_000
|
||||
/** 连着这么多次没跑例子就交,而且错了,她就念一句 */
|
||||
const UNSAMPLED_SUBMITS = 3
|
||||
|
||||
const FINISHED = (result: number | undefined) =>
|
||||
result !== undefined && result !== SubmissionStatus.pending && result !== SubmissionStatus.judging && result !== SubmissionStatus.submitting
|
||||
|
||||
/**
|
||||
* 题目页上看板娘说什么、给哪几个按钮。看的是这一页上最近的那件事:刚交的结果、刚跑的例子,
|
||||
* 都没有就看写没写代码。比赛入口不用管(Mascot.vue 在比赛里不弹)。
|
||||
*
|
||||
* 另外几件事她会主动开口(只对学生,频率上限在 Mascot.vue):同一道题错到第 5 次、连着 3 次
|
||||
* 没跑例子就交还错了、又犯了自己的老毛病(behavior.ts 的 habitSituation)、15 分钟没交没跑。
|
||||
*
|
||||
* `showResult` 是「我错在哪」:把左栏切到「结果」页签的那一段(提交结果 / 运行例子),页签归页面管。
|
||||
*/
|
||||
export function useProblemMascotGuide(showResult: (segment: ResultSegment) => void) {
|
||||
@@ -45,6 +58,31 @@ export function useProblemMascotGuide(showResult: (segment: ResultSegment) => vo
|
||||
const teacherCollab = useTeacherCollab()
|
||||
const { isDesktop } = useBreakpoints()
|
||||
|
||||
/** 学生在这道题上能用的几个按钮,点她和她主动开口共用 */
|
||||
function studentActions(problem: ProblemDetail) {
|
||||
const language = codeStore.code.language
|
||||
const entry = ctx.value
|
||||
const runSamples: MascotAction | null =
|
||||
problem.samples.length && language !== 'Flowchart' && language !== 'SQL' ? { label: '运行例子', run: submissionStore.runSamples } : null
|
||||
// 和工具栏的「举手求助」同一个条件;已经举着手、老师在帮的时候不给
|
||||
const help: MascotAction | null =
|
||||
entry.help && isDesktop.value && !userStore.demoMode && language !== 'Flowchart' && collabStore.helpStatus === 'idle'
|
||||
? { label: '举手求助', run: () => collabStore.requestHelp(problem._id, language) }
|
||||
: null
|
||||
const why: MascotAction = { label: '我错在哪', run: () => showResult('submit') }
|
||||
const whySamples: MascotAction = { label: '我错在哪', run: () => showResult('samples') }
|
||||
const busy = submissionStore.isFormatting || submissionStore.isSubmittingRequest || submissionStore.judging || submissionStore.isCooldown
|
||||
const submit: MascotAction | null = busy
|
||||
? null
|
||||
: { label: '提交', run: () => submissionStore.submit({ contestId: entry.contestId, problemSetId: entry.problemSetId }) }
|
||||
return { runSamples, help, why, whySamples, submit }
|
||||
}
|
||||
|
||||
const guide = (situation: MascotSituation, ...actions: (MascotAction | null)[]): MascotGuide => ({
|
||||
situation,
|
||||
actions: actions.filter((it): it is MascotAction => !!it),
|
||||
})
|
||||
|
||||
useMascotGuide(() => {
|
||||
const problem = problemStore.problem
|
||||
if (!problem) return null
|
||||
@@ -59,28 +97,10 @@ export function useProblemMascotGuide(showResult: (segment: ResultSegment) => vo
|
||||
return { situation: 'teacher.problem', actions }
|
||||
}
|
||||
|
||||
const language = codeStore.code.language
|
||||
const entry = ctx.value
|
||||
const { runSamples, help, why, whySamples, submit } = studentActions(problem)
|
||||
|
||||
const runSamples: MascotAction | null =
|
||||
problem.samples.length && language !== 'Flowchart' && language !== 'SQL' ? { label: '运行例子', run: submissionStore.runSamples } : null
|
||||
// 和工具栏的「举手求助」同一个条件;已经举着手、老师在帮的时候不给
|
||||
const help: MascotAction | null =
|
||||
entry.help && isDesktop.value && !userStore.demoMode && language !== 'Flowchart' && collabStore.helpStatus === 'idle'
|
||||
? { label: '举手求助', run: () => collabStore.requestHelp(problem._id, language) }
|
||||
: null
|
||||
const why: MascotAction = { label: '我错在哪', run: () => showResult('submit') }
|
||||
const busy = submissionStore.isFormatting || submissionStore.isSubmittingRequest || submissionStore.judging || submissionStore.isCooldown
|
||||
const submit: MascotAction | null = busy
|
||||
? null
|
||||
: { label: '提交', run: () => submissionStore.submit({ contestId: entry.contestId, problemSetId: entry.problemSetId }) }
|
||||
|
||||
const guide = (situation: MascotSituation, ...actions: (MascotAction | null)[]): MascotGuide => ({
|
||||
situation,
|
||||
actions: actions.filter((it): it is MascotAction => !!it),
|
||||
})
|
||||
|
||||
if (language === 'Flowchart') return guide('problem.flowchart', help)
|
||||
if (codeStore.code.language === 'Flowchart') return guide('problem.flowchart', help)
|
||||
// 正在判、正在跑例子:没什么好说的,打个招呼
|
||||
if (submissionStore.judging || submissionStore.pending || submissionStore.trial.samplesRunning.value) return null
|
||||
|
||||
@@ -91,7 +111,7 @@ export function useProblemMascotGuide(showResult: (segment: ResultSegment) => vo
|
||||
if (submissionStore.resultSegment === 'samples' && runs.length) {
|
||||
return runs.every((run) => run.result === SubmissionStatus.accepted)
|
||||
? guide('problem.samplesPassed', submit)
|
||||
: guide('problem.samplesFailed', { label: '我错在哪', run: () => showResult('samples') }, help)
|
||||
: guide('problem.samplesFailed', whySamples, help)
|
||||
}
|
||||
|
||||
const result = submissionStore.submission?.result
|
||||
@@ -114,4 +134,71 @@ export function useProblemMascotGuide(showResult: (segment: ResultSegment) => vo
|
||||
return next ? { label: '下一道', run: () => router.push(`/problem/${next.problemDisplayId}`) } : null
|
||||
}
|
||||
})
|
||||
|
||||
// ---- 主动开口(只对学生;比赛里 Mascot.vue 不弹)----
|
||||
|
||||
const student = () => userStore.isAuthed && !userStore.isTeacherOrAbove && ctx.value.entry !== 'contest'
|
||||
|
||||
/** 这道题上连着几次没跑例子就交了。跑过一次例子就清零 */
|
||||
let unsampledSubmits = 0
|
||||
let sampledSinceSubmit = false
|
||||
/** 上一次「动静」(进题、交、跑例子)的时刻,给「盯了好久」用 */
|
||||
let lastActivity = Date.now()
|
||||
|
||||
watch(
|
||||
() => problemStore.problem?.id,
|
||||
() => {
|
||||
unsampledSubmits = 0
|
||||
sampledSinceSubmit = false
|
||||
lastActivity = Date.now()
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => submissionStore.trial.samplesRunAt.value,
|
||||
(at) => {
|
||||
if (!at) return
|
||||
sampledSinceSubmit = true
|
||||
lastActivity = Date.now()
|
||||
},
|
||||
)
|
||||
watch(
|
||||
() => submissionStore.submission?.id,
|
||||
(id) => {
|
||||
if (!id) return
|
||||
unsampledSubmits = sampledSinceSubmit ? 0 : unsampledSubmits + 1
|
||||
sampledSinceSubmit = false
|
||||
lastActivity = Date.now()
|
||||
},
|
||||
)
|
||||
|
||||
// 判完了:错了就看要不要说一句;不管对错都刷新行为数(最近的错、今天做对几道)
|
||||
watch(
|
||||
() => [submissionStore.submission?.id, submissionStore.submission?.result] as const,
|
||||
([id, result]) => {
|
||||
const problem = problemStore.problem
|
||||
if (!id || !FINISHED(result) || !problem || !student()) return
|
||||
refreshMascotProfile(true)
|
||||
const failed = RESULT_SITUATION[result as SubmissionStatus]
|
||||
if (!failed) return
|
||||
const { runSamples, help, why } = studentActions(problem)
|
||||
if (problemStore.failCount >= STUCK_FAILS) {
|
||||
requestSpeech({ key: `stuck:${problem.id}`, ...guide('problem.stuck', help, why) })
|
||||
} else if (unsampledSubmits >= UNSAMPLED_SUBMITS && runSamples) {
|
||||
requestSpeech({ key: `unsampled:${problem.id}`, ...guide('habit.noSamples', runSamples) })
|
||||
} else {
|
||||
const habit = habitSituation(failed)
|
||||
if (habit) requestSpeech({ key: `habit:${habit}`, ...guide(habit, why) })
|
||||
}
|
||||
},
|
||||
)
|
||||
|
||||
// 盯着一道没做对的题 15 分钟,没交也没跑:问一句
|
||||
useIntervalFn(() => {
|
||||
const problem = problemStore.problem
|
||||
if (!problem || !student() || Date.now() - lastActivity < IDLE_MS) return
|
||||
if (problem.myStatus === SubmissionStatus.accepted || submissionStore.submission?.result === SubmissionStatus.accepted) return
|
||||
if (codeStore.code.language === 'Flowchart') return
|
||||
const { runSamples, help } = studentActions(problem)
|
||||
requestSpeech({ key: `idle:${problem.id}`, ...guide('idle.stuck', help, runSamples) })
|
||||
}, 30_000)
|
||||
}
|
||||
@@ -10,7 +10,10 @@
|
||||
* - 在屏幕左半边时整体镜像,脸总朝着页面中间。
|
||||
* - 按下去像玩偶一样压扁(底边不动),松手弹回。
|
||||
* - 点一下冒气泡:一句话加最多三个按钮,按页面、按情况变(页面经 shared/mascot/guide.ts 告诉她),
|
||||
* 台词在 shared/mascot/lines.ts。她从不主动开口;比赛里点她不弹气泡。再点一下、点别处、换页面都收起。
|
||||
* 台词在 shared/mascot/lines.ts。比赛里点她不弹气泡。再点一下、点别处、换页面都收起。
|
||||
* - 对学生按行为挑话(常犯的错、今天做了几道、熟不熟,见 shared/mascot/behavior.ts);
|
||||
* 题目页上有几件事她会主动开口,频率上限在下面 SPEAK_GAP_MS。
|
||||
* - 互动都记进 mascot_event(点开、点按钮、主动开口、摸头、嫌烦、被拖走)。
|
||||
* - 彩蛋:连着快点,有时候(每轮三分之一的机会)点到第 4~8 下(也是随机的)会被摸摸头(同一个仓库的 assets/rua.gif),摸的时候接着点就一直摸。
|
||||
*
|
||||
* 位置只是这台电脑上的个人偏好,存 localStorage(读写都包 try/catch,拿不到就用默认位置)。
|
||||
@@ -21,6 +24,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, 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'
|
||||
import { useUserStore } from 'shared/store/user'
|
||||
@@ -46,6 +50,8 @@ function rollPatClicks() {
|
||||
const STREAK_GAP_MS = 600 // 两下之间超过这么久就不算连点
|
||||
const OVERCLICK = 8 // 一轮连点到这么多下还没摸到头,她就嫌烦了
|
||||
const QUIET_BUBBLE_MS = 5000 // 没有按钮的气泡(只是一句话)过这么久自己收起
|
||||
const SPOKEN_BUBBLE_MS = 10_000 // 她主动说的话,有按钮也过这么久收起:没人点她,不该一直挡着
|
||||
const SPEAK_GAP_MS = 3 * 60_000 // 主动开口至少隔这么久,课上别老打岔
|
||||
|
||||
type HAnchor = 'left' | 'right' | null
|
||||
type VAnchor = 'top' | 'bottom' | null
|
||||
@@ -173,6 +179,7 @@ function onUp() {
|
||||
place.y = pos.y
|
||||
settle()
|
||||
savePlace(place)
|
||||
logMascotEvent({ kind: 'move', line: `${place.h ?? '-'} ${place.v ?? '-'}` })
|
||||
return
|
||||
}
|
||||
boing()
|
||||
@@ -187,7 +194,10 @@ function onUp() {
|
||||
|
||||
// 正在被摸,或者连点到数了:摸摸头,从这一下再摸满一轮
|
||||
if (patting.value || streak >= patClicks) {
|
||||
if (!patting.value) say({ text: pick('patting') })
|
||||
if (!patting.value) {
|
||||
say({ text: pick('patting') })
|
||||
logMascotEvent({ kind: 'pat' })
|
||||
}
|
||||
patting.value = true
|
||||
clearTimeout(patTimer)
|
||||
patTimer = setTimeout(() => {
|
||||
@@ -198,7 +208,10 @@ function onUp() {
|
||||
}
|
||||
// 一轮里只有第一下开关气泡,后面连着点是在找彩蛋,不跟着一开一关
|
||||
if (streak === 1) toggleBubble()
|
||||
else if (streak === OVERCLICK) say({ text: pick('overclick') })
|
||||
else if (streak === OVERCLICK) {
|
||||
say({ text: pick('overclick') })
|
||||
logMascotEvent({ kind: 'overclick' })
|
||||
}
|
||||
}
|
||||
|
||||
// ---- 气泡 ----
|
||||
@@ -211,6 +224,8 @@ const authModal = useAuthModalStore()
|
||||
interface Bubble {
|
||||
text: string
|
||||
actions?: MascotAction[]
|
||||
/** 记互动时带上,看哪种情况下的按钮有人点 */
|
||||
situation?: string
|
||||
}
|
||||
const bubble = ref<Bubble | null>(null)
|
||||
let quietTimer: ReturnType<typeof setTimeout> | undefined
|
||||
@@ -218,11 +233,11 @@ let quietTimer: ReturnType<typeof setTimeout> | undefined
|
||||
/** 比赛、测验里不说话:「运行例子」「举手」这类按钮在考试时算不算帮忙,干脆不弹 */
|
||||
const silent = computed(() => String(route.name ?? '').startsWith('contest '))
|
||||
|
||||
function say(next: Bubble) {
|
||||
function say(next: Bubble, closeAfter = next.actions?.length ? 0 : QUIET_BUBBLE_MS) {
|
||||
if (silent.value) return
|
||||
bubble.value = next
|
||||
clearTimeout(quietTimer)
|
||||
if (!next.actions?.length) quietTimer = setTimeout(close, QUIET_BUBBLE_MS)
|
||||
if (closeAfter) quietTimer = setTimeout(close, closeAfter)
|
||||
}
|
||||
|
||||
function close() {
|
||||
@@ -232,8 +247,13 @@ function close() {
|
||||
|
||||
function toggleBubble() {
|
||||
if (bubble.value) return close()
|
||||
const guide = resolveGuide()
|
||||
say({ text: pickFor(guide), actions: guide.actions?.slice(0, 3) })
|
||||
let guide = resolveGuide()
|
||||
if (!userStore.isTeacherOrAbove) guide = personalize(guide)
|
||||
const text = pickFor(guide)
|
||||
say({ text, actions: guide.actions?.slice(0, 3), situation: guide.situation })
|
||||
if (silent.value) return
|
||||
notePoke()
|
||||
logMascotEvent({ kind: 'open', situation: guide.situation, line: text })
|
||||
}
|
||||
|
||||
/** 先看全站的:没登录、老师这边有人举手;再问页面;都没有就打个招呼 */
|
||||
@@ -247,7 +267,7 @@ function resolveGuide(): MascotGuide {
|
||||
}
|
||||
|
||||
/** 这几种情况要紧,不拿彩蛋换掉 */
|
||||
const SERIOUS = new Set<MascotSituation>(['guest', 'teacher.help', 'problem.stuck'])
|
||||
const SERIOUS = new Set<MascotSituation>(['guest', 'teacher.help', 'problem.stuck', 'first.meet'])
|
||||
const RARE_CHANCE = 0.03
|
||||
const TIME_CHANCE = 0.15
|
||||
|
||||
@@ -294,10 +314,36 @@ function timeSituation(): MascotSituation | null {
|
||||
}
|
||||
|
||||
async function runAction(action: MascotAction) {
|
||||
logMascotEvent({ kind: 'action', action: action.label, situation: bubble.value?.situation })
|
||||
close()
|
||||
await action.run()
|
||||
}
|
||||
|
||||
// ---- 主动开口:页面经 requestSpeech 请她说,这里卡频率 ----
|
||||
let lastSpokeAt = -Infinity
|
||||
const spokenKeys = new Set<string>()
|
||||
watch(speechRequest, (speech) => {
|
||||
if (!speech || silent.value || dragging.value || bubble.value || userStore.isTeacherOrAbove) return
|
||||
if (document.visibilityState !== 'visible' || spokenKeys.has(speech.key)) return
|
||||
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 ?? ''))
|
||||
say({ text, actions: speech.actions?.slice(0, 3), situation: speech.situation }, SPOKEN_BUBBLE_MS)
|
||||
logMascotEvent({ kind: 'speak', situation: speech.situation, line: text })
|
||||
})
|
||||
|
||||
// 学生的行为数:进站、换页面时拉(30 秒内拉过就不拉),登录换人时重拉
|
||||
watch(
|
||||
() => route.fullPath,
|
||||
() => refreshMascotProfile(),
|
||||
)
|
||||
watch(
|
||||
() => userStore.user?.id,
|
||||
() => refreshMascotProfile(true),
|
||||
{ immediate: true },
|
||||
)
|
||||
|
||||
// 点别处收起;点她自己由 onUp 管(开关),不算「别处」
|
||||
onClickOutside(bubbleEl, close, { ignore: [body] })
|
||||
watch(() => route.fullPath, close)
|
||||
|
||||
@@ -0,0 +1,122 @@
|
||||
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 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'])
|
||||
|
||||
/** 点她的时候:按行为把页面给的情况换成更贴这个学生的那一组(按钮不变) */
|
||||
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 }
|
||||
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 }
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -130,6 +130,24 @@ export const MASCOT_LINES = {
|
||||
'teacher.problemset': ['全班进度,记着呢', '谁没做完,一看就知道', '奖章发得出去吗'],
|
||||
'teacher.greet': ['老师好……才不是特意打招呼', '有事?', '忙完了?', '喝口水吧', '下课了吗', '今天几节课?', '辛苦……随口说的'],
|
||||
|
||||
// ---- 按学生的行为挑的(shared/mascot/behavior.ts)----
|
||||
// 最近 20 次提交里某类错特别多,再错这一类时换成这几句
|
||||
'habit.wrongAnswer': ['又是答案错误,例子对了没', '你老栽在输出上', '空格、换行再对一遍', '格式的事,又是', '这类错你很熟了吧'],
|
||||
'habit.compileError': ['又是编译错误。中文标点?', '每次都是符号的事', '红字看完再改', '括号、冒号,老三样'],
|
||||
'habit.runtimeError': ['又崩了,这次哪一行', '运行出错你最拿手了……不是夸你', '又炸了,看看报错'],
|
||||
'habit.NameError': ['又是变量名,拼对了没', '用之前先赋值', '大小写也算不一样的名字', '名字又对不上了'],
|
||||
'habit.TypeError': ['又是类型不对,input 出来的是文字', '数字和文字不能直接加', '先 int() 再算'],
|
||||
'habit.ValueError': ['int() 里放了不是数字的东西', '输入格式再看看,一行几个数?', '又把一整行塞进 int() 了吧'],
|
||||
'habit.EOFError': ['又多读了一行', 'input() 比输入的行数多了', '一行几个数,用一次 input() 再 split()'],
|
||||
'habit.timeout': ['又超时,循环停得下来吗', '死循环又来了', '循环变量改了没'],
|
||||
// 这一页上的习惯
|
||||
'habit.noSamples': ['又没跑例子?', '第几次不跑例子了', '跑一下又不会少块肉', '例子都不跑,就硬交?'],
|
||||
'idle.stuck': ['盯了好久了,举个手?', '卡住了吧', '要不先跑一下例子', '发呆也算做题吗'],
|
||||
// 今天和熟悉程度
|
||||
'today.count': ['今天第 {n} 道了……还行', '{n} 道了,别骄傲', '今天挺能做嘛,{n} 道', '才 {n} 道,再来'],
|
||||
'first.meet': ['初次见面。……才不是特意等你', '你是新来的?', '第一次戳我?', '叫我小鲸鱼就行,别叫大肥鱼'],
|
||||
familiar: ['又是你', '你今天戳了几次了', '习惯了,戳吧', '老熟人了', '又来找我?'],
|
||||
|
||||
// ---- 招呼与彩蛋 ----
|
||||
greet: [
|
||||
'干嘛',
|
||||
|
||||
@@ -12,6 +12,7 @@ export * from './flowchart'
|
||||
export * from './flowchart-doc'
|
||||
export * from './judge-status'
|
||||
export * from './language'
|
||||
export * from './mascot'
|
||||
export * from './problem'
|
||||
export * from './problemset'
|
||||
export * from './ranking'
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
/**
|
||||
* 看板娘的互动记录(POST /mascot/events)。前台每次互动发一条,落进 `mascot_event`:
|
||||
*
|
||||
* - `open`:点她冒出气泡(situation = 她当时判断的情况,line = 说的那句)
|
||||
* - `action`:点了气泡里的按钮(action = 按钮上的字)
|
||||
* - `speak`:她主动开口(situation / line 同 open)
|
||||
* - `pat`:连点触发了摸头彩蛋
|
||||
* - `overclick`:连点到她嫌烦
|
||||
* - `move`:被拖到了别处(line 记落点贴的边,比如 `right bottom`)
|
||||
*
|
||||
* 只记看板娘这边的事,不记代码、不记按键。
|
||||
*/
|
||||
export const MASCOT_EVENT_KINDS = ['open', 'action', 'speak', 'pat', 'overclick', 'move'] as const
|
||||
export const mascotEventKindSchema = z.enum(MASCOT_EVENT_KINDS)
|
||||
export type MascotEventKind = z.infer<typeof mascotEventKindSchema>
|
||||
|
||||
export const mascotEventSchema = z.object({
|
||||
kind: mascotEventKindSchema,
|
||||
situation: z.string().max(64).optional(),
|
||||
line: z.string().max(100).optional(),
|
||||
action: z.string().max(32).optional(),
|
||||
/** 当时在哪一页(路由路径,不带查询串) */
|
||||
path: z.string().max(200),
|
||||
})
|
||||
export type MascotEventInput = z.infer<typeof mascotEventSchema>
|
||||
|
||||
/**
|
||||
* 看板娘按学生的行为挑话用的(GET /mascot/profile)。只给学生自己的,进页面时拉一次。
|
||||
*
|
||||
* `recent` 是最近 20 次题库提交(不含比赛)里各类错误的次数;`runtimeErrorType` 是其中
|
||||
* 运行出错最多的那一种(NameError、TypeError …,C/C++ 没有这一项),没有就是 null。
|
||||
*/
|
||||
export const mascotProfileSchema = z.object({
|
||||
/** 一共点开过她几次(open 事件数),按账号算 */
|
||||
pokes: z.number().int(),
|
||||
/** 今天(东八区)做对了几道不同的题 */
|
||||
todaySolved: z.number().int(),
|
||||
recent: z.object({
|
||||
total: z.number().int(),
|
||||
wrongAnswer: z.number().int(),
|
||||
compileError: z.number().int(),
|
||||
runtimeError: z.number().int(),
|
||||
timeout: z.number().int(),
|
||||
runtimeErrorType: z.string().nullable(),
|
||||
}),
|
||||
})
|
||||
export type MascotProfile = z.infer<typeof mascotProfileSchema>
|
||||
Reference in new issue
Block a user