diff --git a/backend/.dockerignore b/backend/.dockerignore new file mode 100644 index 0000000..e537e12 --- /dev/null +++ b/backend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +*.log +.env diff --git a/backend/db/schema.sql b/backend/db/schema.sql index 860cd82..5dd8e6d 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -48,11 +48,6 @@ CREATE TABLE IF NOT EXISTS acquisition_methods ( name text NOT NULL UNIQUE ); -CREATE TABLE IF NOT EXISTS item_tags ( - id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, - name text NOT NULL UNIQUE -); - CREATE TABLE IF NOT EXISTS recipes ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE @@ -68,23 +63,28 @@ CREATE TABLE IF NOT EXISTS items ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, category_id integer NOT NULL REFERENCES item_categories(id), - usage_id integer NOT NULL REFERENCES item_usages(id), + usage_id integer REFERENCES item_usages(id), recipe_id integer REFERENCES recipes(id), dyeable boolean NOT NULL DEFAULT false, dual_dyeable boolean NOT NULL DEFAULT false, pattern_editable boolean NOT NULL DEFAULT false ); +ALTER TABLE items ALTER COLUMN usage_id DROP NOT NULL; + +DROP TABLE IF EXISTS item_item_tags; +DROP TABLE IF EXISTS item_tags; + CREATE TABLE IF NOT EXISTS item_acquisition_methods ( item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE, acquisition_method_id integer NOT NULL REFERENCES acquisition_methods(id), PRIMARY KEY (item_id, acquisition_method_id) ); -CREATE TABLE IF NOT EXISTS item_item_tags ( +CREATE TABLE IF NOT EXISTS item_favorite_things ( item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE, - item_tag_id integer NOT NULL REFERENCES item_tags(id), - PRIMARY KEY (item_id, item_tag_id) + favorite_thing_id integer NOT NULL REFERENCES favorite_things(id), + PRIMARY KEY (item_id, favorite_thing_id) ); CREATE TABLE IF NOT EXISTS recipe_materials ( diff --git a/backend/src/queries.ts b/backend/src/queries.ts index d27ec79..ed76fb1 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -1,11 +1,12 @@ import { parseIdList, parseMatchMode, sqlForRelationFilter } from './filter.ts'; import { pool, query, queryOne } from './db.ts'; +import type { PoolClient } from 'pg'; type QueryValue = string | string[] | undefined; type QueryParams = Record; -type DbClient = Awaited>; +type DbClient = PoolClient; type ConfigType = | 'skills' @@ -14,7 +15,6 @@ type ConfigType = | 'item-categories' | 'item-usages' | 'acquisition-methods' - | 'item-tags' | 'maps'; type ConfigDefinition = { @@ -40,7 +40,7 @@ type PokemonPayload = { type ItemPayload = { name: string; categoryId: number; - usageId: number; + usageId: number | null; recipeId: number | null; dyeable: boolean; dualDyeable: boolean; @@ -67,6 +67,11 @@ type HabitatPayload = { }>; }; +type ValidationError = Error & { statusCode: number }; + +const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; +const weathers = ['晴天', '阴天', '雨天']; + const configDefinitions: Record = { skills: { table: 'skills', select: 'id, name, subcategory', order: 'name, subcategory', hasSubcategory: true }, environments: { table: 'environments', select: 'id, name', order: 'name' }, @@ -74,7 +79,6 @@ const configDefinitions: Record = { 'item-categories': { table: 'item_categories', select: 'id, name', order: 'name' }, 'item-usages': { table: 'item_usages', select: 'id, name', order: 'name' }, 'acquisition-methods': { table: 'acquisition_methods', select: 'id, name', order: 'name' }, - 'item-tags': { table: 'item_tags', select: 'id, name', order: 'name' }, maps: { table: 'maps', select: 'id, name', order: 'name' } }; @@ -86,17 +90,23 @@ function optionSelect(tableName: string): Promise Number(item)).filter((item) => Number.isInteger(item) && item > 0))]; } +function cleanIdValues(value: unknown): number[] { + return cleanIds(Array.isArray(value) ? value : [value]); +} + function cleanQuantities(value: unknown): IdQuantity[] { if (!Array.isArray(value)) { return []; @@ -124,6 +138,11 @@ function cleanQuantities(value: unknown): IdQuantity[] { .filter((item) => Number.isInteger(item.itemId) && item.itemId > 0 && Number.isInteger(item.quantity) && item.quantity > 0); } +function cleanOptions(value: unknown, allowedValues: string[]): string[] { + const values = Array.isArray(value) ? value : [value]; + return [...new Set(values.map((item) => String(item ?? '')).filter((item) => allowedValues.includes(item)))]; +} + async function withTransaction(callback: (client: DbClient) => Promise): Promise { const client = await pool.connect(); @@ -169,7 +188,6 @@ export async function getOptions() { itemCategories, itemUsages, acquisitionMethods, - itemTags, maps ] = await Promise.all([ query<{ id: number; name: string; subcategory: string | null }>( @@ -180,7 +198,6 @@ export async function getOptions() { optionSelect('item_categories'), optionSelect('item_usages'), optionSelect('acquisition_methods'), - optionSelect('item_tags'), optionSelect('maps') ]); @@ -191,7 +208,7 @@ export async function getOptions() { itemCategories, itemUsages, acquisitionMethods, - itemTags, + itemTags: favoriteThings, maps }; } @@ -321,16 +338,16 @@ function cleanPokemonPayload(payload: Record): PokemonPayload { const favoriteThingIds = cleanIds(payload.favoriteThingIds); if (skillIds.length > 2) { - throw new Error('Pokemon can have at most 2 skills'); + throw validationError('特长最多选择 2 个'); } if (favoriteThingIds.length > 6) { - throw new Error('Pokemon can have at most 6 favorite things'); + throw validationError('喜欢的东西最多选择 6 个'); } return { - id: requirePositiveInteger(payload.id, 'Pokemon ID'), - name: cleanName(payload.name), - environmentId: requirePositiveInteger(payload.environmentId, 'Environment'), + id: requirePositiveInteger(payload.id, '请输入 Pokemon ID'), + name: cleanName(payload.name, '请输入 Pokemon 名字'), + environmentId: requirePositiveInteger(payload.environmentId, '请选择喜欢的环境'), skillIds, favoriteThingIds }; @@ -457,33 +474,39 @@ export async function getHabitat(id: number) { function cleanHabitatPayload(payload: Record): HabitatPayload { const appearances = Array.isArray(payload.pokemonAppearances) ? payload.pokemonAppearances : []; + const pokemonAppearances = new Map(); + + for (const item of appearances) { + const row = item as Record; + const pokemonId = Number(row.pokemonId); + const mapIds = cleanIdValues(row.mapIds ?? row.mapId); + const selectedTimeOfDays = cleanOptions(row.timeOfDays ?? row.timeOfDay, timeOfDays); + const selectedWeathers = cleanOptions(row.weathers ?? row.weather, weathers); + const rarity = Number(row.rarity); + + if (!Number.isInteger(pokemonId) || pokemonId <= 0 || !Number.isInteger(rarity) || rarity < 1 || rarity > 3) { + continue; + } + + for (const mapId of mapIds) { + for (const timeOfDay of selectedTimeOfDays) { + for (const weather of selectedWeathers) { + pokemonAppearances.set(`${pokemonId}:${mapId}:${timeOfDay}:${weather}`, { + pokemonId, + mapId, + timeOfDay, + weather, + rarity + }); + } + } + } + } return { - name: cleanName(payload.name), + name: cleanName(payload.name, '请输入栖息地名字'), recipeItems: cleanQuantities(payload.recipeItems), - pokemonAppearances: appearances - .map((item) => { - const row = item as Record; - return { - pokemonId: Number(row.pokemonId), - mapId: Number(row.mapId), - timeOfDay: String(row.timeOfDay ?? ''), - weather: String(row.weather ?? ''), - rarity: Number(row.rarity) - }; - }) - .filter( - (item) => - Number.isInteger(item.pokemonId) && - item.pokemonId > 0 && - Number.isInteger(item.mapId) && - item.mapId > 0 && - ['早晨', '中午', '傍晚', '晚上'].includes(item.timeOfDay) && - ['晴天', '阴天', '雨天'].includes(item.weather) && - Number.isInteger(item.rarity) && - item.rarity >= 1 && - item.rarity <= 3 - ) + pokemonAppearances: [...pokemonAppearances.values()] }; } @@ -548,7 +571,7 @@ const itemProjection = ` i.id, i.name, json_build_object('id', c.id, 'name', c.name) AS category, - json_build_object('id', u.id, 'name', u.name) AS usage, + CASE WHEN u.id IS NULL THEN NULL ELSE json_build_object('id', u.id, 'name', u.name) END AS usage, json_build_object( 'dyeable', i.dyeable, 'dualDyeable', i.dual_dyeable, @@ -556,13 +579,13 @@ const itemProjection = ` ) AS customization, COALESCE(( SELECT json_agg(json_build_object('id', t.id, 'name', t.name) ORDER BY t.name) - FROM item_item_tags iit - JOIN item_tags t ON t.id = iit.item_tag_id - WHERE iit.item_id = i.id + FROM item_favorite_things ift + JOIN favorite_things t ON t.id = ift.favorite_thing_id + WHERE ift.item_id = i.id ), '[]'::json) AS tags FROM items i JOIN item_categories c ON c.id = i.category_id - JOIN item_usages u ON u.id = i.usage_id + LEFT JOIN item_usages u ON u.id = i.usage_id `; export async function listItems(paramsQuery: QueryParams) { @@ -591,9 +614,9 @@ export async function listItems(paramsQuery: QueryParams) { const tagFilter = sqlForRelationFilter( tagIds, 'any', - 'item_item_tags', + 'item_favorite_things', 'item_id', - 'item_tag_id', + 'favorite_thing_id', 'i.id', params ); @@ -663,12 +686,15 @@ export async function getItem(id: number) { function cleanItemPayload(payload: Record): ItemPayload { const recipeId = payload.recipeId === null || payload.recipeId === '' || payload.recipeId === undefined ? null - : requirePositiveInteger(payload.recipeId, 'Recipe'); + : requirePositiveInteger(payload.recipeId, '请选择材料单'); + const usageId = payload.usageId === null || payload.usageId === '' || payload.usageId === undefined + ? null + : requirePositiveInteger(payload.usageId, '请选择用途'); return { - name: cleanName(payload.name), - categoryId: requirePositiveInteger(payload.categoryId, 'Category'), - usageId: requirePositiveInteger(payload.usageId, 'Usage'), + name: cleanName(payload.name, '请输入物品名字'), + categoryId: requirePositiveInteger(payload.categoryId, '请选择分类'), + usageId, recipeId, dyeable: Boolean(payload.dyeable), dualDyeable: Boolean(payload.dualDyeable), @@ -680,7 +706,7 @@ function cleanItemPayload(payload: Record): ItemPayload { async function replaceItemRelations(client: DbClient, itemId: number, payload: ItemPayload): Promise { await client.query('DELETE FROM item_acquisition_methods WHERE item_id = $1', [itemId]); - await client.query('DELETE FROM item_item_tags WHERE item_id = $1', [itemId]); + await client.query('DELETE FROM item_favorite_things WHERE item_id = $1', [itemId]); for (const methodId of payload.acquisitionMethodIds) { await client.query('INSERT INTO item_acquisition_methods (item_id, acquisition_method_id) VALUES ($1, $2)', [ @@ -690,7 +716,10 @@ async function replaceItemRelations(client: DbClient, itemId: number, payload: I } for (const tagId of payload.tagIds) { - await client.query('INSERT INTO item_item_tags (item_id, item_tag_id) VALUES ($1, $2)', [itemId, tagId]); + await client.query('INSERT INTO item_favorite_things (item_id, favorite_thing_id) VALUES ($1, $2)', [ + itemId, + tagId + ]); } } @@ -805,7 +834,7 @@ export async function getRecipe(id: number) { function cleanRecipePayload(payload: Record): RecipePayload { return { - name: cleanName(payload.name), + name: cleanName(payload.name, '请输入材料单名字'), acquisitionMethodIds: cleanIds(payload.acquisitionMethodIds), materials: cleanQuantities(payload.materials) }; diff --git a/backend/src/server.ts b/backend/src/server.ts index ebbf494..4d9a7d4 100644 --- a/backend/src/server.ts +++ b/backend/src/server.ts @@ -35,6 +35,7 @@ const app = Fastify({ }); await app.register(cors, { + methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS'], origin: process.env.FRONTEND_ORIGIN ?? true }); @@ -42,15 +43,15 @@ app.setErrorHandler(async (error, _request, reply) => { const pgError = error as Error & { code?: string; constraint?: string; detail?: string; statusCode?: number }; if (pgError.code === '23503') { - return reply.code(409).send({ message: 'Referenced data is missing or this item is in use' }); + return reply.code(409).send({ message: '引用的数据不存在,或当前记录正在被使用' }); } if (pgError.code === '23505') { - return reply.code(409).send({ message: 'A record with the same value already exists' }); + return reply.code(409).send({ message: '同名或相同 ID 的记录已存在' }); } if (pgError.code === '23514') { - return reply.code(400).send({ message: 'Invalid field value' }); + return reply.code(400).send({ message: '字段值不合法' }); } if (pgError.statusCode && pgError.statusCode < 500) { @@ -58,7 +59,7 @@ app.setErrorHandler(async (error, _request, reply) => { } app.log.error(error); - return reply.code(500).send({ message: 'Server error' }); + return reply.code(500).send({ message: '服务器错误' }); }); app.get('/health', async () => ({ ok: true })); diff --git a/frontend/.dockerignore b/frontend/.dockerignore new file mode 100644 index 0000000..e537e12 --- /dev/null +++ b/frontend/.dockerignore @@ -0,0 +1,4 @@ +node_modules +dist +*.log +.env diff --git a/frontend/src/services/api.ts b/frontend/src/services/api.ts index d680d9a..d36baca 100644 --- a/frontend/src/services/api.ts +++ b/frontend/src/services/api.ts @@ -48,7 +48,7 @@ export interface Item { id: number; name: string; category: NamedEntity; - usage: NamedEntity; + usage: NamedEntity | null; customization: { dyeable: boolean; dualDyeable: boolean; @@ -91,7 +91,6 @@ export type ConfigType = | 'item-categories' | 'item-usages' | 'acquisition-methods' - | 'item-tags' | 'maps'; export interface PokemonPayload { @@ -105,7 +104,7 @@ export interface PokemonPayload { export interface ItemPayload { name: string; categoryId: number; - usageId: number; + usageId: number | null; recipeId: number | null; dyeable: boolean; dualDyeable: boolean; @@ -125,9 +124,9 @@ export interface HabitatPayload { recipeItems: Array<{ itemId: number; quantity: number }>; pokemonAppearances: Array<{ pokemonId: number; - mapId: number; - timeOfDay: string; - weather: string; + mapIds: number[]; + timeOfDays: string[]; + weathers: string[]; rarity: number; }>; } @@ -145,11 +144,24 @@ export function buildQuery(params: Record): return query ? `?${query}` : ''; } +async function getErrorMessage(response: Response): Promise { + try { + const data = (await response.json()) as { message?: unknown }; + if (typeof data.message === 'string' && data.message.trim() !== '') { + return data.message; + } + } catch { + // Ignore invalid or empty error bodies and use the status fallback. + } + + return `请求失败(${response.status})`; +} + async function getJson(path: string): Promise { const response = await fetch(`${apiBaseUrl}${path}`); if (!response.ok) { - throw new Error(`Request failed with ${response.status}`); + throw new Error(await getErrorMessage(response)); } return response.json() as Promise; @@ -165,7 +177,7 @@ async function sendJson(path: string, method: 'POST' | 'PUT', body: unknown): }); if (!response.ok) { - throw new Error(`Request failed with ${response.status}`); + throw new Error(await getErrorMessage(response)); } return response.json() as Promise; @@ -177,7 +189,7 @@ async function deleteJson(path: string): Promise { }); if (!response.ok) { - throw new Error(`Request failed with ${response.status}`); + throw new Error(await getErrorMessage(response)); } } diff --git a/frontend/src/views/AdminView.vue b/frontend/src/views/AdminView.vue index eca1635..c2d86d5 100644 --- a/frontend/src/views/AdminView.vue +++ b/frontend/src/views/AdminView.vue @@ -4,6 +4,7 @@ import { api, type ConfigType, type Habitat, + type HabitatDetail, type HabitatPayload, type Item, type ItemPayload, @@ -18,6 +19,13 @@ import { type AdminTab = 'config' | 'pokemon' | 'items' | 'recipes' | 'habitats'; type EditableConfig = (NamedEntity | Skill) & { subcategory?: string | null }; +type HabitatAppearanceForm = { + pokemonId: string; + mapIds: string[]; + timeOfDays: string[]; + weathers: string[]; + rarity: number; +}; const tabs: Array<{ key: AdminTab; label: string }> = [ { key: 'config', label: '系统配置' }, @@ -27,6 +35,9 @@ const tabs: Array<{ key: AdminTab; label: string }> = [ { key: 'habitats', label: '栖息地' } ]; +const timeOfDays = ['早晨', '中午', '傍晚', '晚上']; +const weathers = ['晴天', '阴天', '雨天']; + const configTypes: Array<{ key: ConfigType; label: string; hasSubcategory?: boolean }> = [ { key: 'skills', label: '特长', hasSubcategory: true }, { key: 'environments', label: '喜欢的环境' }, @@ -34,7 +45,6 @@ const configTypes: Array<{ key: ConfigType; label: string; hasSubcategory?: bool { key: 'item-categories', label: '物品 / 材料单分类' }, { key: 'item-usages', label: '物品 / 材料单用途' }, { key: 'acquisition-methods', label: '入手方式' }, - { key: 'item-tags', label: '物品标签' }, { key: 'maps', label: '地图' } ]; @@ -73,7 +83,7 @@ const habitatForm = ref({ id: 0, name: '', recipeItems: [] as Array<{ itemId: string; quantity: number }>, - pokemonAppearances: [] as Array<{ pokemonId: string; mapId: string; timeOfDay: string; weather: string; rarity: number }> + pokemonAppearances: [] as HabitatAppearanceForm[] }); const selectedConfig = computed(() => configTypes.find((item) => item.key === activeConfigType.value) ?? configTypes[0]); @@ -93,8 +103,8 @@ async function run(action: () => Promise) { message.value = ''; try { await action(); - } catch { - message.value = '操作失败'; + } catch (error) { + message.value = error instanceof Error && error.message ? error.message : '操作失败'; } finally { busy.value = false; } @@ -235,7 +245,7 @@ async function editItem(item: Item) { id: detail.id, name: detail.name, categoryId: String(detail.category.id), - usageId: String(detail.usage.id), + usageId: detail.usage ? String(detail.usage.id) : '', recipeId: detail.recipe ? String(detail.recipe.id) : '', dyeable: detail.customization.dyeable, dualDyeable: detail.customization.dualDyeable, @@ -251,7 +261,7 @@ async function saveItem() { const payload: ItemPayload = { name: itemForm.value.name, categoryId: Number(itemForm.value.categoryId), - usageId: Number(itemForm.value.usageId), + usageId: itemForm.value.usageId ? Number(itemForm.value.usageId) : null, recipeId: itemForm.value.recipeId ? Number(itemForm.value.recipeId) : null, dyeable: itemForm.value.dyeable, dualDyeable: itemForm.value.dualDyeable, @@ -331,13 +341,36 @@ function addHabitatRecipeItem() { function addPokemonAppearance() { habitatForm.value.pokemonAppearances.push({ pokemonId: '', - mapId: '', - timeOfDay: '早晨', - weather: '晴天', + mapIds: [], + timeOfDays: ['早晨'], + weathers: ['晴天'], rarity: 1 }); } +function groupPokemonAppearances(detail: HabitatDetail): HabitatAppearanceForm[] { + const rows = new Map(); + + detail.pokemon.forEach((pokemon) => { + const key = `${pokemon.id}:${pokemon.rarity}`; + const row = rows.get(key) ?? { + pokemonId: String(pokemon.id), + mapIds: [], + timeOfDays: [], + weathers: [], + rarity: pokemon.rarity + }; + + const mapId = String(pokemon.map.id); + if (!row.mapIds.includes(mapId)) row.mapIds.push(mapId); + if (!row.timeOfDays.includes(pokemon.time_of_day)) row.timeOfDays.push(pokemon.time_of_day); + if (!row.weathers.includes(pokemon.weather)) row.weathers.push(pokemon.weather); + rows.set(key, row); + }); + + return [...rows.values()]; +} + async function editHabitat(item: Habitat) { await run(async () => { const detail = await api.habitatDetail(item.id); @@ -345,13 +378,7 @@ async function editHabitat(item: Habitat) { id: detail.id, name: detail.name, recipeItems: detail.recipe.map((recipeItem) => ({ itemId: String(recipeItem.id), quantity: recipeItem.quantity })), - pokemonAppearances: detail.pokemon.map((pokemon) => ({ - pokemonId: String(pokemon.id), - mapId: String(pokemon.map.id), - timeOfDay: pokemon.time_of_day, - weather: pokemon.weather, - rarity: pokemon.rarity - })) + pokemonAppearances: groupPokemonAppearances(detail) }; }); } @@ -364,12 +391,12 @@ async function saveHabitat() { pokemonAppearances: habitatForm.value.pokemonAppearances .map((item) => ({ pokemonId: Number(item.pokemonId), - mapId: Number(item.mapId), - timeOfDay: item.timeOfDay, - weather: item.weather, + mapIds: toIds(item.mapIds), + timeOfDays: item.timeOfDays.filter((entry) => timeOfDays.includes(entry)), + weathers: item.weathers.filter((entry) => weathers.includes(entry)), rarity: Number(item.rarity) })) - .filter((item) => item.pokemonId > 0 && item.mapId > 0) + .filter((item) => item.pokemonId > 0 && item.mapIds.length > 0 && item.timeOfDays.length > 0 && item.weathers.length > 0) }; if (habitatForm.value.id) { await api.updateHabitat(habitatForm.value.id, payload); @@ -411,7 +438,7 @@ onMounted(() => {

{{ message }}

-
+

系统配置

@@ -428,10 +455,10 @@ onMounted(() => {
- +
-
+

{{ selectedConfig.label }}

@@ -507,7 +534,7 @@ onMounted(() => {
@@ -620,20 +647,14 @@ onMounted(() => { - - + - + diff --git a/frontend/src/views/HabitatDetail.vue b/frontend/src/views/HabitatDetail.vue index 72b7033..d04412e 100644 --- a/frontend/src/views/HabitatDetail.vue +++ b/frontend/src/views/HabitatDetail.vue @@ -32,7 +32,7 @@ onMounted(async () => {

可能出现的宝可梦

    -
  • +
  • {{ item.name }} {{ item.time_of_day }} · {{ item.weather }} · {{ item.rarity }} 星 · {{ item.map.name }}
  • diff --git a/frontend/src/views/ItemDetail.vue b/frontend/src/views/ItemDetail.vue index 0129ae3..581a1bd 100644 --- a/frontend/src/views/ItemDetail.vue +++ b/frontend/src/views/ItemDetail.vue @@ -30,7 +30,7 @@ onMounted(async () => { diff --git a/frontend/src/views/ItemsList.vue b/frontend/src/views/ItemsList.vue index 28c1140..c75f6d8 100644 --- a/frontend/src/views/ItemsList.vue +++ b/frontend/src/views/ItemsList.vue @@ -86,7 +86,7 @@ watch([tab, itemQuery], loadItems);

    {{ item.name }}

    -

    {{ item.category.name }} · {{ item.usage.name }}

    +

    {{ item.usage ? `${item.category.name} · ${item.usage.name}` : item.category.name }}

    diff --git a/frontend/src/views/PokemonDetail.vue b/frontend/src/views/PokemonDetail.vue index b6600e6..3af0b43 100644 --- a/frontend/src/views/PokemonDetail.vue +++ b/frontend/src/views/PokemonDetail.vue @@ -37,7 +37,7 @@ onMounted(async () => {

    栖息地

      -
    • +
    • {{ habitat.name }} {{ habitat.time_of_day }} · {{ habitat.weather }} · {{ habitat.rarity }} 星 · {{ habitat.map.name }}