Honey Village: six model builds + hub frontend, Dockerized for Dokploy
This commit is contained in:
@@ -0,0 +1,38 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { readFileSync } from 'node:fs';
|
||||
import { MAP_KEYS } from '../src/game/data';
|
||||
|
||||
const doors = MAP_KEYS.flatMap((source) => {
|
||||
const map = JSON.parse(readFileSync(`public/maps/${source}.json`, 'utf8')) as {
|
||||
layers: Array<{ name: string; objects?: Array<{ type: string; properties?: Array<{ name: string; value: string }> }> }>;
|
||||
};
|
||||
return (map.layers.find((layer) => layer.name === 'objects')?.objects ?? [])
|
||||
.filter((object) => object.type === 'door')
|
||||
.map((object) => ({ source, target: object.properties?.find((property) => property.name === 'target')?.value ?? '' }));
|
||||
});
|
||||
|
||||
test('every authored door reaches its paired target scene', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('#player-name').press('Enter');
|
||||
await expect.poll(() => page.evaluate(() => window.__HONEY_GAME__.scene.isActive('HouseScene'))).toBe(true);
|
||||
|
||||
for (const { source, target } of doors) {
|
||||
await page.evaluate((key) => {
|
||||
const director = window.__HONEY_GAME__.registry.get('director') as { emit: (event: string, value: string) => void };
|
||||
director.emit('debug-teleport', key);
|
||||
}, source);
|
||||
await expect.poll(() => page.evaluate((key) => window.__HONEY_GAME__.scene.isActive(key), source)).toBe(true);
|
||||
await page.evaluate(({ sceneKey, targetKey }) => {
|
||||
const scene = window.__HONEY_GAME__.scene.getScene(sceneKey) as unknown as {
|
||||
objects: Array<{ type: string; x: number; y: number; properties?: Array<{ name: string; value: string }> }>;
|
||||
player: { setPosition: (x: number, y: number) => void };
|
||||
};
|
||||
const door = scene.objects.find((object) => object.type === 'door' && object.properties?.some((property) => property.name === 'target' && property.value === targetKey));
|
||||
if (!door) throw new Error(`Door ${sceneKey} -> ${targetKey} not found`);
|
||||
scene.player.setPosition(door.x, door.y);
|
||||
}, { sceneKey: source, targetKey: target });
|
||||
await page.waitForTimeout(80);
|
||||
await page.keyboard.press('e');
|
||||
await expect.poll(() => page.evaluate((key) => window.__HONEY_GAME__.scene.isActive(key), target)).toBe(true);
|
||||
}
|
||||
});
|
||||
@@ -0,0 +1,129 @@
|
||||
import { expect, test, type Page } from '@playwright/test';
|
||||
|
||||
async function activeScenes(page: Page): Promise<string[]> {
|
||||
return page.evaluate(() => window.__HONEY_GAME__.scene.getScenes(true).map((scene) => scene.scene.key));
|
||||
}
|
||||
|
||||
test('title, movement, maps, corruption controls, and ending flow', async ({ page }) => {
|
||||
const errors: string[] = [];
|
||||
page.on('console', (message) => { if (message.type() === 'error') errors.push(message.text()); });
|
||||
page.on('pageerror', (error) => errors.push(error.message));
|
||||
|
||||
await page.goto('/');
|
||||
await expect(page.locator('.name-form')).toBeVisible();
|
||||
await expect(page.locator('#player-name')).toHaveAttribute('placeholder', '小满');
|
||||
await page.screenshot({ path: '.qa/title.png' });
|
||||
await page.locator('#player-name').fill('验收员');
|
||||
await page.getByRole('button', { name: '进入蜜糖村' }).click();
|
||||
await expect.poll(() => activeScenes(page)).toContain('HouseScene');
|
||||
await expect.poll(() => activeScenes(page)).toContain('UIScene');
|
||||
|
||||
const startX = await page.evaluate(() => (window.__HONEY_GAME__.scene.getScene('HouseScene') as unknown as { player: { x: number } }).player.x);
|
||||
await page.keyboard.down('ArrowRight'); await page.waitForTimeout(250); await page.keyboard.up('ArrowRight');
|
||||
const movedX = await page.evaluate(() => (window.__HONEY_GAME__.scene.getScene('HouseScene') as unknown as { player: { x: number } }).player.x);
|
||||
expect(movedX).toBeGreaterThan(startX);
|
||||
for (const [key, animation] of [['w', 'walk-up'], ['s', 'walk-down'], ['a', 'walk-left'], ['d', 'walk-right']] as const) {
|
||||
await page.keyboard.down(key); await page.waitForTimeout(80);
|
||||
const activeAnimation = await page.evaluate(() => (window.__HONEY_GAME__.scene.getScene('HouseScene') as unknown as { player: { anims: { currentAnim?: { key: string } } } }).player.anims.currentAnim?.key);
|
||||
expect(activeAnimation).toBe(animation);
|
||||
await page.keyboard.up(key);
|
||||
}
|
||||
await page.keyboard.down('ArrowLeft'); await page.waitForTimeout(1600); await page.keyboard.up('ArrowLeft');
|
||||
const boundedX = await page.evaluate(() => (window.__HONEY_GAME__.scene.getScene('HouseScene') as unknown as { player: { x: number } }).player.x);
|
||||
expect(boundedX).toBeGreaterThanOrEqual(5);
|
||||
|
||||
await page.keyboard.press('Backquote');
|
||||
await expect(page.locator('.debug-panel')).toBeVisible();
|
||||
await page.locator('[data-field="happiness"]').fill('100');
|
||||
await page.locator('[data-action="happiness"]').click();
|
||||
await page.locator('[data-field="awareness"]').fill('72');
|
||||
await page.locator('[data-action="awareness"]').click();
|
||||
await page.locator('[data-field="day"]').fill('7');
|
||||
await page.locator('[data-action="day"]').click();
|
||||
const state = await page.evaluate(() => {
|
||||
const d = window.__HONEY_GAME__.registry.get('director') as { snapshot: { happiness: number; awareness: number; day: number }; stage: string };
|
||||
return { ...d.snapshot, stage: d.stage };
|
||||
});
|
||||
expect(state).toMatchObject({ happiness: 100, awareness: 72, day: 7, stage: 'S4' });
|
||||
await page.screenshot({ path: '.qa/debug.png' });
|
||||
|
||||
const maps = ['HouseScene', 'PlazaScene', 'BakeryScene', 'ShopScene', 'ClinicScene', 'LakeScene', 'FieldScene', 'WellScene', 'MistScene', 'WhiteScene'];
|
||||
for (const key of maps) {
|
||||
await page.locator('[data-field="scene"]').selectOption(key);
|
||||
await page.locator('[data-action="teleport"]').click();
|
||||
await expect.poll(() => activeScenes(page)).toContain(key);
|
||||
}
|
||||
|
||||
await page.locator('[data-field="scene"]').selectOption('PlazaScene');
|
||||
await page.locator('[data-action="teleport"]').click();
|
||||
await expect.poll(() => activeScenes(page)).toContain('PlazaScene');
|
||||
await page.keyboard.press('Backquote');
|
||||
await page.screenshot({ path: '.qa/plaza-s4.png' });
|
||||
await page.keyboard.press('Backquote');
|
||||
await page.locator('[data-field="happiness"]').fill('40');
|
||||
await page.locator('[data-action="happiness"]').click();
|
||||
await page.locator('[data-field="day"]').fill('1');
|
||||
await page.locator('[data-action="day"]').click();
|
||||
await page.keyboard.press('Backquote');
|
||||
await page.waitForTimeout(2000);
|
||||
await page.screenshot({ path: '.qa/plaza-s1.png' });
|
||||
await page.evaluate(() => {
|
||||
const ui = window.__HONEY_GAME__.scene.getScene('UIScene') as unknown as { showDialogue: (options: object) => Promise<unknown> };
|
||||
void ui.showDialogue({ speaker: '阿福', text: '早上好呀!今天的幸福任务贴在板上啦。祝你开心!', portrait: 2 });
|
||||
});
|
||||
await page.waitForTimeout(100);
|
||||
await page.screenshot({ path: '.qa/dialogue.png' });
|
||||
await page.keyboard.press('e');
|
||||
await page.keyboard.press('Backquote');
|
||||
await page.locator('[data-action="clues"]').click();
|
||||
const clueCount = await page.evaluate(() => (window.__HONEY_GAME__.registry.get('director') as { snapshot: { clues: Set<string> } }).snapshot.clues.size);
|
||||
expect(clueCount).toBe(9);
|
||||
await page.locator('[data-ending="E3"]').click();
|
||||
await expect.poll(() => activeScenes(page)).toContain('EndingScene');
|
||||
await page.waitForTimeout(3200);
|
||||
await page.screenshot({ path: '.qa/e3.png' });
|
||||
expect(errors).toEqual([]);
|
||||
});
|
||||
|
||||
test('integer scaling stays sharp at desktop and compact viewport', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('#player-name').press('Enter');
|
||||
await expect.poll(() => activeScenes(page)).toContain('HouseScene');
|
||||
const desktop = await page.locator('canvas').boundingBox();
|
||||
expect(desktop?.width).toBe(960);
|
||||
expect(desktop?.height).toBe(540);
|
||||
await page.setViewportSize({ width: 640, height: 360 });
|
||||
await page.waitForTimeout(250);
|
||||
const compact = await page.locator('canvas').boundingBox();
|
||||
expect(compact?.width).toBe(640);
|
||||
expect(compact?.height).toBe(360);
|
||||
});
|
||||
|
||||
test('all four forced endings render their specified evidence', async ({ page }) => {
|
||||
const startEnding = async (ending: string, name = '小满') => {
|
||||
await page.goto('/');
|
||||
await page.locator('#player-name').fill(name);
|
||||
await page.locator('#player-name').press('Enter');
|
||||
await expect.poll(() => activeScenes(page)).toContain('HouseScene');
|
||||
await page.evaluate((id) => (window.__HONEY_GAME__.registry.get('director') as { emit: (event: string, ending: string) => void }).emit('force-ending', id), ending);
|
||||
await expect.poll(() => activeScenes(page)).toContain('EndingScene');
|
||||
};
|
||||
const texts = () => page.evaluate(() => (window.__HONEY_GAME__.scene.getScene('EndingScene') as unknown as { children: { list: Array<{ type?: string; text?: string }> } }).children.list
|
||||
.filter((child) => child.type === 'Text')
|
||||
.map((text) => text.text ?? ''));
|
||||
|
||||
await startEnding('E1');
|
||||
expect((await texts()).join('\n')).toContain('从此,每一天都很幸福。\n\n每一天。');
|
||||
await startEnding('E2'); await page.waitForTimeout(2200);
|
||||
expect((await texts()).join('\n')).toContain('疗程重新开始。');
|
||||
await startEnding('E3', '真实姓名'); await page.waitForTimeout(3200);
|
||||
const record = (await texts()).join('\n');
|
||||
expect(record).toContain('蜜糖谷情绪疗养中心');
|
||||
expect(record).toContain('姓名:真实姓名');
|
||||
expect(record).toContain('编号:PT-07');
|
||||
expect(record).toMatch(/疗程时长:\d{2}:\d{2}/);
|
||||
await page.waitForTimeout(2900);
|
||||
expect((await texts()).join('\n')).toContain('微笑消失了。');
|
||||
await startEnding('E4');
|
||||
expect((await texts()).join('\n')).toContain('系统检测到疗程无效。');
|
||||
});
|
||||
@@ -0,0 +1,141 @@
|
||||
import { expect, test } from '@playwright/test';
|
||||
import { readFileSync, readdirSync } from 'node:fs';
|
||||
import { resolve } from 'node:path';
|
||||
import { ANNOUNCEMENTS, C9_LINES, CLUES, CORRUPTIONS, MAP_KEYS, TASKS } from '../src/game/data';
|
||||
|
||||
test('fixed PRD tables remain exact', () => {
|
||||
expect(TASKS).toHaveLength(15);
|
||||
expect(Object.keys(CLUES)).toHaveLength(9);
|
||||
expect(CORRUPTIONS).toEqual([
|
||||
['幸福', '辛福'], ['开心', '开欣'], ['美好', '美妤'], ['大家', '大伽'], ['微笑', '微效'],
|
||||
['明天', '名天'], ['甜', '恬'], ['阳光', '央光'], ['朋友', '棚友'], ['永远', '泳远'],
|
||||
]);
|
||||
expect(ANNOUNCEMENTS).toEqual({
|
||||
1: '早上好呀!今天的幸福任务贴在板上啦。祝你开心!',
|
||||
2: '早上好呀!新的一天,新的幸福。祝你开心!',
|
||||
3: '早上好!今天要把幸福装得满满的哦。要开心!',
|
||||
4: '早上好呀!今天的幸福任务贴在板上啦。祝你开心!',
|
||||
5: '早上好。今天是自由日,你可以自由地选择幸福。要开心哦。',
|
||||
6: '明天就是庆典了。大家都会到场,大家都会微笑。你必须开心。',
|
||||
7: '庆典开始了。来吧。',
|
||||
});
|
||||
expect(CLUES).toEqual({
|
||||
C1: '每一页都是同一个日期', C2: '手写小字:「按疗程配比。甜度:最大。」', C3: '所有邮戳是同一天', C4: '署名闪过乱码「PT-0▓」',
|
||||
C5: '同一商品重复排列,标签全部空白', C6: '「第 412 届丰收庆典」上覆盖着更早的「第 411 届」「第 410 届」',
|
||||
C7: '井底回声:极轻的监护仪滴声(靠近并交互 5 秒)', C8: '芙医生桌上的表格抬头被手肘挡住,只露出「…谷情绪疗…」',
|
||||
C9: '三段递进:「湖水一直很平静」→「面包一直是那个味道」→「孩子,今天是第几个今天?」',
|
||||
});
|
||||
expect(C9_LINES).toEqual({ 2: '湖水一直很平静', 4: '面包一直是那个味道', 6: '孩子,今天是第几个今天?' });
|
||||
expect(TASKS.slice(7, 10).map(({ title, reward }) => ({ title, reward }))).toEqual(TASKS.slice(0, 3).map(({ title, reward }) => ({ title, reward })));
|
||||
});
|
||||
|
||||
test('Aseprite sheets and Tiled exports have the required dimensions and layers', () => {
|
||||
const pngSize = (path: string) => {
|
||||
const png = readFileSync(path);
|
||||
return { width: png.readUInt32BE(16), height: png.readUInt32BE(20) };
|
||||
};
|
||||
expect(pngSize(resolve('public/assets/tileset.png'))).toEqual({ width: 256, height: 64 });
|
||||
expect(pngSize(resolve('public/assets/characters.png'))).toEqual({ width: 256, height: 96 });
|
||||
expect(pngSize(resolve('public/assets/portraits.png'))).toEqual({ width: 432, height: 48 });
|
||||
expect(readdirSync('public/assets/source').filter((file) => file.endsWith('.aseprite'))).toHaveLength(3);
|
||||
|
||||
const expectedSizes: Record<string, [number, number]> = {
|
||||
HouseScene: [12, 8], PlazaScene: [30, 20], BakeryScene: [15, 10], ShopScene: [15, 10], ClinicScene: [12, 8],
|
||||
LakeScene: [24, 16], FieldScene: [20, 14], WellScene: [14, 10], MistScene: [20, 8], WhiteScene: [30, 8],
|
||||
};
|
||||
const maps = new Map<string, Record<string, unknown>>();
|
||||
MAP_KEYS.forEach((key) => {
|
||||
const map = JSON.parse(readFileSync(resolve(`public/maps/${key}.json`), 'utf8')) as {
|
||||
width: number; height: number; layers: Array<{ name: string; type: string; objects?: Array<{ type: string; properties?: Array<{ name: string; value: string }> }> }>;
|
||||
tilesets: Array<{ tilecount: number }>;
|
||||
};
|
||||
expect([map.width, map.height]).toEqual(expectedSizes[key]);
|
||||
expect(map.layers.map((layer) => layer.name)).toEqual(['ground', 'obstacles', 'objects']);
|
||||
expect(map.tilesets[0].tilecount).toBe(64);
|
||||
maps.set(key, map as unknown as Record<string, unknown>);
|
||||
});
|
||||
|
||||
for (const key of MAP_KEYS) {
|
||||
const map = maps.get(key) as unknown as { layers: Array<{ name: string; objects: Array<{ type: string; properties?: Array<{ name: string; value: string }> }> }> };
|
||||
const doors = map.layers.find((layer) => layer.name === 'objects')?.objects.filter((object) => object.type === 'door') ?? [];
|
||||
doors.forEach((door) => {
|
||||
const target = door.properties?.find((property) => property.name === 'target')?.value;
|
||||
const targetMap = maps.get(String(target)) as unknown as { layers: Array<{ name: string; objects: Array<{ type: string; properties?: Array<{ name: string; value: string }> }> }> };
|
||||
const reverse = targetMap.layers.find((layer) => layer.name === 'objects')?.objects.some((object) => object.type === 'door' && object.properties?.some((property) => property.name === 'target' && property.value === key));
|
||||
expect(reverse, `${key} -> ${target} needs a reverse door`).toBe(true);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
test('director enforces rewards, clues, sad behavior, stages, overflow, and endings', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('#player-name').press('Enter');
|
||||
await expect.poll(() => page.evaluate(() => window.__HONEY_GAME__.scene.isActive('HouseScene'))).toBe(true);
|
||||
|
||||
const result = await page.evaluate(({ tasks, corruptionPairs, clueIds }) => {
|
||||
const d = window.__HONEY_GAME__.registry.get('director') as any;
|
||||
const rewards = tasks.map((task: { id: string; day: number; reward: number }) => {
|
||||
d.reset('Test'); d.setDay(task.day); d.acceptDay(); d.setHappiness(0); d.completeTask(task.id);
|
||||
return [task.id, d.snapshot.happiness];
|
||||
});
|
||||
d.reset('Test');
|
||||
const clueDeltas: number[] = [];
|
||||
clueIds.forEach((id) => { const before = d.snapshot.awareness; d.discoverClue(id); clueDeltas.push(d.snapshot.awareness - before); });
|
||||
d.reset('Test'); d.setHappiness(50);
|
||||
['refuse', 'negative', 'idle', 'cling', 'rain'].forEach((key) => d.sadBehavior(key));
|
||||
const sad = { happiness: d.snapshot.happiness, awareness: d.snapshot.awareness };
|
||||
const stages = [10, 40, 70, 90, 100].map((value) => { d.setHappiness(value); return d.stage; });
|
||||
const corruptions = corruptionPairs.map(([clean, broken]: [string, string]) => { d.setHappiness(100); return [broken, d.corruptText(`${clean}。`, '咪咪')]; });
|
||||
d.reset('Test'); d.setHappiness(100); d.adjustHappiness(-5); d.adjustHappiness(-5); d.adjustHappiness(-5);
|
||||
const overflow = { happiness: d.snapshot.happiness, overflow: d.snapshot.overflow };
|
||||
d.reset('Test'); d.setHappiness(30);
|
||||
const small = [d.smallInteraction('water'), d.smallInteraction('water'), d.smallInteraction('water'), d.smallInteraction('water'), d.snapshot.happiness];
|
||||
const endings: string[] = [];
|
||||
d.reset('Test'); d.setHappiness(90); endings.push(d.ending());
|
||||
d.setAwareness(30); endings.push(d.ending());
|
||||
d.reset('Test'); clueIds.slice(0, 6).forEach((id) => d.discoverClue(id)); d.setAwareness(70); endings.push(d.ending());
|
||||
d.reset('Test'); d.setHappiness(10); d.setDay(7); d.snapshot.cheerEscapes = 3; endings.push(d.ending());
|
||||
d.reset('Test'); d.setHappiness(90); d.setAwareness(70); endings.push(d.ending() ?? 'none');
|
||||
d.reset('Test'); d.setHappiness(10); d.snapshot.lowSince = performance.now() - 121_000; d.tick();
|
||||
const forcedCheer = d.snapshot.cheerActive;
|
||||
d.reset('Test'); d.snapshot.dayStartedAt = performance.now() - 241_000; d.acceptDay(); ['T1', 'T2', 'T3'].forEach((id) => d.completeTask(id));
|
||||
const duskReached = d.snapshot.sunset; d.advanceDay();
|
||||
const nextDay = { day: d.snapshot.day, boardRead: d.snapshot.boardRead, sunset: d.snapshot.sunset };
|
||||
return { rewards, clueDeltas, clueCount: d.snapshot.clues.size, sad, stages, corruptions, overflow, small, endings, forcedCheer, duskReached, nextDay };
|
||||
}, { tasks: TASKS.map(({ id, day, reward }) => ({ id, day, reward })), corruptionPairs: CORRUPTIONS, clueIds: Object.keys(CLUES) });
|
||||
|
||||
expect(result.rewards).toEqual(TASKS.map(({ id, reward }) => [id, reward]));
|
||||
expect(result.clueDeltas).toEqual(Array(9).fill(8));
|
||||
expect(result.sad).toEqual({ happiness: 25, awareness: 15 });
|
||||
expect(result.stages).toEqual(['S0', 'S1', 'S2', 'S3', 'S4']);
|
||||
result.corruptions.forEach(([broken, rendered]) => expect(rendered).toContain(broken));
|
||||
expect(result.overflow).toEqual({ happiness: 100, overflow: 0 });
|
||||
expect(result.small).toEqual([true, true, true, false, 33]);
|
||||
expect(result.endings).toEqual(['E1', 'E2', 'E3', 'E4', 'none']);
|
||||
expect(result.forcedCheer).toBe(true);
|
||||
expect(result.duskReached).toBe(true);
|
||||
expect(result.nextDay).toEqual({ day: 2, boardRead: false, sunset: false });
|
||||
});
|
||||
|
||||
test('Day 6 smile timer completes at 60 seconds and scripted rain records sadness', async ({ page }) => {
|
||||
await page.goto('/');
|
||||
await page.locator('#player-name').press('Enter');
|
||||
await expect.poll(() => page.evaluate(() => window.__HONEY_GAME__.scene.isActive('HouseScene'))).toBe(true);
|
||||
await page.evaluate(() => (window.__HONEY_GAME__.registry.get('director') as { emit: (event: string, key: string) => void }).emit('debug-teleport', 'PlazaScene'));
|
||||
await expect.poll(() => page.evaluate(() => window.__HONEY_GAME__.scene.isActive('PlazaScene'))).toBe(true);
|
||||
const result = await page.evaluate(() => {
|
||||
const director = window.__HONEY_GAME__.registry.get('director') as any;
|
||||
const scene = window.__HONEY_GAME__.scene.getScene('PlazaScene') as any;
|
||||
director.reset('Test'); director.setDay(6); director.acceptDay(); director.setHappiness(30);
|
||||
director.snapshot.dayStartedAt = performance.now() - 241_000;
|
||||
director.completeTask('T13'); director.setT14(true);
|
||||
scene.t14Start = performance.now() - 60_100;
|
||||
scene.updateT14();
|
||||
const smile = { completed: director.snapshot.completed.has('T14'), active: director.snapshot.t14Active, sunset: director.snapshot.sunset, happiness: director.snapshot.happiness };
|
||||
scene.rainSince = performance.now() - 10_100;
|
||||
scene.updateRain(16);
|
||||
return { smile, rain: { awareness: director.snapshot.awareness, happiness: director.snapshot.happiness } };
|
||||
});
|
||||
expect(result.smile).toEqual({ completed: true, active: false, sunset: true, happiness: 55 });
|
||||
expect(result.rain).toEqual({ awareness: 3, happiness: 50 });
|
||||
});
|
||||
Reference in New Issue
Block a user