feat: scaffold mvp shell and content runtime
This commit is contained in:
146
tests/content-system.test.js
Normal file
146
tests/content-system.test.js
Normal 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)
|
||||
})
|
||||
74
tests/difficulty-generator.test.js
Normal file
74
tests/difficulty-generator.test.js
Normal 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
140
tests/game-session.test.js
Normal 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
124
tests/scene-store.test.js
Normal 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')
|
||||
})
|
||||
Reference in New Issue
Block a user