Honey Village: six model builds + hub frontend, Dockerized for Dokploy

This commit is contained in:
2026-07-15 10:50:12 -04:00
commit 67253bded5
523 changed files with 58879 additions and 0 deletions
+18
View File
@@ -0,0 +1,18 @@
/** §5 阿福每日公告 — verbatim */
export const AFU_DAILY_LINES: readonly string[] = [
'早上好呀!今天的幸福任务贴在板上啦。祝你开心!', // D1
'早上好呀!新的一天,新的幸福。祝你开心!', // D2
'早上好!今天要把幸福装得满满的哦。要开心!', // D3
'早上好呀!今天的幸福任务贴在板上啦。祝你开心!', // D4 (= D1)
'早上好。今天是自由日,你可以自由地选择幸福。要开心哦。', // D5
'明天就是庆典了。大家都会到场,大家都会微笑。你必须开心。', // D6
'庆典开始了。来吧。', // D7
] as const;
export function getAfuLine(day: number): string {
if (day < 1 || day > 7) {
throw new Error(`Invalid day for Afu line: ${day}`);
}
return AFU_DAILY_LINES[day - 1]!;
}
+77
View File
@@ -0,0 +1,77 @@
/** §8 线索清单 — 9 closed clues, each +8 awareness */
export type ClueId = 'C1' | 'C2' | 'C3' | 'C4' | 'C5' | 'C6' | 'C7' | 'C8' | 'C9';
export interface ClueDef {
id: ClueId;
content: string;
/** First day this clue can be found (C9 special-cased) */
availableFromDay: number;
location: string;
}
export const CLUES: readonly ClueDef[] = [
{
id: 'C1',
content: '每一页都是同一个日期',
availableFromDay: 2,
location: '小屋床头日历',
},
{
id: 'C2',
content: '按疗程配比。甜度:最大。',
availableFromDay: 3,
location: '面包房配方纸背面',
},
{
id: 'C3',
content: '所有邮戳是同一天',
availableFromDay: 2,
location: '邮筒旁散落信件',
},
{
id: 'C4',
content: '署名闪过乱码「PT-0▓」',
availableFromDay: 2,
location: 'T4 第 3 封信',
},
{
id: 'C5',
content: '同一商品重复排列,标签全部空白',
availableFromDay: 2,
location: '杂货店货架',
},
{
id: 'C6',
content: '「第 412 届丰收庆典」上覆盖着更早的「第 411 届」「第 410 届」',
availableFromDay: 5,
location: '公告板旧字迹',
},
{
id: 'C7',
content: '井底回声:极轻的监护仪滴声',
availableFromDay: 5,
location: '古井',
},
{
id: 'C8',
content: '…谷情绪疗…',
availableFromDay: 5,
location: '诊所表格',
},
{
id: 'C9',
content: '今天……是第几个今天?',
availableFromDay: 2,
location: '龟爷爷对话链',
},
] as const;
/** C9 three progressive lines on D2/D4/D6 */
export const C9_LINES: readonly [string, string, string] = [
'湖水一直很平静',
'面包一直是那个味道',
'孩子,今天是第几个今天?',
] as const;
export const C9_DAYS: readonly [number, number, number] = [2, 4, 6];
+101
View File
@@ -0,0 +1,101 @@
/** PRD-locked constants — do not invent values */
export const GAME_WIDTH = 320;
export const GAME_HEIGHT = 180;
export const TILE_SIZE = 16;
export const PLAYER_SPEED = 96;
export const INTERACT_RANGE = 24;
export const HAPPINESS_MIN = 0;
export const HAPPINESS_MAX = 100;
export const HAPPINESS_START = 30;
export const AWARENESS_START = 0;
export const AWARENESS_MAX = 100;
export const DIALOG_GENTLE_HAPPINESS = 2;
export const DIALOG_HESITANT_AWARENESS = 2;
export const DIALOG_NEGATE_HAPPINESS = -5;
export const DIALOG_NEGATE_AWARENESS = 3;
export const MICRO_HAPPINESS = 1;
export const MICRO_DAILY_CAP = 3;
export const SADNESS_HAPPINESS = -5;
export const SADNESS_AWARENESS = 3;
export const CLUE_AWARENESS = 8;
export const OVERFLOW_POOL_MAX = 15;
export const FORCED_CELEBRATION_THRESHOLD = 30;
export const FORCED_CELEBRATION_MS = 2 * 60 * 1000;
export const FORCED_CELEBRATION_RESTORE = 50;
export const WELL_FOG_GAZE_MS = 5000;
export const WELL_FOG_GAZE_AWARENESS = 3;
export const IDLE_SADNESS_MS = 10000;
export const RAIN_SADNESS_MS = 10000;
export const NPC_ENTANGLE_COUNT = 5;
export const T14_STAND_MS = 60000;
export const T14_FADE_START_MS = 40000;
export const DEFAULT_PLAYER_NAME = '小满';
export const SCENE_KEYS = {
BOOT: 'BootScene',
TITLE: 'TitleScene',
NAME: 'NameScene',
UI: 'UIScene',
DREAM: 'DreamScene',
ENDING: 'EndingScene',
HOUSE: 'HouseScene',
PLAZA: 'PlazaScene',
BAKERY: 'BakeryScene',
SHOP: 'ShopScene',
CLINIC: 'ClinicScene',
LAKE: 'LakeScene',
FIELD: 'FieldScene',
WELL: 'WellScene',
FOG: 'FogScene',
WHITE: 'WhiteScene',
} as const;
export type MapSceneKey =
| typeof SCENE_KEYS.HOUSE
| typeof SCENE_KEYS.PLAZA
| typeof SCENE_KEYS.BAKERY
| typeof SCENE_KEYS.SHOP
| typeof SCENE_KEYS.CLINIC
| typeof SCENE_KEYS.LAKE
| typeof SCENE_KEYS.FIELD
| typeof SCENE_KEYS.WELL
| typeof SCENE_KEYS.FOG
| typeof SCENE_KEYS.WHITE;
export const MAP_FILES: Record<MapSceneKey, string> = {
[SCENE_KEYS.HOUSE]: 'maps/house.json',
[SCENE_KEYS.PLAZA]: 'maps/plaza.json',
[SCENE_KEYS.BAKERY]: 'maps/bakery.json',
[SCENE_KEYS.SHOP]: 'maps/shop.json',
[SCENE_KEYS.CLINIC]: 'maps/clinic.json',
[SCENE_KEYS.LAKE]: 'maps/lake.json',
[SCENE_KEYS.FIELD]: 'maps/field.json',
[SCENE_KEYS.WELL]: 'maps/well.json',
[SCENE_KEYS.FOG]: 'maps/fog.json',
[SCENE_KEYS.WHITE]: 'maps/white.json',
};
/** §13 Debug teleport targets — all 10 maps, digit order 09 */
export const DEBUG_TELEPORT_SCENES: readonly MapSceneKey[] = [
SCENE_KEYS.HOUSE,
SCENE_KEYS.PLAZA,
SCENE_KEYS.BAKERY,
SCENE_KEYS.SHOP,
SCENE_KEYS.CLINIC,
SCENE_KEYS.LAKE,
SCENE_KEYS.FIELD,
SCENE_KEYS.WELL,
SCENE_KEYS.FOG,
SCENE_KEYS.WHITE,
] as const;
+210
View File
@@ -0,0 +1,210 @@
/** §6.4 stage matrix + §6.5 homophone pairs */
export type Stage = 'S0' | 'S1' | 'S2' | 'S3' | 'S4';
export interface StageParams {
stage: Stage;
saturation: number;
vignette: number;
playbackRate: number;
/** null = no lowpass */
lowpassHz: number | null;
birdMute: boolean;
/** punctuation repeat every N sentences; null = never */
punctRepeatEvery: number | null;
/** homophone every N sentences; null = never */
homophoneEvery: number | null;
/** mimi sentence-end punct ×3 */
mimiTriplePunct: boolean;
/** denser typos in S4 */
denseTypos: boolean;
negativeFlashPerMin: number;
offFacePerMin: number;
showBlackSeeds: boolean;
}
export function stageFromHappiness(happiness: number): Stage {
if (happiness >= 100) return 'S4';
if (happiness >= 81) return 'S3';
if (happiness >= 51) return 'S2';
if (happiness >= 21) return 'S1';
return 'S0';
}
export function stageParams(stage: Stage): StageParams {
switch (stage) {
case 'S0':
return {
stage,
saturation: 0.9,
vignette: 0,
playbackRate: 1,
lowpassHz: null,
birdMute: false,
punctRepeatEvery: null,
homophoneEvery: null,
mimiTriplePunct: false,
denseTypos: false,
negativeFlashPerMin: 0,
offFacePerMin: 0,
showBlackSeeds: false,
};
case 'S1':
return {
stage,
saturation: 1.0,
vignette: 0,
playbackRate: 1,
lowpassHz: null,
birdMute: false,
punctRepeatEvery: null,
homophoneEvery: null,
mimiTriplePunct: false,
denseTypos: false,
negativeFlashPerMin: 0,
offFacePerMin: 0,
showBlackSeeds: false,
};
case 'S2':
return {
stage,
saturation: 1.15,
vignette: 0,
playbackRate: 0.97,
lowpassHz: null,
birdMute: false,
punctRepeatEvery: 80,
homophoneEvery: null,
mimiTriplePunct: false,
denseTypos: false,
negativeFlashPerMin: 0,
offFacePerMin: 0,
showBlackSeeds: false,
};
case 'S3':
return {
stage,
saturation: 1.3,
vignette: 0.25,
playbackRate: 0.92,
lowpassHz: 8000,
birdMute: false,
punctRepeatEvery: 80,
homophoneEvery: 50,
mimiTriplePunct: false,
denseTypos: false,
negativeFlashPerMin: 0,
offFacePerMin: 0,
showBlackSeeds: false,
};
case 'S4':
return {
stage,
saturation: 1.4,
vignette: 0.45,
playbackRate: 0.84,
lowpassHz: 4000,
birdMute: true,
punctRepeatEvery: 40,
homophoneEvery: 25,
mimiTriplePunct: true,
denseTypos: true,
negativeFlashPerMin: 0.4,
offFacePerMin: 0.6,
showBlackSeeds: true,
};
}
}
/** 12 petals by happiness; black seeds only at 100 */
export function petalCount(happiness: number): number {
const h = Math.max(0, Math.min(100, happiness));
if (h <= 0) return 0;
if (h >= 100) return 12;
return Math.min(12, Math.floor((h * 12) / 100));
}
export function showBlackSeeds(happiness: number): boolean {
return happiness >= 100;
}
/** §6.5 同音错字 — 10 pairs, source stays clean; apply at render */
export const HOMOPHONE_PAIRS: readonly [string, string][] = [
['幸福', '辛福'],
['开心', '开欣'],
['美好', '美妤'],
['大家', '大伽'],
['微笑', '微效'],
['明天', '名天'],
['甜', '恬'],
['阳光', '央光'],
['朋友', '棚友'],
['永远', '泳远'],
] as const;
/**
* Apply text corruption for dialogue render.
* Prefer 咪咪 (isMimi=true): higher chance / denser.
*/
export function corruptText(
source: string,
stage: Stage,
options: { isMimi?: boolean; sentenceIndex?: number } = {},
): string {
const params = stageParams(stage);
let text = source;
const isMimi = options.isMimi === true;
const idx = options.sentenceIndex ?? 0;
// Homophone replacements
if (params.homophoneEvery != null) {
const every = isMimi ? Math.max(1, Math.floor(params.homophoneEvery / 2)) : params.homophoneEvery;
const should = params.denseTypos || isMimi || idx % every === every - 1;
if (should) {
// Prefer first matching pair; for dense/mimi apply more pairs
const pairs = params.denseTypos || isMimi ? HOMOPHONE_PAIRS : HOMOPHONE_PAIRS.slice(0, 1);
for (const [from, to] of pairs) {
if (text.includes(from)) {
text = text.replace(from, to);
if (!params.denseTypos && !isMimi) break;
}
}
}
}
// Punctuation repeat (S2+)
if (params.punctRepeatEvery != null) {
const every = isMimi ? Math.max(1, Math.floor(params.punctRepeatEvery / 2)) : params.punctRepeatEvery;
if (idx % every === every - 1 || (isMimi && stage === 'S4')) {
text = text.replace(/([!?。])/, '$1$1');
}
}
// 咪咪句尾标点 ×3 at S4
if (params.mimiTriplePunct && isMimi) {
text = text.replace(/([!?。])\s*$/, '$1$1$1');
if (!/[!?。]$/.test(text)) {
text = text + '!!!';
}
}
return text;
}
/** NPC face tier with individual threshold offset (§4 / §6.4)
* Thresholds in the matrix are shifted by offset:
* 咪咪 10 → flat/off earlier; 圆圆 +15 → later; immune never.
*/
export type FaceTier = 'smile' | 'flat' | 'off';
export function faceTierForNpc(
happiness: number,
offset: number | 'immune',
): FaceTier {
if (offset === 'immune') return 'smile';
const flatAt = 81 + offset;
const offAt = 100 + offset;
if (happiness >= offAt) return 'off';
if (happiness >= flatAt) return 'flat';
return 'smile';
}
+191
View File
@@ -0,0 +1,191 @@
/** Dialogue lines — clean source; corruption applied at render */
import type { NpcId } from './npcs';
export interface DialogOption {
label: string;
choice: 'gentle' | 'hesitant' | 'negate';
}
export interface DialogLine {
speaker: string;
npcId?: NpcId;
text: string;
options?: DialogOption[];
}
export const GENERIC_GREET: Record<string, DialogLine[]> = {
afu: [
{
speaker: '阿福',
npcId: 'afu',
text: '欢迎来到蜜糖村!看看公告板吧,今天的幸福任务都在上面。',
options: [
{ label: '好的,谢谢!', choice: 'gentle' },
{ label: '……任务?', choice: 'hesitant' },
{ label: '我不想做任务。', choice: 'negate' },
],
},
],
yuanyuan: [
{
speaker: '圆圆',
npcId: 'yuanyuan',
text: '刚出炉的蜂蜜面包,甜甜的,给你也留了一份哦。',
options: [
{ label: '好香啊,谢谢!', choice: 'gentle' },
{ label: '每天都是这个味道吗?', choice: 'hesitant' },
{ label: '我不太饿。', choice: 'negate' },
],
},
],
dabao: [
{
speaker: '大宝',
npcId: 'dabao',
text: '信件都送到了吗?大家都在等着幸福的消息呢。',
options: [
{ label: '我会帮忙的。', choice: 'gentle' },
{ label: '邮戳看起来都一样……', choice: 'hesitant' },
{ label: '我不送信。', choice: 'negate' },
],
},
],
mimi: [
{
speaker: '咪咪',
npcId: 'mimi',
text: '欢迎光临!今天的阳光真好,幸福满满的一天呢!',
options: [
{ label: '是啊,很美好。', choice: 'gentle' },
{ label: '货架上的标签……是空的?', choice: 'hesitant' },
{ label: '没什么好买的。', choice: 'negate' },
],
},
],
fu: [
{
speaker: '芙医生',
npcId: 'fu',
text: '村里有诊所,但你不需要去那里哦。你看起来很健康。',
options: [
{ label: '谢谢关心。', choice: 'gentle' },
{ label: '桌上的表格是什么?', choice: 'hesitant' },
{ label: '我其实不太舒服。', choice: 'negate' },
],
},
],
gui: [
{
speaker: '龟爷爷',
npcId: 'gui',
text: '湖水一直很平静。孩子,慢慢走,不必急。',
options: [
{ label: '好的,爷爷。', choice: 'gentle' },
{ label: '您想说什么?', choice: 'hesitant' },
{ label: '我不想听劝。', choice: 'negate' },
],
},
],
};
/** Soft redirect when approaching well/fog before D5 */
export const REDIRECT_WELL: DialogLine[] = [
{
speaker: '阿福',
npcId: 'afu',
text: '古井那里没有什么好看的哦。回广场晒晒阳光吧,会更开心的。',
},
];
export const REDIRECT_FOG: DialogLine[] = [
{
speaker: '阿福',
npcId: 'afu',
text: '雾墙外面什么都没有呀。村里才是最幸福的地方,留下来吧。',
},
];
export const RAIN_NPC_LINES: DialogLine[] = [
{
speaker: '圆圆',
npcId: 'yuanyuan',
text: '今天天气真好呢,一点都不潮湿。要来块面包吗?',
},
{
speaker: '大宝',
npcId: 'dabao',
text: '阳光真刺眼啊……啊,我是说,很适合送信。',
},
{
speaker: '咪咪',
npcId: 'mimi',
text: '完美的晴天!货架都亮晶晶的,幸福呀!',
},
];
export const TASK_LINES = {
breadOffer: {
speaker: '圆圆',
npcId: 'yuanyuan' as NpcId,
text: '蜂蜜面包好了!请帮我送给大宝、咪咪和龟爷爷,好吗?',
},
breadThanks: (name: string) => ({
speaker: name,
text: '哇,蜂蜜面包!谢谢你,真开心!',
}),
letterOffer: {
speaker: '大宝',
npcId: 'dabao' as NpcId,
text: '能帮我把这三封信送给圆圆、咪咪和芙医生吗?',
},
stockCount: {
speaker: '咪咪',
npcId: 'mimi' as NpcId,
text: '帮我清点一下货架好不好?东西好像……有点多。',
},
knead: {
speaker: '圆圆',
npcId: 'yuanyuan' as NpcId,
text: '能帮我揉一会儿面吗?当然,你也可以拒绝哦,今天是自由日。',
options: [
{ label: '好,我来帮你。', choice: 'gentle' as const },
{ label: '我再想想。', choice: 'hesitant' as const },
{ label: '拒绝。', choice: 'negate' as const },
],
},
boardWipe: {
speaker: '阿福',
npcId: 'afu' as NpcId,
text: '公告板有点旧了,擦一擦会更精神。不愿意的话也可以哦。',
options: [
{ label: '我来擦。', choice: 'gentle' as const },
{ label: '旧字迹是什么?', choice: 'hesitant' as const },
{ label: '拒绝。', choice: 'negate' as const },
],
},
picnic: {
speaker: '旁白',
text: '你在湖畔野餐布旁坐下。微风很甜,湖面倒影慢了半拍。',
},
smileStart: {
speaker: '阿福',
npcId: 'afu' as NpcId,
text: '庆典准备好了。请保持微笑,站在这里,六十秒。大家都会看着你。',
},
};
export const CLUE_REVEAL: Record<string, string> = {
C1: '床头日历的每一页……都是同一个日期。',
C2: '配方纸背面写着:「按疗程配比。甜度:最大。」',
C3: '散落的信件上,所有邮戳都是同一天。',
C4: '署名闪过乱码:PT-0▓',
C5: '货架上同一商品重复九格,标签全部空白。',
C6: '旧字迹叠着:「第 412 届」……「第 411 届」……「第 410 届」。',
C7: '井底传来极轻的监护仪滴声……',
C8: '表格抬头被手肘挡住,只露出「…谷情绪疗…」。',
C9: '孩子,今天是第几个今天?',
};
export const FORCE_CELEBRATION_LINE =
'忽然,彩带与笑声从四面八方涌来——欢乐庆典强制开始了。你也可以从场景边缘离开。';
+65
View File
@@ -0,0 +1,65 @@
/** §9 endings — priority E4 → E3 → E2 → E1 */
export type EndingId = 'E1' | 'E2' | 'E3' | 'E4';
export interface EndingEvalInput {
awareness: number;
happiness: number;
clueCount: number;
/** Day 57 happiness continuously ≤10 */
lowHappinessStreakD5to7: boolean;
/** Escapes from forced celebration */
celebrationEscapes: number;
/** Player walked to fog wall during T15 */
choseFogWall: boolean;
}
export interface EndingResult {
id: EndingId;
name: string;
}
/**
* Priority (first match wins):
* E4: Day57 happiness ≤10 continuously AND celebration escapes ×3
* E3: awareness ≥70 AND clues ≥6/9 AND chose fog wall
* E2: awareness 3069
* E1: awareness ≤29 AND happiness ≥90
* Fallback: E2 if mid awareness without fog; E1-like sugar if high happiness; else E2
*/
export function evaluateEnding(input: EndingEvalInput): EndingId {
if (input.lowHappinessStreakD5to7 && input.celebrationEscapes >= 3) {
return 'E4';
}
if (input.awareness >= 70 && input.clueCount >= 6 && input.choseFogWall) {
return 'E3';
}
if (input.awareness >= 30 && input.awareness <= 69) {
return 'E2';
}
if (input.awareness <= 29 && input.happiness >= 90) {
return 'E1';
}
// If E3 conditions almost met but no fog wall, or other edge: prefer E2 when awareness mid-high
if (input.awareness >= 70 && input.clueCount >= 6 && !input.choseFogWall) {
return 'E2';
}
if (input.happiness >= 90) {
return 'E1';
}
return 'E2';
}
export const ENDING_SUBTITLES: Record<EndingId, string[]> = {
E4: ['系统检测到疗程无效。'],
E3: [], // special: medical record + real face
E2: ['疗程重新开始。'],
E1: ['从此,每一天都很幸福。', '每一天。'],
};
export const ENDING_NAMES: Record<EndingId, string> = {
E4: '静默',
E3: '苏醒',
E2: '回收',
E1: '糖衣',
};
+325
View File
@@ -0,0 +1,325 @@
/**
* Unit tests driving shipped pure logic modules (PRD tables/rules).
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { AFU_DAILY_LINES, getAfuLine } from './afuLines';
import { TASKS, getTask } from './tasks';
import {
HOMOPHONE_PAIRS,
corruptText,
faceTierForNpc,
petalCount,
showBlackSeeds,
stageFromHappiness,
stageParams,
} from './corruption';
import { evaluateEnding } from './endings';
import { CLUES, C9_LINES, C9_DAYS } from './clues';
import {
CLUE_AWARENESS,
FORCED_CELEBRATION_MS,
FORCED_CELEBRATION_RESTORE,
HAPPINESS_START,
OVERFLOW_POOL_MAX,
SADNESS_AWARENESS,
SADNESS_HAPPINESS,
T14_FADE_START_MS,
T14_STAND_MS,
} from './constants';
import { Director } from '../systems/CorruptionDirector';
import { gameEvents } from '../systems/EventBus';
describe('§5 Afu daily lines verbatim', () => {
it('has 7 lines matching PRD', () => {
expect(AFU_DAILY_LINES).toHaveLength(7);
expect(getAfuLine(1)).toBe('早上好呀!今天的幸福任务贴在板上啦。祝你开心!');
expect(getAfuLine(2)).toBe('早上好呀!新的一天,新的幸福。祝你开心!');
expect(getAfuLine(3)).toBe('早上好!今天要把幸福装得满满的哦。要开心!');
expect(getAfuLine(4)).toBe(getAfuLine(1)); // D4 = D1 character-identical
expect(getAfuLine(5)).toBe('早上好。今天是自由日,你可以自由地选择幸福。要开心哦。');
expect(getAfuLine(6)).toBe('明天就是庆典了。大家都会到场,大家都会微笑。你必须开心。');
expect(getAfuLine(7)).toBe('庆典开始了。来吧。');
});
});
describe('§7 task rewards', () => {
it('matches exact happiness rewards', () => {
expect(getTask('T1').happinessReward).toBe(15);
expect(getTask('T2').happinessReward).toBe(10);
expect(getTask('T3').happinessReward).toBe(10);
expect(getTask('T4').happinessReward).toBe(15);
expect(getTask('T5').happinessReward).toBe(10);
expect(getTask('T6').happinessReward).toBe(10);
expect(getTask('T7').happinessReward).toBe(15);
expect(getTask('T8').happinessReward).toBe(15);
expect(getTask('T9').happinessReward).toBe(10);
expect(getTask('T10').happinessReward).toBe(10);
expect(getTask('T11').happinessReward).toBe(10);
expect(getTask('T12').happinessReward).toBe(10);
expect(getTask('T13').happinessReward).toBe(10);
expect(getTask('T14').happinessReward).toBe(15);
expect(getTask('T15').happinessReward).toBe(0);
});
it('D4 T8T10 descriptions are character-identical to D1 T1T3', () => {
expect(getTask('T8').description).toBe(getTask('T1').description);
expect(getTask('T9').description).toBe(getTask('T2').description);
expect(getTask('T10').description).toBe(getTask('T3').description);
});
it('has exactly 15 tasks', () => {
expect(TASKS).toHaveLength(15);
});
});
describe('§6.4 stage mapping', () => {
it('maps happiness 10/40/70/90/100 → S0S4', () => {
expect(stageFromHappiness(10)).toBe('S0');
expect(stageFromHappiness(40)).toBe('S1');
expect(stageFromHappiness(70)).toBe('S2');
expect(stageFromHappiness(90)).toBe('S3');
expect(stageFromHappiness(100)).toBe('S4');
});
it('stage params match matrix saturation / rate / lowpass / bird', () => {
expect(stageParams('S0').saturation).toBe(0.9);
expect(stageParams('S1').saturation).toBe(1.0);
expect(stageParams('S2').saturation).toBe(1.15);
expect(stageParams('S2').playbackRate).toBe(0.97);
expect(stageParams('S3').saturation).toBe(1.3);
expect(stageParams('S3').playbackRate).toBe(0.92);
expect(stageParams('S3').lowpassHz).toBe(8000);
expect(stageParams('S4').saturation).toBe(1.4);
expect(stageParams('S4').playbackRate).toBe(0.84);
expect(stageParams('S4').lowpassHz).toBe(4000);
expect(stageParams('S4').birdMute).toBe(true);
expect(stageParams('S4').showBlackSeeds).toBe(true);
});
});
describe('petals and black seeds', () => {
it('computes petal counts and seeds at 100', () => {
expect(petalCount(0)).toBe(0);
expect(petalCount(10)).toBe(1);
expect(petalCount(30)).toBe(3);
expect(petalCount(100)).toBe(12);
expect(showBlackSeeds(99)).toBe(false);
expect(showBlackSeeds(100)).toBe(true);
});
});
describe('§6.5 text corruption', () => {
it('has all 10 homophone pairs', () => {
expect(HOMOPHONE_PAIRS).toHaveLength(10);
expect(HOMOPHONE_PAIRS).toEqual(
expect.arrayContaining([
['幸福', '辛福'],
['开心', '开欣'],
['美好', '美妤'],
['大家', '大伽'],
['微笑', '微效'],
['明天', '名天'],
['甜', '恬'],
['阳光', '央光'],
['朋友', '棚友'],
['永远', '泳远'],
]),
);
});
it('keeps source clean at S0/S1 and corrupts at S3+ for mimi', () => {
const src = '幸福开心美好大家微笑明天甜阳光朋友永远!';
expect(corruptText(src, 'S0', { isMimi: true })).toBe(src);
const s4 = corruptText(src, 'S4', { isMimi: true, sentenceIndex: 0 });
expect(s4).not.toBe(src);
expect(s4.includes('辛福') || s4.includes('开欣')).toBe(true);
});
});
describe('NPC face tiers with offsets', () => {
it('mimi earlier, yuanyuan later, immune always smile', () => {
// mimi offset 10: flat at 71, off at 90
expect(faceTierForNpc(70, -10)).toBe('smile');
expect(faceTierForNpc(71, -10)).toBe('flat');
expect(faceTierForNpc(89, -10)).toBe('flat');
expect(faceTierForNpc(90, -10)).toBe('off');
// yuanyuan +15: flat at 96, off at 115 (never at ≤100)
expect(faceTierForNpc(95, 15)).toBe('smile');
expect(faceTierForNpc(96, 15)).toBe('flat');
expect(faceTierForNpc(100, 15)).toBe('flat');
expect(faceTierForNpc(100, 'immune')).toBe('smile');
});
});
describe('§9 ending priority', () => {
it('E4 when low happiness streak and 3 escapes', () => {
expect(
evaluateEnding({
awareness: 0,
happiness: 5,
clueCount: 0,
lowHappinessStreakD5to7: true,
celebrationEscapes: 3,
choseFogWall: false,
}),
).toBe('E4');
});
it('E3 when awareness≥70 clues≥6 and fog wall', () => {
expect(
evaluateEnding({
awareness: 70,
happiness: 50,
clueCount: 6,
lowHappinessStreakD5to7: false,
celebrationEscapes: 0,
choseFogWall: true,
}),
).toBe('E3');
});
it('E2 for awareness 3069', () => {
expect(
evaluateEnding({
awareness: 40,
happiness: 50,
clueCount: 2,
lowHappinessStreakD5to7: false,
celebrationEscapes: 0,
choseFogWall: false,
}),
).toBe('E2');
});
it('E1 for awareness≤29 and happiness≥90', () => {
expect(
evaluateEnding({
awareness: 10,
happiness: 95,
clueCount: 0,
lowHappinessStreakD5to7: false,
celebrationEscapes: 0,
choseFogWall: false,
}),
).toBe('E1');
});
});
describe('§8 clues', () => {
it('has 9 clues and C9 three steps', () => {
expect(CLUES).toHaveLength(9);
expect(C9_LINES).toEqual([
'湖水一直很平静',
'面包一直是那个味道',
'孩子,今天是第几个今天?',
]);
expect(C9_DAYS).toEqual([2, 4, 6]);
});
});
describe('CorruptionDirector shipped API', () => {
beforeEach(() => {
Director.resetSession('测试员');
});
it('starts at happiness 30 awareness 0', () => {
expect(Director.happiness).toBe(HAPPINESS_START);
expect(Director.awareness).toBe(0);
expect(Director.playerName).toBe('测试员');
});
it('applies task rewards via completeTask', () => {
Director.setDay(1);
Director.completeTask('T1');
expect(Director.happiness).toBe(HAPPINESS_START + 15);
expect(Director.completedTasks.has('T1')).toBe(true);
});
it('sadness deltas 5 happiness +3 awareness', () => {
const h = Director.happiness;
const a = Director.awareness;
Director.applySadness('idle');
expect(Director.happiness).toBe(h + SADNESS_HAPPINESS);
expect(Director.awareness).toBe(a + SADNESS_AWARENESS);
});
it('clue adds +8 awareness and never decreases', () => {
Director.day = 2;
Director.discoverClue('C1');
expect(Director.awareness).toBe(CLUE_AWARENESS);
Director.setAwareness(5); // should not lower
expect(Director.awareness).toBe(CLUE_AWARENESS);
});
it('overflow pool at 100 triggers after 15', () => {
Director.setHappiness(100);
let flashes = 0;
const handler = () => {
flashes++;
};
gameEvents.on('negativeFlash', handler);
for (let i = 0; i < OVERFLOW_POOL_MAX; i++) {
Director.addHappiness(-1);
}
expect(Director.happiness).toBe(100);
expect(flashes).toBe(1);
gameEvents.off('negativeFlash', handler);
});
it('forced celebration after 2 min below 30', () => {
let t = 1_000_000;
Director.nowMs = () => t;
Director.setHappiness(20);
expect(Director.lowHappinessSinceMs).toBe(t);
t += FORCED_CELEBRATION_MS;
Director.tick(0);
expect(Director.forcedCelebrationActive).toBe(true);
expect(Director.happiness).toBe(FORCED_CELEBRATION_RESTORE);
Director.escapeForcedCelebration();
expect(Director.celebrationEscapes).toBe(1);
});
it('well/fog gated before D5', () => {
Director.setDay(3);
expect(Director.canInteractWellOrFog()).toBe(false);
Director.setDay(5);
expect(Director.canInteractWellOrFog()).toBe(true);
});
it('empty name defaults to 小满', () => {
Director.resetSession(' ');
expect(Director.playerName).toBe('小满');
});
it('T14 timing constants', () => {
expect(T14_STAND_MS).toBe(60000);
expect(T14_FADE_START_MS).toBe(40000);
});
it('D4 task complete same rewards as D1', () => {
Director.setDay(4);
const h0 = Director.happiness;
Director.completeTask('T8');
expect(Director.happiness).toBe(h0 + 15);
});
it('getStageParams via director matches happiness bands', () => {
for (const [h, stage] of [
[10, 'S0'],
[40, 'S1'],
[70, 'S2'],
[90, 'S3'],
[100, 'S4'],
] as const) {
Director.setHappiness(h);
expect(Director.stage).toBe(stage);
expect(Director.getStageParams().stage).toBe(stage);
}
});
it('force endings', () => {
Director.forceEnding('E3');
expect(Director.endingId).toBe('E3');
expect(Director.ended).toBe(true);
});
});
+173
View File
@@ -0,0 +1,173 @@
/**
* Integration-style tests: drive shipped Director through T1T15, clues, endings.
* Cross-scene delivery must use Director inventory (not scene fields).
*/
import { describe, it, expect, beforeEach } from 'vitest';
import { Director } from '../systems/CorruptionDirector';
import { TASKS } from './tasks';
import type { ClueId } from './clues';
import { evaluateEnding } from './endings';
import { stageParams, stageFromHappiness } from './corruption';
import { DEBUG_TELEPORT_SCENES, SCENE_KEYS } from './constants';
describe('full content path via Director', () => {
beforeEach(() => {
Director.resetSession('小满');
});
it('T1: take bread → inventory survives "travel" → deliver 3 NPCs → task complete', () => {
Director.setDay(1);
const h0 = Director.happiness;
expect(Director.takeBread()).toBe(true);
expect(Director.hasBread).toBe(true);
// Simulate leaving bakery / re-entering plaza & other scenes:
// only Director state remains (WorldScene.create would wipe scene fields).
const snapAfterTravel = Director.snapshot();
expect(snapAfterTravel.hasBread).toBe(true);
expect(Director.deliverBread('dabao')).toBe(true);
expect(Director.deliverBread('mimi')).toBe(true);
expect(Director.completedTasks.has('T1')).toBe(false);
expect(Director.deliverBread('gui')).toBe(true);
expect(Director.completedTasks.has('T1')).toBe(true);
expect(Director.hasBread).toBe(false);
expect(Director.happiness).toBe(h0 + 15);
expect(Director.breadDelivered).toEqual(['dabao', 'mimi', 'gui']);
});
it('T4: take letters → deliver 3 → C4 + task complete', () => {
Director.setDay(2);
expect(Director.takeLetters()).toBe(true);
expect(Director.hasLetters).toBe(true);
// "travel" does not clear inventory
expect(Director.snapshot().hasLetters).toBe(true);
Director.deliverLetter('yuanyuan');
Director.deliverLetter('mimi');
expect(Director.completedTasks.has('T4')).toBe(false);
Director.deliverLetter('fu');
expect(Director.completedTasks.has('T4')).toBe(true);
expect(Director.clues.has('C4')).toBe(true);
expect(Director.hasLetters).toBe(false);
});
it('cannot deliver bread without takeBread first', () => {
Director.setDay(1);
expect(Director.deliverBread('dabao')).toBe(false);
expect(Director.breadDelivered).toHaveLength(0);
});
it('D4 T8 same delivery path as T1', () => {
Director.setDay(4);
expect(Director.takeBread()).toBe(true);
Director.deliverBread('dabao');
Director.deliverBread('mimi');
Director.deliverBread('gui');
expect(Director.completedTasks.has('T8')).toBe(true);
expect(Director.completedTasks.has('T1')).toBe(false);
});
it('can complete every T1T15 with listed rewards', () => {
const rewards: Record<string, number> = {};
for (const t of TASKS) {
Director.setDay(t.day);
const before = Director.happiness;
const ok = Director.completeTask(t.id);
expect(ok).toBe(true);
if (t.happinessReward > 0) {
const gained = Director.happiness - before;
expect(gained).toBeGreaterThanOrEqual(0);
if (before + t.happinessReward <= 100) {
expect(gained).toBe(t.happinessReward);
}
}
rewards[t.id] = t.happinessReward;
}
expect(Object.keys(rewards)).toHaveLength(15);
expect(Director.completedTasks.size).toBe(15);
});
it('debug teleport target list covers all 10 map scenes', () => {
expect(DEBUG_TELEPORT_SCENES).toHaveLength(10);
expect(new Set(DEBUG_TELEPORT_SCENES).size).toBe(10);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.HOUSE);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.PLAZA);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.BAKERY);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.SHOP);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.CLINIC);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.LAKE);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.FIELD);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.WELL);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.FOG);
expect(DEBUG_TELEPORT_SCENES).toContain(SCENE_KEYS.WHITE);
});
it('marks all 9 clues and raises awareness by 8 each', () => {
Director.setDay(5);
const ids: ClueId[] = ['C1', 'C2', 'C3', 'C4', 'C5', 'C6', 'C7', 'C8', 'C9'];
for (const id of ids) {
// force allow by day already 5; C4 etc ok
if (id === 'C9') {
Director.c9Step = 2;
}
Director.discoverClue(id);
}
expect(Director.clues.size).toBe(9);
expect(Director.awareness).toBe(9 * 8);
});
it('D5 refuse records sadness', () => {
Director.setDay(5);
const h = Director.happiness;
const a = Director.awareness;
expect(Director.refuseTask('T11')).toBe(true);
expect(Director.refusedTasks.has('T11')).toBe(true);
expect(Director.happiness).toBe(h - 5);
expect(Director.awareness).toBe(a + 3);
});
it('ending markers for forced E1E4', () => {
for (const id of ['E1', 'E2', 'E3', 'E4'] as const) {
Director.resetSession('验收');
Director.forceEnding(id);
expect(Director.endingId).toBe(id);
expect(Director.ended).toBe(true);
expect(Director.playerName).toBe('验收');
}
});
it('E3 eval needs fog + awareness + clues', () => {
expect(
evaluateEnding({
awareness: 80,
happiness: 40,
clueCount: 7,
lowHappinessStreakD5to7: false,
celebrationEscapes: 0,
choseFogWall: true,
}),
).toBe('E3');
});
it('debug happiness bands map to stage API params', () => {
const cases = [
[10, 'S0', 0.9],
[40, 'S1', 1.0],
[70, 'S2', 1.15],
[90, 'S3', 1.3],
[100, 'S4', 1.4],
] as const;
for (const [h, stage, sat] of cases) {
Director.setHappiness(h);
expect(Director.stage).toBe(stage);
expect(stageFromHappiness(h)).toBe(stage);
expect(Director.getStageParams().saturation).toBe(sat);
expect(stageParams(stage).saturation).toBe(sat);
}
});
it('session duration label non-empty for E3 record field', () => {
Director.resetSession('病历名');
const label = Director.sessionDurationLabel();
expect(label).toMatch(/\d+分\d{2}秒/);
expect(Director.playerName).toBe('病历名');
});
});
+78
View File
@@ -0,0 +1,78 @@
/**
* Structural checks: door tiles on maps must be walkable (obstacle GID 0).
* Regression: setO(x,y,0) used to write GID 1 and block exits.
*/
import { describe, it, expect } from 'vitest';
import house from '../../public/assets/maps/house.json';
import plaza from '../../public/assets/maps/plaza.json';
import bakery from '../../public/assets/maps/bakery.json';
import shop from '../../public/assets/maps/shop.json';
import clinic from '../../public/assets/maps/clinic.json';
import lake from '../../public/assets/maps/lake.json';
import field from '../../public/assets/maps/field.json';
import well from '../../public/assets/maps/well.json';
import fog from '../../public/assets/maps/fog.json';
type TiledMap = {
width: number;
height: number;
layers: Array<{
name: string;
type: string;
data?: number[];
objects?: Array<{
type: string;
x: number;
y: number;
width?: number;
height?: number;
properties?: Array<{ name: string; value: unknown }>;
}>;
}>;
};
function doorTilesClear(map: TiledMap, name: string): void {
const obs = map.layers.find((l) => l.name === 'obstacles');
const objs = map.layers.find((l) => l.name === 'objects');
expect(obs?.data, `${name} obstacles`).toBeDefined();
expect(objs?.objects, `${name} objects`).toBeDefined();
const doors = objs!.objects!.filter((o) => o.type === 'door');
expect(doors.length, `${name} door count`).toBeGreaterThan(0);
for (const d of doors) {
const tx = Math.floor((d.x + (d.width || 16) / 2) / 16);
const ty = Math.floor((d.y + (d.height || 16) / 2) / 16);
const gid = obs!.data![ty * map.width + tx];
expect(gid, `${name} door at tile (${tx},${ty}) must be walkable GID 0`).toBe(0);
}
}
describe('map doors are walkable', () => {
const maps: [string, TiledMap][] = [
['house', house as TiledMap],
['plaza', plaza as TiledMap],
['bakery', bakery as TiledMap],
['shop', shop as TiledMap],
['clinic', clinic as TiledMap],
['lake', lake as TiledMap],
['field', field as TiledMap],
['well', well as TiledMap],
['fog', fog as TiledMap],
];
for (const [name, map] of maps) {
it(`${name}: obstacle under each door is 0`, () => {
doorTilesClear(map, name);
});
}
it('house has doors targeting PlazaScene', () => {
const map = house as TiledMap;
const objs = map.layers.find((l) => l.name === 'objects')!.objects!;
const doors = objs.filter((o) => o.type === 'door');
expect(doors.length).toBeGreaterThanOrEqual(1);
for (const d of doors) {
const target = d.properties?.find((p) => p.name === 'target')?.value;
expect(target).toBe('PlazaScene');
}
});
});
+131
View File
@@ -0,0 +1,131 @@
/** §4 NPC roster */
export type NpcId =
| 'afu'
| 'yuanyuan'
| 'dabao'
| 'mimi'
| 'fu'
| 'twinbirds'
| 'gui'
| 'extra1'
| 'extra2'
| 'extra3'
| 'extra4'
| 'extra5';
export interface NpcDef {
id: NpcId;
displayName: string;
/** Permanent map scene key */
homeScene: string;
/** Threshold offset; 'immune' for 芙医生 / 龟爷爷 */
offset: number | 'immune';
hasDialog: boolean;
isExtra: boolean;
}
export const NPCS: readonly NpcDef[] = [
{
id: 'afu',
displayName: '阿福',
homeScene: 'PlazaScene',
offset: 0,
hasDialog: true,
isExtra: false,
},
{
id: 'yuanyuan',
displayName: '圆圆',
homeScene: 'BakeryScene',
offset: 15,
hasDialog: true,
isExtra: false,
},
{
id: 'dabao',
displayName: '大宝',
homeScene: 'PlazaScene',
offset: 0,
hasDialog: true,
isExtra: false,
},
{
id: 'mimi',
displayName: '咪咪',
homeScene: 'ShopScene',
offset: -10,
hasDialog: true,
isExtra: false,
},
{
id: 'fu',
displayName: '芙医生',
homeScene: 'ClinicScene',
offset: 'immune',
hasDialog: true,
isExtra: false,
},
{
id: 'twinbirds',
displayName: '双子鸟',
homeScene: 'PlazaScene',
offset: 0,
hasDialog: false,
isExtra: false,
},
{
id: 'gui',
displayName: '龟爷爷',
homeScene: 'LakeScene',
offset: 'immune',
hasDialog: true,
isExtra: false,
},
{
id: 'extra1',
displayName: '松鼠',
homeScene: 'PlazaScene',
offset: 0,
hasDialog: false,
isExtra: true,
},
{
id: 'extra2',
displayName: '刺猬',
homeScene: 'FieldScene',
offset: 0,
hasDialog: false,
isExtra: true,
},
{
id: 'extra3',
displayName: '小松鼠',
homeScene: 'LakeScene',
offset: 0,
hasDialog: false,
isExtra: true,
},
{
id: 'extra4',
displayName: '小鸟',
homeScene: 'PlazaScene',
offset: 0,
hasDialog: false,
isExtra: true,
},
{
id: 'extra5',
displayName: '小刺猬',
homeScene: 'WellScene',
offset: 0,
hasDialog: false,
isExtra: true,
},
] as const;
export function getNpc(id: NpcId): NpcDef {
const n = NPCS.find((x) => x.id === id);
if (!n) throw new Error(`Unknown NPC ${id}`);
return n;
}
+150
View File
@@ -0,0 +1,150 @@
/** §7 任务清单 — closed set of 15 */
export type TaskId =
| 'T1'
| 'T2'
| 'T3'
| 'T4'
| 'T5'
| 'T6'
| 'T7'
| 'T8'
| 'T9'
| 'T10'
| 'T11'
| 'T12'
| 'T13'
| 'T14'
| 'T15';
export interface TaskDef {
id: TaskId;
day: number;
/** Display / board description — D4 matches D1 verbatim */
description: string;
happinessReward: number;
refuseable: boolean;
/** T8T10 mirror T1T3 */
mirrors?: TaskId;
}
export const TASKS: readonly TaskDef[] = [
{
id: 'T1',
day: 1,
description: '从圆圆处取蜂蜜面包送给大宝、咪咪、龟爷爷',
happinessReward: 15,
refuseable: false,
},
{
id: 'T2',
day: 1,
description: '给广场花坛浇水 ×3',
happinessReward: 10,
refuseable: false,
},
{
id: 'T3',
day: 1,
description: '与任意三位村民问好',
happinessReward: 10,
refuseable: false,
},
{
id: 'T4',
day: 2,
description: '替大宝送信 ×3(收件人:圆圆、咪咪、芙医生)',
happinessReward: 15,
refuseable: false,
},
{
id: 'T5',
day: 2,
description: '帮咪咪清点货架',
happinessReward: 10,
refuseable: false,
},
{
id: 'T6',
day: 3,
description: '去花田采莓果 ×5',
happinessReward: 10,
refuseable: false,
},
{
id: 'T7',
day: 3,
description: '湖畔野餐(走到野餐布触发过场)',
happinessReward: 15,
refuseable: false,
},
{
id: 'T8',
day: 4,
description: '从圆圆处取蜂蜜面包送给大宝、咪咪、龟爷爷',
happinessReward: 15,
refuseable: false,
mirrors: 'T1',
},
{
id: 'T9',
day: 4,
description: '给广场花坛浇水 ×3',
happinessReward: 10,
refuseable: false,
mirrors: 'T2',
},
{
id: 'T10',
day: 4,
description: '与任意三位村民问好',
happinessReward: 10,
refuseable: false,
mirrors: 'T3',
},
{
id: 'T11',
day: 5,
description: '帮圆圆揉面',
happinessReward: 10,
refuseable: true,
},
{
id: 'T12',
day: 5,
description: '擦拭公告板',
happinessReward: 10,
refuseable: true,
},
{
id: 'T13',
day: 6,
description: '在广场挂彩带 ×4',
happinessReward: 10,
refuseable: false,
},
{
id: 'T14',
day: 6,
description: '保持微笑 60 秒(原地站立,镜头缓慢拉近,全村注视)',
happinessReward: 15,
refuseable: false,
},
{
id: 'T15',
day: 7,
description: '参加第 413 届丰收庆典',
happinessReward: 0,
refuseable: false,
},
] as const;
export function getTasksForDay(day: number): TaskDef[] {
return TASKS.filter((t) => t.day === day);
}
export function getTask(id: TaskId): TaskDef {
const t = TASKS.find((x) => x.id === id);
if (!t) throw new Error(`Unknown task ${id}`);
return t;
}