diff --git a/DESIGN.md b/DESIGN.md index acfae26..68d8937 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -214,9 +214,9 @@ - Items 与 Recipes 存在依赖关系;选择 Items 进行导出、导入或 Wipe 时,系统必须自动同时纳入 Recipes,前端确认内容也必须显示 Recipes。 - Wipe 行为: - 删除所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。 - - Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。 + - Wipe Pokemon 会删除 Pokemon 及其属性 / 特长 / 喜欢的东西 / 掉落关联 / Trading 观察,并移除栖息地中的 Pokemon 出现配置,但不删除栖息地本身。 - Wipe Habitats 会删除栖息地、栖息地配方项和 Pokemon 出现配置,但不删除 Pokemon、Items 或 Maps。 - - Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项和 Pokemon 掉落关联。 + - Wipe Items 会先删除 Recipes,再删除物品、物品入手方式 / 喜欢的东西关联、栖息地配方项、Pokemon 掉落关联和 Trading 观察。 - Wipe Ancient Artifacts 会清空物品上的 Ancient Artifact 分类并删除对应 Ancient Artifact 讨论;物品本身仍保留在 Items / Event Items 中。 - Wipe Recipes 会删除材料单、材料项和入手方式关联,但不删除 Items。 - Wipe Daily CheckList 会删除清单任务和任务翻译 / 编辑历史。 @@ -225,7 +225,7 @@ - 导出为版本化 JSON bundle,包含 `version`、`exportedAt`、`scopes` 和对应范围数据。 - JSON bundle 用于系统导入,不作为前台展示内容。 - 导出包含所选范围的主数据、关联数据、实体翻译、编辑历史、图片上传记录和实体讨论评论。 - - 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。 + - 导出必须包含对应 Wipe 会移除的跨范围关联行,例如 Pokemon 出现配置、Pokemon 掉落、Trading 观察和栖息地配方项;导入这些关联时,引用的另一侧实体必须已存在。 - JSON 不包含上传文件本身;`backend_uploads` volume 需要单独备份。 - Import 行为: - 当前只支持 Replace selected scopes:导入前先 Wipe bundle 中包含的范围,再在同一事务中还原 bundle 数据。 @@ -440,8 +440,10 @@ - 名称 - 是否有掉落物:`has_item_drop` +- 是否支持 Trading:`has_trading` - 已移除 `subcategory` 字段。 - 当特长允许掉落物时,Pokemon 编辑中可为该 Pokemon + 特长配置一个掉落物品。 +- 当 Pokemon 选择了至少一个支持 Trading 的特长时,Pokemon 详情页可直接维护该 Pokemon 对物品的 Trading 偏好观察。 ### Pokemon Types @@ -504,6 +506,10 @@ Pokemon 可配置: - 特长:可多选,最多 2 个 - 特长掉落物品:按 Pokemon + 特长配置,单选物品 - 喜欢的东西:可多选,最多 6 个 +- Trading:由所选特长是否支持 Trading 决定;当至少一个所选特长支持 Trading 时,可维护该 Pokemon 对物品的 Trading 偏好观察,分为 Likes 与 Neutral + - Likes:该 Pokemon 喜欢交易该物品,交易价格触发 1.5x 加成;用于物品隐藏标签推断的正向证据 + - Neutral:该 Pokemon 对交易该物品无加成;用于物品隐藏标签推断的硬排除证据 + - 每个物品在同一个 Pokemon 的 Trading 列表中只能出现一次,只能属于 Likes 或 Neutral 其中一组 - 六维: - HP - Attack @@ -551,6 +557,7 @@ Pokemon 编辑表单使用标签页组织字段: - 第二行:喜欢的环境、特长 - 第三行:喜欢的东西 - 特长掉落物品随已选择且支持掉落物的特长显示 + - 编辑表单不直接维护 Trading 观察;Trading 由详情页的 Manage Trading 入口维护 - Pokemon 图片选择区 - Advance 标签页: - 第一行:Genus @@ -584,7 +591,9 @@ Pokemon 详情页展示: - 右侧:六维 Stats;图片或默认占位符展示在 Stats 右侧 - 六维使用 ProgressBar 展示,最大值按 150 计算。 - 特长 -- 特长掉落物品:展示掉落物品图标;未配置图标时显示默认物品标记占位符 +- 特长掉落物品:当该 Pokemon 拥有支持掉落物的已选特长时默认展示;已配置时展示掉落物品图标,未配置时展示空状态 +- Trading:当该 Pokemon 拥有支持 Trading 的已选特长时默认展示;分 Likes 与 Neutral 两组展示物品,Likes 表示交易价格 1.5x,Neutral 表示无加成,未配置观察时展示空状态 +- Trading 可在详情页通过 Manage Trading Modal 维护;Modal 左侧顶部为同一行搜索框和分类下拉筛选,下面提供 Likes / Neutral 默认加入目标切换;从左侧选择物品会加入当前默认目标组,右侧按 Likes / Neutral 分组展示已选物品,并可切换分组或移除;列表区域高度稳定,过滤结果减少时 Modal 不应抖动 - 喜欢的环境 - 喜欢的东西 - 相关 Pokemon:与关联喜欢的东西的物品在桌面端左右并排展示;按相同喜欢的环境优先,其次按共同喜欢的东西数量从多到少排序;支持按喜欢的环境筛选,默认筛选当前 Pokemon 的喜欢的环境,也可切换到其他喜欢的环境或全部;每个筛选视图最多展示 6 个;每项左侧展示 Pokemon 图片或默认 Poké Ball 占位符,原列表项信息布局保持不变,第一行左侧展示名称,右侧展示特长和喜欢的环境,第二行展示喜欢的东西,并高亮共同喜欢的东西 @@ -670,6 +679,13 @@ Items 与 Event Items 使用相同数据模型: - 入手方式 - 客制化 - 标签 +- Possible Tags:根据所有拥有支持 Trading 特长的 Pokemon Trading 观察推断该物品可能包含的隐藏标签 + - 每个 Pokemon 的“喜欢的东西”视为该 Pokemon 已知的 6 个隐藏标签集合;不完整数据仍参与展示,但不会强行补足缺失标签 + - 若物品被 Pokemon 标记为 Likes,则该物品至少包含该 Pokemon 标签集合中的一个标签,属于 OR 正向证据 + - 若物品被 Pokemon 标记为 Neutral,则该物品不包含该 Pokemon 标签集合中的任何标签,属于硬排除证据;Neutral 排除优先于 Likes 正向证据 + - 推断流程必须确定性执行:从所有“喜欢的东西 / 标签”开始,先移除所有 Neutral Pokemon 提供的标签,再用 Likes Pokemon 的标签集合收窄候选;多个 Likes 观察的共同候选归为 Highly likely,其余正向候选归为 Possible,被排除或被约束移出的标签归为 Excluded + - 没有可用 Likes 观察时,未被 Neutral 排除的标签保持 Possible;没有任何观察时,所有标签保持 Possible + - Possible Tags 区块必须展示 Likes 与 Neutral 证据来源,包含贡献 Pokemon 及其已知标签,不展示内部字段、调试信息或推断中间状态 - 关联材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符 - 作为材料出现的材料单:展示结果物品图标和材料物品图标;未配置图标时显示默认物品标记占位符 - 相关栖息地:展示栖息地图片或默认栖息地标记占位符,并展示配方材料物品图标 diff --git a/backend/db/schema.sql b/backend/db/schema.sql index bf30e55..15d30b1 100644 --- a/backend/db/schema.sql +++ b/backend/db/schema.sql @@ -809,6 +809,7 @@ CREATE TABLE IF NOT EXISTS skills ( id integer GENERATED BY DEFAULT AS IDENTITY PRIMARY KEY, name text NOT NULL UNIQUE, has_item_drop boolean NOT NULL DEFAULT false, + has_trading boolean NOT NULL DEFAULT false, sort_order integer NOT NULL DEFAULT 0 CHECK (sort_order >= 0), created_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, updated_by_user_id integer REFERENCES users(id) ON DELETE SET NULL, @@ -964,6 +965,16 @@ CREATE TABLE IF NOT EXISTS item_favorite_things ( PRIMARY KEY (item_id, favorite_thing_id) ); +CREATE TABLE IF NOT EXISTS pokemon_trading_items ( + pokemon_id integer NOT NULL REFERENCES pokemon(id) ON DELETE CASCADE, + item_id integer NOT NULL REFERENCES items(id) ON DELETE CASCADE, + preference text NOT NULL CHECK (preference IN ('like', 'neutral')), + PRIMARY KEY (pokemon_id, item_id) +); + +CREATE INDEX IF NOT EXISTS pokemon_trading_items_item_idx + ON pokemon_trading_items(item_id, preference, pokemon_id); + CREATE TABLE IF NOT EXISTS pokemon_skill_item_drops ( pokemon_id integer NOT NULL, skill_id integer NOT NULL, @@ -1262,3 +1273,6 @@ CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_status_idx CREATE INDEX IF NOT EXISTS entity_discussion_comments_ai_moderation_language_idx ON entity_discussion_comments(entity_type, entity_id, ai_moderation_language_code, created_at, id) WHERE deleted_at IS NULL; + +ALTER TABLE skills + ADD COLUMN IF NOT EXISTS has_trading boolean NOT NULL DEFAULT false; diff --git a/backend/src/queries.ts b/backend/src/queries.ts index 4ce3503..f03edf3 100644 --- a/backend/src/queries.ts +++ b/backend/src/queries.ts @@ -98,6 +98,7 @@ type ConfigDefinition = { table: string; entityType: EntityType; hasItemDrop?: boolean; + hasTrading?: boolean; hasDefault?: boolean; hasRateable?: boolean; hasChangeLog?: boolean; @@ -150,6 +151,13 @@ type PokemonImageOptionsResult = { images: PokemonImage[]; }; +type TradingPreference = 'like' | 'neutral'; + +type PokemonTradingItemPayload = { + itemId: number; + preference: TradingPreference; +}; + type PokemonPayload = { dataId: number | null; dataIdentifier: string; @@ -167,6 +175,7 @@ type PokemonPayload = { skillIds: number[]; favoriteThingIds: number[]; skillItemDrops: SkillItemDrop[]; + tradingItems: PokemonTradingItemPayload[]; image: PokemonImage | null; }; @@ -540,6 +549,7 @@ type PokemonChangeSource = { environment: { name: string }; skills: Array<{ name: string; itemDrop?: { name: string } | null }>; favorite_things: Array<{ name: string }>; + tradingItems: Array<{ name: string; preference: TradingPreference }>; } & TranslationChangeSource; type ItemChangeSource = { name: string; @@ -599,6 +609,7 @@ type DailyChecklistChangeSource = { type ConfigChangeSource = { name: string; hasItemDrop?: boolean; + hasTrading?: boolean; isDefault?: boolean; isRateable?: boolean; changeLog?: string; @@ -663,7 +674,7 @@ const ancientArtifactCategoryOptions = [ const configDefinitions: Record = { 'pokemon-types': { table: 'pokemon_types', entityType: 'pokemon-types' }, - skills: { table: 'skills', entityType: 'skills', hasItemDrop: true }, + skills: { table: 'skills', entityType: 'skills', hasItemDrop: true, hasTrading: true }, environments: { table: 'environments', entityType: 'environments' }, 'favorite-things': { table: 'favorite_things', entityType: 'favorite-things' }, 'acquisition-methods': { table: 'acquisition_methods', entityType: 'acquisition-methods' }, @@ -962,9 +973,11 @@ function gameVersionOptions(locale: string): Promise> { +function skillOptions(locale: string): Promise> { const name = localizedName('skills', 's', locale); - return query(`SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop" FROM skills s ORDER BY ${orderByEntity('s')}`); + return query( + `SELECT s.id, ${name} AS name, s.has_item_drop AS "hasItemDrop", s.has_trading AS "hasTrading" FROM skills s ORDER BY ${orderByEntity('s')}` + ); } function auditSelect(entityAlias: string, createdAlias = 'created_user', updatedAlias = 'updated_user'): string { @@ -1000,6 +1013,9 @@ function configSelect(definition: ConfigDefinition, locale: string): string { if (definition.hasItemDrop) { columns.push(`c.has_item_drop AS "hasItemDrop"`); } + if (definition.hasTrading) { + columns.push(`c.has_trading AS "hasTrading"`); + } if (definition.hasDefault) { columns.push(`c.is_default AS "isDefault"`); } @@ -1944,7 +1960,7 @@ async function ensurePokemonTypeCatalog( const changes = configEditChanges( { table: 'pokemon_types', entityType: 'pokemon-types' }, existing.rows[0], - { name, translations, hasItemDrop: false, isDefault: false, isRateable: false, changeLog: '' } + { name, translations, hasItemDrop: false, hasTrading: false, isDefault: false, isRateable: false, changeLog: '' } ); if (changes.length) { await client.query( @@ -2239,6 +2255,19 @@ function namesFromIds(ids: number[], namesById: Map): string { return names.length ? names.join(' / ') : 'None'; } +function namedTradingListValue( + rows: Array<{ name: string; preference: TradingPreference }> | null | undefined +): string { + if (!rows?.length) { + return 'None'; + } + + return rows + .map((row) => `${row.preference === 'like' ? 'Likes' : 'Neutral'}: ${row.name}`) + .sort((a, b) => a.localeCompare(b)) + .join(' / '); +} + async function quantityPayloadValue(client: DbClient, rows: IdQuantity[]): Promise { const namesById = await entityNameMap(client, 'items', rows.map((row) => row.itemId)); return quantityListValue( @@ -2263,6 +2292,15 @@ async function pokemonEditChanges( const favoriteThingNames = await entityNameMap(client, 'favorite_things', after.favoriteThingIds); const dropSkillNames = await entityNameMap(client, 'skills', after.skillItemDrops.map((drop) => drop.skillId)); const dropItemNames = await entityNameMap(client, 'items', after.skillItemDrops.map((drop) => drop.itemId)); + const tradingItemNames = await entityNameMap(client, 'items', after.tradingItems.map((item) => item.itemId)); + const afterTradingItems = after.tradingItems + .map((item) => { + const itemName = tradingItemNames.get(item.itemId); + return itemName ? `${item.preference === 'like' ? 'Likes' : 'Neutral'}: ${itemName}` : null; + }) + .filter((value): value is string => value !== null) + .sort((a, b) => a.localeCompare(b)); + const afterTradingItemsValue = afterTradingItems.length ? afterTradingItems.join(' / ') : 'None'; const afterDrops = after.skillItemDrops .map((drop) => { const skillName = dropSkillNames.get(drop.skillId); @@ -2287,6 +2325,7 @@ async function pokemonEditChanges( pushChange(changes, 'Ideal Habitat', before.environment.name, environmentNames.get(after.environmentId)); pushChange(changes, 'Specialities', namedListValue(before.skills), namesFromIds(after.skillIds, skillNames)); pushChange(changes, 'Favourites', namedListValue(before.favorite_things), namesFromIds(after.favoriteThingIds, favoriteThingNames)); + pushChange(changes, 'Trading items', namedTradingListValue(before.tradingItems), afterTradingItemsValue); pushChange(changes, 'Speciality drops', skillDropListValue(before.skills), afterDrops); return changes; @@ -2330,6 +2369,91 @@ async function itemEditChanges( return changes; } +type ItemPossibleTagEntity = { + id: number; + name: string; +}; + +type ItemPossibleTagPokemon = { + id: number; + displayId: number; + name: string; + isEventItem: boolean; + image: EntityImageValue | null; +}; + +type ItemPossibleTagObservation = { + pokemon: ItemPossibleTagPokemon; + preference: TradingPreference; + tags: ItemPossibleTagEntity[]; +}; + +type ItemPossibleTags = { + highlyLikely: ItemPossibleTagEntity[]; + possible: ItemPossibleTagEntity[]; + excluded: ItemPossibleTagEntity[]; + evidence: { + likes: ItemPossibleTagObservation[]; + neutral: ItemPossibleTagObservation[]; + }; +}; + +function inferItemPossibleTags( + allTags: ItemPossibleTagEntity[], + observations: ItemPossibleTagObservation[] +): ItemPossibleTags { + const allTagIds = new Set(allTags.map((tag) => tag.id)); + const neutralExcludedTagIds = new Set(); + const filteredLikeSets: number[][] = []; + const likes: ItemPossibleTagObservation[] = []; + const neutral: ItemPossibleTagObservation[] = []; + + for (const observation of observations) { + const filteredTagIds = [...new Set(observation.tags.map((tag) => tag.id).filter((id) => allTagIds.has(id)))]; + const filteredObservation = { + ...observation, + tags: observation.tags.filter((tag) => allTagIds.has(tag.id)) + }; + + if (observation.preference === 'neutral') { + neutral.push(filteredObservation); + filteredTagIds.forEach((id) => neutralExcludedTagIds.add(id)); + continue; + } + + likes.push(filteredObservation); + if (filteredTagIds.length > 0) { + filteredLikeSets.push(filteredTagIds); + } + } + + const allowedTagIds = allTags.map((tag) => tag.id).filter((id) => !neutralExcludedTagIds.has(id)); + const conflicts = likes.some((observation) => observation.tags.length > 0 && observation.tags.every((tag) => neutralExcludedTagIds.has(tag.id))); + const unionLikeIds = new Set(filteredLikeSets.flat()); + const intersectionLikeIds = + filteredLikeSets.length >= 2 + ? filteredLikeSets.reduce((result, current) => result.filter((id) => current.includes(id))) + : []; + const candidateTagIds = conflicts + ? [] + : filteredLikeSets.length > 0 + ? allowedTagIds.filter((id) => unionLikeIds.has(id)) + : allowedTagIds; + const highlyLikelyTagIds = filteredLikeSets.length >= 2 + ? intersectionLikeIds.filter((id) => candidateTagIds.includes(id)) + : []; + const possibleTagIds = candidateTagIds.filter((id) => !highlyLikelyTagIds.includes(id)); + const excludedTagIds = allTags.map((tag) => tag.id).filter((id) => !candidateTagIds.includes(id)); + + const tagsById = new Map(allTags.map((tag) => [tag.id, tag])); + return { + highlyLikely: highlyLikelyTagIds.map((id) => tagsById.get(id)).filter((tag): tag is ItemPossibleTagEntity => Boolean(tag)), + possible: possibleTagIds.map((id) => tagsById.get(id)).filter((tag): tag is ItemPossibleTagEntity => Boolean(tag)), + excluded: excludedTagIds.map((id) => tagsById.get(id)).filter((tag): tag is ItemPossibleTagEntity => Boolean(tag)), + evidence: { likes, neutral } + }; +} + async function ancientArtifactEditChanges( client: DbClient, before: AncientArtifactChangeSource, @@ -2405,6 +2529,7 @@ function configEditChanges( name: string; translations: TranslationInput; hasItemDrop: boolean; + hasTrading: boolean; isDefault: boolean; isRateable: boolean; changeLog: string; @@ -2416,6 +2541,9 @@ function configEditChanges( if (definition.hasItemDrop) { pushChange(changes, 'Has item drop', boolValue(Boolean(before.hasItemDrop)), boolValue(after.hasItemDrop)); } + if (definition.hasTrading) { + pushChange(changes, 'Has trading', boolValue(Boolean(before.hasTrading)), boolValue(after.hasTrading)); + } if (definition.hasDefault) { pushChange(changes, 'Default category', boolValue(Boolean(before.isDefault)), boolValue(after.isDefault)); } @@ -2494,7 +2622,7 @@ function pokemonProjection(locale: string): string { WHERE ppt.pokemon_id = p.id ), '[]'::json) AS types, COALESCE(( - SELECT json_agg(json_build_object('id', s.id, 'name', ${skillName}, 'hasItemDrop', s.has_item_drop) ORDER BY ${orderByEntity('s')}) + SELECT json_agg(json_build_object('id', s.id, 'name', ${skillName}, 'hasItemDrop', s.has_item_drop, 'hasTrading', s.has_trading) ORDER BY ${orderByEntity('s')}) FROM pokemon_skills ps JOIN skills s ON s.id = ps.skill_id WHERE ps.pokemon_id = p.id @@ -5343,6 +5471,7 @@ export async function createConfig(type: ConfigType, payload: Record()); + const tradingItemsByPreference = tradingItems.map((item) => ({ + itemId: item.itemId, + preference: item.preference, + id: item.id, + name: item.name, + image: item.image + })); + const skills = Array.isArray(pokemon.skills) ? pokemon.skills.map((skill: { id: number; name: string }) => ({ ...skill, @@ -5738,7 +5929,7 @@ export async function getPokemon(id: number, locale = defaultLocale) { })) : []; - return { ...pokemon, skills, habitats, favoriteThingItems, relatedPokemon, editHistory, imageHistory }; + return { ...pokemon, skills, habitats, favoriteThingItems, tradingItems: tradingItemsByPreference, relatedPokemon, editHistory, imageHistory }; } function cleanPokemonPayload(payload: Record): PokemonPayload { @@ -5748,6 +5939,7 @@ function cleanPokemonPayload(payload: Record): PokemonPayload { const favoriteThingIds = cleanIds(payload.favoriteThingIds); const selectedSkillIds = new Set(skillIds); const skillItemDrops = new Map(); + const tradingItems = new Map(); if (typeIds.length === 0) { throw validationError('server.validation.typeMin'); @@ -5762,6 +5954,20 @@ function cleanPokemonPayload(payload: Record): PokemonPayload { throw validationError('server.validation.favoriteMax'); } + if (Array.isArray(payload.tradingItems)) { + for (const item of payload.tradingItems) { + const row = item as Record; + const itemId = Number(row.itemId); + const preference = row.preference; + + if (!Number.isInteger(itemId) || itemId <= 0 || (preference !== 'like' && preference !== 'neutral')) { + throw validationError('server.validation.invalidField'); + } + + tradingItems.set(String(itemId), { itemId, preference }); + } + } + if (Array.isArray(payload.skillItemDrops)) { for (const item of payload.skillItemDrops) { const row = item as Record; @@ -5799,6 +6005,7 @@ function cleanPokemonPayload(payload: Record): PokemonPayload { skillIds, favoriteThingIds, skillItemDrops: [...skillItemDrops.values()], + tradingItems: [...tradingItems.values()], image: cleanPokemonImage(payload.imagePath, displayId) }; } @@ -5823,6 +6030,7 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl await client.query('DELETE FROM pokemon_pokemon_types WHERE pokemon_id = $1', [pokemonId]); await client.query('DELETE FROM pokemon_skills WHERE pokemon_id = $1', [pokemonId]); await client.query('DELETE FROM pokemon_favorite_things WHERE pokemon_id = $1', [pokemonId]); + await client.query('DELETE FROM pokemon_trading_items WHERE pokemon_id = $1', [pokemonId]); for (const [index, typeId] of payload.typeIds.entries()) { await client.query('INSERT INTO pokemon_pokemon_types (pokemon_id, type_id, slot_order) VALUES ($1, $2, $3)', [ @@ -5843,6 +6051,21 @@ async function replacePokemonRelations(client: DbClient, pokemonId: number, payl ]); } + const tradingSkillResult = payload.skillIds.length + ? await client.query<{ id: number }>('SELECT id FROM skills WHERE id = ANY($1::integer[]) AND has_trading = true', [payload.skillIds]) + : { rows: [] }; + const hasTradingSkill = tradingSkillResult.rows.length > 0; + + if (hasTradingSkill) { + for (const tradingItem of payload.tradingItems) { + await client.query('INSERT INTO pokemon_trading_items (pokemon_id, item_id, preference) VALUES ($1, $2, $3)', [ + pokemonId, + tradingItem.itemId, + tradingItem.preference + ]); + } + } + if (payload.skillItemDrops.length > 0) { const allowedDrops = await client.query<{ id: number }>( 'SELECT id FROM skills WHERE id = ANY($1::integer[]) AND has_item_drop = true', @@ -6414,8 +6637,20 @@ export async function getItem(id: number, locale = defaultLocale) { const recipeItemName = localizedName('items', 'recipe_item', locale); const pokemonName = localizedName('pokemon', 'p', locale); const skillName = localizedName('skills', 's', locale); + const possibleTagName = localizedName('favorite-things', 'possible_tag', locale); + const evidenceTagName = localizedName('favorite-things', 'evidence_tag', locale); - const [acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory, imageHistory] = await Promise.all([ + const [ + acquisitionMethods, + recipe, + relatedRecipes, + relatedHabitats, + droppedByPokemon, + allPossibleTags, + possibleTagObservations, + editHistory, + imageHistory + ] = await Promise.all([ query( ` SELECT am.id, ${acquisitionMethodName} AS name @@ -6544,11 +6779,50 @@ export async function getItem(id: number, locale = defaultLocale) { `, [id] ), + query( + ` + SELECT possible_tag.id, ${possibleTagName} AS name + FROM favorite_things possible_tag + ORDER BY ${orderByEntity('possible_tag')} + ` + ), + query( + ` + SELECT + json_build_object( + 'id', p.id, + 'displayId', p.display_id, + 'name', ${pokemonName}, + 'isEventItem', p.is_event_item, + 'image', ${pokemonImageJson('p')} + ) AS pokemon, + pti.preference, + COALESCE(( + SELECT json_agg(json_build_object('id', evidence_tag.id, 'name', ${evidenceTagName}) ORDER BY ${orderByEntity('evidence_tag')}) + FROM pokemon_favorite_things pft + JOIN favorite_things evidence_tag ON evidence_tag.id = pft.favorite_thing_id + WHERE pft.pokemon_id = p.id + ), '[]'::json) AS tags + FROM pokemon_trading_items pti + JOIN pokemon p ON p.id = pti.pokemon_id + WHERE pti.item_id = $1 + AND EXISTS ( + SELECT 1 + FROM pokemon_skills ps + JOIN skills trading_skill ON trading_skill.id = ps.skill_id + WHERE ps.pokemon_id = p.id + AND trading_skill.has_trading = true + ) + ORDER BY pti.preference DESC, ${orderByEntity('p')} + `, + [id] + ), getEditHistory('items', id), listEntityImageUploads('items', id) ]); - return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, editHistory, imageHistory }; + const possibleTags = inferItemPossibleTags(allPossibleTags, possibleTagObservations); + return { ...item, acquisitionMethods, recipe, relatedRecipes, relatedHabitats, droppedByPokemon, possibleTags, editHistory, imageHistory }; } function cleanItemPayload(payload: Record): ItemPayload { @@ -7236,7 +7510,7 @@ function dishCategoryProjection(locale: string): string { ), '[]'::json), 'pokemonSkill', CASE WHEN dish_skill.id IS NULL THEN NULL - ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop) + ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop, 'hasTrading', dish_skill.has_trading) END ) ORDER BY d.sort_order, d.id @@ -7294,7 +7568,7 @@ function dishProjection(locale: string): string { ), '[]'::json) AS "secondaryMaterials", CASE WHEN dish_skill.id IS NULL THEN NULL - ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop) + ELSE json_build_object('id', dish_skill.id, 'name', ${skillName}, 'hasItemDrop', dish_skill.has_item_drop, 'hasTrading', dish_skill.has_trading) END AS "pokemonSkill" FROM dishes d JOIN dish_categories dc ON dc.id = d.category_id @@ -7682,6 +7956,7 @@ const dataToolColumns = { pokemonSkills: ['pokemon_id', 'skill_id'], pokemonFavoriteThings: ['pokemon_id', 'favorite_thing_id'], pokemonSkillItemDrops: ['pokemon_id', 'skill_id', 'item_id'], + pokemonTradingItems: ['pokemon_id', 'item_id', 'preference'], habitats: ['id', 'name', 'is_event_item', 'image_path', 'sort_order', 'created_by_user_id', 'updated_by_user_id', 'created_at', 'updated_at'], habitatRecipeItems: ['habitat_id', 'item_id', 'quantity'], habitatPokemon: ['habitat_id', 'pokemon_id', 'map_id', 'time_of_day', 'weather', 'rarity'], @@ -7909,10 +8184,14 @@ function normalizeImportValue(value: unknown): unknown { return value === undefined ? null : value; } +function normalizeImportColumnValue(row: Record, column: string): unknown { + return normalizeImportValue(row[column]); +} + async function insertRows(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise { for (const row of rows) { const placeholders = columns.map((_, index) => `$${index + 1}`).join(', '); - const values = columns.map((column) => normalizeImportValue(row[column])); + const values = columns.map((column) => normalizeImportColumnValue(row, column)); await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders})`, values); } } @@ -7922,7 +8201,7 @@ async function upsertRowsById(client: DbClient, tableName: string, columns: read for (const row of rows) { const placeholders = columns.map((_, index) => `$${index + 1}`).join(', '); const assignments = updateColumns.map((column) => `${column} = EXCLUDED.${column}`).join(', '); - const values = columns.map((column) => normalizeImportValue(row[column])); + const values = columns.map((column) => normalizeImportColumnValue(row, column)); await client.query( `INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) ON CONFLICT (id) DO UPDATE SET ${assignments}`, values @@ -7933,7 +8212,7 @@ async function upsertRowsById(client: DbClient, tableName: string, columns: read async function insertRowsIgnoreConflicts(client: DbClient, tableName: string, columns: readonly string[], rows: DataToolRows): Promise { for (const row of rows) { const placeholders = columns.map((_, index) => `$${index + 1}`).join(', '); - const values = columns.map((column) => normalizeImportValue(row[column])); + const values = columns.map((column) => normalizeImportColumnValue(row, column)); await client.query(`INSERT INTO ${tableName} (${columns.join(', ')}) VALUES (${placeholders}) ON CONFLICT DO NOTHING`, values); } } @@ -7984,6 +8263,7 @@ async function wipeItemsData(client: DbClient): Promise { await client.query('DELETE FROM item_favorite_things'); await client.query('DELETE FROM habitat_recipe_items'); await client.query('DELETE FROM pokemon_skill_item_drops'); + await client.query('DELETE FROM pokemon_trading_items'); await client.query('DELETE FROM items'); } @@ -8008,6 +8288,7 @@ async function wipePokemonData(client: DbClient): Promise { await client.query('DELETE FROM pokemon_pokemon_types'); await client.query('DELETE FROM pokemon_skills'); await client.query('DELETE FROM pokemon_favorite_things'); + await client.query('DELETE FROM pokemon_trading_items'); await client.query('DELETE FROM pokemon'); } @@ -8089,6 +8370,7 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise< pokemonSkills: await tableRows(client, 'SELECT * FROM pokemon_skills ORDER BY pokemon_id, skill_id'), pokemonFavoriteThings: await tableRows(client, 'SELECT * FROM pokemon_favorite_things ORDER BY pokemon_id, favorite_thing_id'), pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'), + pokemonTradingItems: await tableRows(client, 'SELECT * FROM pokemon_trading_items ORDER BY pokemon_id, preference, item_id'), habitatPokemon: await tableRows(client, 'SELECT * FROM habitat_pokemon ORDER BY habitat_id, pokemon_id, map_id, time_of_day, weather'), ...(await exportGenericScopeData(client, 'pokemon', true)) }; @@ -8109,6 +8391,7 @@ async function exportScopeData(client: DbClient, scope: DataToolScope): Promise< itemAcquisitionMethods: await tableRows(client, 'SELECT * FROM item_acquisition_methods ORDER BY item_id, acquisition_method_id'), itemFavoriteThings: await tableRows(client, 'SELECT * FROM item_favorite_things ORDER BY item_id, favorite_thing_id'), pokemonSkillItemDrops: await tableRows(client, 'SELECT * FROM pokemon_skill_item_drops ORDER BY pokemon_id, skill_id'), + pokemonTradingItems: await tableRows(client, 'SELECT * FROM pokemon_trading_items ORDER BY pokemon_id, preference, item_id'), habitatRecipeItems: await tableRows(client, 'SELECT * FROM habitat_recipe_items ORDER BY habitat_id, item_id'), ...(await exportGenericScopeData(client, 'items', true)) }; @@ -8229,6 +8512,7 @@ async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle const habitatData = bundle.data.habitats; const recipeData = bundle.data.recipes; const pokemonDropData = dataToolDataWithRows('pokemonSkillItemDrops', pokemonData, itemData); + const pokemonTradingData = dataToolDataWithRows('pokemonTradingItems', pokemonData, itemData); const habitatRecipeData = dataToolDataWithRows('habitatRecipeItems', habitatData, itemData); const habitatPokemonData = dataToolDataWithRows('habitatPokemon', habitatData, pokemonData); @@ -8239,6 +8523,7 @@ async function importScopeRelationRows(client: DbClient, bundle: DataToolsBundle await insertRows(client, 'pokemon_skills', dataToolColumns.pokemonSkills, dataToolTableRows(pokemonData, 'pokemonSkills')); await insertRows(client, 'pokemon_favorite_things', dataToolColumns.pokemonFavoriteThings, dataToolTableRows(pokemonData, 'pokemonFavoriteThings')); await insertRows(client, 'pokemon_skill_item_drops', dataToolColumns.pokemonSkillItemDrops, dataToolTableRows(pokemonDropData, 'pokemonSkillItemDrops')); + await insertRows(client, 'pokemon_trading_items', dataToolColumns.pokemonTradingItems, dataToolTableRows(pokemonTradingData, 'pokemonTradingItems')); await insertRows(client, 'recipe_acquisition_methods', dataToolColumns.recipeAcquisitionMethods, dataToolTableRows(recipeData, 'recipeAcquisitionMethods')); await insertRows(client, 'recipe_materials', dataToolColumns.recipeMaterials, dataToolTableRows(recipeData, 'recipeMaterials')); await insertRows(client, 'habitat_recipe_items', dataToolColumns.habitatRecipeItems, dataToolTableRows(habitatRecipeData, 'habitatRecipeItems')); diff --git a/frontend/src/components/EditHistoryPanel.vue b/frontend/src/components/EditHistoryPanel.vue index d9ff12d..024ad55 100644 --- a/frontend/src/components/EditHistoryPanel.vue +++ b/frontend/src/components/EditHistoryPanel.vue @@ -45,6 +45,8 @@ const changeLabelKeys: Record = { 'Speciality drops': 'pages.pokemon.skillDrops', 'Skill drops': 'pages.pokemon.skillDrops', 特长掉落物: 'pages.pokemon.skillDrops', + Trading: 'pages.pokemon.trading', + 'Trading items': 'pages.pokemon.tradingItems', Category: 'pages.items.category', 分类: 'pages.items.category', Usage: 'pages.items.usage', @@ -76,6 +78,8 @@ const changeLabelKeys: Record = { 排序: 'pages.admin.sortOrder', 'Has item drop': 'pages.admin.hasItemDrop', 有掉落物: 'pages.admin.hasItemDrop', + 'Has trading': 'pages.admin.hasTrading', + '有 Trading': 'pages.admin.hasTrading', 'Default category': 'pages.admin.defaultCategory', 默认分类: 'pages.admin.defaultCategory', Rateable: 'pages.admin.rateableCategory', diff --git a/frontend/src/components/Modal.vue b/frontend/src/components/Modal.vue index 47a3088..c910af4 100644 --- a/frontend/src/components/Modal.vue +++ b/frontend/src/components/Modal.vue @@ -1,3 +1,7 @@ + + + diff --git a/system-wordings.ts b/system-wordings.ts index da91dea..07b202e 100644 --- a/system-wordings.ts +++ b/system-wordings.ts @@ -619,6 +619,18 @@ export const systemWordingMessages = { skillDrops: 'Speciality drops', skillDrop: '{name} drop', dropItem: 'Drop item', + trading: 'Trading', + tradingItems: 'Trading items', + tradingLikes: 'Likes', + tradingNeutral: 'Neutral', + tradingPriceBonus: '1.5x price', + tradingSelectedCount: '{count} selected', + tradingModalSubtitle: 'Likes: 1.5x price · Neutral: no bonus', + tradingAvailableItems: 'Available trading items', + tradingSelectedItems: 'Selected trading items', + tradingPreferenceFor: 'Trading preference for {name}', + tradingDefaultGroup: 'Default add target', + manageTrading: 'Manage trading', searchPokemon: 'Search Pokemon', relatedPokemon: 'Related Pokemon', relatedHabitat: 'Related Pokemon habitat', @@ -705,6 +717,11 @@ export const systemWordingMessages = { relatedRecipes: 'Related recipes', relatedHabitats: 'Related habitats', pokemonDrops: 'Pokemon drops', + possibleTags: 'Possible Tags', + highlyLikelyTags: 'Highly likely', + possibleTagsPossible: 'Possible', + excludedTags: 'Excluded', + possibleTagsEvidence: 'Evidence', createRecipe: 'Create recipe', addItem: 'Add item', createDefaultsMenu: 'New item options', @@ -1053,6 +1070,7 @@ export const systemWordingMessages = { newConfig: 'New {name}', editConfig: 'Edit {name}', hasItemDrop: 'Has item drop', + hasTrading: 'Has trading', rateableCategory: 'Rateable', changeLog: 'ChangeLog', dragSort: 'Drag to reorder: {name}', @@ -1964,6 +1982,18 @@ export const systemWordingMessages = { skillDrops: '特长掉落物', skillDrop: '{name}掉落物', dropItem: '掉落物', + trading: 'Trading', + tradingItems: 'Trading 物品', + tradingLikes: 'Likes', + tradingNeutral: 'Neutral', + tradingPriceBonus: '1.5x 价格', + tradingSelectedCount: '已选择 {count} 个', + tradingModalSubtitle: 'Likes:1.5x 价格 · Neutral:无加成', + tradingAvailableItems: '可选 Trading 物品', + tradingSelectedItems: '已选 Trading 物品', + tradingPreferenceFor: '{name} 的 Trading 偏好', + tradingDefaultGroup: '默认加入组', + manageTrading: '管理 Trading', searchPokemon: '搜索 Pokemon', relatedPokemon: '相关 Pokemon', relatedHabitat: '相关 Pokemon 栖息地', @@ -2050,6 +2080,11 @@ export const systemWordingMessages = { relatedRecipes: '相关材料单', relatedHabitats: '相关栖息地', pokemonDrops: 'Pokemon 掉落', + possibleTags: 'Possible Tags', + highlyLikelyTags: '高度可能', + possibleTagsPossible: '可能', + excludedTags: '已排除', + possibleTagsEvidence: '证据', createRecipe: '创建材料单', addItem: '新增物品', createDefaultsMenu: '新增物品选项', @@ -2398,6 +2433,7 @@ export const systemWordingMessages = { newConfig: '新增{name}', editConfig: '编辑{name}', hasItemDrop: '有掉落物', + hasTrading: '有 Trading', rateableCategory: '可评分', changeLog: 'ChangeLog', dragSort: '拖曳排序:{name}',