feat: add gift zone city team and mashup mode
This commit is contained in:
@@ -0,0 +1,67 @@
|
||||
# Gift Zone CityTeam Mashup Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** Add the MVP cat album and local cityTeam entry flow, then wire the mashup home tile into a playable standalone round with local rewards.
|
||||
|
||||
**Architecture:** Extend the existing `contentSystem` and `scene-store` contracts instead of refactoring core navigation. Reuse the current gameplay session for mashup by feeding it generated board metadata and handling mashup completion in a separate local reward path.
|
||||
|
||||
**Tech Stack:** ES modules, Node test runner, WeChat mini-game canvas shell
|
||||
|
||||
---
|
||||
|
||||
### Task 1: Docs And Data Contracts
|
||||
|
||||
**Files:**
|
||||
- Create: `docs/superpowers/specs/2026-03-29-gift-zone-cityteam-mashup-design.md`
|
||||
- Create: `docs/superpowers/plans/2026-03-29-gift-zone-cityteam-mashup.md`
|
||||
- Modify: `js/content/index.js`
|
||||
|
||||
- [ ] Define the MVP cat album contract in `contentSystem`
|
||||
- [ ] Keep `animals` and `maps` out of scope
|
||||
- [ ] Add any helper projections needed for cityTeam and mashup
|
||||
|
||||
### Task 2: Gift Zone And CityTeam
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/content-system.test.js`
|
||||
- Modify: `tests/scene-store.test.js`
|
||||
- Modify: `js/content/index.js`
|
||||
- Modify: `js/ui/scene-store.js`
|
||||
- Modify: `js/main.js`
|
||||
|
||||
- [ ] Write failing tests for `cats` album visibility and counts
|
||||
- [ ] Write failing tests for cityTeam selection flow
|
||||
- [ ] Implement `cats` album projections with existing city cat data
|
||||
- [ ] Implement `city-team-select` scene and local selection writeback
|
||||
- [ ] Render cityTeam status and entry buttons in the canvas UI
|
||||
- [ ] Re-run targeted tests until green
|
||||
|
||||
### Task 3: Mashup Gameplay
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/difficulty-generator.test.js`
|
||||
- Modify: `tests/scene-store.test.js`
|
||||
- Modify: `js/gameplay/difficulty/generate-board.js`
|
||||
- Create: `js/gameplay/difficulty/generate-mashup-board.js`
|
||||
- Modify: `js/gameplay/difficulty/index.js`
|
||||
- Modify: `js/gameplay/session/index.js`
|
||||
- Modify: `js/ui/scene-store.js`
|
||||
- Modify: `js/main.js`
|
||||
|
||||
- [ ] Write failing tests for mashup board generation
|
||||
- [ ] Write failing tests for mashup scene entry and reward writeback
|
||||
- [ ] Implement mashup board builder from unlocked city content
|
||||
- [ ] Implement mashup scene/session creation in `scene-store`
|
||||
- [ ] Implement mashup win reward path and result messaging
|
||||
- [ ] Re-run targeted tests until green
|
||||
|
||||
### Task 4: Full Verification
|
||||
|
||||
**Files:**
|
||||
- Modify: `tests/*` as needed
|
||||
- Modify: `js/*` as needed
|
||||
|
||||
- [ ] Run the full test suite with `npm test`
|
||||
- [ ] Verify no unrelated placeholders were implemented
|
||||
- [ ] Summarize remaining placeholder areas in the final report
|
||||
@@ -0,0 +1,92 @@
|
||||
# Gift Zone CityTeam Mashup Design
|
||||
|
||||
## Goal
|
||||
|
||||
在现有 MVP 壳子上补齐三个紧邻切片:
|
||||
|
||||
- 礼物区从 2 册扩到 3 册,先把 `猫猫册` 做成可浏览的 MVP 版本
|
||||
- 接通本地 `cityTeam` 选队入口,只做本地选择和展示,不做服务端排行
|
||||
- 把 `主题大混战` 做成可进入的独立对局,并提供本地奖励回写
|
||||
|
||||
## Scope
|
||||
|
||||
### In
|
||||
|
||||
- `gift-zone` 增加 `cats` tab 和汇总计数
|
||||
- `cityTeam` 选择页、本地入队状态、入口按钮
|
||||
- `mashup` 入口从首页可进入独立对局
|
||||
- `mashup` 使用已解锁城市元素混搭生成棋盘
|
||||
- `mashup` 通关奖励写回本地存档
|
||||
|
||||
### Out
|
||||
|
||||
- 真正的分享卡片视觉内容
|
||||
- 开房 PK
|
||||
- 城市战队排行榜服务端逻辑
|
||||
- 礼物区 `动物册 / 地图册`
|
||||
- 地区级猫猫收集规则
|
||||
|
||||
## Design Decisions
|
||||
|
||||
### 1. 猫猫册口径
|
||||
|
||||
先用“已通关城市猫”作为 `猫猫册` MVP 口径,而不是文档里的“地区猫”正式口径。
|
||||
|
||||
原因:
|
||||
|
||||
- 现有存档里已经有 `collectedCats[]` 城市完成态
|
||||
- 现有 CityManifest 已经有 `cat` 和 `cover.catThumb`
|
||||
- 这样可以不引入新资源、不改通关发奖链路
|
||||
|
||||
后续若升级为“地区猫”,只需要替换 `cats` album 的数据投影层,不必重写礼物区 UI 框架。
|
||||
|
||||
### 2. cityTeam 入口
|
||||
|
||||
`cityTeam` 入口同时挂在两个地方:
|
||||
|
||||
- 礼物区顶部信息卡
|
||||
- 主页右侧“排行榜”卡片
|
||||
|
||||
如果未选队,进入 `city-team-select` 选择页;如果已选队,则展示当前所属战队的本地状态说明。选择仅允许已解锁城市,首次选择后本地锁定。
|
||||
|
||||
### 3. 主题大混战实现方式
|
||||
|
||||
不把 `mashup` 硬塞进现有城市关卡合同,而是新增一条轻量 mode 路径:
|
||||
|
||||
- `scene-store` 增加 `mashup` scene
|
||||
- 生成器增加 mashup board 构建辅助函数
|
||||
- `gameplay` 复用现有 `GameSession`
|
||||
- `main.js` 根据 scene metadata 渲染 mashup 标题、结果和奖励文案
|
||||
|
||||
这样不需要把 `contentSystem.getCity()` 改成兼容“虚拟城市”,能控制改动面。
|
||||
|
||||
### 4. 混战奖励
|
||||
|
||||
混战不计入城市关卡进度。通关奖励规则:
|
||||
|
||||
- 优先随机发一个当前已解锁城市的冰箱贴
|
||||
- 如果随机命中的冰箱贴已拥有,则发 `shuffle +1`
|
||||
|
||||
奖励完全本地化,只做存档回写和结果展示。
|
||||
|
||||
## Data Flow
|
||||
|
||||
### Gift Zone
|
||||
|
||||
`playerState` -> `contentSystem.getGiftAlbums()` / `getGiftAlbumEntries()` -> `scene-store gift-zone` -> `main.js` 礼物区页面
|
||||
|
||||
### CityTeam
|
||||
|
||||
`playerState.cityTeam` -> `scene-store` 入口判断 / 选择写回 -> `main.js` 选择页与状态卡
|
||||
|
||||
### Mashup
|
||||
|
||||
首页 `mashup` tile -> `scene-store.openHomeTile('mashup')` -> 构建 mashup board + session -> `main.js` gameplay -> 通关后 `scene-store.completeMashupRun()`
|
||||
|
||||
## Testing
|
||||
|
||||
- 内容系统测试:礼物区 album 列表包含 `cats`,计数与条目投影正确
|
||||
- scene-store 测试:cityTeam 选择页可打开、可选择、选后锁定
|
||||
- scene-store 测试:`mashup` 在通关 2 城后可进入
|
||||
- 对局/难度测试:mashup board 由多个城市元素组成且可复现
|
||||
- scene-store 测试:mashup 通关可写入奖励
|
||||
@@ -44,6 +44,7 @@ export function createContentSystem() {
|
||||
const giftAlbumDefinitions = [
|
||||
{ id: 'magnets', name: '冰箱贴册' },
|
||||
{ id: 'stamps', name: '邮票册' },
|
||||
{ id: 'cats', name: '猫猫册' },
|
||||
]
|
||||
|
||||
function getCompletedCityCount(playerState) {
|
||||
@@ -115,6 +116,35 @@ export function createContentSystem() {
|
||||
})
|
||||
}
|
||||
|
||||
function getCatEntries(playerState) {
|
||||
const collectedCats = new Set(playerState.collectedCats ?? [])
|
||||
|
||||
return cityRegistry.getAllCities().map((city) => ({
|
||||
cityId: city.id,
|
||||
cityName: city.display.name,
|
||||
cityNameEn: city.display.nameEn,
|
||||
themeColor: city.display.bgColor,
|
||||
catId: city.cat.id,
|
||||
catName: city.cat.name,
|
||||
catThumb: city.cover.catThumb,
|
||||
isCollected: collectedCats.has(city.id),
|
||||
}))
|
||||
}
|
||||
|
||||
function summarizeAlbumEntries(albumId, entries) {
|
||||
if (albumId === 'magnets') {
|
||||
return {
|
||||
collectedCount: entries.reduce((sum, entry) => sum + entry.collectedCount, 0),
|
||||
totalCount: entries.reduce((sum, entry) => sum + entry.totalCount, 0),
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
collectedCount: entries.filter((entry) => entry.isCollected).length,
|
||||
totalCount: entries.length,
|
||||
}
|
||||
}
|
||||
|
||||
function getGiftAlbumEntries(albumId, playerState) {
|
||||
if (albumId === 'magnets') {
|
||||
return getMagnetEntries(playerState)
|
||||
@@ -124,23 +154,22 @@ export function createContentSystem() {
|
||||
return getStampEntries(playerState)
|
||||
}
|
||||
|
||||
if (albumId === 'cats') {
|
||||
return getCatEntries(playerState)
|
||||
}
|
||||
|
||||
return []
|
||||
}
|
||||
|
||||
function getGiftAlbums(playerState) {
|
||||
return giftAlbumDefinitions.map((definition) => {
|
||||
const entries = getGiftAlbumEntries(definition.id, playerState)
|
||||
const collectedCount = definition.id === 'magnets'
|
||||
? entries.reduce((sum, entry) => sum + entry.collectedCount, 0)
|
||||
: entries.filter((entry) => entry.isCollected).length
|
||||
const totalCount = definition.id === 'magnets'
|
||||
? entries.reduce((sum, entry) => sum + entry.totalCount, 0)
|
||||
: entries.length
|
||||
const summary = summarizeAlbumEntries(definition.id, entries)
|
||||
|
||||
return {
|
||||
...definition,
|
||||
collectedCount,
|
||||
totalCount,
|
||||
collectedCount: summary.collectedCount,
|
||||
totalCount: summary.totalCount,
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
@@ -105,6 +105,38 @@ function placePieces(piecePool, levelPreset, rng) {
|
||||
return placedPieces
|
||||
}
|
||||
|
||||
export function generateBoardFromDefinition({
|
||||
boardId,
|
||||
cityId,
|
||||
levelId,
|
||||
seed,
|
||||
elements,
|
||||
levelPreset,
|
||||
extraState = {},
|
||||
}) {
|
||||
const effectiveSeed = seed ?? levelPreset.seedBase
|
||||
const rng = createRng(effectiveSeed)
|
||||
const selectedElements = [...elements]
|
||||
const counts = normalizePiecesPerElement(levelPreset.piecesPerElement, levelPreset.elementCount)
|
||||
const piecePool = createPiecePool(selectedElements, counts)
|
||||
const pieces = placePieces(shuffleWithRng(piecePool, rng), levelPreset, rng)
|
||||
const overlapGraph = buildOverlapGraph(pieces)
|
||||
const boardState = {
|
||||
boardId: boardId ?? `${cityId}-${levelId}-${effectiveSeed}`,
|
||||
cityId,
|
||||
levelId,
|
||||
seed: effectiveSeed,
|
||||
pieces,
|
||||
overlapGraph,
|
||||
metrics: {},
|
||||
...extraState,
|
||||
}
|
||||
|
||||
boardState.metrics = evaluateBoard(boardState, levelPreset)
|
||||
|
||||
return boardState
|
||||
}
|
||||
|
||||
export function generateBoard({ cityId, levelId, seed, contentSystem }) {
|
||||
const city = contentSystem.getCity(cityId)
|
||||
const levelPreset = contentSystem.getLevelPreset(cityId, levelId)
|
||||
@@ -116,23 +148,14 @@ export function generateBoard({ cityId, levelId, seed, contentSystem }) {
|
||||
const effectiveSeed = seed ?? levelPreset.seedBase
|
||||
const rng = createRng(effectiveSeed)
|
||||
const selectedElements = pickElements(city, levelPreset, rng)
|
||||
const counts = normalizePiecesPerElement(levelPreset.piecesPerElement, levelPreset.elementCount)
|
||||
const piecePool = createPiecePool(selectedElements, counts)
|
||||
const pieces = placePieces(shuffleWithRng(piecePool, rng), levelPreset, rng)
|
||||
const overlapGraph = buildOverlapGraph(pieces)
|
||||
const boardState = {
|
||||
boardId: `${cityId}-${levelId}-${effectiveSeed}`,
|
||||
|
||||
return generateBoardFromDefinition({
|
||||
cityId,
|
||||
levelId,
|
||||
seed: effectiveSeed,
|
||||
pieces,
|
||||
overlapGraph,
|
||||
metrics: {},
|
||||
}
|
||||
|
||||
boardState.metrics = evaluateBoard(boardState, levelPreset)
|
||||
|
||||
return boardState
|
||||
elements: selectedElements,
|
||||
levelPreset,
|
||||
})
|
||||
}
|
||||
|
||||
export default generateBoard
|
||||
|
||||
99
js/gameplay/difficulty/generate-mashup-board.js
Normal file
99
js/gameplay/difficulty/generate-mashup-board.js
Normal file
@@ -0,0 +1,99 @@
|
||||
import { generateBoardFromDefinition } from './generate-board.js'
|
||||
import { createRng, shuffleWithRng } from './random.js'
|
||||
|
||||
function createMashupLevelPreset(elementCount) {
|
||||
return {
|
||||
id: 1,
|
||||
seedBase: 31001,
|
||||
elementCount,
|
||||
piecesPerElement: Array.from({ length: elementCount }, () => 3),
|
||||
layers: 4,
|
||||
density: 'medium_high',
|
||||
}
|
||||
}
|
||||
|
||||
function getSourceCities(cityIds, contentSystem) {
|
||||
return cityIds
|
||||
.map((cityId) => contentSystem.getCity(cityId))
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function createCityElementPools(cities, rng) {
|
||||
return cities.map((city) => ({
|
||||
cityId: city.id,
|
||||
cursor: 0,
|
||||
elements: shuffleWithRng(
|
||||
city.elements.map((element) => ({
|
||||
...element,
|
||||
sourceCityId: city.id,
|
||||
sourceCityName: city.display.name,
|
||||
})),
|
||||
rng,
|
||||
),
|
||||
}))
|
||||
}
|
||||
|
||||
function pickMashupElements(cities, targetCount, rng) {
|
||||
const pools = createCityElementPools(cities, rng)
|
||||
const selected = []
|
||||
|
||||
while (selected.length < targetCount) {
|
||||
let didAdd = false
|
||||
|
||||
for (const pool of pools) {
|
||||
const element = pool.elements[pool.cursor]
|
||||
if (!element) {
|
||||
continue
|
||||
}
|
||||
|
||||
selected.push(element)
|
||||
pool.cursor += 1
|
||||
didAdd = true
|
||||
|
||||
if (selected.length === targetCount) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if (!didAdd) {
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
return selected
|
||||
}
|
||||
|
||||
export function generateMashupBoard({ cityIds, seed, contentSystem }) {
|
||||
const cities = getSourceCities(cityIds, contentSystem)
|
||||
|
||||
if (cities.length === 0) {
|
||||
throw new Error('Mashup mode requires at least one source city')
|
||||
}
|
||||
|
||||
const effectiveSeed = seed ?? 31001
|
||||
const rng = createRng(effectiveSeed)
|
||||
const maxElementCount = cities.reduce((sum, city) => sum + city.elements.length, 0)
|
||||
const elementCount = Math.min(8, maxElementCount)
|
||||
const levelPreset = createMashupLevelPreset(elementCount)
|
||||
const selectedElements = pickMashupElements(cities, elementCount, rng)
|
||||
|
||||
return generateBoardFromDefinition({
|
||||
boardId: `mashup-${effectiveSeed}`,
|
||||
cityId: 'mashup',
|
||||
levelId: 1,
|
||||
seed: effectiveSeed,
|
||||
elements: selectedElements,
|
||||
levelPreset,
|
||||
extraState: {
|
||||
sourceCityIds: cities.map((city) => city.id),
|
||||
elementDefinitions: selectedElements.map((element) => ({
|
||||
elementId: element.id,
|
||||
name: element.name,
|
||||
sourceCityId: element.sourceCityId,
|
||||
sourceCityName: element.sourceCityName,
|
||||
})),
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
export default generateMashupBoard
|
||||
@@ -1,6 +1,7 @@
|
||||
export { classifyDeadlock } from './classify-deadlock.js'
|
||||
export { evaluateBoard } from './evaluate-board.js'
|
||||
export { generateBoard } from './generate-board.js'
|
||||
export { generateMashupBoard } from './generate-mashup-board.js'
|
||||
export {
|
||||
buildOverlapGraph,
|
||||
getClickablePieces,
|
||||
|
||||
@@ -65,6 +65,7 @@ export function createGameSession({
|
||||
seed,
|
||||
contentSystem,
|
||||
boardState,
|
||||
restartFactory,
|
||||
}) {
|
||||
const resolvedBoard = boardState ? cloneBoardState(boardState) : generateBoard({ cityId, levelId, seed, contentSystem })
|
||||
const state = {
|
||||
@@ -160,6 +161,10 @@ export function createGameSession({
|
||||
}
|
||||
|
||||
function restart(nextSeed = seed ?? resolvedBoard.seed) {
|
||||
if (restartFactory) {
|
||||
return restartFactory(nextSeed)
|
||||
}
|
||||
|
||||
const freshSession = createGameSession({
|
||||
cityId,
|
||||
levelId,
|
||||
|
||||
200
js/main.js
200
js/main.js
@@ -223,10 +223,15 @@ function drawSidebarCard({ x, y, width, height, titleLines, subtitle, onTap, acc
|
||||
}
|
||||
|
||||
function drawHomeScene() {
|
||||
const playerStateSnapshot = sceneStore.getPlayerState()
|
||||
drawHeader('城市抓猫猫', '世界主页面 · 3×3 入口')
|
||||
|
||||
const homeTiles = contentSystem.getHomeTiles(sceneStore.getPlayerState())
|
||||
const playerGiftAlbums = contentSystem.getGiftAlbums(sceneStore.getPlayerState())
|
||||
const homeTiles = contentSystem.getHomeTiles(playerStateSnapshot)
|
||||
const playerGiftAlbums = contentSystem.getGiftAlbums(playerStateSnapshot)
|
||||
const teamCity = playerStateSnapshot.cityTeam?.teamCityId
|
||||
? contentSystem.getCity(playerStateSnapshot.cityTeam.teamCityId)
|
||||
: null
|
||||
const totalGiftCount = playerGiftAlbums.reduce((sum, album) => sum + album.collectedCount, 0)
|
||||
const sidebarWidth = Math.max(58, Math.min(70, Math.floor(windowWidth * 0.18)))
|
||||
const sidebarGap = 8
|
||||
const centerX = 24 + sidebarWidth + sidebarGap
|
||||
@@ -270,7 +275,7 @@ function drawHomeScene() {
|
||||
width: sidebarWidth,
|
||||
height: cardHeight,
|
||||
titleLines: ['礼物', '区'],
|
||||
subtitle: `${playerGiftAlbums[0].collectedCount + playerGiftAlbums[1].collectedCount} 个收藏`,
|
||||
subtitle: `${totalGiftCount} 个收藏`,
|
||||
accentColor: '#26A69A',
|
||||
onTap() {
|
||||
sceneStore.openGiftZone()
|
||||
@@ -284,10 +289,10 @@ function drawHomeScene() {
|
||||
width: sidebarWidth,
|
||||
height: cardHeight,
|
||||
titleLines: ['排行', '榜'],
|
||||
subtitle: '城市战队',
|
||||
subtitle: teamCity ? `${teamCity.display.name} 战队` : '选择战队',
|
||||
accentColor: '#42A5F5',
|
||||
onTap() {
|
||||
showTransientMessage('城市排行榜依赖服务端,V1.1+ 接入')
|
||||
sceneStore.openCityTeamSelect()
|
||||
render()
|
||||
},
|
||||
})
|
||||
@@ -483,8 +488,11 @@ function drawGiftZoneScene(scene) {
|
||||
const playerStateSnapshot = sceneStore.getPlayerState()
|
||||
const albums = contentSystem.getGiftAlbums(playerStateSnapshot)
|
||||
const entries = contentSystem.getGiftAlbumEntries(scene.selectedTab, playerStateSnapshot)
|
||||
const teamCity = playerStateSnapshot.cityTeam?.teamCityId
|
||||
? contentSystem.getCity(playerStateSnapshot.cityTeam.teamCityId)
|
||||
: null
|
||||
|
||||
drawHeader('城市主题礼物区', 'MVP 收集册 · 冰箱贴 / 邮票')
|
||||
drawHeader('城市主题礼物区', 'MVP 收集册 · 冰箱贴 / 邮票 / 猫猫')
|
||||
|
||||
const summaryX = 24
|
||||
const summaryY = 92
|
||||
@@ -507,8 +515,50 @@ function drawGiftZoneScene(scene) {
|
||||
summaryY + summaryHeight / 2,
|
||||
)
|
||||
|
||||
const tabWidth = (windowWidth - 24 * 2 - 12) / 2
|
||||
const tabsY = 158
|
||||
const teamCardY = 156
|
||||
const teamCardHeight = 58
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.strokeStyle = teamCity ? teamCity.display.bgColor : '#8aa3ff'
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(summaryX, teamCardY, summaryWidth, teamCardHeight, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = '#231f2a'
|
||||
ctx.font = 'bold 15px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(
|
||||
teamCity ? `${teamCity.display.name} 战队` : '还没加入城市战队',
|
||||
summaryX + 16,
|
||||
teamCardY + 22,
|
||||
)
|
||||
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '12px sans-serif'
|
||||
ctx.fillText(
|
||||
teamCity ? '本地已锁定,排行榜继续占位' : '先选已解锁城市,本地状态先走通',
|
||||
summaryX + 16,
|
||||
teamCardY + 42,
|
||||
)
|
||||
|
||||
drawButton({
|
||||
x: summaryX + summaryWidth - 116,
|
||||
y: teamCardY + 12,
|
||||
width: 100,
|
||||
height: 34,
|
||||
label: teamCity ? '查看战队' : '选择战队',
|
||||
onTap() {
|
||||
sceneStore.openCityTeamSelect()
|
||||
render()
|
||||
},
|
||||
fillStyle: teamCity ? teamCity.display.bgColor : '#231f2a',
|
||||
textColor: '#ffffff',
|
||||
})
|
||||
|
||||
const tabGap = 12
|
||||
const tabWidth = (windowWidth - 24 * 2 - tabGap * (albums.length - 1)) / albums.length
|
||||
const tabsY = 228
|
||||
|
||||
albums.forEach((album, index) => {
|
||||
drawButton({
|
||||
@@ -529,7 +579,7 @@ function drawGiftZoneScene(scene) {
|
||||
const columns = 2
|
||||
const cardWidth = (windowWidth - 24 * 2 - 14) / columns
|
||||
const cardHeight = 82
|
||||
const startY = 216
|
||||
const startY = 286
|
||||
|
||||
entries.forEach((entry, index) => {
|
||||
const row = Math.floor(index / columns)
|
||||
@@ -553,8 +603,10 @@ function drawGiftZoneScene(scene) {
|
||||
ctx.font = '13px sans-serif'
|
||||
if (scene.selectedTab === 'magnets') {
|
||||
ctx.fillText(`冰箱贴 ${entry.collectedCount}/${entry.totalCount}`, x + 16, y + 52)
|
||||
} else {
|
||||
} else if (scene.selectedTab === 'stamps') {
|
||||
ctx.fillText(entry.isCollected ? '邮票已收集' : '邮票未收集', x + 16, y + 52)
|
||||
} else {
|
||||
ctx.fillText(entry.isCollected ? `${entry.catName} 已收集` : '通关城市后收集', x + 16, y + 52)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -564,8 +616,73 @@ function drawGiftZoneScene(scene) {
|
||||
})
|
||||
}
|
||||
|
||||
function drawCityTeamSelectScene(scene) {
|
||||
const isLocked = Boolean(scene.selectedTeamCityId)
|
||||
const selectedCity = isLocked ? contentSystem.getCity(scene.selectedTeamCityId) : null
|
||||
|
||||
drawHeader(
|
||||
'选择城市战队',
|
||||
selectedCity ? `当前所属 ${selectedCity.display.name} 战队 · 本地先锁定` : '从已解锁城市里选择一个战队',
|
||||
)
|
||||
|
||||
const columns = 2
|
||||
const cardWidth = (windowWidth - 24 * 2 - 14) / columns
|
||||
const cardHeight = 94
|
||||
const startY = 104
|
||||
|
||||
scene.options.forEach((entry, index) => {
|
||||
const row = Math.floor(index / columns)
|
||||
const column = index % columns
|
||||
const x = 24 + column * (cardWidth + 14)
|
||||
const y = startY + row * (cardHeight + 12)
|
||||
const isSelected = scene.selectedTeamCityId === entry.cityId
|
||||
|
||||
ctx.fillStyle = '#ffffff'
|
||||
ctx.strokeStyle = entry.themeColor
|
||||
ctx.lineWidth = 2
|
||||
drawRoundedRect(x, y, cardWidth, cardHeight, 16)
|
||||
ctx.fill()
|
||||
ctx.stroke()
|
||||
|
||||
ctx.fillStyle = entry.themeColor
|
||||
ctx.font = 'bold 18px sans-serif'
|
||||
ctx.textAlign = 'left'
|
||||
ctx.fillText(entry.cityName, x + 16, y + 24)
|
||||
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '13px sans-serif'
|
||||
ctx.fillText(`猫猫 ${entry.catName}`, x + 16, y + 48)
|
||||
ctx.fillText(isSelected ? '已加入,当前不可更换' : '可加入本地战队', x + 16, y + 72)
|
||||
|
||||
registerHitTarget({
|
||||
x,
|
||||
y,
|
||||
width: cardWidth,
|
||||
height: cardHeight,
|
||||
onTap() {
|
||||
const selected = sceneStore.chooseCityTeam(entry.cityId)
|
||||
if (selected) {
|
||||
savePlayerState()
|
||||
showTransientMessage(`已加入 ${entry.cityName} 战队`)
|
||||
} else if (isLocked) {
|
||||
showTransientMessage('当前版本先锁定首次选队')
|
||||
} else {
|
||||
showTransientMessage('只能选择已解锁城市')
|
||||
}
|
||||
render()
|
||||
},
|
||||
})
|
||||
})
|
||||
|
||||
drawBackButton(() => {
|
||||
sceneStore.goBack()
|
||||
render()
|
||||
})
|
||||
}
|
||||
|
||||
function drawGameplayScene(scene) {
|
||||
const city = contentSystem.getCity(scene.cityId)
|
||||
const isMashup = scene.mode === 'mashup'
|
||||
const city = isMashup ? null : contentSystem.getCity(scene.cityId)
|
||||
const session = scene.session
|
||||
const boardState = session.getBoardState()
|
||||
const state = session.getState()
|
||||
@@ -573,10 +690,15 @@ function drawGameplayScene(scene) {
|
||||
const activePieces = boardState.pieces
|
||||
.filter((piece) => !piece.removed)
|
||||
.sort((left, right) => left.layer - right.layer)
|
||||
const elementNameMap = isMashup
|
||||
? new Map(scene.elementDefinitions.map((element) => [element.elementId, element.name]))
|
||||
: new Map(city.elements.map((element) => [element.id, element.name]))
|
||||
const title = isMashup ? scene.title : `${city.display.name} · 关卡 ${scene.levelId}`
|
||||
const subtitle = isMashup
|
||||
? `${scene.subtitle ?? '随机混搭'} · ${state.slot.length}/7 槽位`
|
||||
: `${state.slot.length}/7 槽位`
|
||||
|
||||
const elementNameMap = new Map(city.elements.map((element) => [element.id, element.name]))
|
||||
|
||||
drawHeader(`${city.display.name} · 关卡 ${scene.levelId}`, `${state.slot.length}/7 槽位`)
|
||||
drawHeader(title, subtitle)
|
||||
|
||||
drawButton({
|
||||
x: windowWidth - 112,
|
||||
@@ -624,11 +746,7 @@ function drawGameplayScene(scene) {
|
||||
const result = session.pickPiece(piece.id)
|
||||
|
||||
if (result.status === 'won') {
|
||||
sceneStore.completeLevel({
|
||||
cityId: scene.cityId,
|
||||
levelId: scene.levelId,
|
||||
stars: 3,
|
||||
})
|
||||
sceneStore.completeGameplayVictory(3)
|
||||
savePlayerState()
|
||||
}
|
||||
|
||||
@@ -668,11 +786,29 @@ function drawGameplayScene(scene) {
|
||||
})
|
||||
|
||||
if (state.status === 'won' || state.status === 'failed') {
|
||||
drawResultOverlay(state.status, city.display.bgColor)
|
||||
drawResultOverlay(scene, state.status, isMashup ? scene.accentColor : city.display.bgColor)
|
||||
}
|
||||
}
|
||||
|
||||
function drawResultOverlay(status, accentColor) {
|
||||
function drawResultOverlay(scene, status, accentColor) {
|
||||
const isMashup = scene.mode === 'mashup'
|
||||
const rewardSummary = scene.rewardSummary
|
||||
const title = status === 'won' ? '通关成功' : '挑战失败'
|
||||
let detail = '可以重开当前关卡,继续测试核心循环'
|
||||
|
||||
if (status === 'won' && isMashup) {
|
||||
if (rewardSummary?.type === 'magnet') {
|
||||
const rewardCity = contentSystem.getCity(rewardSummary.cityId)
|
||||
detail = `奖励:${rewardCity?.display.name ?? rewardSummary.cityId} 冰箱贴 ${rewardSummary.levelId}`
|
||||
} else if (rewardSummary?.type === 'inventory') {
|
||||
detail = '奖励:Shuffle +1'
|
||||
} else {
|
||||
detail = '已结算本地混战奖励'
|
||||
}
|
||||
} else if (status === 'won') {
|
||||
detail = '已记录进度,返回关卡页继续'
|
||||
}
|
||||
|
||||
ctx.fillStyle = 'rgba(35, 31, 42, 0.45)'
|
||||
ctx.fillRect(0, 0, windowWidth, windowHeight)
|
||||
|
||||
@@ -691,22 +827,18 @@ function drawResultOverlay(status, accentColor) {
|
||||
ctx.fillStyle = '#231f2a'
|
||||
ctx.font = 'bold 28px sans-serif'
|
||||
ctx.textAlign = 'center'
|
||||
ctx.fillText(status === 'won' ? '通关成功' : '挑战失败', x + boxWidth / 2, y + 48)
|
||||
ctx.fillText(title, x + boxWidth / 2, y + 48)
|
||||
|
||||
ctx.fillStyle = '#6b6474'
|
||||
ctx.font = '15px sans-serif'
|
||||
ctx.fillText(
|
||||
status === 'won' ? '已记录进度,返回关卡页继续' : '可以重开当前关卡,继续测试核心循环',
|
||||
x + boxWidth / 2,
|
||||
y + 86,
|
||||
)
|
||||
ctx.fillText(detail, x + boxWidth / 2, y + 86)
|
||||
|
||||
drawButton({
|
||||
x: x + 20,
|
||||
y: y + 120,
|
||||
width: (boxWidth - 50) / 2,
|
||||
height: 42,
|
||||
label: status === 'won' ? '返回关卡' : '返回',
|
||||
label: status === 'won' ? (isMashup ? '返回主页' : '返回关卡') : '返回',
|
||||
onTap() {
|
||||
sceneStore.goBack()
|
||||
render()
|
||||
@@ -720,10 +852,14 @@ function drawResultOverlay(status, accentColor) {
|
||||
y: y + 120,
|
||||
width: (boxWidth - 50) / 2,
|
||||
height: 42,
|
||||
label: status === 'won' ? '下一步再做' : '重新挑战',
|
||||
label: status === 'won' ? (isMashup ? '再来一局' : '下一步再做') : '重新挑战',
|
||||
onTap() {
|
||||
if (status === 'won') {
|
||||
if (isMashup) {
|
||||
sceneStore.restartCurrentLevel()
|
||||
} else {
|
||||
sceneStore.goBack()
|
||||
}
|
||||
} else {
|
||||
sceneStore.restartCurrentLevel()
|
||||
}
|
||||
@@ -786,6 +922,12 @@ function render() {
|
||||
return
|
||||
}
|
||||
|
||||
if (scene.type === 'city-team-select') {
|
||||
drawCityTeamSelectScene(scene)
|
||||
drawTransientMessage()
|
||||
return
|
||||
}
|
||||
|
||||
if (scene.type === 'gameplay') {
|
||||
drawGameplayScene(scene)
|
||||
drawTransientMessage()
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { createGameSession } from '../gameplay/session/index.js'
|
||||
import { generateMashupBoard } from '../gameplay/difficulty/index.js'
|
||||
|
||||
function createHomeSelectScene() {
|
||||
return {
|
||||
@@ -20,6 +21,41 @@ function createGiftZoneScene(selectedTab = 'magnets') {
|
||||
}
|
||||
}
|
||||
|
||||
function createCityTeamSelectScene(options, selectedTeamCityId) {
|
||||
return {
|
||||
type: 'city-team-select',
|
||||
options,
|
||||
selectedTeamCityId,
|
||||
}
|
||||
}
|
||||
|
||||
function createGameplayScene({
|
||||
cityId,
|
||||
levelId,
|
||||
session,
|
||||
mode = 'city',
|
||||
title = null,
|
||||
subtitle = null,
|
||||
accentColor = null,
|
||||
sourceCityIds = [],
|
||||
elementDefinitions = [],
|
||||
rewardSummary = null,
|
||||
}) {
|
||||
return {
|
||||
type: 'gameplay',
|
||||
cityId,
|
||||
levelId,
|
||||
session,
|
||||
mode,
|
||||
title,
|
||||
subtitle,
|
||||
accentColor,
|
||||
sourceCityIds,
|
||||
elementDefinitions,
|
||||
rewardSummary,
|
||||
}
|
||||
}
|
||||
|
||||
function createLevelProgress(city, playerState) {
|
||||
const cityProgress = playerState.levelProgress[city.id] ?? {}
|
||||
|
||||
@@ -57,6 +93,35 @@ function createAcquiredDate(now) {
|
||||
return new Date(now()).toISOString()
|
||||
}
|
||||
|
||||
function createCityTeamOptions(contentSystem, playerState) {
|
||||
return playerState.unlockedCities
|
||||
.map((cityId) => {
|
||||
const city = contentSystem.getCity(cityId)
|
||||
if (!city) {
|
||||
return null
|
||||
}
|
||||
|
||||
return {
|
||||
cityId: city.id,
|
||||
cityName: city.display.name,
|
||||
catName: city.cat.name,
|
||||
themeColor: city.display.bgColor,
|
||||
}
|
||||
})
|
||||
.filter(Boolean)
|
||||
}
|
||||
|
||||
function createMashupRewardCandidates(contentSystem, playerState) {
|
||||
return playerState.unlockedCities
|
||||
.map((cityId) => contentSystem.getCity(cityId))
|
||||
.filter(Boolean)
|
||||
.flatMap((city) => city.levelPresets.map((preset) => ({
|
||||
magnetId: `magnet_${city.id}_${preset.id}`,
|
||||
cityId: city.id,
|
||||
levelId: preset.id,
|
||||
})))
|
||||
}
|
||||
|
||||
function awardLevelMagnet(playerState, cityId, levelId, now) {
|
||||
const magnets = ensureCollection(playerState, 'collectedMagnets')
|
||||
const magnetId = `magnet_${cityId}_${levelId}`
|
||||
@@ -73,6 +138,41 @@ function awardLevelMagnet(playerState, cityId, levelId, now) {
|
||||
})
|
||||
}
|
||||
|
||||
function awardMashupReward(contentSystem, playerState, seed, now) {
|
||||
const rewardCandidates = createMashupRewardCandidates(contentSystem, playerState)
|
||||
const magnets = ensureCollection(playerState, 'collectedMagnets')
|
||||
|
||||
if (rewardCandidates.length === 0) {
|
||||
playerState.inventory.shuffle = (playerState.inventory.shuffle ?? 0) + 1
|
||||
return {
|
||||
type: 'inventory',
|
||||
itemId: 'shuffle',
|
||||
amount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
const reward = rewardCandidates[seed % rewardCandidates.length]
|
||||
|
||||
if (magnets.some((entry) => entry.magnetId === reward.magnetId)) {
|
||||
playerState.inventory.shuffle = (playerState.inventory.shuffle ?? 0) + 1
|
||||
return {
|
||||
type: 'inventory',
|
||||
itemId: 'shuffle',
|
||||
amount: 1,
|
||||
}
|
||||
}
|
||||
|
||||
magnets.push({
|
||||
...reward,
|
||||
acquiredDate: createAcquiredDate(now),
|
||||
})
|
||||
|
||||
return {
|
||||
type: 'magnet',
|
||||
...reward,
|
||||
}
|
||||
}
|
||||
|
||||
function markCityCompletion(contentSystem, playerState, cityId, now) {
|
||||
if (!playerState.collectedCats.includes(cityId)) {
|
||||
playerState.collectedCats.push(cityId)
|
||||
@@ -105,6 +205,40 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date.
|
||||
const history = []
|
||||
let currentScene = createHomeSelectScene()
|
||||
|
||||
function createMashupSession(seed = now()) {
|
||||
const boardState = generateMashupBoard({
|
||||
cityIds: playerState.unlockedCities,
|
||||
seed,
|
||||
contentSystem,
|
||||
})
|
||||
|
||||
return createGameSession({
|
||||
cityId: 'mashup',
|
||||
levelId: 1,
|
||||
seed,
|
||||
contentSystem,
|
||||
boardState,
|
||||
restartFactory: (nextSeed) => createMashupSession(nextSeed),
|
||||
})
|
||||
}
|
||||
|
||||
function createMashupGameplayScene(seed = now()) {
|
||||
const session = createMashupSession(seed)
|
||||
const boardState = session.getBoardState()
|
||||
|
||||
return createGameplayScene({
|
||||
cityId: 'mashup',
|
||||
levelId: 1,
|
||||
session,
|
||||
mode: 'mashup',
|
||||
title: '主题大混战',
|
||||
subtitle: `${boardState.sourceCityIds.length} 城混搭`,
|
||||
accentColor: '#E74C3C',
|
||||
sourceCityIds: boardState.sourceCityIds ?? [],
|
||||
elementDefinitions: boardState.elementDefinitions ?? [],
|
||||
})
|
||||
}
|
||||
|
||||
function getScene() {
|
||||
return currentScene
|
||||
}
|
||||
@@ -130,7 +264,9 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date.
|
||||
}
|
||||
|
||||
if (tile.id === 'mashup') {
|
||||
return { opened: false, reason: 'mode-unavailable' }
|
||||
history.push(currentScene)
|
||||
currentScene = createMashupGameplayScene()
|
||||
return { opened: true }
|
||||
}
|
||||
|
||||
return { opened: false, reason: 'unavailable' }
|
||||
@@ -167,7 +303,7 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date.
|
||||
return false
|
||||
}
|
||||
|
||||
if (!['magnets', 'stamps'].includes(tabId)) {
|
||||
if (!['magnets', 'stamps', 'cats'].includes(tabId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -179,6 +315,40 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date.
|
||||
return true
|
||||
}
|
||||
|
||||
function openCityTeamSelect() {
|
||||
history.push(currentScene)
|
||||
currentScene = createCityTeamSelectScene(
|
||||
createCityTeamOptions(contentSystem, playerState),
|
||||
playerState.cityTeam?.teamCityId ?? null,
|
||||
)
|
||||
|
||||
return true
|
||||
}
|
||||
|
||||
function chooseCityTeam(cityId) {
|
||||
if (currentScene.type !== 'city-team-select') {
|
||||
return false
|
||||
}
|
||||
|
||||
if (playerState.cityTeam?.teamCityId) {
|
||||
return false
|
||||
}
|
||||
|
||||
if (!playerState.unlockedCities.includes(cityId)) {
|
||||
return false
|
||||
}
|
||||
|
||||
playerState.cityTeam = {
|
||||
...playerState.cityTeam,
|
||||
teamCityId: cityId,
|
||||
joinedDate: createAcquiredDate(now),
|
||||
lastSwitchDate: null,
|
||||
}
|
||||
|
||||
currentScene = history.pop() ?? createHomeSelectScene()
|
||||
return true
|
||||
}
|
||||
|
||||
function openLevel(cityId, levelId) {
|
||||
if (!playerState.unlockedCities.includes(cityId)) {
|
||||
return false
|
||||
@@ -197,8 +367,7 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date.
|
||||
}
|
||||
|
||||
history.push(currentScene)
|
||||
currentScene = {
|
||||
type: 'gameplay',
|
||||
currentScene = createGameplayScene({
|
||||
cityId,
|
||||
levelId,
|
||||
session: createGameSession({
|
||||
@@ -206,7 +375,7 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date.
|
||||
levelId,
|
||||
contentSystem,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
return true
|
||||
}
|
||||
@@ -246,6 +415,33 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date.
|
||||
return true
|
||||
}
|
||||
|
||||
function completeGameplayVictory(stars = 3) {
|
||||
if (currentScene.type !== 'gameplay') {
|
||||
return null
|
||||
}
|
||||
|
||||
if (currentScene.mode === 'mashup') {
|
||||
const reward = awardMashupReward(contentSystem, playerState, currentScene.session.seed, now)
|
||||
currentScene = {
|
||||
...currentScene,
|
||||
rewardSummary: reward,
|
||||
}
|
||||
return reward
|
||||
}
|
||||
|
||||
completeLevel({
|
||||
cityId: currentScene.cityId,
|
||||
levelId: currentScene.levelId,
|
||||
stars,
|
||||
})
|
||||
|
||||
return {
|
||||
type: 'city',
|
||||
cityId: currentScene.cityId,
|
||||
levelId: currentScene.levelId,
|
||||
}
|
||||
}
|
||||
|
||||
function goBack() {
|
||||
if (history.length === 0) {
|
||||
currentScene = createHomeSelectScene()
|
||||
@@ -274,8 +470,11 @@ export function createSceneStore({ contentSystem, playerState, now = () => Date.
|
||||
openCity,
|
||||
openGiftZone,
|
||||
selectGiftTab,
|
||||
openCityTeamSelect,
|
||||
chooseCityTeam,
|
||||
openLevel,
|
||||
completeLevel,
|
||||
completeGameplayVictory,
|
||||
restartCurrentLevel,
|
||||
goBack,
|
||||
}
|
||||
|
||||
@@ -129,18 +129,24 @@ test('content system summarizes MVP gift albums and per-city progress', () => {
|
||||
cityId: 'beijing',
|
||||
acquiredDate: '2026-03-29T00:00:00.000Z',
|
||||
})
|
||||
playerState.collectedCats.push('beijing')
|
||||
|
||||
const albums = contentSystem.getGiftAlbums(playerState)
|
||||
const magnetEntries = contentSystem.getGiftAlbumEntries('magnets', playerState)
|
||||
const stampEntries = contentSystem.getGiftAlbumEntries('stamps', playerState)
|
||||
const catEntries = contentSystem.getGiftAlbumEntries('cats', 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.deepEqual(albums.map((album) => album.id), ['magnets', 'stamps', 'cats'])
|
||||
assert.deepEqual(albums.map((album) => album.collectedCount), [1, 1, 1])
|
||||
assert.deepEqual(albums.map((album) => album.totalCount), [36, 6, 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)
|
||||
assert.equal(catEntries[0].cityId, 'beijing')
|
||||
assert.equal(catEntries[0].catName, '京京')
|
||||
assert.equal(catEntries[0].isCollected, true)
|
||||
assert.equal(catEntries[1].isCollected, false)
|
||||
})
|
||||
|
||||
@@ -6,6 +6,7 @@ import {
|
||||
classifyDeadlock,
|
||||
evaluateBoard,
|
||||
generateBoard,
|
||||
generateMashupBoard,
|
||||
} from '../js/gameplay/difficulty/index.js'
|
||||
|
||||
test('generateBoard is deterministic for the same city, level, and seed', () => {
|
||||
@@ -72,3 +73,23 @@ test('classifyDeadlock identifies a hard deadlock when slot is full and no match
|
||||
|
||||
assert.equal(result.type, 'hard')
|
||||
})
|
||||
|
||||
test('generateMashupBoard is deterministic and mixes unlocked city content', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
|
||||
const first = generateMashupBoard({
|
||||
cityIds: ['beijing', 'tokyo'],
|
||||
seed: 33001,
|
||||
contentSystem,
|
||||
})
|
||||
const second = generateMashupBoard({
|
||||
cityIds: ['beijing', 'tokyo'],
|
||||
seed: 33001,
|
||||
contentSystem,
|
||||
})
|
||||
|
||||
assert.deepEqual(first.pieces, second.pieces)
|
||||
assert.deepEqual(first.overlapGraph, second.overlapGraph)
|
||||
assert.deepEqual(first.sourceCityIds, ['beijing', 'tokyo'])
|
||||
assert.ok(new Set(first.elementDefinitions.map((entry) => entry.sourceCityId)).size >= 2)
|
||||
})
|
||||
|
||||
@@ -118,7 +118,125 @@ test('scene store opens the gift zone and switches between MVP tabs', () => {
|
||||
assert.equal(switched, true)
|
||||
assert.equal(sceneStore.getScene().selectedTab, 'stamps')
|
||||
|
||||
const switchedToCats = sceneStore.selectGiftTab('cats')
|
||||
|
||||
assert.equal(switchedToCats, true)
|
||||
assert.equal(sceneStore.getScene().selectedTab, 'cats')
|
||||
|
||||
sceneStore.goBack()
|
||||
|
||||
assert.equal(sceneStore.getScene().type, 'home-select')
|
||||
})
|
||||
|
||||
test('scene store opens city team selection and locks the first chosen team', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
const sceneStore = createSceneStore({
|
||||
contentSystem,
|
||||
playerState,
|
||||
now: () => new Date('2026-03-29T08:00:00.000Z').getTime(),
|
||||
})
|
||||
|
||||
const opened = sceneStore.openCityTeamSelect()
|
||||
|
||||
assert.equal(opened, true)
|
||||
assert.equal(sceneStore.getScene().type, 'city-team-select')
|
||||
assert.deepEqual(sceneStore.getScene().options.map((entry) => entry.cityId), ['beijing'])
|
||||
|
||||
const selected = sceneStore.chooseCityTeam('beijing')
|
||||
|
||||
assert.equal(selected, true)
|
||||
assert.deepEqual(playerState.cityTeam, {
|
||||
teamCityId: 'beijing',
|
||||
joinedDate: '2026-03-29T08:00:00.000Z',
|
||||
lastSwitchDate: null,
|
||||
})
|
||||
assert.equal(sceneStore.getScene().type, 'home-select')
|
||||
|
||||
sceneStore.openCityTeamSelect()
|
||||
const reselected = sceneStore.chooseCityTeam('beijing')
|
||||
|
||||
assert.equal(reselected, false)
|
||||
assert.equal(playerState.cityTeam.teamCityId, 'beijing')
|
||||
})
|
||||
|
||||
test('scene store opens mashup gameplay after two completed cities', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
const sceneStore = createSceneStore({
|
||||
contentSystem,
|
||||
playerState,
|
||||
now: () => 33001,
|
||||
})
|
||||
|
||||
playerState.unlockedCities.push('tokyo')
|
||||
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 opened = sceneStore.openHomeTile('mashup')
|
||||
|
||||
assert.deepEqual(opened, { opened: true })
|
||||
assert.equal(sceneStore.getScene().type, 'gameplay')
|
||||
assert.equal(sceneStore.getScene().mode, 'mashup')
|
||||
assert.deepEqual(sceneStore.getScene().sourceCityIds, ['beijing', 'tokyo'])
|
||||
})
|
||||
|
||||
test('scene store awards a deterministic local mashup reward', () => {
|
||||
const contentSystem = createContentSystem()
|
||||
const playerState = createDefaultPlayerState()
|
||||
const sceneStore = createSceneStore({
|
||||
contentSystem,
|
||||
playerState,
|
||||
now: () => 33001,
|
||||
})
|
||||
|
||||
playerState.unlockedCities.push('tokyo')
|
||||
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 },
|
||||
}
|
||||
|
||||
sceneStore.openHomeTile('mashup')
|
||||
const reward = sceneStore.completeGameplayVictory()
|
||||
|
||||
assert.deepEqual(reward, {
|
||||
type: 'magnet',
|
||||
magnetId: 'magnet_beijing_2',
|
||||
cityId: 'beijing',
|
||||
levelId: 2,
|
||||
})
|
||||
assert.deepEqual(playerState.collectedMagnets, [
|
||||
{
|
||||
magnetId: 'magnet_beijing_2',
|
||||
cityId: 'beijing',
|
||||
levelId: 2,
|
||||
acquiredDate: '1970-01-01T00:00:33.001Z',
|
||||
},
|
||||
])
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user