Honey Village: six model builds + hub frontend, Dockerized for Dokploy
This commit is contained in:
@@ -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]!;
|
||||
}
|
||||
@@ -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];
|
||||
@@ -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 0–9 */
|
||||
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;
|
||||
@@ -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';
|
||||
}
|
||||
@@ -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 =
|
||||
'忽然,彩带与笑声从四面八方涌来——欢乐庆典强制开始了。你也可以从场景边缘离开。';
|
||||
@@ -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 5–7 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: Day5–7 happiness ≤10 continuously AND celebration escapes ×3
|
||||
* E3: awareness ≥70 AND clues ≥6/9 AND chose fog wall
|
||||
* E2: awareness 30–69
|
||||
* 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: '糖衣',
|
||||
};
|
||||
@@ -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 T8–T10 descriptions are character-identical to D1 T1–T3', () => {
|
||||
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 → S0–S4', () => {
|
||||
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 30–69', () => {
|
||||
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);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,173 @@
|
||||
/**
|
||||
* Integration-style tests: drive shipped Director through T1–T15, 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 T1–T15 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 E1–E4', () => {
|
||||
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('病历名');
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
/** T8–T10 mirror T1–T3 */
|
||||
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;
|
||||
}
|
||||
Reference in New Issue
Block a user