feat: scaffold mvp shell and content runtime

This commit is contained in:
manpengan
2026-03-29 00:36:28 +08:00
parent 25a38cbf05
commit c118e24bd1
25 changed files with 2903 additions and 7 deletions

View File

@@ -0,0 +1,146 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import {
createContentSystem,
createDefaultPlayerState,
validateContent,
} from '../js/content/index.js'
test('built-in content validates without errors', () => {
const result = validateContent()
assert.equal(result.errors.length, 0)
})
test('content system projects city cards from player state', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const beijingCard = contentSystem.getCityCardView('beijing', playerState)
const tokyoCardBefore = contentSystem.getCityCardView('tokyo', playerState)
assert.equal(beijingCard.isUnlocked, true)
assert.equal(beijingCard.isCollected, false)
assert.equal(tokyoCardBefore.isUnlocked, false)
playerState.levelProgress.beijing = {
1: { completed: true, stars: 3 },
2: { completed: true, stars: 3 },
3: { completed: true, stars: 3 },
4: { completed: true, stars: 3 },
5: { completed: true, stars: 3 },
6: { completed: true, stars: 3 },
}
playerState.unlockedCities.push('tokyo')
playerState.collectedCats.push('beijing')
playerState.passportStamps.push('beijing')
const beijingAfter = contentSystem.getCityCardView('beijing', playerState)
const tokyoCardAfter = contentSystem.getCityCardView('tokyo', playerState)
assert.equal(beijingAfter.isCompleted, true)
assert.equal(beijingAfter.isCollected, true)
assert.equal(tokyoCardAfter.isUnlocked, true)
})
test('content system exposes runtime navigation roots and city children for the MVP map', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const roots = contentSystem.getRootNavNodes()
const asia = contentSystem.getNavNode('asia')
const children = contentSystem.getNavChildren('asia', playerState)
assert.deepEqual(roots.map((node) => node.id), ['asia', 'mashup', 'coming_soon'])
assert.equal(asia.childType, 'city')
assert.deepEqual(
children.map((entry) => entry.id),
['beijing', 'tokyo', 'bangkok', 'seoul', 'singapore', 'istanbul'],
)
assert.equal(children[0].type, 'city')
assert.equal(children[0].parentId, 'asia')
assert.equal(children[0].isUnlocked, true)
assert.equal(children[1].isUnlocked, false)
})
test('content system exposes nine home tiles and gates mashup behind two completed cities', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const initialTiles = contentSystem.getHomeTiles(playerState)
assert.deepEqual(
initialTiles.map((tile) => tile.id),
['asia', 'europe', 'north_america', 'south_america', 'africa', 'oceania', 'antarctica', 'mashup', 'coming_soon'],
)
assert.equal(initialTiles[0].isUnlocked, true)
assert.equal(initialTiles[1].isUnlocked, false)
assert.equal(initialTiles[7].isUnlocked, false)
assert.equal(initialTiles[8].isInteractive, true)
playerState.levelProgress.beijing = {
1: { completed: true, stars: 3 },
2: { completed: true, stars: 3 },
3: { completed: true, stars: 3 },
4: { completed: true, stars: 3 },
5: { completed: true, stars: 3 },
6: { completed: true, stars: 3 },
}
playerState.levelProgress.tokyo = {
1: { completed: true, stars: 3 },
2: { completed: true, stars: 3 },
3: { completed: true, stars: 3 },
4: { completed: true, stars: 3 },
5: { completed: true, stars: 3 },
6: { completed: true, stars: 3 },
}
const unlockedTiles = contentSystem.getHomeTiles(playerState)
const mashupTile = unlockedTiles.find((tile) => tile.id === 'mashup')
assert.equal(mashupTile.isUnlocked, true)
})
test('default player state includes city team and empty collection albums', () => {
const playerState = createDefaultPlayerState()
assert.deepEqual(playerState.cityTeam, {
teamCityId: null,
joinedDate: null,
lastSwitchDate: null,
})
assert.deepEqual(playerState.collectedMagnets, [])
assert.deepEqual(playerState.collectedStamps, [])
})
test('content system summarizes MVP gift albums and per-city progress', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
playerState.collectedMagnets.push({
magnetId: 'magnet_beijing_1',
cityId: 'beijing',
levelId: 1,
acquiredDate: '2026-03-29T00:00:00.000Z',
})
playerState.collectedStamps.push({
stampId: 'stamp_beijing',
cityId: 'beijing',
acquiredDate: '2026-03-29T00:00:00.000Z',
})
const albums = contentSystem.getGiftAlbums(playerState)
const magnetEntries = contentSystem.getGiftAlbumEntries('magnets', playerState)
const stampEntries = contentSystem.getGiftAlbumEntries('stamps', playerState)
assert.deepEqual(albums.map((album) => album.id), ['magnets', 'stamps'])
assert.deepEqual(albums.map((album) => album.collectedCount), [1, 1])
assert.deepEqual(albums.map((album) => album.totalCount), [36, 6])
assert.equal(magnetEntries[0].cityId, 'beijing')
assert.equal(magnetEntries[0].collectedCount, 1)
assert.equal(magnetEntries[0].totalCount, 6)
assert.equal(stampEntries[0].cityId, 'beijing')
assert.equal(stampEntries[0].isCollected, true)
assert.equal(stampEntries[1].isCollected, false)
})

View File

@@ -0,0 +1,74 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createContentSystem } from '../js/content/index.js'
import {
classifyDeadlock,
evaluateBoard,
generateBoard,
} from '../js/gameplay/difficulty/index.js'
test('generateBoard is deterministic for the same city, level, and seed', () => {
const contentSystem = createContentSystem()
const first = generateBoard({
cityId: 'beijing',
levelId: 1,
seed: 11001,
contentSystem,
})
const second = generateBoard({
cityId: 'beijing',
levelId: 1,
seed: 11001,
contentSystem,
})
assert.deepEqual(first.pieces, second.pieces)
assert.deepEqual(first.overlapGraph, second.overlapGraph)
})
test('evaluateBoard exposes baseline metrics for intro level boards', () => {
const contentSystem = createContentSystem()
const board = generateBoard({
cityId: 'beijing',
levelId: 1,
seed: 11001,
contentSystem,
})
const levelPreset = contentSystem.getLevelPreset('beijing', 1)
const metrics = evaluateBoard(board, levelPreset)
assert.equal(metrics.totalPieces, 18)
assert.ok(metrics.initialClickableRatio >= 0.3)
})
test('classifyDeadlock identifies a hard deadlock when slot is full and no match path exists', () => {
const runtimeState = {
slot: [
{ elementId: 'a' },
{ elementId: 'b' },
{ elementId: 'c' },
{ elementId: 'd' },
{ elementId: 'e' },
{ elementId: 'f' },
{ elementId: 'g' },
],
bypass: [],
}
const boardState = {
pieces: [
{ id: 'p1', elementId: 'x', removed: false },
{ id: 'p2', elementId: 'y', removed: false },
],
overlapGraph: {
p1: [],
p2: [],
},
}
const result = classifyDeadlock(runtimeState, boardState)
assert.equal(result.type, 'hard')
})

140
tests/game-session.test.js Normal file
View File

@@ -0,0 +1,140 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createContentSystem } from '../js/content/index.js'
import { createGameSession } from '../js/gameplay/session/index.js'
test('pickPiece adds a clickable piece to the slot', () => {
const session = createGameSession({
cityId: 'beijing',
levelId: 1,
seed: 11001,
contentSystem: createContentSystem(),
})
const clickablePiece = session.getClickablePieces()[0]
const result = session.pickPiece(clickablePiece.id)
assert.equal(result.status, 'picked')
assert.equal(session.getState().slot.length, 1)
assert.equal(session.getState().slot[0].pieceId, clickablePiece.id)
})
test('pickPiece auto clears triples from the slot', () => {
const contentSystem = createContentSystem()
const session = createGameSession({
cityId: 'beijing',
levelId: 1,
seed: 11001,
contentSystem,
boardState: {
boardId: 'test',
cityId: 'beijing',
levelId: 1,
seed: 1,
pieces: [
{ id: 'p1', elementId: 'beijing_01', layer: 0, x: 0, y: 0, width: 64, height: 64, rotation: 0, removed: false },
{ id: 'p2', elementId: 'beijing_01', layer: 0, x: 70, y: 0, width: 64, height: 64, rotation: 0, removed: false },
{ id: 'p3', elementId: 'beijing_01', layer: 0, x: 140, y: 0, width: 64, height: 64, rotation: 0, removed: false },
{ id: 'p4', elementId: 'beijing_02', layer: 0, x: 0, y: 70, width: 64, height: 64, rotation: 0, removed: false },
{ id: 'p5', elementId: 'beijing_02', layer: 0, x: 70, y: 70, width: 64, height: 64, rotation: 0, removed: false },
{ id: 'p6', elementId: 'beijing_02', layer: 0, x: 140, y: 70, width: 64, height: 64, rotation: 0, removed: false },
],
overlapGraph: {
p1: [],
p2: [],
p3: [],
p4: [],
p5: [],
p6: [],
},
metrics: {},
},
})
session.pickPiece('p1')
session.pickPiece('p2')
const third = session.pickPiece('p3')
assert.equal(third.status, 'matched')
assert.equal(session.getState().slot.length, 0)
assert.equal(session.getState().removedPieceIds.length, 3)
})
test('session fails when slot fills without a match', () => {
const session = createGameSession({
cityId: 'beijing',
levelId: 1,
seed: 11001,
contentSystem: createContentSystem(),
boardState: {
boardId: 'full-slot',
cityId: 'beijing',
levelId: 1,
seed: 2,
pieces: ['a', 'b', 'c', 'd', 'e', 'f', 'g'].map((key, index) => ({
id: `p${index + 1}`,
elementId: key,
layer: 0,
x: index * 70,
y: 0,
width: 64,
height: 64,
rotation: 0,
removed: false,
})),
overlapGraph: {
p1: [],
p2: [],
p3: [],
p4: [],
p5: [],
p6: [],
p7: [],
},
metrics: {},
},
})
for (let index = 1; index <= 6; index += 1) {
session.pickPiece(`p${index}`)
}
const last = session.pickPiece('p7')
assert.equal(last.status, 'failed')
assert.equal(session.getState().status, 'failed')
})
test('session wins when the final triple clears the board', () => {
const session = createGameSession({
cityId: 'beijing',
levelId: 1,
seed: 11001,
contentSystem: createContentSystem(),
boardState: {
boardId: 'win-state',
cityId: 'beijing',
levelId: 1,
seed: 3,
pieces: [
{ id: 'p1', elementId: 'beijing_01', layer: 0, x: 0, y: 0, width: 64, height: 64, rotation: 0, removed: false },
{ id: 'p2', elementId: 'beijing_01', layer: 0, x: 70, y: 0, width: 64, height: 64, rotation: 0, removed: false },
{ id: 'p3', elementId: 'beijing_01', layer: 0, x: 140, y: 0, width: 64, height: 64, rotation: 0, removed: false },
],
overlapGraph: {
p1: [],
p2: [],
p3: [],
},
metrics: {},
},
})
session.pickPiece('p1')
session.pickPiece('p2')
const third = session.pickPiece('p3')
assert.equal(third.status, 'won')
assert.equal(session.getState().status, 'won')
})

124
tests/scene-store.test.js Normal file
View File

@@ -0,0 +1,124 @@
import test from 'node:test'
import assert from 'node:assert/strict'
import { createContentSystem, createDefaultPlayerState } from '../js/content/index.js'
import { createSceneStore } from '../js/ui/scene-store.js'
test('scene store starts on the home selection page', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const sceneStore = createSceneStore({ contentSystem, playerState })
assert.equal(sceneStore.getScene().type, 'home-select')
})
test('scene store opens level selection for unlocked city and returns back', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const sceneStore = createSceneStore({ contentSystem, playerState })
const openedHome = sceneStore.openHomeTile('asia')
const opened = sceneStore.openCity('beijing')
assert.equal(openedHome.opened, true)
assert.equal(opened, true)
assert.equal(sceneStore.getScene().type, 'level-select')
assert.equal(sceneStore.getScene().cityId, 'beijing')
sceneStore.goBack()
assert.equal(sceneStore.getScene().type, 'city-select')
})
test('scene store opens gameplay scene from a valid level', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const sceneStore = createSceneStore({ contentSystem, playerState })
sceneStore.openHomeTile('asia')
sceneStore.openCity('beijing')
const opened = sceneStore.openLevel('beijing', 1)
assert.equal(opened, true)
assert.equal(sceneStore.getScene().type, 'gameplay')
assert.equal(sceneStore.getScene().session.cityId, 'beijing')
assert.equal(sceneStore.getScene().session.levelId, 1)
})
test('scene store awards magnets, stamps, and unlocks the next city on completion', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const sceneStore = createSceneStore({
contentSystem,
playerState,
now: () => new Date('2026-03-28T12:00:00.000Z').getTime(),
})
sceneStore.completeLevel({
cityId: 'beijing',
levelId: 1,
stars: 3,
})
assert.deepEqual(playerState.collectedMagnets, [
{
magnetId: 'magnet_beijing_1',
cityId: 'beijing',
levelId: 1,
acquiredDate: '2026-03-28T12:00:00.000Z',
},
])
for (let levelId = 2; levelId <= 6; levelId += 1) {
sceneStore.completeLevel({
cityId: 'beijing',
levelId,
stars: 3,
})
}
assert.equal(playerState.collectedCats.includes('beijing'), true)
assert.equal(playerState.passportStamps.includes('beijing'), true)
assert.equal(playerState.unlockedCities.includes('tokyo'), true)
assert.deepEqual(playerState.collectedStamps, [
{
stampId: 'stamp_beijing',
cityId: 'beijing',
acquiredDate: '2026-03-28T12:00:00.000Z',
},
])
})
test('scene store reports locked and placeholder home tiles without leaving the home scene', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const sceneStore = createSceneStore({ contentSystem, playerState })
const locked = sceneStore.openHomeTile('europe')
const soon = sceneStore.openHomeTile('coming_soon')
assert.deepEqual(locked, { opened: false, reason: 'locked' })
assert.deepEqual(soon, { opened: false, reason: 'coming-soon' })
assert.equal(sceneStore.getScene().type, 'home-select')
})
test('scene store opens the gift zone and switches between MVP tabs', () => {
const contentSystem = createContentSystem()
const playerState = createDefaultPlayerState()
const sceneStore = createSceneStore({ contentSystem, playerState })
const opened = sceneStore.openGiftZone()
assert.equal(opened, true)
assert.equal(sceneStore.getScene().type, 'gift-zone')
assert.equal(sceneStore.getScene().selectedTab, 'magnets')
const switched = sceneStore.selectGiftTab('stamps')
assert.equal(switched, true)
assert.equal(sceneStore.getScene().selectedTab, 'stamps')
sceneStore.goBack()
assert.equal(sceneStore.getScene().type, 'home-select')
})