From ed2408008015031c8b6287ef96076f1d73d7260a Mon Sep 17 00:00:00 2001 From: Kimu-Nowchira Date: Sat, 27 Jan 2024 09:44:34 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=EB=8C=80=EC=B6=A9=20=EA=B8=B0=EB=8A=A5?= =?UTF-8?q?=20=EC=B6=94=EA=B0=80?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/constants.ts | 24 ++++++- src/models/counter.ts | 6 +- src/models/fishData.ts | 2 +- src/models/room.ts | 9 ++- src/models/user.ts | 3 +- src/modules/etc.ts | 15 +++++ src/modules/info.ts | 4 +- src/modules/profile.ts | 5 +- src/modules/room/building.ts | 37 ++++++++++ src/modules/room/changeBiome.ts | 72 ++++++++++++++++++++ src/modules/room/info.ts | 7 +- src/modules/room/trade.ts | 116 ++++++++++++++++++++++++++++---- src/services/room/trade.ts | 78 +++++++++++++++++++-- src/types/room.ts | 4 +- 14 files changed, 345 insertions(+), 37 deletions(-) create mode 100644 src/modules/room/building.ts create mode 100644 src/modules/room/changeBiome.ts diff --git a/src/constants.ts b/src/constants.ts index 260821e..a9a5c03 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,4 +1,4 @@ -import { Season } from './types/room' +import { Biome, Season } from './types/room' import { FishRarity, StatEffect } from './types/types' export const ROOM_TIER_EMBED_COLOR = [ @@ -25,6 +25,9 @@ export const DEFAULT_STAT_EFFECTS: StatEffect = { expCoef: 1, } +// 매각수수료 (주인이 있는 땅을 매입할 때 발생함) +export const SELL_FEE = 0.05 + /** 이 아래는 차후 다국어 지원 시 수정해야 합니다. */ export const getSeasonName = (season: Season) => { @@ -69,3 +72,22 @@ export const getFishIcon = (rarity: FishRarity) => { return '🐋' } } + +export const getBiomeName = (biome: Biome) => { + switch (biome) { + case Biome.Desert: + return '🏜️ 메마른 땅' + case Biome.Sea: + return '🏖️ 바닷가' + case Biome.River: + return '🏞️ 강가' + case Biome.Lake: + return '🚤 호수' + case Biome.Valley: + return '⛰️ 계곡' + case Biome.Swamp: + return '🥬 습지' + case Biome.Foreshore: + return '🦀 갯벌' + } +} diff --git a/src/models/counter.ts b/src/models/counter.ts index 0916926..ceba4c3 100644 --- a/src/models/counter.ts +++ b/src/models/counter.ts @@ -5,7 +5,11 @@ interface ICounter { } const counterSchema = new Schema({ - userId: { type: mongoose.Schema.Types.ObjectId, required: true }, + userId: { + type: mongoose.Schema.Types.ObjectId, + required: true, + unique: true, + }, }) export const Counter = model('counters', counterSchema) diff --git a/src/models/fishData.ts b/src/models/fishData.ts index d4dcce3..fe6f57c 100644 --- a/src/models/fishData.ts +++ b/src/models/fishData.ts @@ -13,7 +13,7 @@ interface IFishData { } const fishData = new Schema({ - id: { type: String, required: true }, + id: { type: String, required: true, unique: true }, name: { type: String, required: true }, avgPrice: { type: Number, required: true }, avgLength: { type: Number, required: true }, diff --git a/src/models/room.ts b/src/models/room.ts index 964ead0..a8add6f 100644 --- a/src/models/room.ts +++ b/src/models/room.ts @@ -1,4 +1,4 @@ -import { DEFAULT_STAT_EFFECTS } from '../constants' +import { DEFAULT_STAT_EFFECTS, SELL_FEE } from '../constants' import { findBuilding } from '../game/building' import { Biome, @@ -16,7 +16,7 @@ import mongoose, { Schema, model } from 'mongoose' export const roomSchema = new Schema( { - id: { type: String, required: true }, + id: { type: String, required: true, unique: true }, name: { type: String, required: true }, ownerId: { type: mongoose.Schema.Types.ObjectId }, @@ -44,7 +44,10 @@ export const roomSchema = new Schema( // 매입에 필요한 최소 금액 roomSchema.methods.getMinPrices = function (this: RoomDoc): number { - return this.landPrice ? this.landPrice * 1.1 : 30000 + // 주인이 있을 경우 땅값의 5%를 추가함 + return this.landPrice + ? this.landPrice * (1 + (this.ownerId ? SELL_FEE : 0)) + : 30000 } // 시설 효과 불러오기 diff --git a/src/models/user.ts b/src/models/user.ts index e11420a..fef2fcb 100644 --- a/src/models/user.ts +++ b/src/models/user.ts @@ -1,11 +1,10 @@ -import { RoomDoc } from '../types/room' import { IUser, IUserMethods, UserDoc, UserModel } from '../types/user' import { Room } from './room' import { Schema, model } from 'mongoose' const userSchema = new Schema( { - id: { type: String, required: true }, + id: { type: String, required: true, unique: true }, username: { type: String, required: true }, money: { type: Number, required: true, default: 0 }, exp: { type: Number, required: true, default: 0 }, diff --git a/src/modules/etc.ts b/src/modules/etc.ts index e9ebf3e..a66265e 100644 --- a/src/modules/etc.ts +++ b/src/modules/etc.ts @@ -6,6 +6,7 @@ import { Colors, EmbedBuilder, PermissionsBitField, + inlineCode, } from 'discord.js' class EtcExtension extends Extension { @@ -30,6 +31,20 @@ class EtcExtension extends Extension { ) { if (!i.channel || i.channel.isDMBased() || !i.guild?.members.me) return + // 권한 체크 (이프가 메시지 관리하기 이상의 권한을 가지고 있어야 함) + if ( + !i.guild.members.me.permissions.has( + PermissionsBitField.Flags.ManageMessages + ) + ) { + return i.reply({ + content: + '이프한테 그럴 권한이 없잖아요!\n' + + inlineCode('❗ 이프에게 메시지 관리하기 권한을 주세요'), + ephemeral: true, + }) + } + // 권한 체크 (유저가 메시지 관리하기 이상의 권한을 가지고 있어야 함) if (!i.memberPermissions?.has(PermissionsBitField.Flags.ManageMessages)) { return i.reply({ diff --git a/src/modules/info.ts b/src/modules/info.ts index c55470c..3ef1fb6 100644 --- a/src/modules/info.ts +++ b/src/modules/info.ts @@ -36,9 +36,9 @@ class InfoExtension extends Extension { } @applicationCommand({ - name: 'info', + name: 'ep_info', nameLocalizations: { - ko: '정보', + ko: '이프', }, type: ApplicationCommandType.ChatInput, description: '현재 이프의 정보를 알려줘요!', diff --git a/src/modules/profile.ts b/src/modules/profile.ts index ba08eb3..c66b6c4 100644 --- a/src/modules/profile.ts +++ b/src/modules/profile.ts @@ -31,11 +31,12 @@ class ProfileExtension extends Extension { const profileMsgs = [ `- \`💰 소지금\` ${profile.money.toLocaleString()}원`, + `- \`💲 총 자산\` ${profile.totalPrice.toLocaleString()}원`, `- \`⭐ 레벨\` Lv. ${ profile.level } \`(✨ ${profile.exp.toLocaleString()} EXP)\``, ...(profile.roomCount - ? [`- \`🎣 매입한 낚시터\` ${profile.roomCount}개`] + ? [`- \`🎣 매입한 낚시터\` ${profile.roomCount}곳`] : []), ...(profile.highestRoom ? [`- \`🏠 가장 높은 땅값\` 💰 ${profile.highestRoom.name}`] @@ -69,7 +70,7 @@ class ProfileExtension extends Extension { iconURL: i.user.displayAvatarURL(), }) .setColor(Colors.Gold) - .setDescription(`💰 ${epUser.money.toLocaleString()}원`) + .setDescription(`💰 **${epUser.money.toLocaleString()}**`) .setFooter({ text: '물고기를 낚아 더 많은 이프머니를 모아봐요!' }) .setTimestamp() diff --git a/src/modules/room/building.ts b/src/modules/room/building.ts new file mode 100644 index 0000000..1871459 --- /dev/null +++ b/src/modules/room/building.ts @@ -0,0 +1,37 @@ +import { getRoomAnalysis } from '../../services/room/analysis' +import { roomGroup } from './index' +import { Extension } from '@pikokr/command.ts' +import { + ChatInputCommandInteraction, + Colors, + EmbedBuilder, + codeBlock, + inlineCode, +} from 'discord.js' + +class BuildingExtension extends Extension { + @roomGroup.command({ + name: 'buildings', + nameLocalizations: { ko: '시설' }, + description: + '이 낚시터의 시설을 조회할 수 있어요! (관리자는 시설 철거도 가능)', + dmPermission: false, + }) + async buildings(i: ChatInputCommandInteraction) { + if (!i.channel || i.channel.isDMBased()) return + } + + @roomGroup.command({ + name: 'build', + nameLocalizations: { ko: '시설건설' }, + description: '이 낚시터에 시설을 건설할 수 있어요!', + dmPermission: false, + }) + async info(i: ChatInputCommandInteraction) { + if (!i.channel || i.channel.isDMBased()) return + } +} + +export const setup = async () => { + return new BuildingExtension() +} diff --git a/src/modules/room/changeBiome.ts b/src/modules/room/changeBiome.ts new file mode 100644 index 0000000..a219baf --- /dev/null +++ b/src/modules/room/changeBiome.ts @@ -0,0 +1,72 @@ +import { getBiomeName } from '../../constants' +import { Biome } from '../../types/room' +import { roomGroup } from './index' +import { Extension, option } from '@pikokr/command.ts' +import { + APIApplicationCommandOptionChoice, + ApplicationCommandOptionType, + ChatInputCommandInteraction, + Colors, + EmbedBuilder, +} from 'discord.js' + +const biomeCategories: APIApplicationCommandOptionChoice[] = [ + { name: getBiomeName(Biome.Sea), value: Biome.Sea }, + { name: getBiomeName(Biome.River), value: Biome.River }, + { name: getBiomeName(Biome.Lake), value: Biome.Lake }, + { name: getBiomeName(Biome.Valley), value: Biome.Valley }, + { name: getBiomeName(Biome.Swamp), value: Biome.Swamp }, + { name: getBiomeName(Biome.Foreshore), value: Biome.Foreshore }, + { name: getBiomeName(Biome.Desert), value: Biome.Desert }, +] + +class ChangeBiomeExtension extends Extension { + @roomGroup.command({ + name: 'change_biome', + nameLocalizations: { ko: '지형변경' }, + description: '낚시터의 지형을 변경할 수 있어!', + dmPermission: false, + }) + async buy( + i: ChatInputCommandInteraction, + @option({ + name: 'biome', + name_localizations: { ko: '지형' }, + description: '어떤 지형으로 변경할 건가요?', + type: ApplicationCommandOptionType.Integer, + choices: biomeCategories, + required: true, + }) + biome: Biome + ) { + if (!i.channel || i.channel.isDMBased()) return + await i.deferReply() + + const room = await i.channel.epRoom + const originBiome = room.biome + + if (originBiome === biome) { + return i.editReply('이미 이 낚시터의 지형이에요!') + } + + room.biome = biome + await room.save() + + const embed = new EmbedBuilder() + .setTitle(`🏞️ ' ${room.name} ' 낚시터 지형 변경`) + .setDescription( + [ + `- \`지형\` ${getBiomeName(originBiome)} → ${getBiomeName(biome)}`, + `- \`지형 조성 비용\` 💰 ${(99999).toLocaleString()}`, + ].join('\n') + ) + .setColor(Colors.Blue) + .setTimestamp() + + await i.editReply({ embeds: [embed] }) + } +} + +export const setup = async () => { + return new ChangeBiomeExtension() +} diff --git a/src/modules/room/info.ts b/src/modules/room/info.ts index 52ea836..7ab5226 100644 --- a/src/modules/room/info.ts +++ b/src/modules/room/info.ts @@ -1,4 +1,4 @@ -import { getSeasonIcon, getSeasonName } from '../../constants' +import { getBiomeName, getSeasonIcon, getSeasonName } from '../../constants' import { getRoomInfo } from '../../services/room/info' import { removeEmojis } from '../../utils/demojify' import { roomGroup } from './index' @@ -22,7 +22,7 @@ class RoomInfoExtension extends Extension { if (!i.channel || i.channel.isDMBased()) return const room = await i.channel.epRoom - const { roomThumbnail, effects } = await getRoomInfo( + const { roomThumbnail, effects, roomOwner } = await getRoomInfo( room, this.client, i.guild || undefined @@ -32,9 +32,10 @@ class RoomInfoExtension extends Extension { .setTitle(`ℹ️ ' ${removeEmojis(room.name)} ' 낚시터 정보`) .setDescription( dedent` + - \`지형\` ${getBiomeName(room.biome)} - \`계절\` ${getSeasonIcon(room.season)} ${getSeasonName(room.season)} - \`주인\` ${ - room.ownerId ? `👑 <@${room.ownerId}>` : '공영 낚시터 **(매입 가능)**' + room.ownerId ? `👑 ${roomOwner}` : '공영 낚시터 **(매입 가능)**' } - \`수질\` **2급수** \`(🧹 ${room.clean.toLocaleString()})\` - \`땅값\` 💰 ${room.landPrice.toLocaleString()} diff --git a/src/modules/room/trade.ts b/src/modules/room/trade.ts index efac8a3..ffa1905 100644 --- a/src/modules/room/trade.ts +++ b/src/modules/room/trade.ts @@ -1,12 +1,15 @@ -import { BuyRoomError, buyRoom } from '../../services/room/trade' +import { SELL_FEE } from '../../constants' +import { TradeRoomError, buyRoom, sellRoom } from '../../services/room/trade' import { removeEmojis } from '../../utils/demojify' import { roomGroup } from './index' -import { Extension, option } from '@pikokr/command.ts' +import { Extension, listener, option } from '@pikokr/command.ts' import dedent from 'dedent' import { ApplicationCommandOptionType, ChatInputCommandInteraction, + Colors, EmbedBuilder, + Interaction, inlineCode, } from 'discord.js' @@ -22,10 +25,10 @@ class TradeExtension extends Extension { @option({ name: 'value', name_localizations: { ko: '금액' }, - description: '매입할 금액을 입력해주세요!', + description: '매입할 금액을 입력해주세요! (미입력 시 최소매입금액)', type: ApplicationCommandOptionType.Integer, }) - value: number + value_?: number ) { if (!i.channel || i.channel.isDMBased()) return await i.deferReply() @@ -33,22 +36,33 @@ class TradeExtension extends Extension { const room = await i.channel.epRoom const user = await i.user.epUser - const { error } = await buyRoom(room, user, 1000000) + const { error, lack, minPrice, value } = await buyRoom(room, user, value_) if (error) { switch (error) { - case BuyRoomError.NOT_ENOUGH_MONEY: + case TradeRoomError.ALREADY_OWNED_ROOM: + return i.editReply( + '이미 이 낚시터의 주인이에요!\n' + + inlineCode( + `❔ 낚시터의 땅값을 바꾸고 싶다면 '/낚시터 땅값변경' 명령어를 사용해 보세요.` + ) + ) + + case TradeRoomError.NOT_ENOUGH_MONEY: return i.editReply( '돈이 부족해요!\n' + inlineCode( - `❗ 현재 보유금은 💰 ${user.money.toLocaleString()}원이고, ${( - value - user.money - ).toLocaleString()}원이 더 필요해요!` + `❗ 현재 보유금은 💰 ${user.money.toLocaleString()}원이고, 💰 ${lack.toLocaleString()}원이 더 필요해요!` ) ) - case BuyRoomError.MINIMUM_PRICE_NOT_MET: - return i.editReply('최소 매입 금액을 충족하지 못해요!') - case BuyRoomError.NOT_BUYABLE_ROOM: + case TradeRoomError.MINIMUM_PRICE_NOT_MET: + return i.editReply( + '최소 매입 금액을 충족하지 못해요!\n' + + inlineCode( + `❗ 이 낚시터를 매입하기 위해서는 최소 💰 ${minPrice.toLocaleString()}원이 필요해요!` + ) + ) + case TradeRoomError.NOT_BUYABLE_ROOM: return i.editReply('이 낚시터는 매입할 수 없어요!') } } @@ -58,13 +72,87 @@ class TradeExtension extends Extension { .setDescription( dedent` - \`매입자\` 👑 <@${user.id}> - - \`매입가\` 💰 ${room.landPrice.toLocaleString()} - - \`매입수수료\` 💸 ${room.fee.toLocaleString()}% + - \`매입가\` 💰 ${value.toLocaleString()} + - \`매입수수료\` 💸 5% + - \`남은 돈\` 💰 ${(user.money - value).toLocaleString()}원 ` ) + .setColor(Colors.Blue) + .setTimestamp() await i.editReply({ embeds: [embed] }) } + + @roomGroup.command({ + name: 'sell', + nameLocalizations: { ko: '매각' }, + description: '낚시터를 매각할 수 있어요!', + dmPermission: false, + }) + async sell( + i: ChatInputCommandInteraction, + @option({ + type: ApplicationCommandOptionType.String, + name: 'search_room', + name_localizations: { ko: '검색' }, + description: + '매각할 낚시터를 검색할 수 있어요! (미입력 시 이 낚시터를 매각해요!)', + autocomplete: true, + }) + roomId?: string + ) { + const epUser = await i.user.epUser + const { error, room, fee, landPrice } = await sellRoom( + roomId || i.channelId, + epUser + ) + + if (error) { + switch (error) { + case TradeRoomError.NOT_EXIST_ROOM: + return i.reply('존재하지 않는 낚시터에요!') + case TradeRoomError.NOT_OWNED_ROOM: + return i.reply('이 낚시터의 주인이 아니에요!') + } + } + + const embed = new EmbedBuilder() + .setTitle( + `ℹ️ ' ${removeEmojis(room?.name || '')} ' 낚시터를 매각했어요!` + ) + .setDescription( + dedent` + - \`매각자\` 👑 <@${epUser.id}> + - \`매각가\` 💰 ${landPrice?.toLocaleString()} \`(수수료 💰 ${fee} 포함)\` + - \`매각수수료\` 💸 ${SELL_FEE}% + - \`보유금\` 💰 ${( + epUser.money + (landPrice || 0) + ).toLocaleString()}원 + ` + ) + .setColor(Colors.Blue) + .setTimestamp() + + await i.reply({ embeds: [embed] }) + } + + @listener({ event: 'interactionCreate' }) + async interactionCreate(i: Interaction) { + if (!i.isAutocomplete()) return + + const focused = i.options.getFocused(true) + if (focused.name !== 'search_room') return + + const epUser = await i.user.epUser + const rooms = await epUser.getUserOwnedRooms() + + const options = rooms.map((room) => ({ + name: `${room.name} (💰 ${room.landPrice.toLocaleString()}원)`, + value: room.id, + })) + + await i.respond(options) + } } export const setup = async () => { diff --git a/src/services/room/trade.ts b/src/services/room/trade.ts index 4e1cac6..291a490 100644 --- a/src/services/room/trade.ts +++ b/src/services/room/trade.ts @@ -1,8 +1,10 @@ // 낚시터 매입 +import { SELL_FEE } from '../../constants' +import { Room } from '../../models/room' import { RoomDoc } from '../../types/room' import { UserDoc } from '../../types/user' -export enum BuyRoomError { +export enum TradeRoomError { // 최소 매입 금액 미달 MINIMUM_PRICE_NOT_MET = 1, @@ -11,20 +13,84 @@ export enum BuyRoomError { // 매입 불가능한 낚시터 (공영 낚시터) NOT_BUYABLE_ROOM, + + // 존재하지 않는 낚시터 + NOT_EXIST_ROOM, + + // 자신의 땅이 아님 (권한 부족) + NOT_OWNED_ROOM, + + // 이미 자신이 주인임 + ALREADY_OWNED_ROOM, } -export const buyRoom = async (room: RoomDoc, user: UserDoc, value: number) => { +export const buyRoom = async (room: RoomDoc, user: UserDoc, value?: number) => { const minPrice = room.getMinPrices() const originOwner = await room.getOwner() + if (!value) value = minPrice + + const resultData = { + error: null as TradeRoomError | null, + originOwner: originOwner, + minPrice: minPrice, + value: value, + lack: value - user.money, + } + + // 이미 자신이 주인임 + if (originOwner?._id.toString() === user._id.toString()) { + return { ...resultData, error: TradeRoomError.ALREADY_OWNED_ROOM } + } + + if (user.money < value) + return { ...resultData, error: TradeRoomError.NOT_ENOUGH_MONEY } - if (user.money < value) return { error: BuyRoomError.NOT_ENOUGH_MONEY } // if (room.isPublic) return {error: BuyRoomError.NOT_BUYABLE_ROOM} - if (value < minPrice) return { error: BuyRoomError.MINIMUM_PRICE_NOT_MET } + if (value < minPrice) + return { ...resultData, error: TradeRoomError.MINIMUM_PRICE_NOT_MET } + + // 기존 주인이 있을 경우 돈을 돌려 줌 (강제매각이므로 5%의 수수료를 받음) + if (originOwner) { + originOwner.money += Math.floor(value) + await originOwner.save() + } + + room.landPrice = value + room.ownerId = user._id + await room.save() - await room.updateOne({ ownerId: user.id }) user.money -= minPrice await user.save() - return { error: null } + return resultData +} + +export const sellRoom = async (roomId: string, user: UserDoc) => { + const room = await Room.findOne({ id: roomId }) + if (!room) return { error: TradeRoomError.NOT_EXIST_ROOM } + + // 낚시터 주인인지 확인 + if (user._id.toString() !== room.ownerId?.toString()) { + return { error: TradeRoomError.NOT_OWNED_ROOM } + } + + const fee = room.landPrice * SELL_FEE + const landPrice = room.landPrice - fee + + // 돈을 돌려줌 + user.money += landPrice + await user.save() + + // 낚시터 주인과 땅값을 리셋함 + room.ownerId = undefined + room.landPrice = 0 + await room.save() + + return { + error: null, + room, + landPrice, + fee, + } } diff --git a/src/types/room.ts b/src/types/room.ts index a18a94a..35feeb9 100644 --- a/src/types/room.ts +++ b/src/types/room.ts @@ -1,6 +1,6 @@ import { StatEffect } from './types' import { IUser, IUserMethods, UserDoc } from './user' -import { HydratedDocument, Model, ObjectId } from 'mongoose' +import mongoose, { HydratedDocument, Model, ObjectId } from 'mongoose' export enum Season { Spring = 1, @@ -24,7 +24,7 @@ export interface IRoom { name: string // 낚시터 경영 정보 - ownerId?: ObjectId + ownerId?: mongoose.Types.ObjectId exp: number fame: number fee: number