From 650ffc5047c2239dc49d16031c2a11d744add5df Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 23 Sep 2025 08:35:41 -0400 Subject: [PATCH 01/83] Created blank wz directory and added client setup links --- .gitignore | 2 +- README.md | 9 ++++++++- wz/.gitkeep | 0 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 wz/.gitkeep diff --git a/.gitignore b/.gitignore index bd6e24a6..b1929c8d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,5 +41,5 @@ build/ .idea/ ### Project ### -/wz/ +/wz/*.wz /json/ diff --git a/README.md b/README.md index be2993b6..2803ad4b 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,14 @@ Basic configuration is available via environment variables - the names and defau are defined in [ServerConstants.java](src/main/java/kinoko/server/ServerConstants.java) and [ServerConfig.java](src/main/java/kinoko/server/ServerConfig.java). +### Client Downloads +[GMS V95 Client Setup](https://mega.nz/file/dWIgyR4I#6cDN_ycLLiFtad07Eby3UfjdY3TqGI65g6X-xEqlmds) +[GMS v95.1 Localhost](https://mega.nz/file/dWIgyR4I#6cDN_ycLLiFtad07Eby3UfjdY3TqGI65g6X-xEqlmds) + + + > [!NOTE] -> Client WZ files are expected to be present in the `wz/` directory in order for the provider classes to extract the +> Client .WZ files are expected to be present in the `wz/` directory in order for the provider classes to extract the > required data. The required files are as follows: > ``` > Character.wz @@ -25,6 +31,7 @@ and [ServerConfig.java](src/main/java/kinoko/server/ServerConfig.java). > Etc.wz > ``` + #### Java setup Building the project requires Java 21 and maven. diff --git a/wz/.gitkeep b/wz/.gitkeep new file mode 100644 index 00000000..e69de29b From abbdab6d8bc7e72ba43500c4238e82266baf4f54 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 23 Sep 2025 08:39:06 -0400 Subject: [PATCH 02/83] updated client setup link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2803ad4b..6b7fc844 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ are defined in [ServerConstants.java](src/main/java/kinoko/server/ServerConstant and [ServerConfig.java](src/main/java/kinoko/server/ServerConfig.java). ### Client Downloads -[GMS V95 Client Setup](https://mega.nz/file/dWIgyR4I#6cDN_ycLLiFtad07Eby3UfjdY3TqGI65g6X-xEqlmds) +[GMS V95 Client Setup](https://ia600809.us.archive.org/19/items/GMSSetup93-133/GMS0095/GMSSetupv95.exe) [GMS v95.1 Localhost](https://mega.nz/file/dWIgyR4I#6cDN_ycLLiFtad07Eby3UfjdY3TqGI65g6X-xEqlmds) From 5d3d8fc86b6f9e3bc9c8e3a1a831b49efe7f8347 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 15:11:54 -0400 Subject: [PATCH 03/83] Updated repo based configs --- .env.example | 27 ++ .gitignore | 1 + Dockerfile | 3 + README.md | 29 +- docker-compose.yml | 62 +++- pom.xml | 15 + .../database/postgresql/PostgresAccessor.java | 25 ++ .../postgresql/PostgresAccountAccessor.java | 198 ++++++++++ .../postgresql/PostgresCharacterAccessor.java | 347 ++++++++++++++++++ .../postgresql/PostgresConnector.java | 4 + .../postgresql/PostgresFriendAccessor.java | 84 +++++ .../postgresql/PostgresGiftAccessor.java | 86 +++++ .../postgresql/PostgresGuildAccessor.java | 161 ++++++++ .../postgresql/PostgresIdAccessor.java | 67 ++++ .../postgresql/PostgresMemoAccessor.java | 94 +++++ .../kinoko/database/postgresql/setup/init.sql | 0 16 files changed, 1195 insertions(+), 8 deletions(-) create mode 100644 .env.example create mode 100644 src/main/java/kinoko/database/postgresql/PostgresAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresConnector.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/setup/init.sql diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..9fb73436 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# This example shows a setup for Cassandra on Prod + +# DEV ENV +TESPIA=FALSE + +# DOCKERIZED JAVA SERVER +DB_HOST=cassandra_kinoko +# DB_HOST=postgres_kinoko + +# LOCAL (Undockerized) JAVA SERVER (DB SERVER CAN BE DOCKERIZED) +# DB_HOST=localhost + +# Cassandra Settings +DB_DATACENTER="datacenter1" +DB_PROFILE_ONE="profile_one" + +DB_PORT=9042 +# DB_PORT=5432 + +# DB NAME (Cassandra Keyspace, or Postgres Database Name) +DB_NAME=kinoko + + +# Postgres Settings +DB_USER=postgres +DB_PASS=admin + diff --git a/.gitignore b/.gitignore index b1929c8d..9b66c505 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ target/ !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ +*.env ### IntelliJ IDEA ### .idea/modules.xml diff --git a/Dockerfile b/Dockerfile index 1490808e..2f1caa9b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,9 @@ WORKDIR /kinoko COPY pom.xml . RUN mvn dependency:go-offline +# not sure why this was left out originally, if it's needed to pass tests to build the JAR. +COPY wz ./wz + COPY src ./src RUN mvn clean package diff --git a/README.md b/README.md index 6b7fc844..b83a607e 100644 --- a/README.md +++ b/README.md @@ -41,16 +41,35 @@ Building the project requires Java 21 and maven. $ mvn clean package ``` +#### Environment setup +Before doing any Docker or Database Setup +You should: +1. Make a copy of `.env.example` and rename it to `.env`. +2. Adjust the ENV Variables to the database server you will be using. + + #### Database setup -It is possible to use either CassandraDB or ScyllaDB, no setup is required other than starting the database. +It is possible to use either CassandraDB, ScyllaDB, or Postgres. + + ```bash # Start CassandraDB +$ docker-compose up -d cassandra +# OR $ docker run -d -p 9042:9042 cassandra:5.0.0 # Alternatively, start ScyllaDB $ docker run -d -p 9042:9042 scylladb/scylla --smp 1 + +# Alternatively, start PostgreSQL +$ docker-compose up -d postgres +# OR (CHANGE THE PASSWORD) +$ docker run -d --name postgres_kinoko -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=admin -e POSTGRES_INITDB_ARGS="--auth-host=scram-sha-256 --auth-local=scram-sha-256" -e POSTGRES_DB=kinoko -p 5432:5432 -v "${PWD}\src\main\java\kinoko\database\postgresql\setup\init.sql:/docker-entrypoint-initdb.d/init.sql:ro" postgres:16 + +Important: If you are using PostgreSQL on a local machine (not using a dockerized server), make sure that you have any undockerized postgresql server offline. This can cause conflicts. + ``` You can use [Docker Desktop](https://www.docker.com/products/docker-desktop/) or WSL on Windows. @@ -65,5 +84,11 @@ the [docker-compose.yml](docker-compose.yml) file. The requirements are as follo ```bash # Build and start containers -$ docker compose up -d + +# Cassandra & Server (Recommended, default) +$ docker compose up -d cassandra server + +# Postgres & Server (Alternative) +$ docker compose up -d postgres server + ``` diff --git a/docker-compose.yml b/docker-compose.yml index 034332af..26500324 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,28 @@ version: "3" services: server: + container_name: server_kinoko build: . - depends_on: - database: - condition: service_healthy +# we don't want to build both database servers. Not sure how to incorporate this dependency to only the chosen DB. +# depends_on: +# cassandra: +# condition: service_healthy +# postgres: +# condition: service_healthy ports: - 8484:8484 - 8585-8989:8585-8989 volumes: - ./data:/kinoko/data - ./wz:/kinoko/wz + env_file: + - .env environment: # ServerConstants CENTRAL_HOST: "127.0.0.1" CENTRAL_PORT: "8282" SERVER_HOST: "127.0.0.1" - DATABASE_HOST: "database" + # ServerConfig WORLD_ID: "0" WORLD_NAME: "Kinoko" @@ -25,10 +31,21 @@ services: REQUIRE_SECONDARY_PASSWORD: "true" WZ_DIRECTORY: "/kinoko/wz" DATA_DIRECTORY: "/kinoko/data" - COMMAND_PREFIX: "!" + PLAYER_COMMAND_PREFIX: "@" + STAFF_COMMAND_PREFIX: "!" DEBUG_MODE: "true" - database: + networks: + - kinoko_net + + cassandra: + profiles: ["cassandra"] + env_file: # not necessarily needed unless extra vars are added. + - .env + environment: + DB_TYPE: "cassandra" + DB_HOST: "cassandra" image: cassandra:5.0.0-jammy + container_name: cassandra_kinoko ports: - 9042:9042 healthcheck: @@ -36,3 +53,36 @@ services: interval: 10s timeout: 5s retries: 5 + volumes: + - ./cassanddata:/var/lib/cassandra + networks: + - kinoko_net + + postgres: + profiles: ["postgres", "postgresql"] + image: postgres:16 + container_name: postgres_kinoko + env_file: + - .env + environment: + DB_TYPE: "postgres" + DB_HOST: "postgres" + POSTGRES_USER: "${DB_USER:-postgres}" + POSTGRES_PASSWORD: "${DB_PASS:-admin}" + POSTGRES_DB: "${DB_NAME:-kinoko}" + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-kinoko}"] + interval: 10s + timeout: 5s + retries: 5 + volumes: + - ./pgdata:/var/lib/postgresql/data + - ./src/main/java/kinoko/database/postgresql/setup/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + networks: + - kinoko_net + +networks: + kinoko_net: + name: network_kinoko \ No newline at end of file diff --git a/pom.xml b/pom.xml index 0e397130..a773a9ed 100644 --- a/pom.xml +++ b/pom.xml @@ -23,6 +23,21 @@ + + io.github.cdimascio + java-dotenv + 5.2.2 + + + com.zaxxer + HikariCP + 7.0.2 + + + org.postgresql + postgresql + 42.7.8 + io.netty netty-all diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java new file mode 100644 index 00000000..54142e49 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java @@ -0,0 +1,25 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; + +public abstract class CassandraAccessor { + private final CqlSession session; + private final String keyspace; + + public CassandraAccessor(CqlSession session, String keyspace) { + this.session = session; + this.keyspace = keyspace; + } + + public final CqlSession getSession() { + return session; + } + + public final String getKeyspace() { + return keyspace; + } + + protected final String lowerName(String name) { + return name.toLowerCase(); + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java new file mode 100644 index 00000000..32efdb43 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java @@ -0,0 +1,198 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry; +import kinoko.database.AccountAccessor; +import kinoko.database.DatabaseManager; +import kinoko.database.cassandra.table.AccountTable; +import kinoko.server.ServerConfig; +import kinoko.server.cashshop.CashItemInfo; +import kinoko.world.item.Item; +import kinoko.world.item.Trunk; +import kinoko.world.user.Account; +import kinoko.world.user.Locker; +import org.mindrot.jbcrypt.BCrypt; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; + +public final class CassandraAccountAccessor extends CassandraAccessor implements AccountAccessor { + public CassandraAccountAccessor(CqlSession session, String keyspace) { + super(session, keyspace); + } + + private Account loadAccount(Row row) { + final int accountId = row.getInt(AccountTable.ACCOUNT_ID); + final String username = row.getString(AccountTable.USERNAME); + final String secondaryPassword = row.getString(AccountTable.SECONDARY_PASSWORD); + + final Account account = new Account(accountId, username); + account.setHasSecondaryPassword(secondaryPassword != null && !secondaryPassword.isEmpty()); + account.setSlotCount(row.getInt(AccountTable.CHARACTER_SLOTS)); + account.setNxCredit(row.getInt(AccountTable.NX_CREDIT)); + account.setNxPrepaid(row.getInt(AccountTable.NX_PREPAID)); + account.setMaplePoint(row.getInt(AccountTable.MAPLE_POINT)); + + final Trunk trunk = new Trunk(row.getInt(AccountTable.TRUNK_SIZE)); + final List items = row.getList(AccountTable.TRUNK_ITEMS, Item.class); + if (items != null) { + for (Item item : items) { + trunk.getItems().add(item); + } + } + trunk.setMoney(row.getInt(AccountTable.TRUNK_MONEY)); + account.setTrunk(trunk); + + final Locker locker = new Locker(); + final List cashItems = row.getList(AccountTable.LOCKER_ITEMS, CashItemInfo.class); + if (cashItems != null) { + for (CashItemInfo cii : cashItems) { + locker.addCashItem(cii); + } + } + account.setLocker(locker); + + final List wishlist = row.getList(AccountTable.WISHLIST, Integer.class); + account.setWishlist(Collections.unmodifiableList(wishlist != null ? wishlist : Collections.nCopies(10, 0))); + + return account; + } + + private String lowerUsername(String username) { + return username.toLowerCase(); + } + + private String hashPassword(String password) { + return BCrypt.hashpw(password, BCrypt.gensalt()); + } + + private boolean checkHashedPassword(String password, String hashedPassword) { + return BCrypt.checkpw(password, hashedPassword); + } + + @Override + public Optional getAccountById(int accountId) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), AccountTable.getTableName()).all() + .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(accountId)) + .build() + ); + for (Row row : selectResult) { + return Optional.of(loadAccount(row)); + } + return Optional.empty(); + } + + @Override + public Optional getAccountByUsername(String username) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), AccountTable.getTableName()).all() + .whereColumn(AccountTable.USERNAME).isEqualTo(literal(lowerUsername(username))) + .build() + ); + for (Row row : selectResult) { + return Optional.of(loadAccount(row)); + } + return Optional.empty(); + } + + @Override + public boolean checkPassword(Account account, String password, boolean secondary) { + final String columnName = secondary ? AccountTable.SECONDARY_PASSWORD : AccountTable.PASSWORD; + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), AccountTable.getTableName()).all() + .column(columnName) + .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) + .build() + .setExecutionProfileName(CassandraConnector.PROFILE_ONE) + ); + for (Row row : selectResult) { + final String hashedPassword = row.getString(columnName); + if (hashedPassword == null) { + continue; + } + if (checkHashedPassword(password, hashedPassword)) { + return true; + } + } + return false; + } + + @Override + public boolean savePassword(Account account, String oldPassword, String newPassword, boolean secondary) { + final String columnName = secondary ? AccountTable.SECONDARY_PASSWORD : AccountTable.PASSWORD; + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), AccountTable.getTableName()).all() + .column(columnName) + .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) + .build() + ); + for (Row row : selectResult) { + final String hashedOldPassword = row.getString(columnName); + if (hashedOldPassword == null || checkHashedPassword(oldPassword, hashedOldPassword)) { + final ResultSet updateResult = getSession().execute( + update(getKeyspace(), AccountTable.getTableName()) + .setColumn(columnName, literal(hashPassword(newPassword))) + .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) + .build() + ); + return updateResult.wasApplied(); + } + } + return false; + } + + @Override + public synchronized boolean newAccount(String username, String password) { + final Optional accountId = DatabaseManager.idAccessor().nextAccountId(); + if (accountId.isEmpty()) { + return false; + } + if (getAccountByUsername(username).isPresent()) { + return false; + } + final ResultSet insertResult = getSession().execute( + insertInto(getKeyspace(), AccountTable.getTableName()) + .value(AccountTable.ACCOUNT_ID, literal(accountId.get())) + .value(AccountTable.USERNAME, literal(lowerUsername(username))) + .value(AccountTable.PASSWORD, literal(hashPassword(password))) + .value(AccountTable.CHARACTER_SLOTS, literal(ServerConfig.CHARACTER_BASE_SLOTS)) + .value(AccountTable.NX_CREDIT, literal(0)) + .value(AccountTable.NX_PREPAID, literal(0)) + .value(AccountTable.MAPLE_POINT, literal(0)) + .value(AccountTable.TRUNK_ITEMS, literal(List.of())) + .value(AccountTable.TRUNK_SIZE, literal(ServerConfig.TRUNK_BASE_SLOTS)) + .value(AccountTable.TRUNK_MONEY, literal(0)) + .value(AccountTable.LOCKER_ITEMS, literal(List.of())) + .value(AccountTable.WISHLIST, literal(List.of())) + .ifNotExists() + .build() + ); + return insertResult.wasApplied(); + } + + @Override + public boolean saveAccount(Account account) { + final CodecRegistry registry = getSession().getContext().getCodecRegistry(); + final ResultSet updateResult = getSession().execute( + update(getKeyspace(), AccountTable.getTableName()) + .setColumn(AccountTable.CHARACTER_SLOTS, literal(account.getSlotCount())) + .setColumn(AccountTable.NX_CREDIT, literal(account.getNxCredit())) + .setColumn(AccountTable.NX_PREPAID, literal(account.getNxPrepaid())) + .setColumn(AccountTable.MAPLE_POINT, literal(account.getMaplePoint())) + .setColumn(AccountTable.TRUNK_ITEMS, literal(account.getTrunk().getItems(), registry)) + .setColumn(AccountTable.TRUNK_SIZE, literal(account.getTrunk().getSize())) + .setColumn(AccountTable.TRUNK_MONEY, literal(account.getTrunk().getMoney())) + .setColumn(AccountTable.LOCKER_ITEMS, literal(account.getLocker().getCashItems(), registry)) + .setColumn(AccountTable.WISHLIST, literal(account.getWishlist())) + .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) + .build() + ); + return updateResult.wasApplied(); + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java new file mode 100644 index 00000000..cb27c8be --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -0,0 +1,347 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry; +import kinoko.database.CharacterAccessor; +import kinoko.database.CharacterInfo; +import kinoko.database.cassandra.table.CharacterTable; +import kinoko.server.rank.CharacterRank; +import kinoko.world.item.Inventory; +import kinoko.world.item.InventoryManager; +import kinoko.world.job.JobConstants; +import kinoko.world.quest.QuestManager; +import kinoko.world.quest.QuestRecord; +import kinoko.world.skill.SkillManager; +import kinoko.world.skill.SkillRecord; +import kinoko.world.user.AvatarData; +import kinoko.world.user.CharacterData; +import kinoko.world.user.data.*; +import kinoko.world.user.stat.CharacterStat; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; + +public final class CassandraCharacterAccessor extends CassandraAccessor implements CharacterAccessor { + + public CassandraCharacterAccessor(CqlSession session, String keyspace) { + super(session, keyspace); + } + + private CharacterData loadCharacterData(Row row) { + final int accountId = row.getInt(CharacterTable.ACCOUNT_ID); + + final CharacterData cd = new CharacterData(accountId); + + final CharacterStat cs = row.get(CharacterTable.CHARACTER_STAT, CharacterStat.class); + cs.setId(row.getInt(CharacterTable.CHARACTER_ID)); + cs.setName(row.getString(CharacterTable.CHARACTER_NAME)); + cd.setCharacterStat(cs); + + final InventoryManager im = new InventoryManager(); + im.setEquipped(row.get(CharacterTable.CHARACTER_EQUIPPED, Inventory.class)); + im.setEquipInventory(row.get(CharacterTable.EQUIP_INVENTORY, Inventory.class)); + im.setConsumeInventory(row.get(CharacterTable.CONSUME_INVENTORY, Inventory.class)); + im.setInstallInventory(row.get(CharacterTable.INSTALL_INVENTORY, Inventory.class)); + im.setEtcInventory(row.get(CharacterTable.ETC_INVENTORY, Inventory.class)); + im.setCashInventory(row.get(CharacterTable.CASH_INVENTORY, Inventory.class)); + im.setMoney(row.getInt(CharacterTable.MONEY)); + im.setExtSlotExpire(row.getInstant(CharacterTable.EXT_SLOT_EXPIRE)); + cd.setInventoryManager(im); + + final SkillManager sm = new SkillManager(); + final Map skillCooltimes = row.getMap(CharacterTable.SKILL_COOLTIMES, Integer.class, Instant.class); + if (skillCooltimes != null) { + sm.getSkillCooltimes().putAll(skillCooltimes); + } + final List skillRecords = row.getList(CharacterTable.SKILL_RECORDS, SkillRecord.class); + if (skillRecords != null) { + for (SkillRecord sr : skillRecords) { + sm.addSkill(sr); + } + } + cd.setSkillManager(sm); + + final QuestManager qm = new QuestManager(); + final List questRecords = row.getList(CharacterTable.QUEST_RECORDS, QuestRecord.class); + if (questRecords != null) { + for (QuestRecord qr : questRecords) { + qm.addQuestRecord(qr); + } + } + cd.setQuestManager(qm); + + final ConfigManager cm = row.get(CharacterTable.CONFIG, ConfigManager.class); + cd.setConfigManager(cm); + + final PopularityRecord pr = new PopularityRecord(); + final Map popularityRecords = row.getMap(CharacterTable.POPULARITY_RECORD, Integer.class, Instant.class); + if (popularityRecords != null) { + pr.getRecords().putAll(popularityRecords); + } + cd.setPopularityRecord(pr); + + final MiniGameRecord mgr = row.get(CharacterTable.MINIGAME_RECORD, MiniGameRecord.class); + cd.setMiniGameRecord(mgr); + + final CoupleRecord cr = CoupleRecord.from(im.getEquipped(), im.getEquipInventory()); + cd.setCoupleRecord(cr); + + final MapTransferInfo mti = row.get(CharacterTable.MAP_TRANSFER_INFO, MapTransferInfo.class); + cd.setMapTransferInfo(mti); + + final WildHunterInfo whi = row.get(CharacterTable.WILD_HUNTER_INFO, WildHunterInfo.class); + cd.setWildHunterInfo(whi); + + cd.setItemSnCounter(new AtomicInteger(row.getInt(CharacterTable.ITEM_SN_COUNTER))); + cd.setFriendMax(row.getInt(CharacterTable.FRIEND_MAX)); + cd.setPartyId(row.getInt(CharacterTable.PARTY_ID)); + cd.setGuildId(row.getInt(CharacterTable.GUILD_ID)); + cd.setCreationTime(row.getInstant(CharacterTable.CREATION_TIME)); + cd.setMaxLevelTime(row.getInstant(CharacterTable.MAX_LEVEL_TIME)); + return cd; + } + + @Override + public boolean checkCharacterNameAvailable(String name) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), CharacterTable.getTableName()).all() + .whereColumn(CharacterTable.CHARACTER_NAME_INDEX).isEqualTo(literal(lowerName(name))) + .build() + ); + for (Row row : selectResult) { + final String existingName = row.getString(CharacterTable.CHARACTER_NAME); + if (existingName != null && existingName.equalsIgnoreCase(name)) { + return false; + } + } + return true; + } + + @Override + public Optional getCharacterById(int characterId) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), CharacterTable.getTableName()).all() + .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterId)) + .build() + ); + for (Row row : selectResult) { + return Optional.of(loadCharacterData(row)); + } + return Optional.empty(); + } + + @Override + public Optional getCharacterByName(String name) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), CharacterTable.getTableName()).all() + .whereColumn(CharacterTable.CHARACTER_NAME_INDEX).isEqualTo(literal(lowerName(name))) + .build() + ); + for (Row row : selectResult) { + return Optional.of(loadCharacterData(row)); + } + return Optional.empty(); + } + + @Override + public Optional getCharacterInfoByName(String name) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), CharacterTable.getTableName()) + .columns( + CharacterTable.ACCOUNT_ID, + CharacterTable.CHARACTER_ID, + CharacterTable.CHARACTER_NAME + ) + .whereColumn(CharacterTable.CHARACTER_NAME_INDEX).isEqualTo(literal(lowerName(name))) + .build() + .setExecutionProfileName(CassandraConnector.PROFILE_ONE) + ); + for (Row row : selectResult) { + return Optional.of(new CharacterInfo( + row.getInt(CharacterTable.ACCOUNT_ID), + row.getInt(CharacterTable.CHARACTER_ID), + row.getString(CharacterTable.CHARACTER_NAME) + )); + } + return Optional.empty(); + } + + @Override + public Optional getAccountIdByCharacterId(int characterId) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), CharacterTable.getTableName()) + .columns( + CharacterTable.ACCOUNT_ID + ) + .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterId)) + .build() + .setExecutionProfileName(CassandraConnector.PROFILE_ONE) + ); + for (Row row : selectResult) { + return Optional.of(row.getInt(CharacterTable.ACCOUNT_ID)); + } + return Optional.empty(); + } + + @Override + public List getAvatarDataByAccountId(int accountId) { + final List avatarDataList = new ArrayList<>(); + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), CharacterTable.getTableName()) + .columns( + CharacterTable.CHARACTER_ID, + CharacterTable.CHARACTER_NAME, + CharacterTable.CHARACTER_STAT, + CharacterTable.CHARACTER_EQUIPPED + ) + .whereColumn(CharacterTable.ACCOUNT_ID).isEqualTo(literal(accountId)) + .build() + ); + for (Row row : selectResult) { + final CharacterStat characterStat = row.get(CharacterTable.CHARACTER_STAT, CharacterStat.class); + characterStat.setId(row.getInt(CharacterTable.CHARACTER_ID)); + characterStat.setName(row.getString(CharacterTable.CHARACTER_NAME)); + final Inventory equipped = row.get(CharacterTable.CHARACTER_EQUIPPED, Inventory.class); + avatarDataList.add(AvatarData.from(characterStat, equipped)); + } + return avatarDataList; + } + + @Override + public synchronized boolean newCharacter(CharacterData characterData) { + if (!checkCharacterNameAvailable(characterData.getCharacterName())) { + return false; + } + return saveCharacter(characterData); + } + + @Override + public boolean saveCharacter(CharacterData characterData) { + final CodecRegistry registry = getSession().getContext().getCodecRegistry(); + final ResultSet updateResult = getSession().execute( + update(getKeyspace(), CharacterTable.getTableName()) + .setColumn(CharacterTable.ACCOUNT_ID, literal(characterData.getAccountId())) + .setColumn(CharacterTable.CHARACTER_NAME, literal(characterData.getCharacterName())) + .setColumn(CharacterTable.CHARACTER_NAME_INDEX, literal(lowerName(characterData.getCharacterName()))) + .setColumn(CharacterTable.CHARACTER_STAT, literal(characterData.getCharacterStat(), registry)) + .setColumn(CharacterTable.CHARACTER_EQUIPPED, literal(characterData.getInventoryManager().getEquipped(), registry)) + .setColumn(CharacterTable.EQUIP_INVENTORY, literal(characterData.getInventoryManager().getEquipInventory(), registry)) + .setColumn(CharacterTable.CONSUME_INVENTORY, literal(characterData.getInventoryManager().getConsumeInventory(), registry)) + .setColumn(CharacterTable.INSTALL_INVENTORY, literal(characterData.getInventoryManager().getInstallInventory(), registry)) + .setColumn(CharacterTable.ETC_INVENTORY, literal(characterData.getInventoryManager().getEtcInventory(), registry)) + .setColumn(CharacterTable.CASH_INVENTORY, literal(characterData.getInventoryManager().getCashInventory(), registry)) + .setColumn(CharacterTable.MONEY, literal(characterData.getInventoryManager().getMoney())) + .setColumn(CharacterTable.EXT_SLOT_EXPIRE, literal(characterData.getInventoryManager().getExtSlotExpire())) + .setColumn(CharacterTable.SKILL_COOLTIMES, literal(characterData.getSkillManager().getSkillCooltimes())) + .setColumn(CharacterTable.SKILL_RECORDS, literal(characterData.getSkillManager().getSkillRecords(), registry)) + .setColumn(CharacterTable.QUEST_RECORDS, literal(characterData.getQuestManager().getQuestRecords(), registry)) + .setColumn(CharacterTable.CONFIG, literal(characterData.getConfigManager(), registry)) + .setColumn(CharacterTable.POPULARITY_RECORD, literal(characterData.getPopularityRecord().getRecords(), registry)) + .setColumn(CharacterTable.MINIGAME_RECORD, literal(characterData.getMiniGameRecord(), registry)) + .setColumn(CharacterTable.MAP_TRANSFER_INFO, literal(characterData.getMapTransferInfo(), registry)) + .setColumn(CharacterTable.WILD_HUNTER_INFO, literal(characterData.getWildHunterInfo(), registry)) + .setColumn(CharacterTable.ITEM_SN_COUNTER, literal(characterData.getItemSnCounter().get())) + .setColumn(CharacterTable.FRIEND_MAX, literal(characterData.getFriendMax())) + .setColumn(CharacterTable.PARTY_ID, literal(characterData.getPartyId())) + .setColumn(CharacterTable.GUILD_ID, literal(characterData.getGuildId())) + .setColumn(CharacterTable.CREATION_TIME, literal(characterData.getCreationTime())) + .setColumn(CharacterTable.MAX_LEVEL_TIME, literal(characterData.getMaxLevelTime())) + .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterData.getCharacterId())) + .build() + ); + return updateResult.wasApplied(); + } + + @Override + public boolean deleteCharacter(int accountId, int characterId) { + final ResultSet updateResult = getSession().execute( + deleteFrom(getKeyspace(), CharacterTable.getTableName()) + .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterId)) + .ifColumn(CharacterTable.ACCOUNT_ID).isEqualTo(literal(accountId)) + .build() + ); + return updateResult.wasApplied(); + } + + @Override + public Map getCharacterRanks() { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), CharacterTable.getTableName()) + .columns( + CharacterTable.CHARACTER_ID, + CharacterTable.CHARACTER_STAT, + CharacterTable.MAX_LEVEL_TIME + ) + .build() + .setExecutionProfileName(CassandraConnector.PROFILE_ONE) + ); + final List rankDataList = new ArrayList<>(); + for (Row row : selectResult) { + final int characterId = row.getInt(CharacterTable.CHARACTER_ID); + final CharacterStat characterStat = row.get(CharacterTable.CHARACTER_STAT, CharacterStat.class); + final Instant maxLevelTime = row.getInstant(CharacterTable.MAX_LEVEL_TIME); + final int jobId = characterStat.getJob(); + if (JobConstants.isAdminJob(jobId) || JobConstants.isManagerJob(jobId)) { + continue; + } + rankDataList.add(new CharacterRankData( + characterId, + JobConstants.getJobCategory(jobId), + characterStat.getCumulativeExp(), + maxLevelTime + )); + } + // Sort and process rank data + rankDataList.sort(Comparator.comparing(CharacterRankData::getCumulativeExp).reversed().thenComparing(CharacterRankData::getMaxLevelTime)); + final Map jobRanks = new HashMap<>(); // job rank counter + final Map characterRanks = new HashMap<>(); // character id -> character rank + for (CharacterRankData rankData : rankDataList) { + final int characterId = rankData.getCharacterId(); + final int jobCategory = rankData.getJobCategory(); + final int worldRank = characterRanks.size() + 1; + final int jobRank = jobRanks.getOrDefault(jobCategory, 0) + 1; + jobRanks.put(jobCategory, jobRank); + characterRanks.put(characterId, new CharacterRank( + characterId, + worldRank, + jobRank + )); + } + return characterRanks; + } + + private static class CharacterRankData { + private final int characterId; + private final int jobCategory; + private final long cumulativeExp; + private final Instant maxLevelTime; + + private CharacterRankData(int characterId, int jobCategory, long cumulativeExp, Instant maxLevelTime) { + this.characterId = characterId; + this.jobCategory = jobCategory; + this.cumulativeExp = cumulativeExp; + this.maxLevelTime = maxLevelTime; + } + + public int getCharacterId() { + return characterId; + } + + public int getJobCategory() { + return jobCategory; + } + + public long getCumulativeExp() { + return cumulativeExp; + } + + public Instant getMaxLevelTime() { + return maxLevelTime != null ? maxLevelTime : Instant.MAX; + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java new file mode 100644 index 00000000..8df86137 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -0,0 +1,4 @@ +package kinoko.database.postgresql; + +public class PostgresConnector { +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java new file mode 100644 index 00000000..36b39a47 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java @@ -0,0 +1,84 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.querybuilder.insert.Insert; +import kinoko.database.FriendAccessor; +import kinoko.database.cassandra.table.FriendTable; +import kinoko.world.user.friend.Friend; +import kinoko.world.user.friend.FriendStatus; + +import java.util.ArrayList; +import java.util.List; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; + +public final class CassandraFriendAccessor extends CassandraAccessor implements FriendAccessor { + public CassandraFriendAccessor(CqlSession session, String keyspace) { + super(session, keyspace); + } + + private Friend loadFriend(Row row) { + final int characterId = row.getInt(FriendTable.CHARACTER_ID); + final int friendId = row.getInt(FriendTable.FRIEND_ID); + final String friendName = row.getString(FriendTable.FRIEND_NAME); + final String friendGroup = row.getString(FriendTable.FRIEND_GROUP); + final FriendStatus status = FriendStatus.getByValue(row.getInt(FriendTable.FRIEND_STATUS)); + return new Friend(characterId, friendId, friendName, friendGroup, status); + } + + @Override + public List getFriendsByCharacterId(int characterId) { + final List friends = new ArrayList<>(); + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), FriendTable.getTableName()).all() + .whereColumn(FriendTable.CHARACTER_ID).isEqualTo(literal(characterId)) + .build() + ); + for (Row row : selectResult) { + friends.add(loadFriend(row)); + } + return friends; + } + + @Override + public List getFriendsByFriendId(int friendId) { + final List friends = new ArrayList<>(); + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), FriendTable.getTableName()).all() + .whereColumn(FriendTable.FRIEND_ID).isEqualTo(literal(friendId)) + .build() + ); + for (Row row : selectResult) { + friends.add(loadFriend(row)); + } + return friends; + } + + @Override + public boolean saveFriend(Friend friend, boolean force) { + Insert insert = insertInto(getKeyspace(), FriendTable.getTableName()) + .value(FriendTable.CHARACTER_ID, literal(friend.getCharacterId())) + .value(FriendTable.FRIEND_ID, literal(friend.getFriendId())) + .value(FriendTable.FRIEND_NAME, literal(friend.getFriendName())) + .value(FriendTable.FRIEND_GROUP, literal(friend.getFriendGroup())) + .value(FriendTable.FRIEND_STATUS, literal(friend.getStatus().getValue())); + if (!force) { + insert = insert.ifNotExists(); + } + final ResultSet insertResult = getSession().execute(insert.build()); + return insertResult.wasApplied(); + } + + @Override + public boolean deleteFriend(int characterId, int friendId) { + final ResultSet deleteResult = getSession().execute( + deleteFrom(getKeyspace(), FriendTable.getTableName()) + .whereColumn(FriendTable.CHARACTER_ID).isEqualTo(literal(characterId)) + .whereColumn(FriendTable.FRIEND_ID).isEqualTo(literal(friendId)) + .build() + ); + return deleteResult.wasApplied(); + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java new file mode 100644 index 00000000..cea13ac1 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java @@ -0,0 +1,86 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import kinoko.database.GiftAccessor; +import kinoko.database.cassandra.table.GiftTable; +import kinoko.server.cashshop.Gift; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; + +public final class CassandraGiftAccessor extends CassandraAccessor implements GiftAccessor { + public CassandraGiftAccessor(CqlSession session, String keyspace) { + super(session, keyspace); + } + + private Gift loadGift(Row row) { + return new Gift( + row.getLong(GiftTable.GIFT_SN), + row.getInt(GiftTable.ITEM_ID), + row.getInt(GiftTable.COMMODITY_ID), + row.getInt(GiftTable.SENDER_ID), + row.getString(GiftTable.SENDER_NAME), + row.getString(GiftTable.SENDER_MESSAGE), + row.getLong(GiftTable.PAIR_ITEM_SN) + ); + } + + @Override + public List getGiftsByCharacterId(int characterId) { + final List gifts = new ArrayList<>(); + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), GiftTable.getTableName()).all() + .whereColumn(GiftTable.RECEIVER_ID).isEqualTo(literal(characterId)) + .build() + ); + for (Row row : selectResult) { + gifts.add(loadGift(row)); + } + return gifts; + } + + @Override + public Optional getGiftByItemSn(long itemSn) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), GiftTable.getTableName()).all() + .whereColumn(GiftTable.GIFT_SN).isEqualTo(literal(itemSn)) + .build() + ); + for (Row row : selectResult) { + return Optional.of(loadGift(row)); + } + return Optional.empty(); + } + + @Override + public boolean newGift(Gift gift, int receiverId) { + final ResultSet insertResult = getSession().execute( + insertInto(getKeyspace(), GiftTable.getTableName()) + .value(GiftTable.GIFT_SN, literal(gift.getGiftSn())) + .value(GiftTable.RECEIVER_ID, literal(receiverId)) + .value(GiftTable.ITEM_ID, literal(gift.getItemId())) + .value(GiftTable.COMMODITY_ID, literal(gift.getCommodityId())) + .value(GiftTable.SENDER_NAME, literal(gift.getSenderName())) + .value(GiftTable.SENDER_MESSAGE, literal(gift.getSenderMessage())) + .value(GiftTable.PAIR_ITEM_SN, literal(gift.getPairItemSn())) + .ifNotExists() + .build() + ); + return insertResult.wasApplied(); + } + + @Override + public boolean deleteGift(Gift gift) { + final ResultSet updateResult = getSession().execute( + deleteFrom(getKeyspace(), GiftTable.getTableName()) + .whereColumn(GiftTable.GIFT_SN).isEqualTo(literal(gift.getGiftSn())) + .build() + ); + return updateResult.wasApplied(); + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java new file mode 100644 index 00000000..f8fd07a0 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -0,0 +1,161 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry; +import kinoko.database.GuildAccessor; +import kinoko.database.cassandra.table.GuildTable; +import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildBoardEntry; +import kinoko.server.guild.GuildMember; +import kinoko.server.guild.GuildRanking; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; + +public final class CassandraGuildAccessor extends CassandraAccessor implements GuildAccessor { + public CassandraGuildAccessor(CqlSession session, String keyspace) { + super(session, keyspace); + } + + private Guild loadGuild(Row row) { + final int guildId = row.getInt(GuildTable.GUILD_ID); + final String guildName = row.getString(GuildTable.GUILD_NAME); + final Guild guild = new Guild(guildId, guildName); + final List gradeNames = row.getList(GuildTable.GRADE_NAMES, String.class); + if (gradeNames != null) { + guild.setGradeNames(gradeNames); + } + final List members = row.getList(GuildTable.MEMBERS, GuildMember.class); + if (members != null) { + for (GuildMember member : members) { + guild.addMember(member); + } + } + guild.setMemberMax(row.getInt(GuildTable.MEMBER_MAX)); + guild.setMarkBg(row.getShort(GuildTable.MARK_BG)); + guild.setMarkBgColor(row.getByte(GuildTable.MARK_BG_COLOR)); + guild.setMark(row.getShort(GuildTable.MARK)); + guild.setMarkColor(row.getByte(GuildTable.MARK_COLOR)); + guild.setNotice(row.getString(GuildTable.NOTICE)); + guild.setPoints(row.getInt(GuildTable.POINTS)); + guild.setLevel(row.getByte(GuildTable.LEVEL)); + final List boardEntries = row.getList(GuildTable.BOARD_ENTRY_LIST, GuildBoardEntry.class); + if (boardEntries != null) { + guild.getBoardEntries().addAll(boardEntries); + } + guild.setBoardNoticeEntry(row.get(GuildTable.BOARD_ENTRY_NOTICE, GuildBoardEntry.class)); + guild.setBoardEntryCounter(new AtomicInteger(row.getInt(GuildTable.BOARD_ENTRY_COUNTER))); + return guild; + } + + @Override + public Optional getGuildById(int guildId) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), GuildTable.getTableName()).all() + .whereColumn(GuildTable.GUILD_ID).isEqualTo(literal(guildId)) + .build() + ); + for (Row row : selectResult) { + return Optional.of(loadGuild(row)); + } + return Optional.empty(); + } + + @Override + public boolean checkGuildNameAvailable(String name) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), GuildTable.getTableName()).all() + .whereColumn(GuildTable.GUILD_NAME_INDEX).isEqualTo(literal(lowerName(name))) + .build() + ); + for (Row row : selectResult) { + final String existingName = row.getString(GuildTable.GUILD_NAME_INDEX); + if (existingName != null && existingName.equalsIgnoreCase(name)) { + return false; + } + } + return true; + } + + @Override + public synchronized boolean newGuild(Guild guild) { + if (!checkGuildNameAvailable(guild.getGuildName())) { + return false; + } + return saveGuild(guild); + } + + @Override + public boolean saveGuild(Guild guild) { + final CodecRegistry registry = getSession().getContext().getCodecRegistry(); + final ResultSet updateResult = getSession().execute( + update(getKeyspace(), GuildTable.getTableName()) + .setColumn(GuildTable.GUILD_NAME, literal(guild.getGuildName())) + .setColumn(GuildTable.GUILD_NAME_INDEX, literal(lowerName(guild.getGuildName()))) + .setColumn(GuildTable.GRADE_NAMES, literal(guild.getGradeNames())) + .setColumn(GuildTable.MEMBERS, literal(guild.getGuildMembers(), registry)) + .setColumn(GuildTable.MEMBER_MAX, literal(guild.getMemberMax())) + .setColumn(GuildTable.MARK_BG, literal(guild.getMarkBg())) + .setColumn(GuildTable.MARK_BG_COLOR, literal(guild.getMarkBgColor())) + .setColumn(GuildTable.MARK, literal(guild.getMark())) + .setColumn(GuildTable.MARK_COLOR, literal(guild.getMarkColor())) + .setColumn(GuildTable.NOTICE, literal(guild.getNotice())) + .setColumn(GuildTable.POINTS, literal(guild.getPoints())) + .setColumn(GuildTable.LEVEL, literal(guild.getLevel())) + .setColumn(GuildTable.BOARD_ENTRY_LIST, literal(guild.getBoardEntries(), registry)) + .setColumn(GuildTable.BOARD_ENTRY_NOTICE, literal(guild.getBoardNoticeEntry(), registry)) + .setColumn(GuildTable.BOARD_ENTRY_COUNTER, literal(guild.getBoardEntryCounter().get())) + .whereColumn(GuildTable.GUILD_ID).isEqualTo(literal(guild.getGuildId())) + .build() + ); + return updateResult.wasApplied(); + } + + @Override + public boolean deleteGuild(int guildId) { + final ResultSet updateResult = getSession().execute( + deleteFrom(getKeyspace(), GuildTable.getTableName()) + .whereColumn(GuildTable.GUILD_ID).isEqualTo(literal(guildId)) + .build() + ); + return updateResult.wasApplied(); + } + + @Override + public List getGuildRankings() { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), GuildTable.getTableName()) + .columns( + GuildTable.GUILD_NAME, + GuildTable.POINTS, + GuildTable.MARK, + GuildTable.MARK_COLOR, + GuildTable.MARK_BG, + GuildTable.MARK_BG_COLOR + ) + .build() + .setExecutionProfileName(CassandraConnector.PROFILE_ONE) + ); + final List guildRankings = new ArrayList<>(); + for (Row row : selectResult) { + guildRankings.add(new GuildRanking( + row.getString(GuildTable.GUILD_NAME), + row.getInt(GuildTable.POINTS), + row.getShort(GuildTable.MARK), + row.getByte(GuildTable.MARK_COLOR), + row.getShort(GuildTable.MARK_BG), + row.getByte(GuildTable.MARK_BG_COLOR) + )); + } + return guildRankings.stream() + .sorted(Comparator.comparing(GuildRanking::getPoints).reversed()) + .toList(); + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java new file mode 100644 index 00000000..50b9249d --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java @@ -0,0 +1,67 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import kinoko.database.IdAccessor; +import kinoko.database.cassandra.table.IdTable; + +import java.util.Optional; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; + +public final class CassandraIdAccessor extends CassandraAccessor implements IdAccessor { + public CassandraIdAccessor(CqlSession session, String keyspace) { + super(session, keyspace); + } + + private Optional getNextId(String type) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), IdTable.getTableName()).all() + .whereColumn(IdTable.ID_TYPE).isEqualTo(literal(type)) + .build() + ); + for (Row selectRow : selectResult) { + final int nextId = selectRow.getInt(IdTable.NEXT_ID); + final ResultSet updateResult = getSession().execute( + update(getKeyspace(), IdTable.getTableName()) + .setColumn(IdTable.NEXT_ID, literal(nextId + 1)) // increment ID + .whereColumn(IdTable.ID_TYPE).isEqualTo(literal(type)) + .ifColumn(IdTable.NEXT_ID).isEqualTo(literal(nextId)) // if not already updated + .build() + ); + if (updateResult.wasApplied()) { + return Optional.of(nextId); + } else { + // retry + return getNextId(type); + } + } + return Optional.empty(); + } + + @Override + public synchronized Optional nextAccountId() { + return getNextId(IdTable.ACCOUNT_ID); + } + + @Override + public synchronized Optional nextCharacterId() { + return getNextId(IdTable.CHARACTER_ID); + } + + @Override + public synchronized Optional nextPartyId() { + return getNextId(IdTable.PARTY_ID); + } + + @Override + public synchronized Optional nextGuildId() { + return getNextId(IdTable.GUILD_ID); + } + + @Override + public synchronized Optional nextMemoId() { + return getNextId(IdTable.MEMO_ID); + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java new file mode 100644 index 00000000..67699745 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java @@ -0,0 +1,94 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import kinoko.database.MemoAccessor; +import kinoko.database.cassandra.table.MemoTable; +import kinoko.server.memo.Memo; +import kinoko.server.memo.MemoType; + +import java.util.ArrayList; +import java.util.List; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; + +public final class CassandraMemoAccessor extends CassandraAccessor implements MemoAccessor { + public CassandraMemoAccessor(CqlSession session, String keyspace) { + super(session, keyspace); + } + + @Override + public List getMemosByCharacterId(int characterId) { + final List memos = new ArrayList<>(); + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), MemoTable.getTableName()) + .columns( + MemoTable.MEMO_ID, + MemoTable.MEMO_TYPE, + MemoTable.MEMO_CONTENT, + MemoTable.SENDER_NAME, + MemoTable.DATE_SENT + ) + .whereColumn(MemoTable.RECEIVER_ID).isEqualTo(literal(characterId)) + .build() + ); + for (Row row : selectResult) { + final MemoType type = MemoType.getByValue(row.getInt(MemoTable.MEMO_TYPE)); + final Memo memo = new Memo( + type != null ? type : MemoType.DEFAULT, + row.getInt(MemoTable.MEMO_ID), + row.getString(MemoTable.SENDER_NAME), + row.getString(MemoTable.MEMO_CONTENT), + row.getInstant(MemoTable.DATE_SENT) + ); + memos.add(memo); + } + return memos; + } + + @Override + public boolean hasMemo(int characterId) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), MemoTable.getTableName()) + .columns( + MemoTable.RECEIVER_ID + ) + .whereColumn(MemoTable.RECEIVER_ID).isEqualTo(literal(characterId)) + .build() + ); + for (Row row : selectResult) { + final int receiverId = row.getInt(MemoTable.RECEIVER_ID); + if (receiverId == characterId) { + return true; + } + } + return false; + } + + @Override + public boolean newMemo(Memo memo, int receiverId) { + final ResultSet updateResult = getSession().execute( + insertInto(getKeyspace(), MemoTable.getTableName()) + .value(MemoTable.MEMO_ID, literal(memo.getMemoId())) + .value(MemoTable.RECEIVER_ID, literal(receiverId)) + .value(MemoTable.MEMO_TYPE, literal(memo.getType().getValue())) + .value(MemoTable.MEMO_CONTENT, literal(memo.getContent())) + .value(MemoTable.SENDER_NAME, literal(memo.getSender())) + .value(MemoTable.DATE_SENT, literal(memo.getDateSent())) + .ifNotExists() + .build() + ); + return updateResult.wasApplied(); + } + + @Override + public boolean deleteMemo(int memoId, int receiverId) { + final ResultSet updateResult = getSession().execute( + deleteFrom(getKeyspace(), MemoTable.getTableName()) + .whereColumn(MemoTable.MEMO_ID).isEqualTo(literal(memoId)) + .build() + ); + return updateResult.wasApplied(); + } +} diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql new file mode 100644 index 00000000..e69de29b From b0eed30f6dc08c58b1bf3c88638fe69423b2d881 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 15:12:24 -0400 Subject: [PATCH 04/83] Added support for PSQL --- .../java/kinoko/database/DatabaseManager.java | 29 +- .../cassandra/CassandraConnector.java | 8 +- .../database/postgresql/PostgresAccessor.java | 30 +- .../postgresql/PostgresAccountAccessor.java | 398 +++-- .../postgresql/PostgresCharacterAccessor.java | 1480 ++++++++++++++--- .../postgresql/PostgresConnector.java | 80 +- .../postgresql/PostgresFriendAccessor.java | 115 +- .../postgresql/PostgresGiftAccessor.java | 114 +- .../postgresql/PostgresGuildAccessor.java | 422 +++-- .../postgresql/PostgresIdAccessor.java | 52 +- .../postgresql/PostgresMemoAccessor.java | 120 +- .../kinoko/database/postgresql/setup/init.sql | 488 ++++++ .../kinoko/handler/stage/LoginHandler.java | 19 +- .../java/kinoko/handler/user/UserHandler.java | 4 +- src/main/java/kinoko/server/ServerConfig.java | 6 +- .../java/kinoko/server/ServerConstants.java | 19 +- .../kinoko/server/command/AdminCommands.java | 2 +- .../server/command/CommandProcessor.java | 4 +- src/main/java/kinoko/util/Util.java | 32 +- src/main/java/kinoko/world/GameConstants.java | 1 + .../java/kinoko/world/item/EquipData.java | 42 + .../java/kinoko/world/item/Inventory.java | 4 + .../kinoko/world/item/InventoryManager.java | 11 + src/main/java/kinoko/world/item/Item.java | 31 + src/main/java/kinoko/world/item/PetData.java | 19 + src/main/java/kinoko/world/item/RingData.java | 11 + .../java/kinoko/world/quest/QuestRecord.java | 8 + .../java/kinoko/world/skill/SkillRecord.java | 6 + .../java/kinoko/world/user/CharacterData.java | 11 +- .../kinoko/world/user/data/ConfigManager.java | 12 + .../kinoko/world/user/stat/CharacterStat.java | 40 + 31 files changed, 2913 insertions(+), 705 deletions(-) diff --git a/src/main/java/kinoko/database/DatabaseManager.java b/src/main/java/kinoko/database/DatabaseManager.java index 2808fd09..e2b3d19a 100644 --- a/src/main/java/kinoko/database/DatabaseManager.java +++ b/src/main/java/kinoko/database/DatabaseManager.java @@ -1,8 +1,15 @@ package kinoko.database; +import kinoko.database.postgresql.PostgresConnector; import kinoko.database.cassandra.CassandraConnector; +import kinoko.server.ServerConfig; +import kinoko.server.ServerConstants; +import kinoko.world.GameConstants; + +import java.util.Objects; public final class DatabaseManager { + private static DatabaseConnector connector; public static IdAccessor idAccessor() { @@ -33,8 +40,28 @@ public static MemoAccessor memoAccessor() { return connector.getMemoAccessor(); } + + public static boolean isRelational() { + // Get whether the database connection is a relational database. + if (connector != null){ + return connector instanceof PostgresConnector; + } + return false; + }; + + public static void initialize() { - connector = new CassandraConnector(); + // Prod Environment + if (Objects.equals(ServerConstants.DATABASE_HOST, "cassandra_kinoko")) { + connector = new CassandraConnector(); + } + else if (Objects.equals(ServerConstants.DATABASE_HOST, "postgres_kinoko")){ + connector = new PostgresConnector(); + } + else { // Your choice, likely in a dev environment. +// connector = new CassandraConnector(); + connector = new PostgresConnector(); + } connector.initialize(); } diff --git a/src/main/java/kinoko/database/cassandra/CassandraConnector.java b/src/main/java/kinoko/database/cassandra/CassandraConnector.java index 44ae4a0e..676c665e 100644 --- a/src/main/java/kinoko/database/cassandra/CassandraConnector.java +++ b/src/main/java/kinoko/database/cassandra/CassandraConnector.java @@ -17,6 +17,8 @@ import kinoko.database.cassandra.codec.*; import kinoko.database.cassandra.table.*; import kinoko.database.cassandra.type.*; +import kinoko.server.Server; +import kinoko.server.ServerConfig; import kinoko.server.ServerConstants; import kinoko.server.cashshop.CashItemInfo; import kinoko.server.guild.GuildBoardComment; @@ -38,9 +40,9 @@ public final class CassandraConnector implements DatabaseConnector { public static final InetSocketAddress DATABASE_ADDRESS = new InetSocketAddress(ServerConstants.DATABASE_HOST, ServerConstants.DATABASE_PORT); - public static final String DATABASE_DATACENTER = "datacenter1"; - public static final String DATABASE_KEYSPACE = "kinoko"; - public static final String PROFILE_ONE = "profile_one"; + public static final String DATABASE_DATACENTER = ServerConstants.DATABASE_DATACENTER; + public static final String DATABASE_KEYSPACE = ServerConstants.DATABASE_NAME; + public static final String PROFILE_ONE = ServerConstants.DATABASE_PROFILE; private CqlSession cqlSession; private IdAccessor idAccessor; private AccountAccessor accountAccessor; diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java index 54142e49..8afa32b6 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java @@ -1,24 +1,28 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; +import com.zaxxer.hikari.HikariDataSource; -public abstract class CassandraAccessor { - private final CqlSession session; - private final String keyspace; +import java.sql.Connection; +import java.sql.SQLException; - public CassandraAccessor(CqlSession session, String keyspace) { - this.session = session; - this.keyspace = keyspace; - } +public abstract class PostgresAccessor { + private final HikariDataSource dataSource; - public final CqlSession getSession() { - return session; + public PostgresAccessor(HikariDataSource dataSource) { + this.dataSource = dataSource; } - public final String getKeyspace() { - return keyspace; + /** + * Get a connection from the pool for a single operation. + * Use try-with-resources when calling this! + */ + protected final Connection getConnection() throws SQLException { + return dataSource.getConnection(); } + /** + * Helper to lowercase strings (like usernames) + */ protected final String lowerName(String name) { return name.toLowerCase(); } diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java index 32efdb43..820b0147 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java @@ -1,12 +1,8 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; -import com.datastax.oss.driver.api.core.cql.ResultSet; -import com.datastax.oss.driver.api.core.cql.Row; -import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry; +import com.zaxxer.hikari.HikariDataSource; import kinoko.database.AccountAccessor; import kinoko.database.DatabaseManager; -import kinoko.database.cassandra.table.AccountTable; import kinoko.server.ServerConfig; import kinoko.server.cashshop.CashItemInfo; import kinoko.world.item.Item; @@ -15,52 +11,123 @@ import kinoko.world.user.Locker; import org.mindrot.jbcrypt.BCrypt; +import java.sql.*; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Optional; -import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; +public final class PostgresAccountAccessor extends PostgresAccessor implements AccountAccessor { -public final class CassandraAccountAccessor extends CassandraAccessor implements AccountAccessor { - public CassandraAccountAccessor(CqlSession session, String keyspace) { - super(session, keyspace); + public PostgresAccountAccessor(HikariDataSource dataSource) { + super(dataSource); } - private Account loadAccount(Row row) { - final int accountId = row.getInt(AccountTable.ACCOUNT_ID); - final String username = row.getString(AccountTable.USERNAME); - final String secondaryPassword = row.getString(AccountTable.SECONDARY_PASSWORD); + private Account loadAccount(ResultSet rs) throws SQLException { + final int accountId = rs.getInt("id"); + final String username = rs.getString("username"); + final String secondaryPassword = rs.getString("secondary_password"); final Account account = new Account(accountId, username); account.setHasSecondaryPassword(secondaryPassword != null && !secondaryPassword.isEmpty()); - account.setSlotCount(row.getInt(AccountTable.CHARACTER_SLOTS)); - account.setNxCredit(row.getInt(AccountTable.NX_CREDIT)); - account.setNxPrepaid(row.getInt(AccountTable.NX_PREPAID)); - account.setMaplePoint(row.getInt(AccountTable.MAPLE_POINT)); + account.setSlotCount(rs.getInt("character_slots")); + account.setNxCredit(rs.getInt("nx_credit")); + account.setNxPrepaid(rs.getInt("nx_prepaid")); + account.setMaplePoint(rs.getInt("maple_point")); - final Trunk trunk = new Trunk(row.getInt(AccountTable.TRUNK_SIZE)); - final List items = row.getList(AccountTable.TRUNK_ITEMS, Item.class); - if (items != null) { - for (Item item : items) { - trunk.getItems().add(item); + account.setTrunk(loadTrunk(accountId)); + account.setLocker(loadLocker(accountId)); + account.setWishlist(loadWishlist(accountId)); + + return account; + } + + private Trunk loadTrunk(int accountId) throws SQLException { + int trunkSize = ServerConfig.TRUNK_BASE_SLOTS; + int trunkMoney = 0; + + String accountSql = "SELECT trunk_size, trunk_money FROM account.accounts WHERE id = ?"; + String itemsSql = """ + SELECT ti.slot, i.item_id, i.quantity + FROM account.trunk_item ti + JOIN item.items i ON ti.item_sn = i.item_sn + WHERE ti.account_id = ? + ORDER BY ti.slot + """; + + Trunk trunk; + + try (Connection conn = getConnection()) { + // Query trunk info + try (PreparedStatement stmt = conn.prepareStatement(accountSql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + trunkSize = rs.getInt("trunk_size"); + trunkMoney = rs.getInt("trunk_money"); + } + } } - } - trunk.setMoney(row.getInt(AccountTable.TRUNK_MONEY)); - account.setTrunk(trunk); - final Locker locker = new Locker(); - final List cashItems = row.getList(AccountTable.LOCKER_ITEMS, CashItemInfo.class); - if (cashItems != null) { - for (CashItemInfo cii : cashItems) { - locker.addCashItem(cii); + // Initialize trunk with proper size + trunk = new Trunk(trunkSize); + trunk.setMoney(trunkMoney); + + // Query items + try (PreparedStatement stmt = conn.prepareStatement(itemsSql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + Item item = new Item(rs.getInt("item_id"), (short) rs.getInt("quantity")); + trunk.getItems().add(item); + } + } } } - account.setLocker(locker); - final List wishlist = row.getList(AccountTable.WISHLIST, Integer.class); - account.setWishlist(Collections.unmodifiableList(wishlist != null ? wishlist : Collections.nCopies(10, 0))); + return trunk; + } - return account; + + private Locker loadLocker(int accountId) throws SQLException { + Locker locker = new Locker(); + String sql = "SELECT li.slot, li.item_sn, li.commodity_id, i.item_id, i.quantity " + + "FROM account.locker_item li " + + "JOIN item.items i ON li.item_sn = i.item_sn " + + "WHERE li.account_id = ? ORDER BY li.slot"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + Item item = new Item(rs.getInt("item_id"), (short) rs.getInt("quantity")); + CashItemInfo info = new CashItemInfo( + item, + rs.getInt("commodity_id"), + accountId, // account owner + -1, // character owner unknown at this point + null + ); + locker.addCashItem(info); + } + } + } + return locker; + } + + private List loadWishlist(int accountId) throws SQLException { + List wishlist = new ArrayList<>(); + String sql = "SELECT w.item_id FROM account.wishlist w " + + "WHERE w.account_id = ? ORDER BY w.slot"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + wishlist.add(rs.getInt("item_id")); + } + } + } + while (wishlist.size() < 10) wishlist.add(0); + return Collections.unmodifiableList(wishlist); } private String lowerUsername(String username) { @@ -77,122 +144,215 @@ private boolean checkHashedPassword(String password, String hashedPassword) { @Override public Optional getAccountById(int accountId) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), AccountTable.getTableName()).all() - .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(accountId)) - .build() - ); - for (Row row : selectResult) { - return Optional.of(loadAccount(row)); + String sql = "SELECT * FROM account.accounts WHERE id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(loadAccount(rs)); + } + } + } catch (SQLException e) { + e.printStackTrace(); } return Optional.empty(); } @Override public Optional getAccountByUsername(String username) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), AccountTable.getTableName()).all() - .whereColumn(AccountTable.USERNAME).isEqualTo(literal(lowerUsername(username))) - .build() - ); - for (Row row : selectResult) { - return Optional.of(loadAccount(row)); + String sql = "SELECT * FROM account.accounts WHERE username = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setString(1, lowerUsername(username)); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(loadAccount(rs)); + } + } + } catch (SQLException e) { + e.printStackTrace(); } return Optional.empty(); } @Override public boolean checkPassword(Account account, String password, boolean secondary) { - final String columnName = secondary ? AccountTable.SECONDARY_PASSWORD : AccountTable.PASSWORD; - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), AccountTable.getTableName()).all() - .column(columnName) - .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) - .build() - .setExecutionProfileName(CassandraConnector.PROFILE_ONE) - ); - for (Row row : selectResult) { - final String hashedPassword = row.getString(columnName); - if (hashedPassword == null) { - continue; - } - if (checkHashedPassword(password, hashedPassword)) { - return true; + String column = secondary ? "secondary_password" : "password"; + String sql = "SELECT " + column + " FROM account.accounts WHERE id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, account.getId()); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + String hashed = rs.getString(column); + return hashed != null && checkHashedPassword(password, hashed); + } } + } catch (SQLException e) { + e.printStackTrace(); } return false; } @Override public boolean savePassword(Account account, String oldPassword, String newPassword, boolean secondary) { - final String columnName = secondary ? AccountTable.SECONDARY_PASSWORD : AccountTable.PASSWORD; - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), AccountTable.getTableName()).all() - .column(columnName) - .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) - .build() - ); - for (Row row : selectResult) { - final String hashedOldPassword = row.getString(columnName); - if (hashedOldPassword == null || checkHashedPassword(oldPassword, hashedOldPassword)) { - final ResultSet updateResult = getSession().execute( - update(getKeyspace(), AccountTable.getTableName()) - .setColumn(columnName, literal(hashPassword(newPassword))) - .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) - .build() - ); - return updateResult.wasApplied(); + String column = secondary ? "secondary_password" : "password"; + String sqlSelect = "SELECT " + column + " FROM account.accounts WHERE id = ?"; + String sqlUpdate = "UPDATE account.accounts SET " + column + " = ? WHERE id = ?"; + try (PreparedStatement selectStmt = getConnection().prepareStatement(sqlSelect)) { + selectStmt.setInt(1, account.getId()); + try (ResultSet rs = selectStmt.executeQuery()) { + if (rs.next()) { + String hashedOld = rs.getString(column); + if (hashedOld == null || checkHashedPassword(oldPassword, hashedOld)) { + try (PreparedStatement updateStmt = getConnection().prepareStatement(sqlUpdate)) { + updateStmt.setString(1, hashPassword(newPassword)); + updateStmt.setInt(2, account.getId()); + return updateStmt.executeUpdate() > 0; + } + } + } } + } catch (SQLException e) { + e.printStackTrace(); } return false; } @Override public synchronized boolean newAccount(String username, String password) { - final Optional accountId = DatabaseManager.idAccessor().nextAccountId(); - if (accountId.isEmpty()) { + Optional accountIdOpt = DatabaseManager.idAccessor().nextAccountId(); // should be -1 + if (accountIdOpt.isEmpty() || getAccountByUsername(username).isPresent()) { return false; } - if (getAccountByUsername(username).isPresent()) { - return false; + int accountId; + + String sql = "INSERT INTO account.accounts (username, password, character_slots, nx_credit, nx_prepaid, maple_point, trunk_size, trunk_money) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + + "RETURNING ID"; + System.out.println("Creating account."); + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setString(1, lowerUsername(username)); + stmt.setString(2, hashPassword(password)); + stmt.setInt(3, ServerConfig.CHARACTER_BASE_SLOTS); + stmt.setInt(4, 0); + stmt.setInt(5, 0); + stmt.setInt(6, 0); + stmt.setInt(7, ServerConfig.TRUNK_BASE_SLOTS); + stmt.setInt(8, 0); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + accountId = rs.getInt("id"); + } else { + throw new SQLException("Failed to retrieve account ID after insert."); + } + } + + // Initialize a base trunk, locker, wishlist +// for (int i = 0; i < ServerConfig.TRUNK_BASE_SLOTS; i++) { +// try (PreparedStatement tStmt = getConnection().prepareStatement( +// "INSERT INTO account.trunk_item (account_id, slot, item_sn) VALUES (?, ?, ?)")) { +// tStmt.setInt(1, accountId); +// tStmt.setInt(2, i); +// tStmt.setLong(3, itemSn); +// tStmt.executeUpdate(); +// } +// } + +// for (int i = 0; i < 10; i++) { +// try (PreparedStatement wStmt = getConnection().prepareStatement( +// "INSERT INTO account.wishlist (account_id, slot, item_sn) VALUES (?, ?, ?)")) { +// wStmt.setInt(1, accountId); +// wStmt.setInt(2, i); +// wStmt.setLong(3, itemSn); +// wStmt.executeUpdate(); +// } +// } + + // Locker starts empty, no rows needed initially + return true; + } catch (SQLException e) { + e.printStackTrace(); } - final ResultSet insertResult = getSession().execute( - insertInto(getKeyspace(), AccountTable.getTableName()) - .value(AccountTable.ACCOUNT_ID, literal(accountId.get())) - .value(AccountTable.USERNAME, literal(lowerUsername(username))) - .value(AccountTable.PASSWORD, literal(hashPassword(password))) - .value(AccountTable.CHARACTER_SLOTS, literal(ServerConfig.CHARACTER_BASE_SLOTS)) - .value(AccountTable.NX_CREDIT, literal(0)) - .value(AccountTable.NX_PREPAID, literal(0)) - .value(AccountTable.MAPLE_POINT, literal(0)) - .value(AccountTable.TRUNK_ITEMS, literal(List.of())) - .value(AccountTable.TRUNK_SIZE, literal(ServerConfig.TRUNK_BASE_SLOTS)) - .value(AccountTable.TRUNK_MONEY, literal(0)) - .value(AccountTable.LOCKER_ITEMS, literal(List.of())) - .value(AccountTable.WISHLIST, literal(List.of())) - .ifNotExists() - .build() - ); - return insertResult.wasApplied(); + return false; } @Override public boolean saveAccount(Account account) { - final CodecRegistry registry = getSession().getContext().getCodecRegistry(); - final ResultSet updateResult = getSession().execute( - update(getKeyspace(), AccountTable.getTableName()) - .setColumn(AccountTable.CHARACTER_SLOTS, literal(account.getSlotCount())) - .setColumn(AccountTable.NX_CREDIT, literal(account.getNxCredit())) - .setColumn(AccountTable.NX_PREPAID, literal(account.getNxPrepaid())) - .setColumn(AccountTable.MAPLE_POINT, literal(account.getMaplePoint())) - .setColumn(AccountTable.TRUNK_ITEMS, literal(account.getTrunk().getItems(), registry)) - .setColumn(AccountTable.TRUNK_SIZE, literal(account.getTrunk().getSize())) - .setColumn(AccountTable.TRUNK_MONEY, literal(account.getTrunk().getMoney())) - .setColumn(AccountTable.LOCKER_ITEMS, literal(account.getLocker().getCashItems(), registry)) - .setColumn(AccountTable.WISHLIST, literal(account.getWishlist())) - .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) - .build() - ); - return updateResult.wasApplied(); + try (Connection conn = getConnection()) { + conn.setAutoCommit(false); + + try (PreparedStatement stmt = conn.prepareStatement( + "UPDATE account.accounts SET character_slots = ?, nx_credit = ?, nx_prepaid = ?, maple_point = ?, trunk_size = ?, trunk_money = ? WHERE id = ?")) { + stmt.setInt(1, account.getSlotCount()); + stmt.setInt(2, account.getNxCredit()); + stmt.setInt(3, account.getNxPrepaid()); + stmt.setInt(4, account.getMaplePoint()); + stmt.setInt(5, account.getTrunk().getSize()); + stmt.setInt(6, account.getTrunk().getMoney()); + stmt.setInt(7, account.getId()); + stmt.executeUpdate(); + } + + // Save trunk + try (PreparedStatement delTrunk = conn.prepareStatement("DELETE FROM account.trunk_item WHERE account_id = ?")) { + delTrunk.setInt(1, account.getId()); + delTrunk.executeUpdate(); + } + int slot = 0; + for (Item item : account.getTrunk().getItems()) { + try (PreparedStatement insertTrunk = conn.prepareStatement( + "INSERT INTO account.trunk_item (account_id, slot, item_sn) VALUES (?, ?, ?)")) { + insertTrunk.setInt(1, account.getId()); + insertTrunk.setInt(2, slot++); + insertTrunk.setObject(3, item.getItemSn()); // null if empty + insertTrunk.executeUpdate(); + } + } + + // Save wishlist + try (PreparedStatement delWish = conn.prepareStatement("DELETE FROM account.wishlist WHERE account_id = ?")) { + delWish.setInt(1, account.getId()); + delWish.executeUpdate(); + } + slot = 0; + for (Integer itemId : account.getWishlist()) { + try (PreparedStatement insertWish = conn.prepareStatement( + """ + INSERT INTO account.wishlist (account_id, slot, item_id) + VALUES (?, ?, ?) + ON CONFLICT (account_id, slot) DO UPDATE + SET item_id = EXCLUDED.item_id + """ + )) { + insertWish.setInt(1, account.getId()); + insertWish.setInt(2, slot++); + insertWish.setInt(3, itemId); // now using item_id, not item_sn + insertWish.executeUpdate(); + } + } + + // Save locker + try (PreparedStatement delLocker = conn.prepareStatement("DELETE FROM account.locker_item WHERE account_id = ?")) { + delLocker.setInt(1, account.getId()); + delLocker.executeUpdate(); + } + slot = 0; + for (CashItemInfo cash : account.getLocker().getCashItems()) { + try (PreparedStatement insertLocker = conn.prepareStatement( + "INSERT INTO account.locker_item (account_id, slot, item_sn, commodity_id) VALUES (?, ?, ?, ?)")) { + insertLocker.setInt(1, account.getId()); + insertLocker.setInt(2, slot++); + insertLocker.setObject(3, cash.getItem().getItemSn()); + insertLocker.setInt(4, cash.getCommodityId()); + insertLocker.executeUpdate(); + } + } + + conn.commit(); + conn.setAutoCommit(true); + return true; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java index cb27c8be..ebc73e64 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -1,320 +1,1339 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; -import com.datastax.oss.driver.api.core.cql.ResultSet; -import com.datastax.oss.driver.api.core.cql.Row; -import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry; +import com.zaxxer.hikari.HikariDataSource; import kinoko.database.CharacterAccessor; import kinoko.database.CharacterInfo; -import kinoko.database.cassandra.table.CharacterTable; import kinoko.server.rank.CharacterRank; -import kinoko.world.item.Inventory; -import kinoko.world.item.InventoryManager; +import kinoko.world.GameConstants; +import kinoko.world.item.*; import kinoko.world.job.JobConstants; import kinoko.world.quest.QuestManager; import kinoko.world.quest.QuestRecord; +import kinoko.world.quest.QuestState; import kinoko.world.skill.SkillManager; import kinoko.world.skill.SkillRecord; import kinoko.world.user.AvatarData; import kinoko.world.user.CharacterData; import kinoko.world.user.data.*; import kinoko.world.user.stat.CharacterStat; +import org.postgresql.util.PGobject; +import java.io.*; +import java.sql.*; import java.time.Instant; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; -import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; +public final class PostgresCharacterAccessor implements CharacterAccessor { + private final HikariDataSource dataSource; -public final class CassandraCharacterAccessor extends CassandraAccessor implements CharacterAccessor { - - public CassandraCharacterAccessor(CqlSession session, String keyspace) { - super(session, keyspace); + public PostgresCharacterAccessor(HikariDataSource dataSource) { + this.dataSource = dataSource; } - private CharacterData loadCharacterData(Row row) { - final int accountId = row.getInt(CharacterTable.ACCOUNT_ID); + private byte[] serialize(Object obj) throws IOException { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(obj); + return baos.toByteArray(); + } + } - final CharacterData cd = new CharacterData(accountId); + private T deserialize(byte[] bytes, Class clazz) throws IOException, ClassNotFoundException { + try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes); + ObjectInputStream ois = new ObjectInputStream(bais)) { + return clazz.cast(ois.readObject()); + } + } - final CharacterStat cs = row.get(CharacterTable.CHARACTER_STAT, CharacterStat.class); - cs.setId(row.getInt(CharacterTable.CHARACTER_ID)); - cs.setName(row.getString(CharacterTable.CHARACTER_NAME)); + private CharacterData loadCharacterData(ResultSet rs) throws SQLException, IOException, ClassNotFoundException { + int accountId = rs.getInt("account_id"); + CharacterData cd = new CharacterData(accountId); + int characterID = rs.getInt("id"); + CharacterStat cs = new CharacterStat( + characterID, + rs.getString("name"), + rs.getByte("gender"), + rs.getByte("skin"), + rs.getInt("face"), + rs.getInt("hair"), + rs.getShort("level"), + rs.getShort("job"), + rs.getShort("sub_job"), + rs.getShort("base_str"), + rs.getShort("base_dex"), + rs.getShort("base_int"), + rs.getShort("base_luk"), + rs.getInt("hp"), + rs.getInt("max_hp"), + rs.getInt("mp"), + rs.getInt("max_mp"), + rs.getShort("ap"), + rs.getInt("exp"), + rs.getShort("pop"), + rs.getInt("pos_map"), + rs.getByte("portal"), + rs.getLong("pet_1"), + rs.getLong("pet_2"), + rs.getLong("pet_3") + ); cd.setCharacterStat(cs); - final InventoryManager im = new InventoryManager(); - im.setEquipped(row.get(CharacterTable.CHARACTER_EQUIPPED, Inventory.class)); - im.setEquipInventory(row.get(CharacterTable.EQUIP_INVENTORY, Inventory.class)); - im.setConsumeInventory(row.get(CharacterTable.CONSUME_INVENTORY, Inventory.class)); - im.setInstallInventory(row.get(CharacterTable.INSTALL_INVENTORY, Inventory.class)); - im.setEtcInventory(row.get(CharacterTable.ETC_INVENTORY, Inventory.class)); - im.setCashInventory(row.get(CharacterTable.CASH_INVENTORY, Inventory.class)); - im.setMoney(row.getInt(CharacterTable.MONEY)); - im.setExtSlotExpire(row.getInstant(CharacterTable.EXT_SLOT_EXPIRE)); + InventoryManager im = loadInventory(characterID); cd.setInventoryManager(im); + im.setMoney(rs.getInt("money")); + + + Timestamp extSlotExpireTs = rs.getTimestamp("ext_slot_expire"); + im.setExtSlotExpire(extSlotExpireTs != null ? extSlotExpireTs.toInstant() : null); + cd.setInventoryManager(im); + + SkillManager sm = loadSkillCooltimesAndRecords(characterID); - final SkillManager sm = new SkillManager(); - final Map skillCooltimes = row.getMap(CharacterTable.SKILL_COOLTIMES, Integer.class, Instant.class); - if (skillCooltimes != null) { - sm.getSkillCooltimes().putAll(skillCooltimes); - } - final List skillRecords = row.getList(CharacterTable.SKILL_RECORDS, SkillRecord.class); - if (skillRecords != null) { - for (SkillRecord sr : skillRecords) { - sm.addSkill(sr); - } - } cd.setSkillManager(sm); - final QuestManager qm = new QuestManager(); - final List questRecords = row.getList(CharacterTable.QUEST_RECORDS, QuestRecord.class); - if (questRecords != null) { - for (QuestRecord qr : questRecords) { - qm.addQuestRecord(qr); - } - } + QuestManager qm = loadQuestRecords(characterID); + cd.setQuestManager(qm); - final ConfigManager cm = row.get(CharacterTable.CONFIG, ConfigManager.class); + ConfigManager cm = loadConfig(characterID); cd.setConfigManager(cm); - final PopularityRecord pr = new PopularityRecord(); - final Map popularityRecords = row.getMap(CharacterTable.POPULARITY_RECORD, Integer.class, Instant.class); - if (popularityRecords != null) { - pr.getRecords().putAll(popularityRecords); - } + PopularityRecord pr = loadPopularityRecord(characterID); cd.setPopularityRecord(pr); - final MiniGameRecord mgr = row.get(CharacterTable.MINIGAME_RECORD, MiniGameRecord.class); + MiniGameRecord mgr = loadMiniGameRecord(characterID); cd.setMiniGameRecord(mgr); - final CoupleRecord cr = CoupleRecord.from(im.getEquipped(), im.getEquipInventory()); - cd.setCoupleRecord(cr); - final MapTransferInfo mti = row.get(CharacterTable.MAP_TRANSFER_INFO, MapTransferInfo.class); - cd.setMapTransferInfo(mti); + cd.setCoupleRecord(CoupleRecord.from( + im.getEquipped(), im.getEquipInventory() + )); - final WildHunterInfo whi = row.get(CharacterTable.WILD_HUNTER_INFO, WildHunterInfo.class); + MapTransferInfo mto = loadMapTransferInfo(characterID); + cd.setMapTransferInfo(mto); + + WildHunterInfo whi = loadWildHunterInfo(characterID); cd.setWildHunterInfo(whi); - cd.setItemSnCounter(new AtomicInteger(row.getInt(CharacterTable.ITEM_SN_COUNTER))); - cd.setFriendMax(row.getInt(CharacterTable.FRIEND_MAX)); - cd.setPartyId(row.getInt(CharacterTable.PARTY_ID)); - cd.setGuildId(row.getInt(CharacterTable.GUILD_ID)); - cd.setCreationTime(row.getInstant(CharacterTable.CREATION_TIME)); - cd.setMaxLevelTime(row.getInstant(CharacterTable.MAX_LEVEL_TIME)); + cd.setItemSnCounter(new AtomicInteger(-1)); // Let Postgres handle item sn + + cd.setFriendMax(rs.getInt("friend_max")); + cd.setPartyId(rs.getInt("party_id")); + cd.setGuildId(rs.getInt("guild_id")); + + Timestamp creationTs = rs.getTimestamp("creation_time"); + cd.setCreationTime(creationTs != null ? creationTs.toInstant() : null); + Timestamp maxLevelTs = rs.getTimestamp("max_level_time"); + cd.setMaxLevelTime(maxLevelTs != null ? maxLevelTs.toInstant() : null); return cd; } + private WildHunterInfo loadWildHunterInfo(int characterId) throws SQLException { + WildHunterInfo wh = new WildHunterInfo(); + + String sqlRiding = "SELECT riding_type FROM player.wild_hunter WHERE character_id = ?"; + String sqlMobs = "SELECT mob_id FROM player.wild_hunter_mob WHERE character_id = ?"; + + try (Connection conn = dataSource.getConnection()) { + // Load riding_type + try (PreparedStatement stmt = conn.prepareStatement(sqlRiding)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + wh.setRidingType(rs.getInt("riding_type")); + } + } + } + + // Load captured mobs + try (PreparedStatement stmt = conn.prepareStatement(sqlMobs)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + wh.getCapturedMobs().add(rs.getInt("mob_id")); + if (wh.getCapturedMobs().size() >= 5) break; // enforce max 5 + } + } + } + } + + return wh; + } + + + private MapTransferInfo loadMapTransferInfo(int characterId) throws SQLException { + MapTransferInfo mti = new MapTransferInfo(); + + // Query the new table for this character + String sql = "SELECT map_id, old_map_id FROM player.map_transfer WHERE character_id = ?"; + try (PreparedStatement stmt = dataSource.getConnection().prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet mapRs = stmt.executeQuery()) { + if (mapRs.next()) { + int mapId = mapRs.getInt("map_id"); + int oldMapId = mapRs.getInt("old_map_id"); + + mti.getMapTransfer().add(mapId); // main list + mti.getMapTransferEx().add(oldMapId); // legacy/old map + } + } + } + + return mti; + } + + private MiniGameRecord loadMiniGameRecord(int characterId) throws SQLException { + MiniGameRecord record = new MiniGameRecord(); + + String sql = """ + SELECT omok_wins, omok_ties, omok_losses, omok_score, + memory_wins, memory_ties, memory_losses, memory_score + FROM player.minigame + WHERE character_id = ? + """; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)){ + stmt.setInt(1, characterId); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + record.setOmokGameWins(rs.getInt("omok_wins")); + record.setOmokGameTies(rs.getInt("omok_ties")); + record.setOmokGameLosses(rs.getInt("omok_losses")); + record.setOmokGameScore(rs.getDouble("omok_score")); + + record.setMemoryGameWins(rs.getInt("memory_wins")); + record.setMemoryGameTies(rs.getInt("memory_ties")); + record.setMemoryGameLosses(rs.getInt("memory_losses")); + record.setMemoryGameScore(rs.getDouble("memory_score")); + } + } + } + + return record; + } + + + private PopularityRecord loadPopularityRecord(int characterId) throws SQLException { + PopularityRecord pr = new PopularityRecord(); + + String sql = "SELECT other_character_id, timestamp FROM player.popularity WHERE character_id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int otherCharId = rs.getInt("other_character_id"); + Timestamp ts = rs.getTimestamp("timestamp"); + if (ts != null) { + pr.getRecords().put(otherCharId, ts.toInstant()); + } + } + } + } + + return pr; + } + + + private ConfigManager loadConfig(int characterId) throws SQLException { + String sql = """ + SELECT pet_consume_item, pet_consume_mp_item, pet_exception_list, + func_key_types, func_key_ids, quickslot_key_map + FROM player.config + WHERE character_id = ? + """; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + return ConfigManager.defaults(); + } + + int petConsumeItem = rs.getInt("pet_consume_item"); + int petConsumeMpItem = rs.getInt("pet_consume_mp_item"); + + // --- Pet exception list --- + List petExceptionList; + var petExArr = rs.getArray("pet_exception_list"); + if (petExArr != null) { + Integer[] arr = (Integer[]) petExArr.getArray(); + petExceptionList = Arrays.asList(arr); + } else { + petExceptionList = List.of(); + } + + // --- Function key map --- + FuncKeyMapped[] funcKeyMap = new FuncKeyMapped[GameConstants.FUNC_KEY_MAP_SIZE]; + var funcTypeArr = rs.getArray("func_key_types"); + var funcIdArr = rs.getArray("func_key_ids"); + + if (funcTypeArr != null && funcIdArr != null) { + Short[] typeValues = (Short[]) funcTypeArr.getArray(); + Integer[] idValues = (Integer[]) funcIdArr.getArray(); + + for (int i = 0; i < funcKeyMap.length; i++) { + FuncKeyType type = FuncKeyType.getByValue(typeValues[i].byteValue()); + int id = idValues[i]; + funcKeyMap[i] = FuncKeyMapped.of(type, id); + } + } else { + funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); + } + + // --- Quickslot key map --- + int[] quickslotKeyMap; + var quickArr = rs.getArray("quickslot_key_map"); + if (quickArr != null) { + Integer[] arr = (Integer[]) quickArr.getArray(); + quickslotKeyMap = Arrays.stream(arr).mapToInt(Integer::intValue).toArray(); + } else { + quickslotKeyMap = Arrays.copyOf(GameConstants.DEFAULT_QUICKSLOT_KEY_MAP, GameConstants.QUICKSLOT_KEY_MAP_SIZE); + } + + return new ConfigManager(petConsumeItem, petConsumeMpItem, petExceptionList, funcKeyMap, quickslotKeyMap); + } + } + } + + + private QuestManager loadQuestRecords(int characterId) throws SQLException { + QuestManager qm = new QuestManager(); + String sql = "SELECT quest_id, status, progress, completed_time FROM player.quest_record WHERE character_id = ?"; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int questId = rs.getInt("quest_id"); + int statusInt = rs.getInt("status"); + QuestState state = QuestState.getByValue(statusInt); // map int -> QuestState + String value = rs.getString("progress"); + Timestamp completedTs = rs.getTimestamp("completed_time"); + Instant completedTime = completedTs != null ? completedTs.toInstant() : null; + QuestRecord record = new QuestRecord(questId, state, value, completedTime); + qm.addQuestRecord(record); + } + } + } + + return qm; + } + + + private SkillManager loadSkillCooltimesAndRecords(int characterId) throws SQLException { + SkillManager sm = new SkillManager(); + + // Load skill cooldowns + String cooldownSql = "SELECT skill_id, cooldown_end FROM player.skill_cooltime WHERE character_id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(cooldownSql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int skillId = rs.getInt("skill_id"); + Timestamp cooldownEnd = rs.getTimestamp("cooldown_end"); + if (cooldownEnd != null) { + sm.getSkillCooltimes().put(skillId, cooldownEnd.toInstant()); + } + } + } + } + + // Load skill records + String recordSql = "SELECT skill_id, level, master_level FROM player.skill_record WHERE character_id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(recordSql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int skillId = rs.getInt("skill_id"); + int level = rs.getInt("level"); + int masterLevel = rs.getInt("master_level"); + SkillRecord record = new SkillRecord(skillId, level, masterLevel); + sm.addSkill(record); + } + } + } + + return sm; + } + + + + private String lowerName(String name) { + return name.toLowerCase(); + } + @Override public boolean checkCharacterNameAvailable(String name) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), CharacterTable.getTableName()).all() - .whereColumn(CharacterTable.CHARACTER_NAME_INDEX).isEqualTo(literal(lowerName(name))) - .build() - ); - for (Row row : selectResult) { - final String existingName = row.getString(CharacterTable.CHARACTER_NAME); - if (existingName != null && existingName.equalsIgnoreCase(name)) { - return false; + String sql = "SELECT COUNT(*) > 0 AS exists FROM player.characters WHERE name ILIKE ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, name); // original name + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + boolean exists = rs.getBoolean("exists"); + return !exists; // available if it does NOT exist + } } + } catch (SQLException e) { + e.printStackTrace(); } - return true; + return false; } + private InventoryManager loadInventory(int characterId) throws SQLException { + InventoryManager im = new InventoryManager(); + + String sql = """ + SELECT inv.inventory_type, inv.slot, fi.* + FROM player.inventory inv + JOIN item.full_item fi ON inv.item_sn = fi.item_sn + WHERE inv.character_id = ? + ORDER BY inv.inventory_type, inv.slot + """; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + ResultSet rs = stmt.executeQuery(); + + while (rs.next()) { + long itemSn = rs.getLong("item_sn"); + int slot = rs.getInt("slot"); + int itemId = rs.getInt("item_id"); + short quantity = rs.getShort("quantity"); + short attribute = rs.getShort("attribute"); + String title = rs.getString("title"); + Timestamp dateExpireTs = rs.getTimestamp("date_expire"); + + // EquipData + EquipData equipData; // declare once + if (rs.getObject("inc_str") != null) { + equipData = new EquipData( + rs.getShort("inc_str"), + rs.getShort("inc_dex"), + rs.getShort("inc_int"), + rs.getShort("inc_luk"), + rs.getShort("inc_max_hp"), + rs.getShort("inc_max_mp"), + rs.getShort("inc_pad"), + rs.getShort("inc_mad"), + rs.getShort("inc_pdd"), + rs.getShort("inc_mdd"), + rs.getShort("inc_acc"), + rs.getShort("inc_eva"), + rs.getShort("inc_craft"), + rs.getShort("inc_speed"), + rs.getShort("inc_jump"), + rs.getByte("ruc"), + rs.getByte("cuc"), + rs.getInt("iuc"), + rs.getByte("chuc"), + rs.getByte("grade"), + rs.getShort("option_1"), + rs.getShort("option_2"), + rs.getShort("option_3"), + rs.getShort("socket_1"), + rs.getShort("socket_2"), + rs.getByte("level_up_type"), + rs.getByte("level"), + rs.getInt("exp"), + rs.getInt("durability") + ); + } + else{ + equipData = new EquipData(); + } + + // PetData + PetData petData = null; + if (rs.getObject("pet_name") != null) { + petData = new PetData( + rs.getString("pet_name"), + rs.getByte("pet_level"), + rs.getByte("fullness"), + rs.getShort("tameness"), + rs.getShort("pet_skill"), + rs.getShort("pet_attribute"), + rs.getInt("remain_life") + ); + } + + // RingData + RingData ringData = null; + if (rs.getObject("pair_character_id") != null) { + ringData = new RingData( + rs.getInt("pair_character_id"), + rs.getString("pair_character_name"), + rs.getLong("pair_item_sn") + ); + } + + Item item = new Item( + itemId, + quantity, + itemSn, + false, // cash flag + attribute, + title, + dateExpireTs != null ? dateExpireTs.toInstant() : null, + equipData, + petData, + ringData + ); + + String invType = rs.getString("inventory_type"); + switch (invType.toUpperCase()) { + case "EQUIPPED" -> im.getEquipped().addItem(slot, item); + case "EQUIP" -> im.getEquipInventory().addItem(slot, item); + case "CONSUME" -> im.getConsumeInventory().addItem(slot, item); + case "INSTALL" -> im.getInstallInventory().addItem(slot, item); + case "ETC" -> im.getEtcInventory().addItem(slot, item); + case "CASH" -> im.getCashInventory().addItem(slot, item); + default -> throw new IllegalArgumentException("Unknown inventory type: " + invType); + } + } + } + + return im; + } + + @Override public Optional getCharacterById(int characterId) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), CharacterTable.getTableName()).all() - .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterId)) - .build() - ); - for (Row row : selectResult) { - return Optional.of(loadCharacterData(row)); + String sql = """ + SELECT c.*, s.* + FROM player.characters c + LEFT JOIN player.stats s ON c.id = s.character_id + WHERE c.id = ? + """; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + ResultSet rs = stmt.executeQuery(); + if (rs.next()) { + return Optional.of(loadCharacterData(rs)); + } + } catch (Exception e) { + e.printStackTrace(); } return Optional.empty(); } @Override public Optional getCharacterByName(String name) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), CharacterTable.getTableName()).all() - .whereColumn(CharacterTable.CHARACTER_NAME_INDEX).isEqualTo(literal(lowerName(name))) - .build() - ); - for (Row row : selectResult) { - return Optional.of(loadCharacterData(row)); + String sql = "SELECT * FROM player.characters WHERE name ILIKE ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, lowerName(name)); + ResultSet rs = stmt.executeQuery(); + if (rs.next()) { + return Optional.of(loadCharacterData(rs)); + } + } catch (Exception e) { + e.printStackTrace(); } return Optional.empty(); } @Override public Optional getCharacterInfoByName(String name) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), CharacterTable.getTableName()) - .columns( - CharacterTable.ACCOUNT_ID, - CharacterTable.CHARACTER_ID, - CharacterTable.CHARACTER_NAME - ) - .whereColumn(CharacterTable.CHARACTER_NAME_INDEX).isEqualTo(literal(lowerName(name))) - .build() - .setExecutionProfileName(CassandraConnector.PROFILE_ONE) - ); - for (Row row : selectResult) { - return Optional.of(new CharacterInfo( - row.getInt(CharacterTable.ACCOUNT_ID), - row.getInt(CharacterTable.CHARACTER_ID), - row.getString(CharacterTable.CHARACTER_NAME) - )); + String sql = "SELECT account_id, id, name FROM player.characters WHERE name ILIKE ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, lowerName(name)); + ResultSet rs = stmt.executeQuery(); + if (rs.next()) { + return Optional.of(new CharacterInfo( + rs.getInt("account_id"), + rs.getInt("id"), + rs.getString("name") + )); + } + } catch (SQLException e) { + e.printStackTrace(); } return Optional.empty(); } @Override public Optional getAccountIdByCharacterId(int characterId) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), CharacterTable.getTableName()) - .columns( - CharacterTable.ACCOUNT_ID - ) - .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterId)) - .build() - .setExecutionProfileName(CassandraConnector.PROFILE_ONE) - ); - for (Row row : selectResult) { - return Optional.of(row.getInt(CharacterTable.ACCOUNT_ID)); + String sql = "SELECT account_id FROM player.characters WHERE id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + ResultSet rs = stmt.executeQuery(); + if (rs.next()) return Optional.of(rs.getInt("account_id")); + } catch (SQLException e) { + e.printStackTrace(); } return Optional.empty(); } @Override public List getAvatarDataByAccountId(int accountId) { - final List avatarDataList = new ArrayList<>(); - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), CharacterTable.getTableName()) - .columns( - CharacterTable.CHARACTER_ID, - CharacterTable.CHARACTER_NAME, - CharacterTable.CHARACTER_STAT, - CharacterTable.CHARACTER_EQUIPPED - ) - .whereColumn(CharacterTable.ACCOUNT_ID).isEqualTo(literal(accountId)) - .build() - ); - for (Row row : selectResult) { - final CharacterStat characterStat = row.get(CharacterTable.CHARACTER_STAT, CharacterStat.class); - characterStat.setId(row.getInt(CharacterTable.CHARACTER_ID)); - characterStat.setName(row.getString(CharacterTable.CHARACTER_NAME)); - final Inventory equipped = row.get(CharacterTable.CHARACTER_EQUIPPED, Inventory.class); - avatarDataList.add(AvatarData.from(characterStat, equipped)); + List list = new ArrayList<>(); + String sql = """ + SELECT c.id AS character_id, + c.name AS character_name, + c.money, + s.gender, + s.skin, + s.face, + s.hair, + s.level, + s.job, + s.sub_job, + s.base_str, + s.base_dex, + s.base_int, + s.base_luk, + s.hp, + s.max_hp, + s.mp, + s.max_mp, + s.ap, + s.exp, + s.pop, + s.pos_map, + s.portal, + s.pet_1, + s.pet_2, + s.pet_3 + FROM player.characters c + JOIN player.stats s ON c.id = s.character_id + WHERE c.account_id = ? + """; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + CharacterStat cs = new CharacterStat( + rs.getInt("character_id"), + rs.getString("character_name"), + rs.getByte("gender"), + rs.getByte("skin"), + rs.getInt("face"), + rs.getInt("hair"), + rs.getShort("level"), + rs.getShort("job"), + rs.getShort("sub_job"), + rs.getShort("base_str"), + rs.getShort("base_dex"), + rs.getShort("base_int"), + rs.getShort("base_luk"), + rs.getInt("hp"), + rs.getInt("max_hp"), + rs.getInt("mp"), + rs.getInt("max_mp"), + rs.getShort("ap"), + rs.getInt("exp"), + rs.getShort("pop"), + rs.getInt("pos_map"), + rs.getByte("portal"), + rs.getLong("pet_1"), + rs.getLong("pet_2"), + rs.getLong("pet_3") + ); + + // For inventory, query normalized player.inventory table separately + Inventory equipped = loadEquippedInventory(cs.getId()); + + list.add(AvatarData.from(cs, equipped)); + } + } + } catch (SQLException e) { + e.printStackTrace(); } - return avatarDataList; + + return list; } + private Inventory loadEquippedInventory(int characterId) throws SQLException { + Inventory equipped = new Inventory(24); // default equipped size + + String sql = """ + SELECT f.*, i.slot + FROM player.inventory i + JOIN item.full_item f ON i.item_sn = f.item_sn + WHERE i.character_id = ? AND i.inventory_type = ? + """; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + + PGobject enumValue = new PGobject(); + enumValue.setType("inventory_type_enum"); + enumValue.setValue(InventoryType.EQUIPPED.name()); + stmt.setObject(2, enumValue); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + long itemSn = rs.getLong("item_sn"); + int slot = rs.getInt("slot"); + int itemId = rs.getInt("item_id"); + short quantity = rs.getShort("quantity"); + short attribute = rs.getShort("attribute"); + String title = rs.getString("title"); + Timestamp dateExpireTs = rs.getTimestamp("date_expire"); + + // Build EquipData if applicable + EquipData equipData = null; + if (rs.getObject("inc_str") != null) { + equipData = new EquipData( + rs.getShort("inc_str"), + rs.getShort("inc_dex"), + rs.getShort("inc_int"), + rs.getShort("inc_luk"), + rs.getShort("inc_max_hp"), + rs.getShort("inc_max_mp"), + rs.getShort("inc_pad"), + rs.getShort("inc_mad"), + rs.getShort("inc_pdd"), + rs.getShort("inc_mdd"), + rs.getShort("inc_acc"), + rs.getShort("inc_eva"), + rs.getShort("inc_craft"), + rs.getShort("inc_speed"), + rs.getShort("inc_jump"), + rs.getByte("ruc"), + rs.getByte("cuc"), + rs.getInt("iuc"), + rs.getByte("chuc"), + rs.getByte("grade"), + rs.getShort("option_1"), + rs.getShort("option_2"), + rs.getShort("option_3"), + rs.getShort("socket_1"), + rs.getShort("socket_2"), + rs.getByte("level_up_type"), + rs.getByte("level"), + rs.getInt("exp"), + rs.getInt("durability") + ); + } + + // Build PetData if applicable + PetData petData = null; + if (rs.getObject("pet_name") != null) { + petData = new PetData( + rs.getString("pet_name"), + rs.getByte("pet_level"), + rs.getByte("fullness"), + rs.getShort("tameness"), + rs.getShort("pet_skill"), + rs.getShort("pet_attribute"), + rs.getInt("remain_life") + ); + } + + // Build RingData if applicable + RingData ringData = null; + if (rs.getObject("pair_character_id") != null) { + ringData = new RingData( + rs.getInt("pair_character_id"), + rs.getString("pair_character_name"), + rs.getLong("pair_item_sn") + ); + } + + Item item = new Item( + itemId, + quantity, + itemSn, + false, // cash flag, adjust if you have it + attribute, + title, + dateExpireTs != null ? dateExpireTs.toInstant() : null, + equipData, + petData, + ringData + ); + + equipped.putItem(slot, item); + } + } + } + + return equipped; + } + + @Override public synchronized boolean newCharacter(CharacterData characterData) { - if (!checkCharacterNameAvailable(characterData.getCharacterName())) { - return false; + if (!checkCharacterNameAvailable(characterData.getCharacterName())) return false; + + String sql = """ + INSERT INTO player.characters + (account_id, name, money, ext_slot_expire, friend_max, party_id, guild_id, creation_time, max_level_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id + """; + + Connection conn = null; + boolean success = false; + + try { + conn = dataSource.getConnection(); + conn.setAutoCommit(false); + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterData.getAccountId()); + stmt.setString(2, characterData.getCharacterName()); + stmt.setInt(3, characterData.getInventoryManager().getMoney()); + stmt.setTimestamp(4, characterData.getInventoryManager().getExtSlotExpire() != null ? + Timestamp.from(characterData.getInventoryManager().getExtSlotExpire()) : null); + stmt.setInt(5, characterData.getFriendMax()); + stmt.setInt(6, characterData.getPartyId()); + stmt.setInt(7, characterData.getGuildId()); + stmt.setTimestamp(8, characterData.getCreationTime() != null ? Timestamp.from(characterData.getCreationTime()) : null); + stmt.setTimestamp(9, characterData.getMaxLevelTime() != null ? Timestamp.from(characterData.getMaxLevelTime()) : null); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + int newCharacterId = rs.getInt(1); + characterData.getCharacterStat().setId(newCharacterId); + } else { + throw new SQLException("Failed to insert new character"); + } + } + } + + // Pass the same connection to all dependent methods + saveCharacterStats(conn, characterData); + saveCharacterInventory(conn, characterData); + saveCharacterSkills(conn, characterData); + saveCharacterQuests(conn, characterData); + saveCharacterConfig(conn, characterData); + saveCharacterMacros(conn, characterData); + saveCharacterPopularity(conn, characterData); + + conn.commit(); + success = true; + } catch (Exception e) { + e.printStackTrace(); + if (conn != null) { + try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } + } + } finally { + if (conn != null) { + try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ex) { ex.printStackTrace(); } + } + } + + return success; + } + + + private void saveCharacterConfig(Connection conn, CharacterData characterData) throws SQLException { + ConfigManager config = characterData.getConfigManager(); + + String sql = """ + INSERT INTO player.config + (character_id, pet_consume_item, pet_consume_mp_item, pet_exception_list, func_key_types, func_key_ids, quickslot_key_map) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (character_id) DO UPDATE + SET pet_consume_item = EXCLUDED.pet_consume_item, + pet_consume_mp_item = EXCLUDED.pet_consume_mp_item, + pet_exception_list = EXCLUDED.pet_exception_list, + func_key_types = EXCLUDED.func_key_types, + func_key_ids = EXCLUDED.func_key_ids, + quickslot_key_map = EXCLUDED.quickslot_key_map + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, config.getPetConsumeItem()); + stmt.setInt(3, config.getPetConsumeMpItem()); + + // pet_exception_list -> List -> Integer[] + List exceptionList = config.getPetExceptionList(); + Integer[] exceptionArray = exceptionList != null ? exceptionList.toArray(new Integer[0]) : new Integer[0]; + stmt.setArray(4, conn.createArrayOf("integer", exceptionArray)); + + // func_key_types & func_key_ids from FuncKeyMapped[] + FuncKeyMapped[] funcKeyMap = config.getFuncKeyMap(); + if (funcKeyMap == null) { + funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); + } + + Short[] funcTypes = Arrays.stream(funcKeyMap) + .map(f -> (short) f.getType().getValue()) + .toArray(Short[]::new); + Integer[] funcIds = Arrays.stream(funcKeyMap) + .map(FuncKeyMapped::getId) + .toArray(Integer[]::new); + + stmt.setArray(5, conn.createArrayOf("smallint", funcTypes)); // func_key_types + stmt.setArray(6, conn.createArrayOf("integer", funcIds)); // func_key_ids + + // quickslot_key_map -> int[] -> Integer[] + int[] quickslot = config.getQuickslotKeyMap(); + Integer[] quickslotKeys = quickslot != null + ? Arrays.stream(quickslot).boxed().toArray(Integer[]::new) + : new Integer[0]; + stmt.setArray(7, conn.createArrayOf("integer", quickslotKeys)); + + stmt.executeUpdate(); + } + } + + + + private void saveCharacterMacros(Connection conn, CharacterData characterData) throws SQLException { + List macros = characterData.getConfigManager().getMacroSysData(); + + String sql = """ + INSERT INTO player.character_macro + (character_id, macro_index, name, mute, skills) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (character_id, macro_index) DO UPDATE + SET name = EXCLUDED.name, + mute = EXCLUDED.mute, + skills = EXCLUDED.skills + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (int i = 0; i < macros.size(); i++) { + SingleMacro macro = macros.get(i); + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, i); // macro_index + stmt.setString(3, macro.getName()); + stmt.setBoolean(4, macro.isMute()); + + // skills array -> Integer[] + int[] skills = macro.getSkills(); + Integer[] skillArray = Arrays.stream(skills).boxed().toArray(Integer[]::new); + stmt.setArray(5, conn.createArrayOf("INT", skillArray)); + + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + + private void saveCharacterPopularity(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.popularity (character_id, other_character_id, timestamp) + VALUES (?, ?, ?) + ON CONFLICT (character_id, other_character_id) + DO UPDATE SET timestamp = EXCLUDED.timestamp + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + PopularityRecord pr = characterData.getPopularityRecord(); + int charId = characterData.getCharacterId(); + + for (var entry : pr.getRecords().entrySet()) { + stmt.setInt(1, charId); + stmt.setInt(2, entry.getKey()); + stmt.setTimestamp(3, Timestamp.from(entry.getValue())); + stmt.addBatch(); + } + + stmt.executeBatch(); + } + } + + + + private void saveCharacterSkills(Connection conn, CharacterData characterData) throws SQLException { + String skillRecordSql = """ + INSERT INTO player.skill_record (character_id, skill_id, level, master_level) + VALUES (?, ?, ?, ?) + ON CONFLICT (character_id, skill_id) + DO UPDATE SET level = EXCLUDED.level, master_level = EXCLUDED.master_level + """; + + try (PreparedStatement stmt = conn.prepareStatement(skillRecordSql)) { + for (SkillRecord sr : characterData.getSkillManager().getSkillRecords()) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, sr.getSkillId()); + stmt.setInt(3, sr.getSkillLevel()); + if (sr.getMasterLevel() > 0) { + stmt.setInt(4, sr.getMasterLevel()); + } else { + stmt.setNull(4, java.sql.Types.INTEGER); + } + stmt.addBatch(); + } + stmt.executeBatch(); + } + + // Save skill cooltimes + String skillCooltimeSql = """ + INSERT INTO player.skill_cooltime (character_id, skill_id, cooldown_end) + VALUES (?, ?, ?) + ON CONFLICT (character_id, skill_id) + DO UPDATE SET cooldown_end = EXCLUDED.cooldown_end + """; + + try (PreparedStatement stmt = conn.prepareStatement(skillCooltimeSql)) { + for (var entry : characterData.getSkillManager().getSkillCooltimes().entrySet()) { + int skillId = entry.getKey(); + Instant endTime = entry.getValue(); + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, skillId); + stmt.setTimestamp(3, Timestamp.from(endTime)); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + private void saveCharacterQuests(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.quest_record (character_id, quest_id, status, progress, completed_time) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (character_id, quest_id) + DO UPDATE SET status = EXCLUDED.status, + progress = EXCLUDED.progress, + completed_time = EXCLUDED.completed_time + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (QuestRecord qr : characterData.getQuestManager().getQuestRecords()) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, qr.getQuestId()); + stmt.setInt(3, qr.getState().getValue()); + stmt.setString(4, qr.getValue()); + stmt.setTimestamp(5, qr.getCompletedTime() != null ? Timestamp.from(qr.getCompletedTime()) : null); + stmt.addBatch(); + } + stmt.executeBatch(); } - return saveCharacter(characterData); } @Override public boolean saveCharacter(CharacterData characterData) { - final CodecRegistry registry = getSession().getContext().getCodecRegistry(); - final ResultSet updateResult = getSession().execute( - update(getKeyspace(), CharacterTable.getTableName()) - .setColumn(CharacterTable.ACCOUNT_ID, literal(characterData.getAccountId())) - .setColumn(CharacterTable.CHARACTER_NAME, literal(characterData.getCharacterName())) - .setColumn(CharacterTable.CHARACTER_NAME_INDEX, literal(lowerName(characterData.getCharacterName()))) - .setColumn(CharacterTable.CHARACTER_STAT, literal(characterData.getCharacterStat(), registry)) - .setColumn(CharacterTable.CHARACTER_EQUIPPED, literal(characterData.getInventoryManager().getEquipped(), registry)) - .setColumn(CharacterTable.EQUIP_INVENTORY, literal(characterData.getInventoryManager().getEquipInventory(), registry)) - .setColumn(CharacterTable.CONSUME_INVENTORY, literal(characterData.getInventoryManager().getConsumeInventory(), registry)) - .setColumn(CharacterTable.INSTALL_INVENTORY, literal(characterData.getInventoryManager().getInstallInventory(), registry)) - .setColumn(CharacterTable.ETC_INVENTORY, literal(characterData.getInventoryManager().getEtcInventory(), registry)) - .setColumn(CharacterTable.CASH_INVENTORY, literal(characterData.getInventoryManager().getCashInventory(), registry)) - .setColumn(CharacterTable.MONEY, literal(characterData.getInventoryManager().getMoney())) - .setColumn(CharacterTable.EXT_SLOT_EXPIRE, literal(characterData.getInventoryManager().getExtSlotExpire())) - .setColumn(CharacterTable.SKILL_COOLTIMES, literal(characterData.getSkillManager().getSkillCooltimes())) - .setColumn(CharacterTable.SKILL_RECORDS, literal(characterData.getSkillManager().getSkillRecords(), registry)) - .setColumn(CharacterTable.QUEST_RECORDS, literal(characterData.getQuestManager().getQuestRecords(), registry)) - .setColumn(CharacterTable.CONFIG, literal(characterData.getConfigManager(), registry)) - .setColumn(CharacterTable.POPULARITY_RECORD, literal(characterData.getPopularityRecord().getRecords(), registry)) - .setColumn(CharacterTable.MINIGAME_RECORD, literal(characterData.getMiniGameRecord(), registry)) - .setColumn(CharacterTable.MAP_TRANSFER_INFO, literal(characterData.getMapTransferInfo(), registry)) - .setColumn(CharacterTable.WILD_HUNTER_INFO, literal(characterData.getWildHunterInfo(), registry)) - .setColumn(CharacterTable.ITEM_SN_COUNTER, literal(characterData.getItemSnCounter().get())) - .setColumn(CharacterTable.FRIEND_MAX, literal(characterData.getFriendMax())) - .setColumn(CharacterTable.PARTY_ID, literal(characterData.getPartyId())) - .setColumn(CharacterTable.GUILD_ID, literal(characterData.getGuildId())) - .setColumn(CharacterTable.CREATION_TIME, literal(characterData.getCreationTime())) - .setColumn(CharacterTable.MAX_LEVEL_TIME, literal(characterData.getMaxLevelTime())) - .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterData.getCharacterId())) - .build() - ); - return updateResult.wasApplied(); + String sql = "UPDATE player.characters SET account_id=?, name=?, money=?, ext_slot_expire=?, " + + "friend_max=?, party_id=?, guild_id=?, creation_time=?, max_level_time=? " + + "WHERE id=?"; + Connection conn = null; + boolean previousAutoCommit = true; + + try { + conn = dataSource.getConnection(); + + // save previous auto-commit state + previousAutoCommit = conn.getAutoCommit(); + conn.setAutoCommit(false); + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterData.getAccountId()); + stmt.setString(2, characterData.getCharacterName()); + stmt.setInt(3, characterData.getInventoryManager().getMoney()); + stmt.setTimestamp(4, characterData.getInventoryManager().getExtSlotExpire() != null ? + Timestamp.from(characterData.getInventoryManager().getExtSlotExpire()) : null); + stmt.setInt(5, characterData.getFriendMax()); + stmt.setInt(6, characterData.getPartyId()); + stmt.setInt(7, characterData.getGuildId()); + stmt.setTimestamp(8, characterData.getCreationTime() != null ? Timestamp.from(characterData.getCreationTime()) : null); + stmt.setTimestamp(9, characterData.getMaxLevelTime() != null ? Timestamp.from(characterData.getMaxLevelTime()) : null); + stmt.setInt(10, characterData.getCharacterId()); + + int updated = stmt.executeUpdate(); + if (updated == 0) { + conn.rollback(); + return false; + } + } + + // Save dependent tables using the same connection + saveCharacterStats(conn, characterData); + saveCharacterInventory(conn, characterData); + saveCharacterSkills(conn, characterData); + saveCharacterQuests(conn, characterData); + saveCharacterConfig(conn, characterData); + saveCharacterMacros(conn, characterData); + saveCharacterPopularity(conn, characterData); + + conn.commit(); + return true; + } catch (Exception e) { + if (conn != null) { + try { + conn.rollback(); + } catch (SQLException rollbackEx) { + rollbackEx.printStackTrace(); + } + } + e.printStackTrace(); + return false; + } finally { + if (conn != null) { + try { + conn.setAutoCommit(previousAutoCommit); + conn.close(); + } catch (SQLException ex) { + ex.printStackTrace(); + } + } + } + } + + + private void saveCharacterStats(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.stats ( + character_id, gender, skin, face, hair, level, job, sub_job, + base_str, base_dex, base_int, base_luk, hp, max_hp, mp, max_mp, + ap, exp, pop, pos_map, portal, pet_1, pet_2, pet_3 + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + ON CONFLICT (character_id) DO UPDATE SET + gender = EXCLUDED.gender, + skin = EXCLUDED.skin, + face = EXCLUDED.face, + hair = EXCLUDED.hair, + level = EXCLUDED.level, + job = EXCLUDED.job, + sub_job = EXCLUDED.sub_job, + base_str = EXCLUDED.base_str, + base_dex = EXCLUDED.base_dex, + base_int = EXCLUDED.base_int, + base_luk = EXCLUDED.base_luk, + hp = EXCLUDED.hp, + max_hp = EXCLUDED.max_hp, + mp = EXCLUDED.mp, + max_mp = EXCLUDED.max_mp, + ap = EXCLUDED.ap, + exp = EXCLUDED.exp, + pop = EXCLUDED.pop, + pos_map = EXCLUDED.pos_map, + portal = EXCLUDED.portal, + pet_1 = EXCLUDED.pet_1, + pet_2 = EXCLUDED.pet_2, + pet_3 = EXCLUDED.pet_3 + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + CharacterStat cs = characterData.getCharacterStat(); + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, cs.getGender()); + stmt.setInt(3, cs.getSkin()); + stmt.setInt(4, cs.getFace()); + stmt.setInt(5, cs.getHair()); + stmt.setInt(6, cs.getLevel()); + stmt.setInt(7, cs.getJob()); + stmt.setInt(8, cs.getSubJob()); + stmt.setInt(9, cs.getBaseStr()); + stmt.setInt(10, cs.getBaseDex()); + stmt.setInt(11, cs.getBaseInt()); + stmt.setInt(12, cs.getBaseLuk()); + stmt.setInt(13, cs.getHp()); + stmt.setInt(14, cs.getMaxHp()); + stmt.setInt(15, cs.getMp()); + stmt.setInt(16, cs.getMaxMp()); + stmt.setInt(17, cs.getAp()); + stmt.setInt(18, cs.getExp()); + stmt.setInt(19, cs.getPop()); + stmt.setInt(20, cs.getPosMap()); + stmt.setInt(21, cs.getPortal()); + stmt.setLong(22, cs.getPetSn1()); + stmt.setLong(23, cs.getPetSn2()); + stmt.setLong(24, cs.getPetSn3()); + + stmt.executeUpdate(); + } + } + + + private void saveCharacterInventory(Connection conn, CharacterData characterData) throws SQLException { + String sqlInventory = """ + INSERT INTO player.inventory (character_id, inventory_type, slot, item_sn) + VALUES (?, ?, ?, ?) + ON CONFLICT (character_id, item_sn) + DO UPDATE SET slot = EXCLUDED.slot, inventory_type = EXCLUDED.inventory_type + """; + + String sqlItems = """ + INSERT INTO item.items (item_sn, item_id, quantity, attribute, title, date_expire) + VALUES (?, ?, ?, ?, ?, ?) + """; + + try (PreparedStatement stmtInventory = conn.prepareStatement(sqlInventory); + PreparedStatement stmtItems = conn.prepareStatement(sqlItems)) { + + InventoryManager inv = characterData.getInventoryManager(); + + saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.EQUIPPED, inv.getEquipped().getItems()); + saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.EQUIP, inv.getEquipInventory().getItems()); + saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.CONSUME, inv.getConsumeInventory().getItems()); + saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.INSTALL, inv.getInstallInventory().getItems()); + saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.ETC, inv.getEtcInventory().getItems()); + saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.CASH, inv.getCashInventory().getItems()); + + stmtItems.executeBatch(); + stmtInventory.executeBatch(); + } + } + + + + private void saveInventoryBatch( + Connection conn, + PreparedStatement stmtItems, + PreparedStatement stmtInventory, + int charId, + InventoryType type, + Map items + ) throws SQLException { + + PGobject enumValue = new PGobject(); + enumValue.setType("inventory_type_enum"); + enumValue.setValue(type.name()); + + // --- Delete inventory items that no longer exist --- + if (!items.isEmpty()) { + Long[] itemSnArray = items.values().stream() + .map(Item::getItemSn) + .toArray(Long[]::new); + + try (PreparedStatement deleteStmt = conn.prepareStatement("DELETE FROM player.inventory WHERE character_id = ? AND item_sn <> ALL (?)")) { + deleteStmt.setInt(1, charId); + Array sqlArray = conn.createArrayOf("bigint", itemSnArray); + deleteStmt.setArray(2, sqlArray); + deleteStmt.executeUpdate(); + } + } else { // delete all items. + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM player.inventory WHERE character_id = ?")) { + deleteStmt.setInt(1, charId); + deleteStmt.executeUpdate(); + } + } + + // --- Insert/update items --- + for (var entry : items.entrySet()) { + Item item = entry.getValue(); + long itemSn = item.getItemSn(); + + if (itemSn <= 0) { // DNE + try (PreparedStatement seqStmt = conn.prepareStatement( + "SELECT nextval(pg_get_serial_sequence('item.items', 'item_sn'))"); + ResultSet rs = seqStmt.executeQuery()) { + rs.next(); + itemSn = rs.getLong(1); + item.setItemSn(itemSn); + } + + stmtItems.setLong(1, itemSn); + stmtItems.setInt(2, item.getItemId()); + stmtItems.setInt(3, item.getQuantity()); + stmtItems.setShort(4, item.getAttribute()); + stmtItems.setString(5, item.getTitle()); + stmtItems.setTimestamp(6, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); + stmtItems.addBatch(); + } else { + try (PreparedStatement updateStmt = conn.prepareStatement( + "UPDATE item.items SET quantity = ?, attribute = ?, title = ?, date_expire = ? WHERE item_sn = ?")) { + updateStmt.setInt(1, item.getQuantity()); + updateStmt.setShort(2, item.getAttribute()); + updateStmt.setString(3, item.getTitle()); + updateStmt.setTimestamp(4, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); + updateStmt.setLong(5, itemSn); + updateStmt.executeUpdate(); + } + } + + stmtInventory.setInt(1, charId); + stmtInventory.setObject(2, enumValue); + stmtInventory.setInt(3, entry.getKey()); + stmtInventory.setLong(4, itemSn); + stmtInventory.addBatch(); + } } @Override public boolean deleteCharacter(int accountId, int characterId) { - final ResultSet updateResult = getSession().execute( - deleteFrom(getKeyspace(), CharacterTable.getTableName()) - .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterId)) - .ifColumn(CharacterTable.ACCOUNT_ID).isEqualTo(literal(accountId)) - .build() - ); - return updateResult.wasApplied(); + String sql = "DELETE FROM player.characters WHERE id=? AND account_id=?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + stmt.setInt(2, accountId); + return stmt.executeUpdate() > 0; + } catch (SQLException e) { + e.printStackTrace(); + return false; + } } @Override public Map getCharacterRanks() { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), CharacterTable.getTableName()) - .columns( - CharacterTable.CHARACTER_ID, - CharacterTable.CHARACTER_STAT, - CharacterTable.MAX_LEVEL_TIME - ) - .build() - .setExecutionProfileName(CassandraConnector.PROFILE_ONE) - ); - final List rankDataList = new ArrayList<>(); - for (Row row : selectResult) { - final int characterId = row.getInt(CharacterTable.CHARACTER_ID); - final CharacterStat characterStat = row.get(CharacterTable.CHARACTER_STAT, CharacterStat.class); - final Instant maxLevelTime = row.getInstant(CharacterTable.MAX_LEVEL_TIME); - final int jobId = characterStat.getJob(); - if (JobConstants.isAdminJob(jobId) || JobConstants.isManagerJob(jobId)) { - continue; - } - rankDataList.add(new CharacterRankData( - characterId, - JobConstants.getJobCategory(jobId), - characterStat.getCumulativeExp(), - maxLevelTime - )); - } - // Sort and process rank data - rankDataList.sort(Comparator.comparing(CharacterRankData::getCumulativeExp).reversed().thenComparing(CharacterRankData::getMaxLevelTime)); - final Map jobRanks = new HashMap<>(); // job rank counter - final Map characterRanks = new HashMap<>(); // character id -> character rank - for (CharacterRankData rankData : rankDataList) { - final int characterId = rankData.getCharacterId(); - final int jobCategory = rankData.getJobCategory(); - final int worldRank = characterRanks.size() + 1; - final int jobRank = jobRanks.getOrDefault(jobCategory, 0) + 1; - jobRanks.put(jobCategory, jobRank); - characterRanks.put(characterId, new CharacterRank( - characterId, - worldRank, - jobRank - )); - } - return characterRanks; + Map ranks = new HashMap<>(); + String sql = """ + SELECT c.id, c.max_level_time, s.job, s.exp + FROM player.characters c + JOIN player.stats s ON c.id = s.character_id + """; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + ResultSet rs = stmt.executeQuery(); + List rankDataList = new ArrayList<>(); + + while (rs.next()) { + int characterId = rs.getInt("id"); + int jobId = rs.getInt("job"); + long cumulativeExp = rs.getLong("exp"); + Timestamp ts = rs.getTimestamp("max_level_time"); + + // skip admin/manager characters + if (JobConstants.isAdminJob(jobId) || JobConstants.isManagerJob(jobId)) { + continue; + } + + rankDataList.add(new CharacterRankData( + characterId, + JobConstants.getJobCategory(jobId), + cumulativeExp, + ts != null ? ts.toInstant() : Instant.MAX + )); + } + + // Sort by EXP (descending) and then by earliest max level time + rankDataList.sort( + Comparator.comparingLong(CharacterRankData::getCumulativeExp).reversed() + .thenComparing(CharacterRankData::getMaxLevelTime) + ); + + // Compute world rank and job rank + Map jobRanks = new HashMap<>(); + for (CharacterRankData data : rankDataList) { + int worldRank = ranks.size() + 1; + int jobRank = jobRanks.getOrDefault(data.getJobCategory(), 0) + 1; + jobRanks.put(data.getJobCategory(), jobRank); + + ranks.put(data.getCharacterId(), new CharacterRank( + data.getCharacterId(), + worldRank, + jobRank + )); + } + + } catch (Exception e) { + e.printStackTrace(); + } + + return ranks; } + private static class CharacterRankData { private final int characterId; private final int jobCategory; @@ -328,20 +1347,9 @@ private CharacterRankData(int characterId, int jobCategory, long cumulativeExp, this.maxLevelTime = maxLevelTime; } - public int getCharacterId() { - return characterId; - } - - public int getJobCategory() { - return jobCategory; - } - - public long getCumulativeExp() { - return cumulativeExp; - } - - public Instant getMaxLevelTime() { - return maxLevelTime != null ? maxLevelTime : Instant.MAX; - } + public int getCharacterId() { return characterId; } + public int getJobCategory() { return jobCategory; } + public long getCumulativeExp() { return cumulativeExp; } + public Instant getMaxLevelTime() { return maxLevelTime != null ? maxLevelTime : Instant.MAX; } } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java index 8df86137..52a9cd4c 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresConnector.java +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -1,4 +1,82 @@ package kinoko.database.postgresql; -public class PostgresConnector { +import kinoko.database.*; +import java.util.TimeZone; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import kinoko.server.ServerConfig; +import kinoko.server.ServerConstants; + +public final class PostgresConnector implements DatabaseConnector { + private HikariDataSource dataSource; + private IdAccessor idAccessor; + private AccountAccessor accountAccessor; + private CharacterAccessor characterAccessor; + private FriendAccessor friendAccessor; + private GuildAccessor guildAccessor; + private GiftAccessor giftAccessor; + private MemoAccessor memoAccessor; + + @Override + public void initialize() { + try { + // Connect + String DATABASE_URL = String.format( + "jdbc:postgresql://%s:%s/%s", + ServerConstants.DATABASE_HOST, + ServerConstants.DATABASE_PORT, + ServerConstants.DATABASE_NAME + ); + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); // Set a custom timezone. + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(DATABASE_URL); + config.setUsername(ServerConstants.DATABASE_USER); + config.setPassword(ServerConstants.DATABASE_PASSWORD); + config.setMaximumPoolSize(50); // Adjust as needed + config.setConnectionTimeout(5000); // 5s + config.setIdleTimeout(60000); // 60s + config.setMaxLifetime(1800000); // 30min + config.setLeakDetectionThreshold(5000); + + dataSource = new HikariDataSource(config); + + // Run init.sql if needed +// Path initPath = Path.of("src/main/java/kinoko/database/postgresql/setup/init.sql"); +// if (Files.exists(initPath)) { +// String sql = Files.readString(initPath); +// try (Statement stmt = connection.createStatement()) { +// stmt.execute(sql); +// } +// } + + // Create Accessors + idAccessor = new PostgresIdAccessor(dataSource); + accountAccessor = new PostgresAccountAccessor(dataSource); + characterAccessor = new PostgresCharacterAccessor(dataSource); + friendAccessor = new PostgresFriendAccessor(dataSource); + guildAccessor = new PostgresGuildAccessor(dataSource); + giftAccessor = new PostgresGiftAccessor(dataSource); + memoAccessor = new PostgresMemoAccessor(dataSource); + + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException("Failed to initialize PostgresConnector", e); + } + } + + @Override + public void shutdown() { + if (dataSource != null) { + dataSource.close(); // safely closes all pooled connections + dataSource = null; + } + } + + @Override public IdAccessor getIdAccessor() { return idAccessor; } + @Override public AccountAccessor getAccountAccessor() { return accountAccessor; } + @Override public CharacterAccessor getCharacterAccessor() { return characterAccessor; } + @Override public FriendAccessor getFriendAccessor() { return friendAccessor; } + @Override public GuildAccessor getGuildAccessor() { return guildAccessor; } + @Override public GiftAccessor getGiftAccessor() { return giftAccessor; } + @Override public MemoAccessor getMemoAccessor() { return memoAccessor; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java index 36b39a47..88d3cb7f 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java @@ -1,84 +1,101 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; -import com.datastax.oss.driver.api.core.cql.ResultSet; -import com.datastax.oss.driver.api.core.cql.Row; -import com.datastax.oss.driver.api.querybuilder.insert.Insert; +import com.zaxxer.hikari.HikariDataSource; import kinoko.database.FriendAccessor; -import kinoko.database.cassandra.table.FriendTable; import kinoko.world.user.friend.Friend; import kinoko.world.user.friend.FriendStatus; +import java.sql.*; import java.util.ArrayList; import java.util.List; -import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; +public final class PostgresFriendAccessor implements FriendAccessor { + private final HikariDataSource dataSource; -public final class CassandraFriendAccessor extends CassandraAccessor implements FriendAccessor { - public CassandraFriendAccessor(CqlSession session, String keyspace) { - super(session, keyspace); + public PostgresFriendAccessor(HikariDataSource dataSource) { + this.dataSource = dataSource; } - private Friend loadFriend(Row row) { - final int characterId = row.getInt(FriendTable.CHARACTER_ID); - final int friendId = row.getInt(FriendTable.FRIEND_ID); - final String friendName = row.getString(FriendTable.FRIEND_NAME); - final String friendGroup = row.getString(FriendTable.FRIEND_GROUP); - final FriendStatus status = FriendStatus.getByValue(row.getInt(FriendTable.FRIEND_STATUS)); + private Friend loadFriend(ResultSet rs) throws SQLException { + int characterId = rs.getInt("character_id"); + int friendId = rs.getInt("friend_id"); + String friendName = rs.getString("friend_name"); + String friendGroup = rs.getString("friend_group"); + FriendStatus status = FriendStatus.getByValue(rs.getInt("friend_status")); return new Friend(characterId, friendId, friendName, friendGroup, status); } @Override public List getFriendsByCharacterId(int characterId) { - final List friends = new ArrayList<>(); - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), FriendTable.getTableName()).all() - .whereColumn(FriendTable.CHARACTER_ID).isEqualTo(literal(characterId)) - .build() - ); - for (Row row : selectResult) { - friends.add(loadFriend(row)); + List friends = new ArrayList<>(); + String sql = "SELECT * FROM friend.friends WHERE character_id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + ResultSet rs = stmt.executeQuery(); + while (rs.next()) { + friends.add(loadFriend(rs)); + } + } catch (SQLException e) { + e.printStackTrace(); } return friends; } @Override public List getFriendsByFriendId(int friendId) { - final List friends = new ArrayList<>(); - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), FriendTable.getTableName()).all() - .whereColumn(FriendTable.FRIEND_ID).isEqualTo(literal(friendId)) - .build() - ); - for (Row row : selectResult) { - friends.add(loadFriend(row)); + List friends = new ArrayList<>(); + String sql = "SELECT * FROM friend.friends WHERE friend_id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, friendId); + ResultSet rs = stmt.executeQuery(); + while (rs.next()) { + friends.add(loadFriend(rs)); + } + } catch (SQLException e) { + e.printStackTrace(); } return friends; } @Override public boolean saveFriend(Friend friend, boolean force) { - Insert insert = insertInto(getKeyspace(), FriendTable.getTableName()) - .value(FriendTable.CHARACTER_ID, literal(friend.getCharacterId())) - .value(FriendTable.FRIEND_ID, literal(friend.getFriendId())) - .value(FriendTable.FRIEND_NAME, literal(friend.getFriendName())) - .value(FriendTable.FRIEND_GROUP, literal(friend.getFriendGroup())) - .value(FriendTable.FRIEND_STATUS, literal(friend.getStatus().getValue())); - if (!force) { - insert = insert.ifNotExists(); + String sql; + if (force) { + sql = "INSERT INTO friend.friends (character_id, friend_id, friend_name, friend_group, friend_status) " + + "VALUES (?, ?, ?, ?, ?) " + + "ON CONFLICT (character_id, friend_id) DO UPDATE SET friend_name = EXCLUDED.friend_name, " + + "friend_group = EXCLUDED.friend_group, friend_status = EXCLUDED.friend_status"; + } else { + sql = "INSERT INTO friend.friends (character_id, friend_id, friend_name, friend_group, friend_status) " + + "VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING"; } - final ResultSet insertResult = getSession().execute(insert.build()); - return insertResult.wasApplied(); + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, friend.getCharacterId()); + stmt.setInt(2, friend.getFriendId()); + stmt.setString(3, friend.getFriendName()); + stmt.setString(4, friend.getFriendGroup()); + stmt.setInt(5, friend.getStatus().getValue()); + return stmt.executeUpdate() > 0; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } @Override public boolean deleteFriend(int characterId, int friendId) { - final ResultSet deleteResult = getSession().execute( - deleteFrom(getKeyspace(), FriendTable.getTableName()) - .whereColumn(FriendTable.CHARACTER_ID).isEqualTo(literal(characterId)) - .whereColumn(FriendTable.FRIEND_ID).isEqualTo(literal(friendId)) - .build() - ); - return deleteResult.wasApplied(); + String sql = "DELETE FROM friend.friends WHERE character_id = ? AND friend_id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + stmt.setInt(2, friendId); + return stmt.executeUpdate() > 0; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java index cea13ac1..34b283e1 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java @@ -1,86 +1,98 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; -import com.datastax.oss.driver.api.core.cql.ResultSet; -import com.datastax.oss.driver.api.core.cql.Row; +import com.zaxxer.hikari.HikariDataSource; import kinoko.database.GiftAccessor; -import kinoko.database.cassandra.table.GiftTable; import kinoko.server.cashshop.Gift; +import java.sql.*; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; +public final class PostgresGiftAccessor implements GiftAccessor { + private final HikariDataSource dataSource; -public final class CassandraGiftAccessor extends CassandraAccessor implements GiftAccessor { - public CassandraGiftAccessor(CqlSession session, String keyspace) { - super(session, keyspace); + public PostgresGiftAccessor(HikariDataSource dataSource) { + this.dataSource = dataSource; } - private Gift loadGift(Row row) { + private Gift loadGift(ResultSet rs) throws SQLException { return new Gift( - row.getLong(GiftTable.GIFT_SN), - row.getInt(GiftTable.ITEM_ID), - row.getInt(GiftTable.COMMODITY_ID), - row.getInt(GiftTable.SENDER_ID), - row.getString(GiftTable.SENDER_NAME), - row.getString(GiftTable.SENDER_MESSAGE), - row.getLong(GiftTable.PAIR_ITEM_SN) + rs.getLong("gift_sn"), + rs.getInt("item_id"), + rs.getInt("commodity_id"), + rs.getInt("sender_id"), + rs.getString("sender_name"), + rs.getString("sender_message"), + rs.getLong("pair_item_sn") ); } @Override public List getGiftsByCharacterId(int characterId) { - final List gifts = new ArrayList<>(); - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), GiftTable.getTableName()).all() - .whereColumn(GiftTable.RECEIVER_ID).isEqualTo(literal(characterId)) - .build() - ); - for (Row row : selectResult) { - gifts.add(loadGift(row)); + List gifts = new ArrayList<>(); + String sql = "SELECT * FROM gifts WHERE receiver_id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + ResultSet rs = stmt.executeQuery(); + while (rs.next()) { + gifts.add(loadGift(rs)); + } + } catch (SQLException e) { + e.printStackTrace(); } return gifts; } @Override public Optional getGiftByItemSn(long itemSn) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), GiftTable.getTableName()).all() - .whereColumn(GiftTable.GIFT_SN).isEqualTo(literal(itemSn)) - .build() - ); - for (Row row : selectResult) { - return Optional.of(loadGift(row)); + String sql = "SELECT * FROM gifts WHERE gift_sn = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, itemSn); + ResultSet rs = stmt.executeQuery(); + if (rs.next()) { + return Optional.of(loadGift(rs)); + } + } catch (SQLException e) { + e.printStackTrace(); } return Optional.empty(); } @Override public boolean newGift(Gift gift, int receiverId) { - final ResultSet insertResult = getSession().execute( - insertInto(getKeyspace(), GiftTable.getTableName()) - .value(GiftTable.GIFT_SN, literal(gift.getGiftSn())) - .value(GiftTable.RECEIVER_ID, literal(receiverId)) - .value(GiftTable.ITEM_ID, literal(gift.getItemId())) - .value(GiftTable.COMMODITY_ID, literal(gift.getCommodityId())) - .value(GiftTable.SENDER_NAME, literal(gift.getSenderName())) - .value(GiftTable.SENDER_MESSAGE, literal(gift.getSenderMessage())) - .value(GiftTable.PAIR_ITEM_SN, literal(gift.getPairItemSn())) - .ifNotExists() - .build() - ); - return insertResult.wasApplied(); + String sql = "INSERT INTO gifts (gift_sn, receiver_id, item_id, commodity_id, sender_id, sender_name, sender_message, pair_item_sn) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (gift_sn) DO NOTHING"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, gift.getGiftSn()); + stmt.setInt(2, receiverId); + stmt.setInt(3, gift.getItemId()); + stmt.setInt(4, gift.getCommodityId()); + stmt.setInt(5, gift.getSenderId()); + stmt.setString(6, gift.getSenderName()); + stmt.setString(7, gift.getSenderMessage()); + stmt.setLong(8, gift.getPairItemSn()); + return stmt.executeUpdate() > 0; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } @Override public boolean deleteGift(Gift gift) { - final ResultSet updateResult = getSession().execute( - deleteFrom(getKeyspace(), GiftTable.getTableName()) - .whereColumn(GiftTable.GIFT_SN).isEqualTo(literal(gift.getGiftSn())) - .build() - ); - return updateResult.wasApplied(); + String sql = "DELETE FROM gifts WHERE gift_sn = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, gift.getGiftSn()); + return stmt.executeUpdate() > 0; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java index f8fd07a0..04360a08 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -1,161 +1,353 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; -import com.datastax.oss.driver.api.core.cql.ResultSet; -import com.datastax.oss.driver.api.core.cql.Row; -import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry; +import com.zaxxer.hikari.HikariDataSource; import kinoko.database.GuildAccessor; -import kinoko.database.cassandra.table.GuildTable; import kinoko.server.guild.Guild; import kinoko.server.guild.GuildBoardEntry; import kinoko.server.guild.GuildMember; import kinoko.server.guild.GuildRanking; +import kinoko.server.guild.GuildRank; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Optional; +import java.sql.*; +import java.util.*; import java.util.concurrent.atomic.AtomicInteger; -import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; +public final class PostgresGuildAccessor extends PostgresAccessor implements GuildAccessor { -public final class CassandraGuildAccessor extends CassandraAccessor implements GuildAccessor { - public CassandraGuildAccessor(CqlSession session, String keyspace) { - super(session, keyspace); + public PostgresGuildAccessor(HikariDataSource dataSource) { + super(dataSource); } - private Guild loadGuild(Row row) { - final int guildId = row.getInt(GuildTable.GUILD_ID); - final String guildName = row.getString(GuildTable.GUILD_NAME); - final Guild guild = new Guild(guildId, guildName); - final List gradeNames = row.getList(GuildTable.GRADE_NAMES, String.class); - if (gradeNames != null) { - guild.setGradeNames(gradeNames); - } - final List members = row.getList(GuildTable.MEMBERS, GuildMember.class); - if (members != null) { - for (GuildMember member : members) { - guild.addMember(member); - } - } - guild.setMemberMax(row.getInt(GuildTable.MEMBER_MAX)); - guild.setMarkBg(row.getShort(GuildTable.MARK_BG)); - guild.setMarkBgColor(row.getByte(GuildTable.MARK_BG_COLOR)); - guild.setMark(row.getShort(GuildTable.MARK)); - guild.setMarkColor(row.getByte(GuildTable.MARK_COLOR)); - guild.setNotice(row.getString(GuildTable.NOTICE)); - guild.setPoints(row.getInt(GuildTable.POINTS)); - guild.setLevel(row.getByte(GuildTable.LEVEL)); - final List boardEntries = row.getList(GuildTable.BOARD_ENTRY_LIST, GuildBoardEntry.class); - if (boardEntries != null) { - guild.getBoardEntries().addAll(boardEntries); + // --------------------------------------------- + // LOAD A GUILD + // --------------------------------------------- + private Guild loadGuild(ResultSet rs) throws SQLException { + final int guildId = rs.getInt("guild_id"); + final String guildName = rs.getString("guild_name"); + Guild guild = new Guild(guildId, guildName); + + guild.setMemberMax(rs.getInt("member_max")); + guild.setMarkBg(rs.getShort("mark_bg")); + guild.setMarkBgColor(rs.getByte("mark_bg_color")); + guild.setMark(rs.getShort("mark")); + guild.setMarkColor(rs.getByte("mark_color")); + guild.setNotice(rs.getString("notice")); + guild.setPoints(rs.getInt("points")); + guild.setLevel(rs.getByte("level")); + + final List members = loadMembers(guildId); + for (GuildMember member : members) { + guild.addMember(member); } - guild.setBoardNoticeEntry(row.get(GuildTable.BOARD_ENTRY_NOTICE, GuildBoardEntry.class)); - guild.setBoardEntryCounter(new AtomicInteger(row.getInt(GuildTable.BOARD_ENTRY_COUNTER))); + + guild.setGradeNames(loadGrades(guild.getGuildId())); + + final List boardEntries = loadBoardEntries(guildId); + guild.getBoardEntries().addAll(boardEntries); + + guild.setBoardNoticeEntry(loadBoardNotice(guildId)); + return guild; } @Override public Optional getGuildById(int guildId) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), GuildTable.getTableName()).all() - .whereColumn(GuildTable.GUILD_ID).isEqualTo(literal(guildId)) - .build() - ); - for (Row row : selectResult) { - return Optional.of(loadGuild(row)); + String sql = "SELECT * FROM guild.guilds WHERE guild_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(loadGuild(rs)); + } + } + } catch (SQLException e) { + e.printStackTrace(); } return Optional.empty(); } + + private List loadGrades(int guildId) throws SQLException { + List grades = new ArrayList<>(); + String sql = "SELECT grade_name FROM guild.grade WHERE guild_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + grades.add(rs.getString("grade_name")); + } + } + } + return grades; + } + + // --------------------------------------------- + // MEMBERS + // --------------------------------------------- + private List loadMembers(int guildId) { + List members = new ArrayList<>(); + String sql = """ + SELECT c.character_id, c.character_name, s.job, s.level, + m.grade AS guildRank, NULL AS allianceRank, c.online + FROM guild.member m + JOIN player.characters c ON c.character_id = m.character_id + JOIN character.stats s ON s.character_id = c.character_id + WHERE m.guild_id = ? + """; + + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int charId = rs.getInt("character_id"); + String charName = rs.getString("character_name"); + int job = rs.getInt("job"); + int level = rs.getInt("level"); + boolean online = rs.getBoolean("online"); // now works with the new column + int guildRankInt = rs.getInt("guildRank"); + Integer allianceRankInt = null; // no alliance rank yet + + members.add(new GuildMember( + charId, + charName, + job, + level, + online, + GuildRank.getByValue(guildRankInt), + allianceRankInt != null ? GuildRank.getByValue(allianceRankInt) : null // setting to null for now. + )); + } + } + } catch (SQLException e) { + e.printStackTrace(); + } + + return members; + } + + + + + // --------------------------------------------- + // BOARD ENTRIES + // --------------------------------------------- + private List loadBoardEntries(int guildId) { + List entries = new ArrayList<>(); + String sql = "SELECT entry_id, character_id, title, message, timestamp, 0 AS emoticon " + + "FROM guild.board_entry WHERE guild_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + entries.add(new GuildBoardEntry( + rs.getInt("entry_id"), + rs.getInt("character_id"), + rs.getString("title"), + rs.getString("message"), + rs.getTimestamp("timestamp").toInstant(), + rs.getInt("emoticon") + )); + } + } + } catch (SQLException e) { + e.printStackTrace(); + } + return entries; + } + + private GuildBoardEntry loadBoardNotice(int guildId) { + String sql = "SELECT entry_id FROM guild.notice WHERE guild_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + int entryId = rs.getInt("entry_id"); + // Load full entry + String entrySql = "SELECT entry_id, character_id, title, message, timestamp, 0 AS emoticon " + + "FROM guild.board_entry WHERE entry_id = ?"; + try (PreparedStatement entryStmt = getConnection().prepareStatement(entrySql)) { + entryStmt.setInt(1, entryId); + try (ResultSet ers = entryStmt.executeQuery()) { + if (ers.next()) { + return new GuildBoardEntry( + ers.getInt("entry_id"), + ers.getInt("character_id"), + ers.getString("title"), + ers.getString("message"), + ers.getTimestamp("timestamp").toInstant(), + ers.getInt("emoticon") + ); + } + } + } + } + } + } catch (SQLException e) { + e.printStackTrace(); + } + return null; + } + + // --------------------------------------------- + // CHECK NAME + // --------------------------------------------- @Override public boolean checkGuildNameAvailable(String name) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), GuildTable.getTableName()).all() - .whereColumn(GuildTable.GUILD_NAME_INDEX).isEqualTo(literal(lowerName(name))) - .build() - ); - for (Row row : selectResult) { - final String existingName = row.getString(GuildTable.GUILD_NAME_INDEX); - if (existingName != null && existingName.equalsIgnoreCase(name)) { - return false; + String sql = "SELECT 1 FROM guild.guilds WHERE LOWER(guild_name) = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setString(1, name.toLowerCase()); + try (ResultSet rs = stmt.executeQuery()) { + return !rs.next(); } + } catch (SQLException e) { + e.printStackTrace(); } - return true; + return false; } + // --------------------------------------------- + // SAVE / CREATE + // --------------------------------------------- @Override public synchronized boolean newGuild(Guild guild) { - if (!checkGuildNameAvailable(guild.getGuildName())) { - return false; - } + if (!checkGuildNameAvailable(guild.getGuildName())) return false; return saveGuild(guild); } @Override public boolean saveGuild(Guild guild) { - final CodecRegistry registry = getSession().getContext().getCodecRegistry(); - final ResultSet updateResult = getSession().execute( - update(getKeyspace(), GuildTable.getTableName()) - .setColumn(GuildTable.GUILD_NAME, literal(guild.getGuildName())) - .setColumn(GuildTable.GUILD_NAME_INDEX, literal(lowerName(guild.getGuildName()))) - .setColumn(GuildTable.GRADE_NAMES, literal(guild.getGradeNames())) - .setColumn(GuildTable.MEMBERS, literal(guild.getGuildMembers(), registry)) - .setColumn(GuildTable.MEMBER_MAX, literal(guild.getMemberMax())) - .setColumn(GuildTable.MARK_BG, literal(guild.getMarkBg())) - .setColumn(GuildTable.MARK_BG_COLOR, literal(guild.getMarkBgColor())) - .setColumn(GuildTable.MARK, literal(guild.getMark())) - .setColumn(GuildTable.MARK_COLOR, literal(guild.getMarkColor())) - .setColumn(GuildTable.NOTICE, literal(guild.getNotice())) - .setColumn(GuildTable.POINTS, literal(guild.getPoints())) - .setColumn(GuildTable.LEVEL, literal(guild.getLevel())) - .setColumn(GuildTable.BOARD_ENTRY_LIST, literal(guild.getBoardEntries(), registry)) - .setColumn(GuildTable.BOARD_ENTRY_NOTICE, literal(guild.getBoardNoticeEntry(), registry)) - .setColumn(GuildTable.BOARD_ENTRY_COUNTER, literal(guild.getBoardEntryCounter().get())) - .whereColumn(GuildTable.GUILD_ID).isEqualTo(literal(guild.getGuildId())) - .build() - ); - return updateResult.wasApplied(); + String sql = "INSERT INTO guild.guilds (guild_id, guild_name, grade_names, member_max, mark_bg, mark_bg_color, mark, mark_color, notice, points, level, board_entry_counter) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (guild_id) DO UPDATE SET " + + "guild_name = EXCLUDED.guild_name, " + + "grade_names = EXCLUDED.grade_names, " + + "member_max = EXCLUDED.member_max, " + + "mark_bg = EXCLUDED.mark_bg, " + + "mark_bg_color = EXCLUDED.mark_bg_color, " + + "mark = EXCLUDED.mark, " + + "mark_color = EXCLUDED.mark_color, " + + "notice = EXCLUDED.notice, " + + "points = EXCLUDED.points, " + + "level = EXCLUDED.level, " + + "board_entry_counter = EXCLUDED.board_entry_counter"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, guild.getGuildId()); + stmt.setString(2, guild.getGuildName()); + stmt.setArray(3, getConnection().createArrayOf("text", guild.getGradeNames().toArray())); + stmt.setInt(4, guild.getMemberMax()); + stmt.setShort(5, guild.getMarkBg()); + stmt.setByte(6, guild.getMarkBgColor()); + stmt.setShort(7, guild.getMark()); + stmt.setByte(8, guild.getMarkColor()); + stmt.setString(9, guild.getNotice()); + stmt.setInt(10, guild.getPoints()); + stmt.setByte(11, guild.getLevel()); + stmt.setInt(12, guild.getBoardEntryCounter().get()); + stmt.executeUpdate(); + + saveMembers(guild); + saveBoardEntries(guild); + saveBoardNotice(guild); + + return true; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } + private void saveMembers(Guild guild) throws SQLException { + String deleteSql = "DELETE FROM guild.member WHERE guild_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(deleteSql)) { + stmt.setInt(1, guild.getGuildId()); + stmt.executeUpdate(); + } + + String insertSql = "INSERT INTO guild.member (guild_id, character_id, grade) VALUES (?, ?, ?)"; + try (PreparedStatement stmt = getConnection().prepareStatement(insertSql)) { + for (GuildMember member : guild.getGuildMembers()) { + stmt.setInt(1, guild.getGuildId()); + stmt.setInt(2, member.getCharacterId()); + stmt.setShort(3, (short) member.getGuildRank().getValue()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + private void saveBoardEntries(Guild guild) throws SQLException { + String deleteSql = "DELETE FROM guild.board_entry WHERE guild_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(deleteSql)) { + stmt.setInt(1, guild.getGuildId()); + stmt.executeUpdate(); + } + + String insertSql = "INSERT INTO guild.board_entry (entry_id, guild_id, character_id, title, message, timestamp, emoticon) VALUES (?, ?, ?, ?, ?, ?, ?)"; + try (PreparedStatement stmt = getConnection().prepareStatement(insertSql)) { + for (GuildBoardEntry entry : guild.getBoardEntries()) { + stmt.setInt(1, entry.getEntryId()); + stmt.setInt(2, guild.getGuildId()); + stmt.setInt(3, entry.getCharacterId()); + stmt.setString(4, entry.getTitle()); + stmt.setString(5, entry.getText()); + stmt.setTimestamp(6, Timestamp.from(entry.getDate())); + stmt.setInt(7, entry.getEmoticon()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + private void saveBoardNotice(Guild guild) throws SQLException { + String sql = "INSERT INTO guild.notice (guild_id, entry_id) VALUES (?, ?) " + + "ON CONFLICT (guild_id) DO UPDATE SET entry_id = EXCLUDED.entry_id"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + GuildBoardEntry notice = guild.getBoardNoticeEntry(); + if (notice != null) { + stmt.setInt(1, guild.getGuildId()); + stmt.setInt(2, notice.getEntryId()); + stmt.executeUpdate(); + } + } + } + + // --------------------------------------------- + // DELETE + // --------------------------------------------- @Override public boolean deleteGuild(int guildId) { - final ResultSet updateResult = getSession().execute( - deleteFrom(getKeyspace(), GuildTable.getTableName()) - .whereColumn(GuildTable.GUILD_ID).isEqualTo(literal(guildId)) - .build() - ); - return updateResult.wasApplied(); + String sql = "DELETE FROM guild.guilds WHERE guild_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, guildId); + return stmt.executeUpdate() > 0; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } + // --------------------------------------------- + // RANKINGS + // --------------------------------------------- @Override public List getGuildRankings() { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), GuildTable.getTableName()) - .columns( - GuildTable.GUILD_NAME, - GuildTable.POINTS, - GuildTable.MARK, - GuildTable.MARK_COLOR, - GuildTable.MARK_BG, - GuildTable.MARK_BG_COLOR - ) - .build() - .setExecutionProfileName(CassandraConnector.PROFILE_ONE) - ); - final List guildRankings = new ArrayList<>(); - for (Row row : selectResult) { - guildRankings.add(new GuildRanking( - row.getString(GuildTable.GUILD_NAME), - row.getInt(GuildTable.POINTS), - row.getShort(GuildTable.MARK), - row.getByte(GuildTable.MARK_COLOR), - row.getShort(GuildTable.MARK_BG), - row.getByte(GuildTable.MARK_BG_COLOR) - )); + List rankings = new ArrayList<>(); + String sql = "SELECT name, points, mark, mark_color, mark_bg, mark_bg_color FROM guild.guilds ORDER BY points DESC"; + try (Statement stmt = getConnection().createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + rankings.add(new GuildRanking( + rs.getString("name"), + rs.getInt("points"), + rs.getShort("mark"), + rs.getByte("mark_color"), + rs.getShort("mark_bg"), + rs.getByte("mark_bg_color") + )); + } + } catch (SQLException e) { + e.printStackTrace(); } - return guildRankings.stream() - .sorted(Comparator.comparing(GuildRanking::getPoints).reversed()) - .toList(); + return rankings; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java index 50b9249d..038dcf39 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java @@ -1,67 +1,45 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; -import com.datastax.oss.driver.api.core.cql.ResultSet; -import com.datastax.oss.driver.api.core.cql.Row; +import com.zaxxer.hikari.HikariDataSource; import kinoko.database.IdAccessor; -import kinoko.database.cassandra.table.IdTable; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; import java.util.Optional; -import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; +public final class PostgresIdAccessor extends PostgresAccessor implements IdAccessor { -public final class CassandraIdAccessor extends CassandraAccessor implements IdAccessor { - public CassandraIdAccessor(CqlSession session, String keyspace) { - super(session, keyspace); + public PostgresIdAccessor(HikariDataSource dataSource) { + super(dataSource); } private Optional getNextId(String type) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), IdTable.getTableName()).all() - .whereColumn(IdTable.ID_TYPE).isEqualTo(literal(type)) - .build() - ); - for (Row selectRow : selectResult) { - final int nextId = selectRow.getInt(IdTable.NEXT_ID); - final ResultSet updateResult = getSession().execute( - update(getKeyspace(), IdTable.getTableName()) - .setColumn(IdTable.NEXT_ID, literal(nextId + 1)) // increment ID - .whereColumn(IdTable.ID_TYPE).isEqualTo(literal(type)) - .ifColumn(IdTable.NEXT_ID).isEqualTo(literal(nextId)) // if not already updated - .build() - ); - if (updateResult.wasApplied()) { - return Optional.of(nextId); - } else { - // retry - return getNextId(type); - } - } - return Optional.empty(); + return Optional.of(-1); // Postgres auto-generates IDs, so we return -1 as a placeholder } - @Override public synchronized Optional nextAccountId() { - return getNextId(IdTable.ACCOUNT_ID); + return getNextId("account_id"); } @Override public synchronized Optional nextCharacterId() { - return getNextId(IdTable.CHARACTER_ID); + return getNextId("character_id"); } @Override public synchronized Optional nextPartyId() { - return getNextId(IdTable.PARTY_ID); + return getNextId("party_id"); } @Override public synchronized Optional nextGuildId() { - return getNextId(IdTable.GUILD_ID); + return getNextId("guild_id"); } @Override public synchronized Optional nextMemoId() { - return getNextId(IdTable.MEMO_ID); + return getNextId("memo_id"); } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java index 67699745..d58f4fe1 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java @@ -1,94 +1,90 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; -import com.datastax.oss.driver.api.core.cql.ResultSet; -import com.datastax.oss.driver.api.core.cql.Row; +import com.zaxxer.hikari.HikariDataSource; import kinoko.database.MemoAccessor; -import kinoko.database.cassandra.table.MemoTable; import kinoko.server.memo.Memo; import kinoko.server.memo.MemoType; +import java.sql.*; import java.util.ArrayList; import java.util.List; -import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; +public final class PostgresMemoAccessor extends PostgresAccessor implements MemoAccessor { -public final class CassandraMemoAccessor extends CassandraAccessor implements MemoAccessor { - public CassandraMemoAccessor(CqlSession session, String keyspace) { - super(session, keyspace); + public PostgresMemoAccessor(HikariDataSource dataSource) { + super(dataSource); } @Override public List getMemosByCharacterId(int characterId) { - final List memos = new ArrayList<>(); - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), MemoTable.getTableName()) - .columns( - MemoTable.MEMO_ID, - MemoTable.MEMO_TYPE, - MemoTable.MEMO_CONTENT, - MemoTable.SENDER_NAME, - MemoTable.DATE_SENT - ) - .whereColumn(MemoTable.RECEIVER_ID).isEqualTo(literal(characterId)) - .build() - ); - for (Row row : selectResult) { - final MemoType type = MemoType.getByValue(row.getInt(MemoTable.MEMO_TYPE)); - final Memo memo = new Memo( - type != null ? type : MemoType.DEFAULT, - row.getInt(MemoTable.MEMO_ID), - row.getString(MemoTable.SENDER_NAME), - row.getString(MemoTable.MEMO_CONTENT), - row.getInstant(MemoTable.DATE_SENT) - ); - memos.add(memo); + List memos = new ArrayList<>(); + String sql = "SELECT id, memo_type, memo_content, sender_name, date_sent " + + "FROM memo.memo WHERE receiver_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + MemoType type = MemoType.getByValue(rs.getInt("memo_type")); + Memo memo = new Memo( + type != null ? type : MemoType.DEFAULT, + rs.getInt("id"), + rs.getString("sender_name"), + rs.getString("memo_content"), + rs.getTimestamp("date_sent").toInstant() + ); + memos.add(memo); + } + } + } catch (SQLException e) { + e.printStackTrace(); } return memos; } @Override public boolean hasMemo(int characterId) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), MemoTable.getTableName()) - .columns( - MemoTable.RECEIVER_ID - ) - .whereColumn(MemoTable.RECEIVER_ID).isEqualTo(literal(characterId)) - .build() - ); - for (Row row : selectResult) { - final int receiverId = row.getInt(MemoTable.RECEIVER_ID); - if (receiverId == characterId) { - return true; + String sql = "SELECT 1 FROM memo.memo WHERE receiver_id = ? LIMIT 1"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + return rs.next(); } + } catch (SQLException e) { + e.printStackTrace(); } return false; } @Override public boolean newMemo(Memo memo, int receiverId) { - final ResultSet updateResult = getSession().execute( - insertInto(getKeyspace(), MemoTable.getTableName()) - .value(MemoTable.MEMO_ID, literal(memo.getMemoId())) - .value(MemoTable.RECEIVER_ID, literal(receiverId)) - .value(MemoTable.MEMO_TYPE, literal(memo.getType().getValue())) - .value(MemoTable.MEMO_CONTENT, literal(memo.getContent())) - .value(MemoTable.SENDER_NAME, literal(memo.getSender())) - .value(MemoTable.DATE_SENT, literal(memo.getDateSent())) - .ifNotExists() - .build() - ); - return updateResult.wasApplied(); + // `id` is SERIAL, no need to provide it manually + String sql = "INSERT INTO memo.memo (receiver_id, memo_type, memo_content, sender_name, date_sent) " + + "VALUES (?, ?, ?, ?, ?)"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, receiverId); + stmt.setInt(2, memo.getType().getValue()); + stmt.setString(3, memo.getContent()); + stmt.setString(4, memo.getSender()); + stmt.setTimestamp(5, memo.getDateSent() != null ? Timestamp.from(memo.getDateSent()) : Timestamp.from(java.time.Instant.now())); + stmt.executeUpdate(); + return true; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } @Override public boolean deleteMemo(int memoId, int receiverId) { - final ResultSet updateResult = getSession().execute( - deleteFrom(getKeyspace(), MemoTable.getTableName()) - .whereColumn(MemoTable.MEMO_ID).isEqualTo(literal(memoId)) - .build() - ); - return updateResult.wasApplied(); + String sql = "DELETE FROM memo.memo WHERE id = ? AND receiver_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, memoId); + stmt.setInt(2, receiverId); + int affected = stmt.executeUpdate(); + return affected > 0; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } } diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index e69de29b..39937392 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -0,0 +1,488 @@ +BEGIN TRANSACTION; + +------------------------------------------ +--------------SCHEMAS--------------------- +------------------------------------------ +CREATE SCHEMA IF NOT EXISTS account; +CREATE SCHEMA IF NOT EXISTS player; +CREATE SCHEMA IF NOT EXISTS guild; +CREATE SCHEMA IF NOT EXISTS friend; +CREATE SCHEMA IF NOT EXISTS gift; +CREATE SCHEMA IF NOT EXISTS memo; +CREATE SCHEMA IF NOT EXISTS item; + + +------------------------------------------ +--------------FUNCTIONS------------------- +------------------------------------------ +CREATE OR REPLACE FUNCTION public.utc_now() +RETURNS timestamp without time zone +LANGUAGE sql +AS $function$ + SELECT now() AT TIME ZONE 'UTC'; +$function$; + +------------------------------------------ +--------------ITEM TABLES----------------- +------------------------------------------ +CREATE TABLE item.items ( + item_sn BIGSERIAL PRIMARY KEY, -- auto-increment unique ID for every item instance + item_id INT NOT NULL, + quantity INT NOT NULL DEFAULT 1, + attribute SMALLINT DEFAULT 0, + title TEXT DEFAULT '', + date_expire TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_items_item_id + ON item.items(item_id); + +CREATE INDEX IF NOT EXISTS idx_items_date_expire + ON item.items(date_expire); + +CREATE TABLE IF NOT EXISTS item.equip_data ( + item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE, -- unique for every equip item instance + inc_str SMALLINT DEFAULT 0, + inc_dex SMALLINT DEFAULT 0, + inc_int SMALLINT DEFAULT 0, + inc_luk SMALLINT DEFAULT 0, + inc_max_hp SMALLINT DEFAULT 0, + inc_max_mp SMALLINT DEFAULT 0, + inc_pad SMALLINT DEFAULT 0, + inc_mad SMALLINT DEFAULT 0, + inc_pdd SMALLINT DEFAULT 0, + inc_mdd SMALLINT DEFAULT 0, + inc_acc SMALLINT DEFAULT 0, + inc_eva SMALLINT DEFAULT 0, + inc_craft SMALLINT DEFAULT 0, + inc_speed SMALLINT DEFAULT 0, + inc_jump SMALLINT DEFAULT 0, + ruc SMALLINT DEFAULT 0, + cuc SMALLINT DEFAULT 0, + iuc INT DEFAULT 0, + chuc SMALLINT DEFAULT 0, + grade SMALLINT DEFAULT 0, + option_1 SMALLINT DEFAULT 0, + option_2 SMALLINT DEFAULT 0, + option_3 SMALLINT DEFAULT 0, + socket_1 SMALLINT DEFAULT 0, + socket_2 SMALLINT DEFAULT 0, + level_up_type SMALLINT DEFAULT 0, + level SMALLINT DEFAULT 0, + exp INT DEFAULT 0, + durability INT DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_equip_data_item_sn + ON item.equip_data(item_sn); + + +CREATE TABLE IF NOT EXISTS item.pet_data ( + item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE, + pet_name TEXT, + level SMALLINT DEFAULT 0, + fullness SMALLINT DEFAULT 0, + tameness SMALLINT DEFAULT 0, + pet_skill SMALLINT DEFAULT 0, + pet_attribute SMALLINT DEFAULT 0, + remain_life INT DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_pet_data_item_sn + ON item.pet_data(item_sn); + + +CREATE TABLE IF NOT EXISTS item.ring_data ( + item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE, + pair_character_id INT, + pair_character_name TEXT, + pair_item_sn BIGINT +); + +CREATE INDEX IF NOT EXISTS idx_ring_data_item_sn + ON item.ring_data(item_sn); + + +------------------------------------------ +--------------ACCOUNT TABLES-------------- +------------------------------------------ + +CREATE TABLE IF NOT EXISTS account.accounts ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + secondary_password TEXT, + character_slots INT NOT NULL DEFAULT 3, + nx_credit INT NOT NULL DEFAULT 0, + nx_prepaid INT NOT NULL DEFAULT 0, + maple_point INT NOT NULL DEFAULT 0, + trunk_size INT NOT NULL DEFAULT 24, + trunk_money INT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS account.trunk_item ( + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + slot INT NOT NULL, + PRIMARY KEY (account_id, slot) +); + +CREATE INDEX IF NOT EXISTS idx_trunk_item_sn + ON account.trunk_item(item_sn); + +CREATE INDEX IF NOT EXISTS idx_trunk_item_account_item + ON account.trunk_item(account_id, item_sn); + + +CREATE TABLE IF NOT EXISTS account.locker_item ( + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + slot INT NOT NULL, + commodity_id INT, + PRIMARY KEY (account_id, slot) +); + +CREATE INDEX IF NOT EXISTS idx_locker_item_sn + ON account.locker_item(item_sn); + +CREATE INDEX IF NOT EXISTS idx_locker_item_account_item + ON account.locker_item(account_id, item_sn); + + +CREATE TABLE IF NOT EXISTS account.wishlist ( + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, + item_id BIGINT NOT NULL, + slot INT NOT NULL, + PRIMARY KEY (account_id, slot) +); + +CREATE INDEX IF NOT EXISTS idx_wishlist_item_id + ON account.wishlist(item_id); + +CREATE INDEX IF NOT EXISTS idx_wishlist_account_item + ON account.wishlist(account_id, item_id); + + +------------------------------------------ +-------------PLAYER TABLES---------------- +------------------------------------------ + +CREATE TABLE IF NOT EXISTS player.characters ( + id SERIAL PRIMARY KEY, + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, + name TEXT NOT NULL, + money INT NOT NULL DEFAULT 0, + ext_slot_expire TIMESTAMP, + friend_max INT NOT NULL DEFAULT 100, + party_id INT, + guild_id INT, + creation_time TIMESTAMP NOT NULL DEFAULT UTC_NOW(), + max_level_time TIMESTAMP, + online BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS player.stats ( + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + gender SMALLINT NOT NULL, + skin SMALLINT NOT NULL, + face INT NOT NULL, + hair INT NOT NULL, + level SMALLINT NOT NULL DEFAULT 1, + job SMALLINT NOT NULL DEFAULT 0, + sub_job SMALLINT NOT NULL DEFAULT 0, + base_str SMALLINT NOT NULL DEFAULT 4, + base_dex SMALLINT NOT NULL DEFAULT 4, + base_int SMALLINT NOT NULL DEFAULT 4, + base_luk SMALLINT NOT NULL DEFAULT 4, + hp INT NOT NULL DEFAULT 50, + max_hp INT NOT NULL DEFAULT 50, + mp INT NOT NULL DEFAULT 50, + max_mp INT NOT NULL DEFAULT 50, + ap SMALLINT NOT NULL DEFAULT 0, + exp INT NOT NULL DEFAULT 0, + pop SMALLINT NOT NULL DEFAULT 0, + pos_map INT NOT NULL DEFAULT 0, + portal SMALLINT NOT NULL DEFAULT 0, + pet_1 BIGINT, + pet_2 BIGINT, + pet_3 BIGINT +); + +CREATE TABLE IF NOT EXISTS player.skill_points ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + skill_id INT NOT NULL, + points INT NOT NULL DEFAULT 0, + PRIMARY KEY (character_id, skill_id) +); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'inventory_type_enum') THEN + CREATE TYPE inventory_type_enum AS ENUM ( + 'EQUIPPED', + 'EQUIP', + 'CONSUME', + 'INSTALL', + 'ETC', + 'CASH' + ); + END IF; +END$$; + +CREATE TABLE IF NOT EXISTS player.inventory ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + inventory_type inventory_type_enum NOT NULL, + slot INT NOT NULL, + PRIMARY KEY (character_id, item_sn) +); + +CREATE INDEX IF NOT EXISTS idx_inventory_item_sn + ON player.inventory(item_sn); + +CREATE INDEX IF NOT EXISTS idx_inventory_char_item + ON player.inventory(character_id, item_sn); + + +CREATE TABLE IF NOT EXISTS player.skill_cooltime ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + skill_id INT NOT NULL, + cooldown_end TIMESTAMP NOT NULL, + PRIMARY KEY (character_id, skill_id) +); + + +CREATE TABLE IF NOT EXISTS player.skill_record ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + skill_id INT NOT NULL, + level INT NOT NULL, + master_level INT, + PRIMARY KEY (character_id, skill_id) +); + +CREATE TABLE IF NOT EXISTS player.quest_record ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + quest_id INT NOT NULL, + status INT NOT NULL, + progress TEXT, + completed_time TIMESTAMP, + PRIMARY KEY (character_id, quest_id) +); + +CREATE TABLE IF NOT EXISTS player.config ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + pet_consume_item INT NOT NULL DEFAULT 0, + pet_consume_mp_item INT NOT NULL DEFAULT 0, + pet_exception_list INT[] NOT NULL DEFAULT '{}', + func_key_types SMALLINT[] NOT NULL DEFAULT '{}', + func_key_ids INT[] NOT NULL DEFAULT '{}', + quickslot_key_map INT[] NOT NULL DEFAULT '{}', + PRIMARY KEY (character_id) +); + +CREATE TABLE IF NOT EXISTS player.character_macro ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + macro_index INT NOT NULL, -- index/order of the macro + name TEXT NOT NULL, + mute BOOLEAN NOT NULL DEFAULT FALSE, + skills INT[] NOT NULL, -- size: GameConstants.MACRO_SKILL_COUNT + PRIMARY KEY (character_id, macro_index) +); + +CREATE TABLE IF NOT EXISTS player.popularity ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + other_character_id INT NOT NULL, + timestamp TIMESTAMP NOT NULL, + PRIMARY KEY (character_id, other_character_id) +); + +CREATE TABLE IF NOT EXISTS player.minigame ( + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + omok_wins INT NOT NULL DEFAULT 0, + omok_ties INT NOT NULL DEFAULT 0, + omok_losses INT NOT NULL DEFAULT 0, + omok_score DOUBLE PRECISION NOT NULL DEFAULT 0, + memory_wins INT NOT NULL DEFAULT 0, + memory_ties INT NOT NULL DEFAULT 0, + memory_losses INT NOT NULL DEFAULT 0, + memory_score DOUBLE PRECISION NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS player.map_transfer ( + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + map_id INT NOT NULL, + old_map_id INT NOT NULL +); + +CREATE TABLE IF NOT EXISTS player.wild_hunter ( + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + riding_type INT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS player.wild_hunter_mob ( + character_id INT REFERENCES player.characters(id) ON DELETE CASCADE, + mob_id INT NOT NULL, + PRIMARY KEY (character_id, mob_id) +); + +CREATE TABLE IF NOT EXISTS player.config ( + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + config_key TEXT NOT NULL, + config_value TEXT +); + + +------------------------------------------ +--------------FRIEND TABLES--------------- +------------------------------------------ + +CREATE TABLE IF NOT EXISTS friend.friends ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + friend_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + friend_name TEXT NOT NULL, + friend_group TEXT, + friend_status INT NOT NULL DEFAULT 0, + PRIMARY KEY (character_id, friend_id) +); + +CREATE INDEX IF NOT EXISTS idx_friend_friend_id + ON friend.friends(friend_id); + + +------------------------------------------ +---------------GIFT TABLES---------------- +------------------------------------------ + +CREATE TABLE IF NOT EXISTS gift.gift ( + id BIGSERIAL PRIMARY KEY, -- auto-increment unique ID for the gift + receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + commodity_id INT, + sender_id INT, + sender_name TEXT, + sender_message TEXT +); + +CREATE INDEX IF NOT EXISTS idx_gift_item_sn + ON gift.gift(item_sn); + + +CREATE INDEX IF NOT EXISTS idx_gift_receiver + ON gift.gift(receiver_id); + +CREATE INDEX IF NOT EXISTS idx_gift_receiver_item + ON gift.gift(receiver_id, item_sn); + + +------------------------------------------ +---------------GUILD TABLES--------------- +------------------------------------------ + +CREATE TABLE IF NOT EXISTS guild.guilds ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + member_max INT NOT NULL DEFAULT 50, + mark_bg SMALLINT, + mark_bg_color SMALLINT, + mark SMALLINT, + mark_color SMALLINT, + notice TEXT, + points INT NOT NULL DEFAULT 0, + level SMALLINT NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS guild.grade ( + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, + grade_index INT NOT NULL, + grade_name TEXT NOT NULL, + PRIMARY KEY (guild_id, grade_index) +); + +CREATE TABLE IF NOT EXISTS guild.member ( + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + grade SMALLINT NOT NULL, + join_date TIMESTAMP NOT NULL, + last_login TIMESTAMP, + PRIMARY KEY (guild_id, character_id) +); + +CREATE TABLE IF NOT EXISTS guild.board_entry ( + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, + entry_id SERIAL PRIMARY KEY, + poster_id INT NOT NULL, + poster_name TEXT, + message TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS guild.notice ( + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, + poster_id INT NOT NULL, + poster_name TEXT, + message TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL, + PRIMARY KEY (guild_id) +); + + +------------------------------------------ +---------------MEMO TABLES---------------- +------------------------------------------ + +CREATE TABLE IF NOT EXISTS memo.memo ( + id SERIAL PRIMARY KEY, + receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + memo_type INT NOT NULL, + memo_content TEXT NOT NULL, + sender_name TEXT, + date_sent TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_memo_receiver + ON memo.memo(receiver_id); + + +------------------------------------------ +---------------CREATE VIEWS--------------- +------------------------------------------ +CREATE OR REPLACE VIEW item.full_item AS +SELECT + i.item_sn, + i.item_id, + i.quantity, + i.attribute, + i.title, + i.date_expire, + e.inc_str, e.inc_dex, e.inc_int, e.inc_luk, + e.inc_max_hp, e.inc_max_mp, e.inc_pad, e.inc_mad, + e.inc_pdd, e.inc_mdd, e.inc_acc, e.inc_eva, + e.inc_craft, e.inc_speed, e.inc_jump, e.ruc, + e.cuc, e.iuc, e.chuc, e.grade, e.option_1, e.option_2, e.option_3, + e.socket_1, e.socket_2, e.level_up_type, e.level, e.exp, e.durability, + p.pet_name, p.level AS pet_level, p.fullness, p.tameness, p.pet_skill, p.pet_attribute, p.remain_life, + r.pair_character_id, r.pair_character_name, r.pair_item_sn +FROM item.items i +LEFT JOIN item.equip_data e ON i.item_sn = e.item_sn +LEFT JOIN item.pet_data p ON i.item_sn = p.item_sn +LEFT JOIN item.ring_data r ON i.item_sn = r.item_sn; + + +------------------------------------------ +---------------ON CREATION---------------- +------------------------------------------ + +INSERT INTO account.accounts (username, password, secondary_password, character_slots, nx_credit, nx_prepaid, maple_point, trunk_size, trunk_money) +VALUES ( + 'admin', + '$2a$10$LGtpvyti5yVVdWxN8L5sH.UiioiRweGw84mFaWJlfasSFDJ8.QPaW', -- bcrypt hash of "admin" + NULL, + 3, + 0, + 0, + 0, + 24, + 0 +); + +COMMIT TRANSACTION; \ No newline at end of file diff --git a/src/main/java/kinoko/handler/stage/LoginHandler.java b/src/main/java/kinoko/handler/stage/LoginHandler.java index ca425ef3..9f102ff2 100644 --- a/src/main/java/kinoko/handler/stage/LoginHandler.java +++ b/src/main/java/kinoko/handler/stage/LoginHandler.java @@ -56,14 +56,23 @@ public static void handleCheckPassword(Client c, InPacket inPacket) { final byte[] partnerCode = inPacket.decodeArray(4); // Resolve account - final Optional accountResult = DatabaseManager.accountAccessor().getAccountByUsername(username); + Optional accountResult = DatabaseManager.accountAccessor().getAccountByUsername(username); if (accountResult.isEmpty()) { if (ServerConfig.AUTO_CREATE_ACCOUNT) { DatabaseManager.accountAccessor().newAccount(username, password); + // allow an instant login + accountResult = DatabaseManager.accountAccessor().getAccountByUsername(username); } + else { + c.write(LoginPacket.checkPasswordResultFail(LoginResultType.NotRegistered)); + return; + } + } + if (accountResult.isEmpty()){ // final check for ID being registered. c.write(LoginPacket.checkPasswordResultFail(LoginResultType.NotRegistered)); return; } + final Account account = accountResult.get(); // Check if logged in @@ -212,6 +221,9 @@ public static void handleCreateNewCharacter(Client c, InPacket inPacket) { c.write(LoginPacket.createNewCharacterResultFail(LoginResultType.Timeout)); return; } + + + final CharacterData characterData = new CharacterData(c.getAccount().getId()); characterData.setItemSnCounter(new AtomicInteger(1)); characterData.setCreationTime(Instant.now()); @@ -221,7 +233,10 @@ public static void handleCreateNewCharacter(Client c, InPacket inPacket) { final int hp = StatConstants.getMinHp(level, job.getJobId()); final int mp = StatConstants.getMinMp(level, job.getJobId()); final CharacterStat cs = new CharacterStat(); - cs.setId(characterIdResult.get()); + if (characterIdResult.get() != -1) { + // let non-relational databases handle IDs here. + cs.setId(characterIdResult.get()); + } cs.setName(name); cs.setGender(gender); cs.setSkin((byte) selectedAL[3]); diff --git a/src/main/java/kinoko/handler/user/UserHandler.java b/src/main/java/kinoko/handler/user/UserHandler.java index 0fc011c2..0416d4d0 100644 --- a/src/main/java/kinoko/handler/user/UserHandler.java +++ b/src/main/java/kinoko/handler/user/UserHandler.java @@ -124,7 +124,7 @@ public static void handleUserChat(User user, InPacket inPacket) { inPacket.decodeInt(); // update_time final String text = inPacket.decodeString(); // sText final boolean onlyBalloon = inPacket.decodeBoolean(); // bOnlyBalloon - if (text.startsWith(ServerConfig.COMMAND_PREFIX) && text.length() > 1) { + if (text.startsWith(ServerConfig.PLAYER_COMMAND_PREFIX) && text.length() > 1) { CommandProcessor.tryProcessCommand(user, text); return; } @@ -1217,7 +1217,7 @@ public static void handleGroupMessage(User user, InPacket inPacket) { targetIds.add(inPacket.decodeInt()); } final String text = inPacket.decodeString(); // sText - if (text.startsWith(ServerConfig.COMMAND_PREFIX) && text.length() > 1) { + if (text.startsWith(ServerConfig.PLAYER_COMMAND_PREFIX) && text.length() > 1) { CommandProcessor.tryProcessCommand(user, text); return; } diff --git a/src/main/java/kinoko/server/ServerConfig.java b/src/main/java/kinoko/server/ServerConfig.java index e67eaf70..99c9b974 100644 --- a/src/main/java/kinoko/server/ServerConfig.java +++ b/src/main/java/kinoko/server/ServerConfig.java @@ -3,7 +3,10 @@ import kinoko.util.Util; import kinoko.world.GameConstants; + public final class ServerConfig { + public static final boolean TESPIA = Util.getEnv("TESPIA", true); // DEV ENV + public static final int WORLD_ID = Util.getEnv("WORLD_ID", 0); public static final String WORLD_NAME = Util.getEnv("WORLD_NAME", "Kinoko"); public static final int CHANNELS_PER_WORLD = Util.getEnv("CHANNEL_COUNT", 5); @@ -25,7 +28,8 @@ public final class ServerConfig { public static final int ITEM_EXPIRE_INTERVAL = 60; // 180 seconds in BMS public static final int WORLD_SPEAKER_COOLTIME = 60; - public static final String COMMAND_PREFIX = Util.getEnv("COMMAND_PREFIX", "!"); + public static final String PLAYER_COMMAND_PREFIX = Util.getEnv("PLAYER_COMMAND_PREFIX", "@"); + public static final String STAFF_COMMAND_PREFIX = Util.getEnv("STAFF_COMMAND_PREFIX", "!"); public static final boolean DEBUG_MODE = Util.getEnv("DEBUG_MODE", true); public static final boolean PLAIN_TRAFFIC = Util.getEnv("PLAIN_TRAFFIC", false); } diff --git a/src/main/java/kinoko/server/ServerConstants.java b/src/main/java/kinoko/server/ServerConstants.java index e3abd88f..0fc9a361 100644 --- a/src/main/java/kinoko/server/ServerConstants.java +++ b/src/main/java/kinoko/server/ServerConstants.java @@ -14,7 +14,22 @@ public final class ServerConstants { public static final int LOGIN_PORT = 8484; public static final int CHANNEL_PORT = 8585; - public static final String DATABASE_HOST = Util.getEnv("DATABASE_HOST", "127.0.0.1"); - public static final int DATABASE_PORT = 9042; + + // ----------------- Database ----------------- + // Supports localized and containerized env variables. + // It is advised to set these variables in the .env file. + + // General + public static final String DATABASE_HOST = Util.getEnv("DB_HOST", "127.0.0.1"); + public static final int DATABASE_PORT = Util.getEnv("DB_PORT", 9042); // Defaulting to Cassandra port + public static final String DATABASE_NAME = Util.getEnv("DB_NAME", "kinoko"); // Cassandra KeySpace, Postgres DB Name + + // Postgres Specific + public static final String DATABASE_USER = Util.getEnv("DB_USER", "postgres"); + public static final String DATABASE_PASSWORD = Util.getEnv("DB_PASS","admin"); + + // Cassandra Specific + public static final String DATABASE_DATACENTER = Util.getEnv("DB_DATACENTER","datacenter1"); + public static final String DATABASE_PROFILE = Util.getEnv("DB_PROFILE_ONE","profile_one"); } diff --git a/src/main/java/kinoko/server/command/AdminCommands.java b/src/main/java/kinoko/server/command/AdminCommands.java index 46a87387..8b92879d 100644 --- a/src/main/java/kinoko/server/command/AdminCommands.java +++ b/src/main/java/kinoko/server/command/AdminCommands.java @@ -620,7 +620,7 @@ public static void stat(User user, String[] args) { } } default -> { - user.write(MessagePacket.system("Syntax : %sstat hp/mp/str/dex/int/luk/ap/sp ", ServerConfig.COMMAND_PREFIX)); + user.write(MessagePacket.system("Syntax : %sstat hp/mp/str/dex/int/luk/ap/sp ", ServerConfig.PLAYER_COMMAND_PREFIX)); return; } } diff --git a/src/main/java/kinoko/server/command/CommandProcessor.java b/src/main/java/kinoko/server/command/CommandProcessor.java index d8ad56cf..95c8d19b 100644 --- a/src/main/java/kinoko/server/command/CommandProcessor.java +++ b/src/main/java/kinoko/server/command/CommandProcessor.java @@ -44,11 +44,11 @@ public static String getHelpString(Method method) { final Arguments arguments = method.getAnnotation(Arguments.class); final String commandString = String.join("|", command.value()); final List argumentString = Arrays.stream(arguments != null ? arguments.value() : new String[]{}).map((value) -> String.format("<%s>", value)).toList(); - return String.format("%s%s %s", ServerConfig.COMMAND_PREFIX, commandString, String.join(" ", argumentString)); + return String.format("%s%s %s", ServerConfig.PLAYER_COMMAND_PREFIX, commandString, String.join(" ", argumentString)); } public static void tryProcessCommand(User user, String text) { - final String[] arguments = text.replaceFirst(ServerConfig.COMMAND_PREFIX, "").split(" "); + final String[] arguments = text.replaceFirst(ServerConfig.PLAYER_COMMAND_PREFIX, "").split(" "); final String commandName = arguments[0].toLowerCase(); final Optional commandResult = getCommand(commandName); if (commandResult.isEmpty()) { diff --git a/src/main/java/kinoko/util/Util.java b/src/main/java/kinoko/util/Util.java index 83a61f72..1c70ce20 100644 --- a/src/main/java/kinoko/util/Util.java +++ b/src/main/java/kinoko/util/Util.java @@ -1,5 +1,7 @@ package kinoko.util; +import io.github.cdimascio.dotenv.Dotenv; + import java.net.InetAddress; import java.net.UnknownHostException; import java.security.SecureRandom; @@ -13,19 +15,37 @@ public final class Util { private static final HexFormat hexFormat = HexFormat.ofDelimiter(" ").withUpperCase(); private static final Random random = new SecureRandom(); + + private static final Dotenv dotenv = Dotenv.configure() + .ignoreIfMissing() // doesn't fail if .env is missing + .load(); + public static String getEnv(String name, String defaultValue) { - final String value = System.getenv(name); - return value != null ? value : defaultValue; + String value = System.getenv(name); // check system env + if (value != null) return value; + + value = dotenv.get(name); // check .env file + if (value != null) return value; + + return defaultValue; // fallback to the default } public static int getEnv(String name, int defaultValue) { - final String value = System.getenv(name); - return value != null ? Integer.parseInt(value) : defaultValue; + String value = getEnv(name, null); + if (value != null) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException ignored) {} + } + return defaultValue; } public static boolean getEnv(String name, boolean defaultValue) { - final String value = System.getenv(name); - return value != null ? Boolean.parseBoolean(value) : defaultValue; + String value = getEnv(name, null); + if (value != null) { + return Boolean.parseBoolean(value); + } + return defaultValue; } public static byte[] getHost(String name) { diff --git a/src/main/java/kinoko/world/GameConstants.java b/src/main/java/kinoko/world/GameConstants.java index 18617e51..385688a1 100644 --- a/src/main/java/kinoko/world/GameConstants.java +++ b/src/main/java/kinoko/world/GameConstants.java @@ -1,5 +1,6 @@ package kinoko.world; +import kinoko.database.DatabaseManager; import kinoko.server.ServerConfig; import kinoko.util.Tuple; import kinoko.world.job.Job; diff --git a/src/main/java/kinoko/world/item/EquipData.java b/src/main/java/kinoko/world/item/EquipData.java index fa0010fc..dc7d167c 100644 --- a/src/main/java/kinoko/world/item/EquipData.java +++ b/src/main/java/kinoko/world/item/EquipData.java @@ -81,6 +81,48 @@ public EquipData(EquipData equipData) { this.durability = equipData.durability; } + public EquipData( + short incStr, short incDex, short incInt, short incLuk, + short incMaxHp, short incMaxMp, short incPad, short incMad, + short incPdd, short incMdd, short incAcc, short incEva, + short incCraft, short incSpeed, short incJump, + byte ruc, byte cuc, int iuc, byte chuc, byte grade, + short option1, short option2, short option3, + short socket1, short socket2, + byte levelUpType, byte level, + int exp, int durability + ) { + this.incStr = incStr; + this.incDex = incDex; + this.incInt = incInt; + this.incLuk = incLuk; + this.incMaxHp = incMaxHp; + this.incMaxMp = incMaxMp; + this.incPad = incPad; + this.incMad = incMad; + this.incPdd = incPdd; + this.incMdd = incMdd; + this.incAcc = incAcc; + this.incEva = incEva; + this.incCraft = incCraft; + this.incSpeed = incSpeed; + this.incJump = incJump; + this.ruc = ruc; + this.cuc = cuc; + this.iuc = iuc; + this.chuc = chuc; + this.grade = grade; + this.option1 = option1; + this.option2 = option2; + this.option3 = option3; + this.socket1 = socket1; + this.socket2 = socket2; + this.levelUpType = levelUpType; + this.level = level; + this.exp = exp; + this.durability = durability; + } + public void encode(OutPacket outPacket, Item item) { outPacket.encodeByte(getRuc()); // nRUC outPacket.encodeByte(getCuc()); // nCUC diff --git a/src/main/java/kinoko/world/item/Inventory.java b/src/main/java/kinoko/world/item/Inventory.java index 83e87ca2..a3aedb78 100644 --- a/src/main/java/kinoko/world/item/Inventory.java +++ b/src/main/java/kinoko/world/item/Inventory.java @@ -39,6 +39,10 @@ public void putItem(int position, Item item) { } } + public void addItem(int position, Item item) { + putItem(position, item); + } + public Item removeItem(int position) { return items.remove(Math.abs(position)); } diff --git a/src/main/java/kinoko/world/item/InventoryManager.java b/src/main/java/kinoko/world/item/InventoryManager.java index 6716e00b..8d875686 100644 --- a/src/main/java/kinoko/world/item/InventoryManager.java +++ b/src/main/java/kinoko/world/item/InventoryManager.java @@ -20,6 +20,17 @@ public final class InventoryManager { private int money; private Instant extSlotExpire; + public InventoryManager() { + this.equipped = new Inventory(24); + this.equipInventory = new Inventory(96); + this.consumeInventory = new Inventory(96); + this.installInventory = new Inventory(96); + this.etcInventory = new Inventory(96); + this.cashInventory = new Inventory(96); + this.money = 0; + this.extSlotExpire = null; + } + public Inventory getEquipped() { return equipped; } diff --git a/src/main/java/kinoko/world/item/Item.java b/src/main/java/kinoko/world/item/Item.java index 231cc8c1..a9ab16bb 100644 --- a/src/main/java/kinoko/world/item/Item.java +++ b/src/main/java/kinoko/world/item/Item.java @@ -36,6 +36,37 @@ public Item(Item item) { this.ringData = item.ringData != null ? new RingData(item.ringData) : null; } + public Item(int itemId, short quantity) { + this(ItemType.getByItemId(itemId)); + this.itemId = itemId; + this.quantity = quantity; + } + + public Item( + int itemId, + short quantity, + long itemSn, + boolean cash, + short attribute, + String title, + Instant dateExpire, + EquipData equipData, + PetData petData, + RingData ringData + ) { + this(ItemType.getByItemId(itemId)); + this.itemId = itemId; + this.quantity = quantity; + this.itemSn = itemSn; + this.cash = cash; + this.attribute = attribute; + this.title = title; + this.dateExpire = dateExpire; + this.equipData = equipData; + this.petData = petData; + this.ringData = ringData; + } + @Override public void encode(OutPacket outPacket) { outPacket.encodeByte(getItemType().getValue()); // nType diff --git a/src/main/java/kinoko/world/item/PetData.java b/src/main/java/kinoko/world/item/PetData.java index 5d8e93b2..7392a819 100644 --- a/src/main/java/kinoko/world/item/PetData.java +++ b/src/main/java/kinoko/world/item/PetData.java @@ -26,6 +26,25 @@ public PetData(PetData petData) { this.remainLife = petData.remainLife; } + public PetData( + String petName, + byte level, + byte fullness, + short tameness, + short petSkill, + short petAttribute, + int remainLife + ) { + this.petName = petName; + this.level = level; + this.fullness = fullness; + this.tameness = tameness; + this.petSkill = petSkill; + this.petAttribute = petAttribute; + this.remainLife = remainLife; + } + + public void encode(OutPacket outPacket, Item item) { outPacket.encodeString(getPetName(), 13); // sPetName outPacket.encodeByte(getLevel()); // nLevel diff --git a/src/main/java/kinoko/world/item/RingData.java b/src/main/java/kinoko/world/item/RingData.java index 5aa9879c..2d5e6b60 100644 --- a/src/main/java/kinoko/world/item/RingData.java +++ b/src/main/java/kinoko/world/item/RingData.java @@ -14,6 +14,17 @@ public RingData(RingData ringData) { this.pairItemSn = ringData.pairItemSn; } + public RingData( + int pairCharacterId, + String pairCharacterName, + long pairItemSn + ) { + this.pairCharacterId = pairCharacterId; + this.pairCharacterName = pairCharacterName; + this.pairItemSn = pairItemSn; + } + + public int getPairCharacterId() { return pairCharacterId; } diff --git a/src/main/java/kinoko/world/quest/QuestRecord.java b/src/main/java/kinoko/world/quest/QuestRecord.java index 24ec6385..cd340e24 100644 --- a/src/main/java/kinoko/world/quest/QuestRecord.java +++ b/src/main/java/kinoko/world/quest/QuestRecord.java @@ -12,6 +12,14 @@ public QuestRecord(int questId) { this.questId = questId; } + public QuestRecord(int questId, QuestState state, String value, Instant completedTime) { + this.questId = questId; + this.state = state; + this.value = value; + this.completedTime = completedTime; + } + + public int getQuestId() { return questId; } diff --git a/src/main/java/kinoko/world/skill/SkillRecord.java b/src/main/java/kinoko/world/skill/SkillRecord.java index 9baa3b8d..5b45b56a 100644 --- a/src/main/java/kinoko/world/skill/SkillRecord.java +++ b/src/main/java/kinoko/world/skill/SkillRecord.java @@ -9,6 +9,12 @@ public SkillRecord(int skillId) { this.skillId = skillId; } + public SkillRecord(int skillId, int skillLevel, int masterLevel) { + this.skillId = skillId; + this.skillLevel = skillLevel; + this.masterLevel = masterLevel; + } + public int getSkillId() { return skillId; } diff --git a/src/main/java/kinoko/world/user/CharacterData.java b/src/main/java/kinoko/world/user/CharacterData.java index ed9e8e25..d8440b24 100644 --- a/src/main/java/kinoko/world/user/CharacterData.java +++ b/src/main/java/kinoko/world/user/CharacterData.java @@ -1,5 +1,6 @@ package kinoko.world.user; +import kinoko.database.DatabaseManager; import kinoko.server.dialog.miniroom.MiniRoomType; import kinoko.server.packet.OutPacket; import kinoko.util.Encodable; @@ -16,6 +17,7 @@ import kinoko.world.user.data.*; import kinoko.world.user.stat.CharacterStat; +import javax.xml.crypto.Data; import java.time.Duration; import java.time.Instant; import java.util.HashMap; @@ -131,11 +133,11 @@ public void setWildHunterInfo(WildHunterInfo wildHunterInfo) { } public AtomicInteger getItemSnCounter() { - return itemSnCounter; + return DatabaseManager.isRelational() ? new AtomicInteger(-1) : itemSnCounter; } public void setItemSnCounter(AtomicInteger itemSnCounter) { - this.itemSnCounter = itemSnCounter; + this.itemSnCounter = DatabaseManager.isRelational() ? new AtomicInteger(-1) : itemSnCounter; } public int getFriendMax() { @@ -179,6 +181,11 @@ public void setMaxLevelTime(Instant maxLevelTime) { } public long getNextItemSn() { + if (DatabaseManager.isRelational()) { + // Let the relational database handle SN generation; return placeholder + return -1; + } + return ((long) itemSnCounter.getAndIncrement()) | (((long) getCharacterId()) << 32); } diff --git a/src/main/java/kinoko/world/user/data/ConfigManager.java b/src/main/java/kinoko/world/user/data/ConfigManager.java index 9e153581..2bf1b0a9 100644 --- a/src/main/java/kinoko/world/user/data/ConfigManager.java +++ b/src/main/java/kinoko/world/user/data/ConfigManager.java @@ -25,6 +25,18 @@ public ConfigManager(FuncKeyMapped[] funcKeyMap, int[] quickslotKeyMap) { this.petExceptionList = List.of(); } + public ConfigManager(int petConsumeItem, int petConsumeMpItem, List petExceptionList, + FuncKeyMapped[] funcKeyMap, int[] quickslotKeyMap) { + assert funcKeyMap.length == GameConstants.FUNC_KEY_MAP_SIZE; + assert quickslotKeyMap.length == GameConstants.QUICKSLOT_KEY_MAP_SIZE; + + this.petConsumeItem = petConsumeItem; + this.petConsumeMpItem = petConsumeMpItem; + this.petExceptionList = petExceptionList != null ? petExceptionList : List.of(); + this.funcKeyMap = funcKeyMap; + this.quickslotKeyMap = quickslotKeyMap; + } + public List getMacroSysData() { return macroSysData; } diff --git a/src/main/java/kinoko/world/user/stat/CharacterStat.java b/src/main/java/kinoko/world/user/stat/CharacterStat.java index 0c171f6c..0dd20bd7 100644 --- a/src/main/java/kinoko/world/user/stat/CharacterStat.java +++ b/src/main/java/kinoko/world/user/stat/CharacterStat.java @@ -7,6 +7,7 @@ import kinoko.world.job.JobConstants; import java.util.EnumMap; +import java.util.HashMap; import java.util.Map; public final class CharacterStat implements Encodable { @@ -37,6 +38,45 @@ public final class CharacterStat implements Encodable { private long petSn2; private long petSn3; + public CharacterStat(){ + + } + + public CharacterStat(int id, String name, byte gender, byte skin, int face, int hair, + short level, short job, short subJob, + short baseStr, short baseDex, short baseInt, short baseLuk, + int hp, int maxHp, int mp, int maxMp, short ap, + int exp, short pop, int posMap, byte portal, + long petSn1, long petSn2, long petSn3) { + this.id = id; + this.name = name; + this.gender = gender; + this.skin = skin; + this.face = face; + this.hair = hair; + this.level = level; + this.job = job; + this.subJob = subJob; + this.baseStr = baseStr; + this.baseDex = baseDex; + this.baseInt = baseInt; + this.baseLuk = baseLuk; + this.hp = hp; + this.maxHp = maxHp; + this.mp = mp; + this.maxMp = maxMp; + this.ap = ap; + this.exp = exp; + this.pop = pop; + this.posMap = posMap; + this.portal = portal; + this.petSn1 = petSn1; + this.petSn2 = petSn2; + this.petSn3 = petSn3; + // TODO: give this a legit value. + this.sp = new ExtendSp(new HashMap<>()); + } + public int getId() { return id; } From b78df49a7c22d87f69b2ff1aa9bcc1df3ce4f335 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 15:16:37 -0400 Subject: [PATCH 05/83] updated readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b83a607e..79d00861 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ $ mvn clean package #### Environment setup Before doing any Docker or Database Setup You should: -1. Make a copy of `.env.example` and rename it to `.env`. +1. Make a copy of `.env.example` and rename it to `.env` 2. Adjust the ENV Variables to the database server you will be using. @@ -53,7 +53,6 @@ You should: It is possible to use either CassandraDB, ScyllaDB, or Postgres. - ```bash # Start CassandraDB $ docker-compose up -d cassandra @@ -68,7 +67,8 @@ $ docker-compose up -d postgres # OR (CHANGE THE PASSWORD) $ docker run -d --name postgres_kinoko -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=admin -e POSTGRES_INITDB_ARGS="--auth-host=scram-sha-256 --auth-local=scram-sha-256" -e POSTGRES_DB=kinoko -p 5432:5432 -v "${PWD}\src\main\java\kinoko\database\postgresql\setup\init.sql:/docker-entrypoint-initdb.d/init.sql:ro" postgres:16 -Important: If you are using PostgreSQL on a local machine (not using a dockerized server), make sure that you have any undockerized postgresql server offline. This can cause conflicts. +Important: If you are using PostgreSQL on a local machine (not using a dockerized server), +make sure that you have any undockerized postgresql server offline. This can cause conflicts. ``` From 97c3a01fb5f17e8b03d227a11710dca04d80067e Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 15:39:31 -0400 Subject: [PATCH 06/83] Added a DB TYPE to the .env.example --- .env.example | 3 +++ src/main/java/kinoko/database/DatabaseManager.java | 14 ++++++++------ src/main/java/kinoko/server/ServerConstants.java | 2 ++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 9fb73436..a9edac1c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ # This example shows a setup for Cassandra on Prod +# cassandra, postgres +DB_TYPE=cassandra + # DEV ENV TESPIA=FALSE diff --git a/src/main/java/kinoko/database/DatabaseManager.java b/src/main/java/kinoko/database/DatabaseManager.java index e2b3d19a..6feddc9d 100644 --- a/src/main/java/kinoko/database/DatabaseManager.java +++ b/src/main/java/kinoko/database/DatabaseManager.java @@ -6,6 +6,7 @@ import kinoko.server.ServerConstants; import kinoko.world.GameConstants; +import java.util.List; import java.util.Objects; public final class DatabaseManager { @@ -51,16 +52,17 @@ public static boolean isRelational() { public static void initialize() { - // Prod Environment - if (Objects.equals(ServerConstants.DATABASE_HOST, "cassandra_kinoko")) { + if (Objects.equals(ServerConstants.DATABASE_HOST, "cassandra_kinoko") + || Objects.equals(ServerConstants.DATABASE_TYPE, "cassandra")) { connector = new CassandraConnector(); } - else if (Objects.equals(ServerConstants.DATABASE_HOST, "postgres_kinoko")){ + else if (Objects.equals(ServerConstants.DATABASE_HOST, "postgres_kinoko") + || (List.of("psql", "postgres", "postgresql").contains(ServerConstants.DATABASE_TYPE))){ connector = new PostgresConnector(); } - else { // Your choice, likely in a dev environment. -// connector = new CassandraConnector(); - connector = new PostgresConnector(); + else { // Your choice, defaulting to cassandra. + connector = new CassandraConnector(); +// connector = new PostgresConnector(); } connector.initialize(); } diff --git a/src/main/java/kinoko/server/ServerConstants.java b/src/main/java/kinoko/server/ServerConstants.java index 0fc9a361..c95d53ed 100644 --- a/src/main/java/kinoko/server/ServerConstants.java +++ b/src/main/java/kinoko/server/ServerConstants.java @@ -20,6 +20,7 @@ public final class ServerConstants { // It is advised to set these variables in the .env file. // General + public static final String DATABASE_TYPE = Util.getEnv("DB_TYPE","cassandra").toLowerCase(); public static final String DATABASE_HOST = Util.getEnv("DB_HOST", "127.0.0.1"); public static final int DATABASE_PORT = Util.getEnv("DB_PORT", 9042); // Defaulting to Cassandra port public static final String DATABASE_NAME = Util.getEnv("DB_NAME", "kinoko"); // Cassandra KeySpace, Postgres DB Name @@ -31,5 +32,6 @@ public final class ServerConstants { // Cassandra Specific public static final String DATABASE_DATACENTER = Util.getEnv("DB_DATACENTER","datacenter1"); public static final String DATABASE_PROFILE = Util.getEnv("DB_PROFILE_ONE","profile_one"); + } From 92f6112d365a6b9a1d88ee3f8c67704fd934b7c2 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 15:40:15 -0400 Subject: [PATCH 07/83] updated gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9b66c505..e4b9dcdb 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,6 @@ build/ .idea/ ### Project ### +/wz/ /wz/*.wz /json/ From b8ceb7989f0d073154ca96376b1f115359cf5198 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 15:47:22 -0400 Subject: [PATCH 08/83] removed unused imports --- src/main/java/kinoko/database/DatabaseManager.java | 2 -- .../java/kinoko/database/cassandra/CassandraConnector.java | 2 -- .../java/kinoko/database/postgresql/PostgresConnector.java | 1 - .../kinoko/database/postgresql/PostgresGuildAccessor.java | 1 - .../java/kinoko/database/postgresql/PostgresIdAccessor.java | 4 ---- src/main/java/kinoko/world/GameConstants.java | 1 - src/main/java/kinoko/world/user/CharacterData.java | 1 - 7 files changed, 12 deletions(-) diff --git a/src/main/java/kinoko/database/DatabaseManager.java b/src/main/java/kinoko/database/DatabaseManager.java index 6feddc9d..baba218e 100644 --- a/src/main/java/kinoko/database/DatabaseManager.java +++ b/src/main/java/kinoko/database/DatabaseManager.java @@ -2,9 +2,7 @@ import kinoko.database.postgresql.PostgresConnector; import kinoko.database.cassandra.CassandraConnector; -import kinoko.server.ServerConfig; import kinoko.server.ServerConstants; -import kinoko.world.GameConstants; import java.util.List; import java.util.Objects; diff --git a/src/main/java/kinoko/database/cassandra/CassandraConnector.java b/src/main/java/kinoko/database/cassandra/CassandraConnector.java index 676c665e..9960261e 100644 --- a/src/main/java/kinoko/database/cassandra/CassandraConnector.java +++ b/src/main/java/kinoko/database/cassandra/CassandraConnector.java @@ -17,8 +17,6 @@ import kinoko.database.cassandra.codec.*; import kinoko.database.cassandra.table.*; import kinoko.database.cassandra.type.*; -import kinoko.server.Server; -import kinoko.server.ServerConfig; import kinoko.server.ServerConstants; import kinoko.server.cashshop.CashItemInfo; import kinoko.server.guild.GuildBoardComment; diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java index 52a9cd4c..fe656e54 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresConnector.java +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -4,7 +4,6 @@ import java.util.TimeZone; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; -import kinoko.server.ServerConfig; import kinoko.server.ServerConstants; public final class PostgresConnector implements DatabaseConnector { diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java index 04360a08..9d69cdae 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -10,7 +10,6 @@ import java.sql.*; import java.util.*; -import java.util.concurrent.atomic.AtomicInteger; public final class PostgresGuildAccessor extends PostgresAccessor implements GuildAccessor { diff --git a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java index 038dcf39..d8fc3b99 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java @@ -3,10 +3,6 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.IdAccessor; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; import java.util.Optional; public final class PostgresIdAccessor extends PostgresAccessor implements IdAccessor { diff --git a/src/main/java/kinoko/world/GameConstants.java b/src/main/java/kinoko/world/GameConstants.java index 385688a1..18617e51 100644 --- a/src/main/java/kinoko/world/GameConstants.java +++ b/src/main/java/kinoko/world/GameConstants.java @@ -1,6 +1,5 @@ package kinoko.world; -import kinoko.database.DatabaseManager; import kinoko.server.ServerConfig; import kinoko.util.Tuple; import kinoko.world.job.Job; diff --git a/src/main/java/kinoko/world/user/CharacterData.java b/src/main/java/kinoko/world/user/CharacterData.java index d8440b24..2b20e5c3 100644 --- a/src/main/java/kinoko/world/user/CharacterData.java +++ b/src/main/java/kinoko/world/user/CharacterData.java @@ -17,7 +17,6 @@ import kinoko.world.user.data.*; import kinoko.world.user.stat.CharacterStat; -import javax.xml.crypto.Data; import java.time.Duration; import java.time.Instant; import java.util.HashMap; From fe5ff110b7bb336d98b1b4a6ace4e5fd12ea6268 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 19:33:31 -0400 Subject: [PATCH 09/83] Fixed Trunk --- .../postgresql/PostgresAccountAccessor.java | 134 ++--------- .../postgresql/PostgresCharacterAccessor.java | 105 +-------- .../postgresql/PostgresGuildAccessor.java | 39 ++-- .../postgresql/PostgresMemoAccessor.java | 12 +- .../postgresql/type/InventoryDao.java | 134 +++++++++++ .../database/postgresql/type/ItemDao.java | 217 ++++++++++++++++++ .../database/postgresql/type/LockerDao.java | 92 ++++++++ .../database/postgresql/type/TrunkDao.java | 131 +++++++++++ .../database/postgresql/type/WishlistDao.java | 75 ++++++ .../java/kinoko/world/item/Inventory.java | 21 +- .../kinoko/world/item/InventoryEntry.java | 5 + .../kinoko/world/item/InventoryManager.java | 12 +- src/main/java/kinoko/world/item/Item.java | 13 ++ 13 files changed, 752 insertions(+), 238 deletions(-) create mode 100644 src/main/java/kinoko/database/postgresql/type/InventoryDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/ItemDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/LockerDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/TrunkDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/WishlistDao.java create mode 100644 src/main/java/kinoko/world/item/InventoryEntry.java diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java index 820b0147..495819ee 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java @@ -3,6 +3,9 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.AccountAccessor; import kinoko.database.DatabaseManager; +import kinoko.database.postgresql.type.LockerDao; +import kinoko.database.postgresql.type.TrunkDao; +import kinoko.database.postgresql.type.WishlistDao; import kinoko.server.ServerConfig; import kinoko.server.cashshop.CashItemInfo; import kinoko.world.item.Item; @@ -23,7 +26,7 @@ public PostgresAccountAccessor(HikariDataSource dataSource) { super(dataSource); } - private Account loadAccount(ResultSet rs) throws SQLException { + private Account loadAccount(Connection conn, ResultSet rs) throws SQLException { final int accountId = rs.getInt("id"); final String username = rs.getString("username"); final String secondaryPassword = rs.getString("secondary_password"); @@ -35,59 +38,14 @@ private Account loadAccount(ResultSet rs) throws SQLException { account.setNxPrepaid(rs.getInt("nx_prepaid")); account.setMaplePoint(rs.getInt("maple_point")); - account.setTrunk(loadTrunk(accountId)); + account.setTrunk(TrunkDao.load(conn, accountId)); + account.setLocker(loadLocker(accountId)); account.setWishlist(loadWishlist(accountId)); return account; } - private Trunk loadTrunk(int accountId) throws SQLException { - int trunkSize = ServerConfig.TRUNK_BASE_SLOTS; - int trunkMoney = 0; - - String accountSql = "SELECT trunk_size, trunk_money FROM account.accounts WHERE id = ?"; - String itemsSql = """ - SELECT ti.slot, i.item_id, i.quantity - FROM account.trunk_item ti - JOIN item.items i ON ti.item_sn = i.item_sn - WHERE ti.account_id = ? - ORDER BY ti.slot - """; - - Trunk trunk; - - try (Connection conn = getConnection()) { - // Query trunk info - try (PreparedStatement stmt = conn.prepareStatement(accountSql)) { - stmt.setInt(1, accountId); - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - trunkSize = rs.getInt("trunk_size"); - trunkMoney = rs.getInt("trunk_money"); - } - } - } - - // Initialize trunk with proper size - trunk = new Trunk(trunkSize); - trunk.setMoney(trunkMoney); - - // Query items - try (PreparedStatement stmt = conn.prepareStatement(itemsSql)) { - stmt.setInt(1, accountId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - Item item = new Item(rs.getInt("item_id"), (short) rs.getInt("quantity")); - trunk.getItems().add(item); - } - } - } - } - - return trunk; - } - private Locker loadLocker(int accountId) throws SQLException { Locker locker = new Locker(); @@ -95,7 +53,8 @@ private Locker loadLocker(int accountId) throws SQLException { "FROM account.locker_item li " + "JOIN item.items i ON li.item_sn = i.item_sn " + "WHERE li.account_id = ? ORDER BY li.slot"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, accountId); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { @@ -118,7 +77,8 @@ private List loadWishlist(int accountId) throws SQLException { List wishlist = new ArrayList<>(); String sql = "SELECT w.item_id FROM account.wishlist w " + "WHERE w.account_id = ? ORDER BY w.slot"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, accountId); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { @@ -145,11 +105,12 @@ private boolean checkHashedPassword(String password, String hashedPassword) { @Override public Optional getAccountById(int accountId) { String sql = "SELECT * FROM account.accounts WHERE id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, accountId); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - return Optional.of(loadAccount(rs)); + return Optional.of(loadAccount(conn, rs)); } } } catch (SQLException e) { @@ -161,11 +122,12 @@ public Optional getAccountById(int accountId) { @Override public Optional getAccountByUsername(String username) { String sql = "SELECT * FROM account.accounts WHERE username = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, lowerUsername(username)); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - return Optional.of(loadAccount(rs)); + return Optional.of(loadAccount(conn, rs)); } } } catch (SQLException e) { @@ -178,7 +140,8 @@ public Optional getAccountByUsername(String username) { public boolean checkPassword(Account account, String password, boolean secondary) { String column = secondary ? "secondary_password" : "password"; String sql = "SELECT " + column + " FROM account.accounts WHERE id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, account.getId()); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { @@ -229,7 +192,8 @@ public synchronized boolean newAccount(String username, String password) { "VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + "RETURNING ID"; System.out.println("Creating account."); - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, lowerUsername(username)); stmt.setString(2, hashPassword(password)); stmt.setInt(3, ServerConfig.CHARACTER_BASE_SLOTS); @@ -292,60 +256,10 @@ public boolean saveAccount(Account account) { stmt.executeUpdate(); } - // Save trunk - try (PreparedStatement delTrunk = conn.prepareStatement("DELETE FROM account.trunk_item WHERE account_id = ?")) { - delTrunk.setInt(1, account.getId()); - delTrunk.executeUpdate(); - } - int slot = 0; - for (Item item : account.getTrunk().getItems()) { - try (PreparedStatement insertTrunk = conn.prepareStatement( - "INSERT INTO account.trunk_item (account_id, slot, item_sn) VALUES (?, ?, ?)")) { - insertTrunk.setInt(1, account.getId()); - insertTrunk.setInt(2, slot++); - insertTrunk.setObject(3, item.getItemSn()); // null if empty - insertTrunk.executeUpdate(); - } - } - - // Save wishlist - try (PreparedStatement delWish = conn.prepareStatement("DELETE FROM account.wishlist WHERE account_id = ?")) { - delWish.setInt(1, account.getId()); - delWish.executeUpdate(); - } - slot = 0; - for (Integer itemId : account.getWishlist()) { - try (PreparedStatement insertWish = conn.prepareStatement( - """ - INSERT INTO account.wishlist (account_id, slot, item_id) - VALUES (?, ?, ?) - ON CONFLICT (account_id, slot) DO UPDATE - SET item_id = EXCLUDED.item_id - """ - )) { - insertWish.setInt(1, account.getId()); - insertWish.setInt(2, slot++); - insertWish.setInt(3, itemId); // now using item_id, not item_sn - insertWish.executeUpdate(); - } - } - - // Save locker - try (PreparedStatement delLocker = conn.prepareStatement("DELETE FROM account.locker_item WHERE account_id = ?")) { - delLocker.setInt(1, account.getId()); - delLocker.executeUpdate(); - } - slot = 0; - for (CashItemInfo cash : account.getLocker().getCashItems()) { - try (PreparedStatement insertLocker = conn.prepareStatement( - "INSERT INTO account.locker_item (account_id, slot, item_sn, commodity_id) VALUES (?, ?, ?, ?)")) { - insertLocker.setInt(1, account.getId()); - insertLocker.setInt(2, slot++); - insertLocker.setObject(3, cash.getItem().getItemSn()); - insertLocker.setInt(4, cash.getCommodityId()); - insertLocker.executeUpdate(); - } - } + int accountId = account.getId(); + TrunkDao.save(conn, accountId, account.getTrunk()); + WishlistDao.save(conn, accountId, account.getWishlist()); + LockerDao.save(conn, accountId, account.getLocker()); conn.commit(); conn.setAutoCommit(true); diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java index ebc73e64..49486c4d 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -3,6 +3,7 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.CharacterAccessor; import kinoko.database.CharacterInfo; +import kinoko.database.postgresql.type.InventoryDao; import kinoko.server.rank.CharacterRank; import kinoko.world.GameConstants; import kinoko.world.item.*; @@ -1154,109 +1155,7 @@ ON CONFLICT (character_id) DO UPDATE SET private void saveCharacterInventory(Connection conn, CharacterData characterData) throws SQLException { - String sqlInventory = """ - INSERT INTO player.inventory (character_id, inventory_type, slot, item_sn) - VALUES (?, ?, ?, ?) - ON CONFLICT (character_id, item_sn) - DO UPDATE SET slot = EXCLUDED.slot, inventory_type = EXCLUDED.inventory_type - """; - - String sqlItems = """ - INSERT INTO item.items (item_sn, item_id, quantity, attribute, title, date_expire) - VALUES (?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement stmtInventory = conn.prepareStatement(sqlInventory); - PreparedStatement stmtItems = conn.prepareStatement(sqlItems)) { - - InventoryManager inv = characterData.getInventoryManager(); - - saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.EQUIPPED, inv.getEquipped().getItems()); - saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.EQUIP, inv.getEquipInventory().getItems()); - saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.CONSUME, inv.getConsumeInventory().getItems()); - saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.INSTALL, inv.getInstallInventory().getItems()); - saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.ETC, inv.getEtcInventory().getItems()); - saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.CASH, inv.getCashInventory().getItems()); - - stmtItems.executeBatch(); - stmtInventory.executeBatch(); - } - } - - - - private void saveInventoryBatch( - Connection conn, - PreparedStatement stmtItems, - PreparedStatement stmtInventory, - int charId, - InventoryType type, - Map items - ) throws SQLException { - - PGobject enumValue = new PGobject(); - enumValue.setType("inventory_type_enum"); - enumValue.setValue(type.name()); - - // --- Delete inventory items that no longer exist --- - if (!items.isEmpty()) { - Long[] itemSnArray = items.values().stream() - .map(Item::getItemSn) - .toArray(Long[]::new); - - try (PreparedStatement deleteStmt = conn.prepareStatement("DELETE FROM player.inventory WHERE character_id = ? AND item_sn <> ALL (?)")) { - deleteStmt.setInt(1, charId); - Array sqlArray = conn.createArrayOf("bigint", itemSnArray); - deleteStmt.setArray(2, sqlArray); - deleteStmt.executeUpdate(); - } - } else { // delete all items. - try (PreparedStatement deleteStmt = conn.prepareStatement( - "DELETE FROM player.inventory WHERE character_id = ?")) { - deleteStmt.setInt(1, charId); - deleteStmt.executeUpdate(); - } - } - - // --- Insert/update items --- - for (var entry : items.entrySet()) { - Item item = entry.getValue(); - long itemSn = item.getItemSn(); - - if (itemSn <= 0) { // DNE - try (PreparedStatement seqStmt = conn.prepareStatement( - "SELECT nextval(pg_get_serial_sequence('item.items', 'item_sn'))"); - ResultSet rs = seqStmt.executeQuery()) { - rs.next(); - itemSn = rs.getLong(1); - item.setItemSn(itemSn); - } - - stmtItems.setLong(1, itemSn); - stmtItems.setInt(2, item.getItemId()); - stmtItems.setInt(3, item.getQuantity()); - stmtItems.setShort(4, item.getAttribute()); - stmtItems.setString(5, item.getTitle()); - stmtItems.setTimestamp(6, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); - stmtItems.addBatch(); - } else { - try (PreparedStatement updateStmt = conn.prepareStatement( - "UPDATE item.items SET quantity = ?, attribute = ?, title = ?, date_expire = ? WHERE item_sn = ?")) { - updateStmt.setInt(1, item.getQuantity()); - updateStmt.setShort(2, item.getAttribute()); - updateStmt.setString(3, item.getTitle()); - updateStmt.setTimestamp(4, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); - updateStmt.setLong(5, itemSn); - updateStmt.executeUpdate(); - } - } - - stmtInventory.setInt(1, charId); - stmtInventory.setObject(2, enumValue); - stmtInventory.setInt(3, entry.getKey()); - stmtInventory.setLong(4, itemSn); - stmtInventory.addBatch(); - } + InventoryDao.saveCharacter(conn, characterData); } @Override diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java index 9d69cdae..f9abedf7 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -52,7 +52,8 @@ private Guild loadGuild(ResultSet rs) throws SQLException { @Override public Optional getGuildById(int guildId) { String sql = "SELECT * FROM guild.guilds WHERE guild_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { @@ -69,7 +70,8 @@ public Optional getGuildById(int guildId) { private List loadGrades(int guildId) throws SQLException { List grades = new ArrayList<>(); String sql = "SELECT grade_name FROM guild.grade WHERE guild_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { @@ -94,7 +96,8 @@ private List loadMembers(int guildId) { WHERE m.guild_id = ? """; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { @@ -134,7 +137,8 @@ private List loadBoardEntries(int guildId) { List entries = new ArrayList<>(); String sql = "SELECT entry_id, character_id, title, message, timestamp, 0 AS emoticon " + "FROM guild.board_entry WHERE guild_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { @@ -156,7 +160,8 @@ private List loadBoardEntries(int guildId) { private GuildBoardEntry loadBoardNotice(int guildId) { String sql = "SELECT entry_id FROM guild.notice WHERE guild_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { @@ -193,7 +198,8 @@ private GuildBoardEntry loadBoardNotice(int guildId) { @Override public boolean checkGuildNameAvailable(String name) { String sql = "SELECT 1 FROM guild.guilds WHERE LOWER(guild_name) = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, name.toLowerCase()); try (ResultSet rs = stmt.executeQuery()) { return !rs.next(); @@ -229,7 +235,8 @@ public boolean saveGuild(Guild guild) { "points = EXCLUDED.points, " + "level = EXCLUDED.level, " + "board_entry_counter = EXCLUDED.board_entry_counter"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guild.getGuildId()); stmt.setString(2, guild.getGuildName()); stmt.setArray(3, getConnection().createArrayOf("text", guild.getGradeNames().toArray())); @@ -257,13 +264,15 @@ public boolean saveGuild(Guild guild) { private void saveMembers(Guild guild) throws SQLException { String deleteSql = "DELETE FROM guild.member WHERE guild_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(deleteSql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(deleteSql)) { stmt.setInt(1, guild.getGuildId()); stmt.executeUpdate(); } String insertSql = "INSERT INTO guild.member (guild_id, character_id, grade) VALUES (?, ?, ?)"; - try (PreparedStatement stmt = getConnection().prepareStatement(insertSql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(insertSql)) { for (GuildMember member : guild.getGuildMembers()) { stmt.setInt(1, guild.getGuildId()); stmt.setInt(2, member.getCharacterId()); @@ -276,13 +285,15 @@ private void saveMembers(Guild guild) throws SQLException { private void saveBoardEntries(Guild guild) throws SQLException { String deleteSql = "DELETE FROM guild.board_entry WHERE guild_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(deleteSql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(deleteSql)) { stmt.setInt(1, guild.getGuildId()); stmt.executeUpdate(); } String insertSql = "INSERT INTO guild.board_entry (entry_id, guild_id, character_id, title, message, timestamp, emoticon) VALUES (?, ?, ?, ?, ?, ?, ?)"; - try (PreparedStatement stmt = getConnection().prepareStatement(insertSql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(insertSql)) { for (GuildBoardEntry entry : guild.getBoardEntries()) { stmt.setInt(1, entry.getEntryId()); stmt.setInt(2, guild.getGuildId()); @@ -300,7 +311,8 @@ private void saveBoardEntries(Guild guild) throws SQLException { private void saveBoardNotice(Guild guild) throws SQLException { String sql = "INSERT INTO guild.notice (guild_id, entry_id) VALUES (?, ?) " + "ON CONFLICT (guild_id) DO UPDATE SET entry_id = EXCLUDED.entry_id"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { GuildBoardEntry notice = guild.getBoardNoticeEntry(); if (notice != null) { stmt.setInt(1, guild.getGuildId()); @@ -316,7 +328,8 @@ private void saveBoardNotice(Guild guild) throws SQLException { @Override public boolean deleteGuild(int guildId) { String sql = "DELETE FROM guild.guilds WHERE guild_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); return stmt.executeUpdate() > 0; } catch (SQLException e) { diff --git a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java index d58f4fe1..3b0e51c8 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java @@ -20,7 +20,8 @@ public List getMemosByCharacterId(int characterId) { List memos = new ArrayList<>(); String sql = "SELECT id, memo_type, memo_content, sender_name, date_sent " + "FROM memo.memo WHERE receiver_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, characterId); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { @@ -44,7 +45,8 @@ public List getMemosByCharacterId(int characterId) { @Override public boolean hasMemo(int characterId) { String sql = "SELECT 1 FROM memo.memo WHERE receiver_id = ? LIMIT 1"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, characterId); try (ResultSet rs = stmt.executeQuery()) { return rs.next(); @@ -60,7 +62,8 @@ public boolean newMemo(Memo memo, int receiverId) { // `id` is SERIAL, no need to provide it manually String sql = "INSERT INTO memo.memo (receiver_id, memo_type, memo_content, sender_name, date_sent) " + "VALUES (?, ?, ?, ?, ?)"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, receiverId); stmt.setInt(2, memo.getType().getValue()); stmt.setString(3, memo.getContent()); @@ -77,7 +80,8 @@ public boolean newMemo(Memo memo, int receiverId) { @Override public boolean deleteMemo(int memoId, int receiverId) { String sql = "DELETE FROM memo.memo WHERE id = ? AND receiver_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, memoId); stmt.setInt(2, receiverId); int affected = stmt.executeUpdate(); diff --git a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java new file mode 100644 index 00000000..bd381f26 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java @@ -0,0 +1,134 @@ +package kinoko.database.postgresql.type; + + +import kinoko.world.item.InventoryEntry; +import kinoko.world.item.InventoryManager; +import kinoko.world.item.InventoryType; +import kinoko.world.item.Item; +import kinoko.world.user.CharacterData; +import org.postgresql.util.PGobject; + +import java.util.Map; +import java.util.EnumMap; +import java.sql.*; +import java.util.Collection; +import java.util.stream.Stream; + + +public class InventoryDao { + + /** + * Saves a character's inventory to the database. + * + * This method collects all inventory entries from the character's InventoryManager, + * saves all the items to the items table (creating new item_sn values if needed), + * removes any items that the character no longer has, and updates the player inventory + * table with the current inventory entries using a batch insert/update. + * + * @param conn the active database connection + * @param characterData the character whose inventory is being saved + * @throws SQLException if any SQL error occurs during the operation + */ + public static void saveCharacter(Connection conn, CharacterData characterData) throws SQLException { + String sqlInventory = """ + INSERT INTO player.inventory (character_id, inventory_type, slot, item_sn) + VALUES (?, ?, ?, ?) + ON CONFLICT (character_id, item_sn) + DO UPDATE SET slot = EXCLUDED.slot, inventory_type = EXCLUDED.inventory_type + """; + + int characterId = characterData.getCharacterId(); + + InventoryManager inv = characterData.getInventoryManager(); + Collection allEntries = Stream.of( + inv.getEquipped(), + inv.getEquipInventory(), + inv.getConsumeInventory(), + inv.getInstallInventory(), + inv.getEtcInventory(), + inv.getCashInventory() + ) + .flatMap(inventory -> inventory.getType() + .map(type -> inventory.asInventoryEntries(type).stream()) + .orElseGet(Stream::empty)) // skip inventories with no type + .toList(); + + // Extract just the Item objects + Collection allItems = allEntries.stream() + .map(InventoryEntry::item) + .toList(); + + ItemDao.saveItemsBatch(conn, allItems); // Insert or Save the Items themselves. + deleteUnusedItems(conn, characterId, allItems); // Remove any inventory items that the player no longer has. + + Map typeObjects = new EnumMap<>(InventoryType.class); + for (InventoryType type : InventoryType.values()) { + PGobject obj = new PGobject(); + obj.setType("inventory_type_enum"); + obj.setValue(type.name()); + typeObjects.put(type, obj); + } + + // Add in items to the player inventory. + try (PreparedStatement stmtInventory = conn.prepareStatement(sqlInventory); + ) { + // --- Insert/update items --- + for (InventoryEntry entry : allEntries) { + Item item = entry.item(); + InventoryType type = entry.type(); // from InventoryEntry + PGobject enumValue = typeObjects.get(type); + + stmtInventory.setInt(1, characterId); + stmtInventory.setObject(2, enumValue); + stmtInventory.setInt(3, entry.slot()); + // Item SN should be updated since we handled all items earlier. + stmtInventory.setLong(4, item.getItemSn()); + stmtInventory.addBatch(); + } + + stmtInventory.executeBatch(); + } + } + + + /** + * Remove inventory items that are no longer in use for a specific character. + * + * If the provided collection of items is non-empty, this method deletes all rows in + * `player.inventory` for the given character whose `item_sn` is not present in the collection. + * CAUTION: If the collection is empty, it deletes all inventory items for the character. + * This is because it is expected for the player's entire inventory to be passed in. + * + * @param conn the database connection to use + * @param charId the ID of the character whose inventory should be cleaned + * @param items the collection of items to retain; all others will be deleted + * @throws SQLException if a database access error occurs + */ + private static void deleteUnusedItems(Connection conn, int charId, Collection items) throws SQLException { + if (!items.isEmpty()) { + Long[] itemSnArray = items.stream() + .filter(item -> !item.hasNoSN()) // skip items with no SN, aka has not been created in item.Items yet. + .map(Item::getItemSn) + .toArray(Long[]::new); + + if (itemSnArray.length == 0) { + return; // nothing to delete + } + + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM player.inventory WHERE character_id = ? AND item_sn <> ALL (?)")) { + deleteStmt.setInt(1, charId); + Array sqlArray = conn.createArrayOf("bigint", itemSnArray); + deleteStmt.setArray(2, sqlArray); + deleteStmt.executeUpdate(); + } + } else { + // delete all items if the player's collection is empty + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM player.inventory WHERE character_id = ?")) { + deleteStmt.setInt(1, charId); + deleteStmt.executeUpdate(); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/type/ItemDao.java b/src/main/java/kinoko/database/postgresql/type/ItemDao.java new file mode 100644 index 00000000..89c8ab4b --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/ItemDao.java @@ -0,0 +1,217 @@ +package kinoko.database.postgresql.type; + + +import kinoko.world.item.EquipData; +import kinoko.world.item.Item; +import kinoko.world.item.PetData; +import kinoko.world.item.RingData; + +import java.sql.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class ItemDao { + + // Save an item: insert if not exists, update quantity if exists + public void saveItemToAccount(Connection conn, int accountId, Item item) throws SQLException { + String sql = """ + INSERT INTO account.items (account_id, item_sn, item_id, quantity) + VALUES (?, ?, ?, ?) + ON CONFLICT (account_id, item_sn) + DO UPDATE SET quantity = EXCLUDED.quantity + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + if (item.getItemSn() <= 0){ + item.setItemSn(createNewItem(conn, item)); + } + stmt.setInt(1, accountId); + stmt.setLong(2, item.getItemSn()); + stmt.setInt(3, item.getItemId()); + stmt.setInt(4, item.getQuantity()); + stmt.executeUpdate(); + } + } + + /** + * Inserts a new item into the `item.items` table and returns its generated item_sn. + * + * If the insertion is successful, the auto-generated item_sn is also set in the provided Item object. + * This method is useful when creating a new item that does not yet have an item_sn. + * + * @param conn the active database connection + * @param item the Item object to insert + * @return the auto-generated item_sn for the newly inserted item + * @throws SQLException if the insertion fails or the item_sn cannot be generated + */ + public static long createNewItem(Connection conn, Item item) throws SQLException { + + String sql = """ + INSERT INTO item.items (item_id, quantity, attribute, title, date_expire) + VALUES (?, ?, ?, ?, ?) + RETURNING item_sn + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, item.getItemId()); + stmt.setInt(2, item.getQuantity()); + stmt.setShort(3, item.getAttribute()); + stmt.setString(4, item.getTitle()); + stmt.setTimestamp(5, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + long generatedSn = rs.getLong("item_sn"); + item.setItemSn(generatedSn); // store it in the item object + return generatedSn; + } else { + throw new SQLException("Failed to generate item_sn for new item"); + } + } + } + } + + /** + * Saves a collection of items to the database in batch. + * + * For each item, this method checks if it already has an item_sn: + * - If the item_sn is missing (<=0), a new one is generated and the item is inserted. + * - If the item_sn exists, the item is updated with the latest quantity, attributes, title, and expiration date. + * + * This approach ensures efficient batch inserts for new items while keeping existing items up to date. + * + * @param conn the active database connection + * @param items the collection of items to insert or update + * @throws SQLException if any SQL error occurs during insert or update + */ + public static void saveItemsBatch(Connection conn, Collection items) throws SQLException { + if (items.isEmpty()) return; + + String sqlInsert = """ + INSERT INTO item.items (item_sn, item_id, quantity, attribute, title, date_expire) + VALUES (?, ?, ?, ?, ?, ?) + """; + + String sqlUpdate = """ + UPDATE item.items + SET quantity = ?, attribute = ?, title = ?, date_expire = ? + WHERE item_sn = ? + """; + + try (PreparedStatement stmtInsert = conn.prepareStatement(sqlInsert)) { + for (Item item : items) { + long itemSn = item.getItemSn(); + + // Generate new item_sn if needed + if (item.hasNoSN()) { + try (PreparedStatement seqStmt = conn.prepareStatement( + "SELECT nextval(pg_get_serial_sequence('item.items', 'item_sn'))"); + ResultSet rs = seqStmt.executeQuery()) { + rs.next(); + itemSn = rs.getLong(1); + item.setItemSn(itemSn); + } + + stmtInsert.setLong(1, itemSn); + stmtInsert.setInt(2, item.getItemId()); + stmtInsert.setInt(3, item.getQuantity()); + stmtInsert.setShort(4, item.getAttribute()); + stmtInsert.setString(5, item.getTitle()); + stmtInsert.setTimestamp(6, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); + stmtInsert.addBatch(); + } else { + try (PreparedStatement stmtUpdate = conn.prepareStatement(sqlUpdate)) { + stmtUpdate.setInt(1, item.getQuantity()); + stmtUpdate.setShort(2, item.getAttribute()); + stmtUpdate.setString(3, item.getTitle()); + stmtUpdate.setTimestamp(4, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); + stmtUpdate.setLong(5, itemSn); + stmtUpdate.executeUpdate(); + } + } + } + + // Execute all insert batches + stmtInsert.executeBatch(); + } + } + + public static Item from(ResultSet rs) throws SQLException { + long itemSn = rs.getLong("item_sn"); + int itemId = rs.getInt("item_id"); + short quantity = rs.getShort("quantity"); + short attribute = rs.getShort("attribute"); + String title = rs.getString("title"); + Timestamp dateExpireTs = rs.getTimestamp("date_expire"); + + EquipData equipData = new EquipData( + rs.getShort("inc_str"), + rs.getShort("inc_dex"), + rs.getShort("inc_int"), + rs.getShort("inc_luk"), + rs.getShort("inc_max_hp"), + rs.getShort("inc_max_mp"), + rs.getShort("inc_pad"), + rs.getShort("inc_mad"), + rs.getShort("inc_pdd"), + rs.getShort("inc_mdd"), + rs.getShort("inc_acc"), + rs.getShort("inc_eva"), + rs.getShort("inc_craft"), + rs.getShort("inc_speed"), + rs.getShort("inc_jump"), + rs.getByte("ruc"), + rs.getByte("cuc"), + rs.getInt("iuc"), + rs.getByte("chuc"), + rs.getByte("grade"), + rs.getShort("option_1"), + rs.getShort("option_2"), + rs.getShort("option_3"), + rs.getShort("socket_1"), + rs.getShort("socket_2"), + rs.getByte("level_up_type"), + rs.getByte("level"), + rs.getInt("exp"), + rs.getInt("durability") + ); + + PetData petData = null; + if (rs.getObject("pet_name") != null) { + petData = new PetData( + rs.getString("pet_name"), + rs.getByte("pet_level"), + rs.getByte("fullness"), + rs.getShort("tameness"), + rs.getShort("pet_skill"), + rs.getShort("pet_attribute"), + rs.getInt("remain_life") + ); + } + + // RingData + RingData ringData = null; + if (rs.getObject("pair_character_id") != null) { + ringData = new RingData( + rs.getInt("pair_character_id"), + rs.getString("pair_character_name"), + rs.getLong("pair_item_sn") + ); + } + + Item item = new Item( + itemId, + quantity, + itemSn, + false, // cash flag + attribute, + title, + dateExpireTs != null ? dateExpireTs.toInstant() : null, + equipData, + petData, + ringData + ); + return item; + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/type/LockerDao.java b/src/main/java/kinoko/database/postgresql/type/LockerDao.java new file mode 100644 index 00000000..c6e9bd22 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/LockerDao.java @@ -0,0 +1,92 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.cashshop.CashItemInfo; +import kinoko.world.item.Item; +import kinoko.world.user.Locker; + +import java.sql.*; +import java.util.Collection; + +public class LockerDao { + + /** + * Saves all items in the given Locker for a specific account. + * + * For each item in the locker, a new item SN will be generated if it doesn't exist. + * Items are then inserted into the `account.locker_item` table with their slot and optional commodity_id. + * + * @param conn the database connection to use + * @param accountId the ID of the account whose locker is being saved + * @param locker the Locker object containing items to save + * @throws SQLException if a database error occurs + */ + public static void save(Connection conn, int accountId, Locker locker) throws SQLException { + Collection cashItems = locker.getCashItems(); + Collection allItems = cashItems.stream() + .map(CashItemInfo::getItem) + .toList(); + + deleteUnusedItems(conn, accountId, allItems); + ItemDao.saveItemsBatch(conn, allItems); // insert/update + + String sql = """ + INSERT INTO account.locker_item (account_id, slot, item_sn, commodity_id) + VALUES (?, ?, ?, ?) + ON CONFLICT (account_id, slot) DO UPDATE + SET item_sn = EXCLUDED.item_sn, + commodity_id = EXCLUDED.commodity_id + """; // There can be conflicts if items swapped slots + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int slot = 0; + for (CashItemInfo cash : cashItems) { + stmt.setInt(1, accountId); + stmt.setInt(2, slot++); + stmt.setLong(3, cash.getItem().getItemSn()); + stmt.setInt(4, cash.getCommodityId()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Remove locker items that are no longer in use for a specific account. + * + * If the provided collection of CashItemInfo is non-empty, this method deletes all rows in + * `account.locker_item` for the given account whose `item_sn` is not present in the collection. + * If the collection is empty, all locker items for the account are deleted. + * + * @param conn the database connection to use + * @param accountId the ID of the account whose locker should be cleaned + * @param items the collection of CashItemInfo to retain; all others will be deleted + * @throws SQLException if a database access error occurs + */ + private static void deleteUnusedItems(Connection conn, int accountId, Collection items) throws SQLException { + if (!items.isEmpty()) { + Long[] itemSnArray = items.stream() + .filter(item -> !item.hasNoSN()) // skip items with no SN, aka has not been created in item.Items yet. + .map(Item::getItemSn) + .toArray(Long[]::new); + + if (itemSnArray.length == 0) { + return; // nothing to delete + } + + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM account.locker_item WHERE account_id = ? AND item_sn <> ALL (?)")) { + deleteStmt.setInt(1, accountId); + Array sqlArray = conn.createArrayOf("bigint", itemSnArray); + deleteStmt.setArray(2, sqlArray); + deleteStmt.executeUpdate(); + } + } else { + // delete all items if the locker collection is empty + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM account.locker_item WHERE account_id = ?")) { + deleteStmt.setInt(1, accountId); + deleteStmt.executeUpdate(); + } + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/TrunkDao.java b/src/main/java/kinoko/database/postgresql/type/TrunkDao.java new file mode 100644 index 00000000..3fd5cb86 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/TrunkDao.java @@ -0,0 +1,131 @@ +package kinoko.database.postgresql.type; + + +import kinoko.provider.item.ItemInfo; +import kinoko.server.ServerConfig; +import kinoko.world.item.*; + +import java.sql.*; +import java.util.Collection; + +public class TrunkDao { + + /** + * Saves all items in the given Trunk (Account Storage) for a specific account. + + * For each item in the trunk, a new item SN will be generated if it doesn't exist. + * Items are then inserted into the `account.trunk_item` table with their slot. + * + * @param conn the database connection to use + * @param accountId the ID of the account whose trunk is being saved + * @param trunk the Trunk object containing items to save + * @throws SQLException if a database error occurs + */ + public static void save(Connection conn, int accountId, Trunk trunk) throws SQLException { + String sql = """ + INSERT INTO account.trunk_item (account_id, slot, item_sn) + VALUES (?, ?, ?) + ON CONFLICT (account_id, slot) + DO UPDATE SET item_sn = EXCLUDED.item_sn + """; // There can be conflicts if items swapped slots + Collection items = trunk.getItems(); + deleteUnusedItems(conn, accountId, items); + ItemDao.saveItemsBatch(conn, items); // insert/update + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int slot = 0; + for (Item item : items) { + stmt.setInt(1, accountId); + stmt.setInt(2, slot++); + stmt.setLong(3, item.getItemSn()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Remove trunk items that are no longer in use for a specific account. + * + * If the provided collection of items is non-empty, this method deletes all rows in + * `account.trunk_item` for the given account whose `item_sn` is not present in the collection. + * CAUTION: If the collection is empty, it deletes all trunk items for the account. + * This is because it is expected for the account's entire trunk to be passed in. + * + * @param conn the database connection to use + * @param accountId the ID of the account whose trunk should be cleaned + * @param items the collection of items to retain; all others will be deleted + * @throws SQLException if a database access error occurs + */ + private static void deleteUnusedItems(Connection conn, int accountId, Collection items) throws SQLException { + if (!items.isEmpty()) { + Long[] itemSnArray = items.stream() + .filter(item -> !item.hasNoSN()) // skip items with no SN, aka has not been created in item.Items yet. + .map(Item::getItemSn) + .toArray(Long[]::new); + + if (itemSnArray.length == 0) { + return; // nothing to delete + } + + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM account.trunk_item WHERE account_id = ? AND item_sn <> ALL (?)")) { + deleteStmt.setInt(1, accountId); + Array sqlArray = conn.createArrayOf("bigint", itemSnArray); + deleteStmt.setArray(2, sqlArray); + deleteStmt.executeUpdate(); + } + } else { + // delete all items if the trunk collection is empty + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM account.trunk_item WHERE account_id = ?")) { + deleteStmt.setInt(1, accountId); + deleteStmt.executeUpdate(); + } + } + } + + public static Trunk load(Connection conn, int accountId) throws SQLException { + String accountSql = "SELECT trunk_size, trunk_money FROM account.accounts WHERE id = ?"; + String itemsSql = """ + SELECT ti.slot, fi.* + FROM account.trunk_item ti + JOIN item.full_item fi ON ti.item_sn = fi.item_sn + WHERE ti.account_id = ? + """; + + + int trunkSize = ServerConfig.TRUNK_BASE_SLOTS; + int trunkMoney = 0; + Trunk trunk; + + + try (PreparedStatement stmt = conn.prepareStatement(accountSql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + trunkSize = rs.getInt("trunk_size"); + trunkMoney = rs.getInt("trunk_money"); + } + } + } + + // Initialize trunk with proper size + trunk = new Trunk(trunkSize); + trunk.setMoney(trunkMoney); + + try (PreparedStatement stmt = conn.prepareStatement(itemsSql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int slot = rs.getInt("slot"); + Item item = ItemDao.from(rs); +// trunk.getItems().add(item); + trunk.addItem(item); + } + } + } + + return trunk; + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/type/WishlistDao.java b/src/main/java/kinoko/database/postgresql/type/WishlistDao.java new file mode 100644 index 00000000..2751f7ef --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/WishlistDao.java @@ -0,0 +1,75 @@ +package kinoko.database.postgresql.type; + +import java.sql.*; +import java.util.List; + + +public class WishlistDao { + + /** + * Saves the account's wishlist to the database. + * + * Existing wishlist entries for the account are deleted first. + * Each item_id is then inserted into the `account.wishlist` table + * with its slot. If a slot already exists, the item_id is updated. + * + * @param conn the active database connection + * @param wishlist The wishlist to save. + * @throws SQLException if a database error occurs + */ + public static void save(Connection conn, int accountId, List wishlist) throws SQLException { + deleteUnusedItems(conn, accountId, wishlist); + + String sqlInsert = """ + INSERT INTO account.wishlist (account_id, slot, item_id) + VALUES (?, ?, ?) + ON CONFLICT (account_id, slot) DO UPDATE + SET item_id = EXCLUDED.item_id + """; + try (PreparedStatement insertStmt = conn.prepareStatement(sqlInsert)) { + int slot = 0; + for (Integer itemId : wishlist) { + insertStmt.setInt(1, accountId); + insertStmt.setInt(2, slot++); + insertStmt.setInt(3, itemId); + insertStmt.addBatch(); + } + insertStmt.executeBatch(); + } + } + + /** + * Deletes wishlist entries for a specific account that are no longer in use. + * + * If the provided wishlist is non-empty, only entries whose item_id + * is not in the list are deleted. If the list is empty, all wishlist + * entries for the account are deleted. + * + * @param conn the active database connection + * @param accountId the ID of the account whose wishlist should be cleaned + * @param wishlist the list of item IDs to retain in the wishlist + * @throws SQLException if a database error occurs + */ + public static void deleteUnusedItems(Connection conn, int accountId, List wishlist) throws SQLException { + if (wishlist != null && !wishlist.isEmpty()) { + Integer[] itemArray = wishlist.toArray(new Integer[0]); + + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM account.wishlist WHERE account_id = ? AND item_id <> ALL (?)" + )) { + deleteStmt.setInt(1, accountId); + Array sqlArray = conn.createArrayOf("integer", itemArray); + deleteStmt.setArray(2, sqlArray); + deleteStmt.executeUpdate(); + } + } else { + // Delete all wishlist entries if the list is empty + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM account.wishlist WHERE account_id = ?" + )) { + deleteStmt.setInt(1, accountId); + deleteStmt.executeUpdate(); + } + } + } +} diff --git a/src/main/java/kinoko/world/item/Inventory.java b/src/main/java/kinoko/world/item/Inventory.java index a3aedb78..78ac1677 100644 --- a/src/main/java/kinoko/world/item/Inventory.java +++ b/src/main/java/kinoko/world/item/Inventory.java @@ -1,16 +1,27 @@ package kinoko.world.item; -import java.util.SortedMap; -import java.util.TreeMap; +import java.util.*; +import java.util.stream.Collectors; + public final class Inventory { private final SortedMap items = new TreeMap<>(); private int size; + private InventoryType type; public Inventory(int size) { this.size = size; } + public Inventory(int size, InventoryType type) { + this.size = size; + this.type = type; + } + + public Optional getType(){ + return Optional.ofNullable(type); + } + public SortedMap getItems() { return items; } @@ -50,4 +61,10 @@ public Item removeItem(int position) { public boolean removeItem(int position, Item item) { return items.remove(Math.abs(position), item); } + + public Collection asInventoryEntries(InventoryType type) { + return items.entrySet().stream() + .map(entry -> new InventoryEntry(entry.getKey(), entry.getValue(), type)) + .collect(Collectors.toCollection(ArrayList::new)); + } } diff --git a/src/main/java/kinoko/world/item/InventoryEntry.java b/src/main/java/kinoko/world/item/InventoryEntry.java new file mode 100644 index 00000000..bd4fd326 --- /dev/null +++ b/src/main/java/kinoko/world/item/InventoryEntry.java @@ -0,0 +1,5 @@ +package kinoko.world.item; + +public record InventoryEntry(int slot, Item item, InventoryType type) { + +} diff --git a/src/main/java/kinoko/world/item/InventoryManager.java b/src/main/java/kinoko/world/item/InventoryManager.java index 8d875686..0dbf8a9f 100644 --- a/src/main/java/kinoko/world/item/InventoryManager.java +++ b/src/main/java/kinoko/world/item/InventoryManager.java @@ -21,12 +21,12 @@ public final class InventoryManager { private Instant extSlotExpire; public InventoryManager() { - this.equipped = new Inventory(24); - this.equipInventory = new Inventory(96); - this.consumeInventory = new Inventory(96); - this.installInventory = new Inventory(96); - this.etcInventory = new Inventory(96); - this.cashInventory = new Inventory(96); + this.equipped = new Inventory(24, InventoryType.EQUIPPED); + this.equipInventory = new Inventory(96, InventoryType.EQUIP); + this.consumeInventory = new Inventory(96, InventoryType.CONSUME); + this.installInventory = new Inventory(96, InventoryType.INSTALL); + this.etcInventory = new Inventory(96, InventoryType.ETC); + this.cashInventory = new Inventory(96, InventoryType.CASH); this.money = 0; this.extSlotExpire = null; } diff --git a/src/main/java/kinoko/world/item/Item.java b/src/main/java/kinoko/world/item/Item.java index a9ab16bb..40dc045c 100644 --- a/src/main/java/kinoko/world/item/Item.java +++ b/src/main/java/kinoko/world/item/Item.java @@ -213,4 +213,17 @@ public void setPossibleTrading(boolean set) { removeAttribute(ItemAttribute.getPossibleTradingAttribute(getItemType())); } } + + /** + * Checks whether this Item has a valid item serial number (SN). + + * Useful in relational databases where item SNs are automatically generated. + * Returns true if the item has no SN and therefore needs to be inserted + * into the database to obtain one. + * + * @return true if the item SN is zero or negative, false otherwise + */ + public boolean hasNoSN() { + return getItemSn() <= 0; + } } From 651ea88113a8fd363241af06a2a88d4b28eb96c4 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 20:53:05 -0400 Subject: [PATCH 10/83] Fixed Gifting + Characters not spawning with items --- .../postgresql/PostgresCharacterAccessor.java | 144 ++---------------- .../postgresql/PostgresGiftAccessor.java | 51 +++++-- .../kinoko/database/postgresql/setup/init.sql | 14 +- .../postgresql/type/InventoryDao.java | 40 ++++- .../database/postgresql/type/ItemDao.java | 23 --- .../kinoko/handler/stage/LoginHandler.java | 12 +- .../java/kinoko/world/user/AvatarData.java | 3 +- 7 files changed, 103 insertions(+), 184 deletions(-) diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java index 49486c4d..a23dc0e2 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -80,14 +80,22 @@ private CharacterData loadCharacterData(ResultSet rs) throws SQLException, IOExc ); cd.setCharacterStat(cs); - InventoryManager im = loadInventory(characterID); - cd.setInventoryManager(im); - im.setMoney(rs.getInt("money")); + try (Connection conn = dataSource.getConnection()) { + InventoryManager im = InventoryDao.loadInventoryManager(conn, characterID); + + cd.setInventoryManager(im); + im.setMoney(rs.getInt("money")); + + Timestamp extSlotExpireTs = rs.getTimestamp("ext_slot_expire"); + im.setExtSlotExpire(extSlotExpireTs != null ? extSlotExpireTs.toInstant() : null); + cd.setInventoryManager(im); + + cd.setCoupleRecord(CoupleRecord.from( + im.getEquipped(), im.getEquipInventory() + )); + } - Timestamp extSlotExpireTs = rs.getTimestamp("ext_slot_expire"); - im.setExtSlotExpire(extSlotExpireTs != null ? extSlotExpireTs.toInstant() : null); - cd.setInventoryManager(im); SkillManager sm = loadSkillCooltimesAndRecords(characterID); @@ -106,11 +114,6 @@ private CharacterData loadCharacterData(ResultSet rs) throws SQLException, IOExc MiniGameRecord mgr = loadMiniGameRecord(characterID); cd.setMiniGameRecord(mgr); - - cd.setCoupleRecord(CoupleRecord.from( - im.getEquipped(), im.getEquipInventory() - )); - MapTransferInfo mto = loadMapTransferInfo(characterID); cd.setMapTransferInfo(mto); @@ -392,123 +395,6 @@ public boolean checkCharacterNameAvailable(String name) { return false; } - private InventoryManager loadInventory(int characterId) throws SQLException { - InventoryManager im = new InventoryManager(); - - String sql = """ - SELECT inv.inventory_type, inv.slot, fi.* - FROM player.inventory inv - JOIN item.full_item fi ON inv.item_sn = fi.item_sn - WHERE inv.character_id = ? - ORDER BY inv.inventory_type, inv.slot - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - ResultSet rs = stmt.executeQuery(); - - while (rs.next()) { - long itemSn = rs.getLong("item_sn"); - int slot = rs.getInt("slot"); - int itemId = rs.getInt("item_id"); - short quantity = rs.getShort("quantity"); - short attribute = rs.getShort("attribute"); - String title = rs.getString("title"); - Timestamp dateExpireTs = rs.getTimestamp("date_expire"); - - // EquipData - EquipData equipData; // declare once - if (rs.getObject("inc_str") != null) { - equipData = new EquipData( - rs.getShort("inc_str"), - rs.getShort("inc_dex"), - rs.getShort("inc_int"), - rs.getShort("inc_luk"), - rs.getShort("inc_max_hp"), - rs.getShort("inc_max_mp"), - rs.getShort("inc_pad"), - rs.getShort("inc_mad"), - rs.getShort("inc_pdd"), - rs.getShort("inc_mdd"), - rs.getShort("inc_acc"), - rs.getShort("inc_eva"), - rs.getShort("inc_craft"), - rs.getShort("inc_speed"), - rs.getShort("inc_jump"), - rs.getByte("ruc"), - rs.getByte("cuc"), - rs.getInt("iuc"), - rs.getByte("chuc"), - rs.getByte("grade"), - rs.getShort("option_1"), - rs.getShort("option_2"), - rs.getShort("option_3"), - rs.getShort("socket_1"), - rs.getShort("socket_2"), - rs.getByte("level_up_type"), - rs.getByte("level"), - rs.getInt("exp"), - rs.getInt("durability") - ); - } - else{ - equipData = new EquipData(); - } - - // PetData - PetData petData = null; - if (rs.getObject("pet_name") != null) { - petData = new PetData( - rs.getString("pet_name"), - rs.getByte("pet_level"), - rs.getByte("fullness"), - rs.getShort("tameness"), - rs.getShort("pet_skill"), - rs.getShort("pet_attribute"), - rs.getInt("remain_life") - ); - } - - // RingData - RingData ringData = null; - if (rs.getObject("pair_character_id") != null) { - ringData = new RingData( - rs.getInt("pair_character_id"), - rs.getString("pair_character_name"), - rs.getLong("pair_item_sn") - ); - } - - Item item = new Item( - itemId, - quantity, - itemSn, - false, // cash flag - attribute, - title, - dateExpireTs != null ? dateExpireTs.toInstant() : null, - equipData, - petData, - ringData - ); - - String invType = rs.getString("inventory_type"); - switch (invType.toUpperCase()) { - case "EQUIPPED" -> im.getEquipped().addItem(slot, item); - case "EQUIP" -> im.getEquipInventory().addItem(slot, item); - case "CONSUME" -> im.getConsumeInventory().addItem(slot, item); - case "INSTALL" -> im.getInstallInventory().addItem(slot, item); - case "ETC" -> im.getEtcInventory().addItem(slot, item); - case "CASH" -> im.getCashInventory().addItem(slot, item); - default -> throw new IllegalArgumentException("Unknown inventory type: " + invType); - } - } - } - - return im; - } - @Override public Optional getCharacterById(int characterId) { @@ -663,7 +549,7 @@ public List getAvatarDataByAccountId(int accountId) { } private Inventory loadEquippedInventory(int characterId) throws SQLException { - Inventory equipped = new Inventory(24); // default equipped size + Inventory equipped = new Inventory(24, InventoryType.EQUIPPED); // default equipped size String sql = """ SELECT f.*, i.slot diff --git a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java index 34b283e1..50c938be 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java @@ -2,7 +2,9 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.GiftAccessor; +import kinoko.database.postgresql.type.ItemDao; import kinoko.server.cashshop.Gift; +import kinoko.world.item.Item; import java.sql.*; import java.util.ArrayList; @@ -18,7 +20,7 @@ public PostgresGiftAccessor(HikariDataSource dataSource) { private Gift loadGift(ResultSet rs) throws SQLException { return new Gift( - rs.getLong("gift_sn"), + rs.getLong("item_sn"), rs.getInt("item_id"), rs.getInt("commodity_id"), rs.getInt("sender_id"), @@ -31,7 +33,13 @@ private Gift loadGift(ResultSet rs) throws SQLException { @Override public List getGiftsByCharacterId(int characterId) { List gifts = new ArrayList<>(); - String sql = "SELECT * FROM gifts WHERE receiver_id = ?"; + String sql = """ + SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, + g.sender_name, g.sender_message, fi.pair_item_sn + FROM gift.gifts g + JOIN item.full_item fi ON fi.item_sn = g.item_sn + WHERE g.receiver_id = ? + """; try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, characterId); @@ -47,7 +55,13 @@ public List getGiftsByCharacterId(int characterId) { @Override public Optional getGiftByItemSn(long itemSn) { - String sql = "SELECT * FROM gifts WHERE gift_sn = ?"; + String sql = """ + SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, + g.sender_name, g.sender_message, fi.pair_item_sn FROM gift.gifts g + JOIN item.full_item fi ON fi.item_sn = g.item_sn + WHERE g.item_sn = ? + """; + try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setLong(1, itemSn); @@ -61,22 +75,31 @@ public Optional getGiftByItemSn(long itemSn) { return Optional.empty(); } + @Override public boolean newGift(Gift gift, int receiverId) { - String sql = "INSERT INTO gifts (gift_sn, receiver_id, item_id, commodity_id, sender_id, sender_name, sender_message, pair_item_sn) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?) " + - "ON CONFLICT (gift_sn) DO NOTHING"; + String sql = """ + INSERT INTO gift.gifts (item_sn, receiver_id, commodity_id, sender_id, sender_name, sender_message) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (item_sn) DO NOTHING + """; + try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setLong(1, gift.getGiftSn()); + + // We need a new item created. + Item basicItem = new Item(gift.getItemId(), (short) 1); + ItemDao.createNewItem(conn, basicItem); + + stmt.setLong(1, basicItem.getItemSn()); // item_sn is now the primary key stmt.setInt(2, receiverId); - stmt.setInt(3, gift.getItemId()); - stmt.setInt(4, gift.getCommodityId()); - stmt.setInt(5, gift.getSenderId()); - stmt.setString(6, gift.getSenderName()); - stmt.setString(7, gift.getSenderMessage()); - stmt.setLong(8, gift.getPairItemSn()); + stmt.setInt(3, gift.getCommodityId()); + stmt.setInt(4, gift.getSenderId()); + stmt.setString(5, gift.getSenderName()); + stmt.setString(6, gift.getSenderMessage()); + return stmt.executeUpdate() > 0; + } catch (SQLException e) { e.printStackTrace(); } @@ -85,7 +108,7 @@ public boolean newGift(Gift gift, int receiverId) { @Override public boolean deleteGift(Gift gift) { - String sql = "DELETE FROM gifts WHERE gift_sn = ?"; + String sql = "DELETE FROM gift.gifts WHERE item_sn = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setLong(1, gift.getGiftSn()); diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index 39937392..77b5ec57 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -353,25 +353,25 @@ CREATE INDEX IF NOT EXISTS idx_friend_friend_id ---------------GIFT TABLES---------------- ------------------------------------------ -CREATE TABLE IF NOT EXISTS gift.gift ( - id BIGSERIAL PRIMARY KEY, -- auto-increment unique ID for the gift - receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, +CREATE TABLE IF NOT EXISTS gift.gifts ( item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, commodity_id INT, sender_id INT, sender_name TEXT, - sender_message TEXT + sender_message TEXT, + PRIMARY KEY (item_sn) ); CREATE INDEX IF NOT EXISTS idx_gift_item_sn - ON gift.gift(item_sn); + ON gift.gifts(item_sn); CREATE INDEX IF NOT EXISTS idx_gift_receiver - ON gift.gift(receiver_id); + ON gift.gifts(receiver_id); CREATE INDEX IF NOT EXISTS idx_gift_receiver_item - ON gift.gift(receiver_id, item_sn); + ON gift.gifts(receiver_id, item_sn); ------------------------------------------ diff --git a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java index bd381f26..68658389 100644 --- a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java +++ b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java @@ -1,10 +1,7 @@ package kinoko.database.postgresql.type; -import kinoko.world.item.InventoryEntry; -import kinoko.world.item.InventoryManager; -import kinoko.world.item.InventoryType; -import kinoko.world.item.Item; +import kinoko.world.item.*; import kinoko.world.user.CharacterData; import org.postgresql.util.PGobject; @@ -117,6 +114,7 @@ private static void deleteUnusedItems(Connection conn, int charId, Collection ALL (?)")) { + System.out.println("DELETING INVENTORY"); deleteStmt.setInt(1, charId); Array sqlArray = conn.createArrayOf("bigint", itemSnArray); deleteStmt.setArray(2, sqlArray); @@ -131,4 +129,38 @@ private static void deleteUnusedItems(Connection conn, int charId, Collection im.getEquipped().addItem(slot, item); + case "EQUIP" -> im.getEquipInventory().addItem(slot, item); + case "CONSUME" -> im.getConsumeInventory().addItem(slot, item); + case "INSTALL" -> im.getInstallInventory().addItem(slot, item); + case "ETC" -> im.getEtcInventory().addItem(slot, item); + case "CASH" -> im.getCashInventory().addItem(slot, item); + default -> throw new IllegalArgumentException("Unknown inventory type: " + invType); + } + } + } + + return im; + } } \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/type/ItemDao.java b/src/main/java/kinoko/database/postgresql/type/ItemDao.java index 89c8ab4b..572e8cc6 100644 --- a/src/main/java/kinoko/database/postgresql/type/ItemDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ItemDao.java @@ -7,33 +7,10 @@ import kinoko.world.item.RingData; import java.sql.*; -import java.util.ArrayList; import java.util.Collection; -import java.util.List; public class ItemDao { - // Save an item: insert if not exists, update quantity if exists - public void saveItemToAccount(Connection conn, int accountId, Item item) throws SQLException { - String sql = """ - INSERT INTO account.items (account_id, item_sn, item_id, quantity) - VALUES (?, ?, ?, ?) - ON CONFLICT (account_id, item_sn) - DO UPDATE SET quantity = EXCLUDED.quantity - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - if (item.getItemSn() <= 0){ - item.setItemSn(createNewItem(conn, item)); - } - stmt.setInt(1, accountId); - stmt.setLong(2, item.getItemSn()); - stmt.setInt(3, item.getItemId()); - stmt.setInt(4, item.getQuantity()); - stmt.executeUpdate(); - } - } - /** * Inserts a new item into the `item.items` table and returns its generated item_sn. * diff --git a/src/main/java/kinoko/handler/stage/LoginHandler.java b/src/main/java/kinoko/handler/stage/LoginHandler.java index 9f102ff2..327c4187 100644 --- a/src/main/java/kinoko/handler/stage/LoginHandler.java +++ b/src/main/java/kinoko/handler/stage/LoginHandler.java @@ -263,12 +263,12 @@ public static void handleCreateNewCharacter(Client c, InPacket inPacket) { // Initialize inventory and add starting equips final InventoryManager im = new InventoryManager(); - im.setEquipped(new Inventory(Short.MAX_VALUE)); - im.setEquipInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS)); - im.setConsumeInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS)); - im.setInstallInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS)); - im.setEtcInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS)); - im.setCashInventory(new Inventory(ServerConfig.INVENTORY_CASH_SLOTS)); + im.setEquipped(new Inventory(Short.MAX_VALUE, InventoryType.EQUIPPED)); + im.setEquipInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS, InventoryType.EQUIP)); + im.setConsumeInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS, InventoryType.CONSUME)); + im.setInstallInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS, InventoryType.INSTALL)); + im.setEtcInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS, InventoryType.ETC)); + im.setCashInventory(new Inventory(ServerConfig.INVENTORY_CASH_SLOTS, InventoryType.CASH)); im.setMoney(0); im.setExtSlotExpire(Instant.now()); characterData.setInventoryManager(im); diff --git a/src/main/java/kinoko/world/user/AvatarData.java b/src/main/java/kinoko/world/user/AvatarData.java index 9d8b8c70..583fad9e 100644 --- a/src/main/java/kinoko/world/user/AvatarData.java +++ b/src/main/java/kinoko/world/user/AvatarData.java @@ -3,10 +3,11 @@ import kinoko.server.packet.OutPacket; import kinoko.util.Encodable; import kinoko.world.item.Inventory; +import kinoko.world.item.InventoryType; import kinoko.world.user.stat.CharacterStat; public final class AvatarData implements Encodable { - private static final Inventory EMPTY_INVENTORY = new Inventory(0); + private static final Inventory EMPTY_INVENTORY = new Inventory(0, InventoryType.EQUIPPED); private final CharacterStat characterStat; private final AvatarLook avatarLook; From f05c6c0abdd461181ecfe471bedd3da51586e8fd Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 21:22:11 -0400 Subject: [PATCH 11/83] Added invalid item reference cleanup on bootup --- .../postgresql/PostgresConnector.java | 2 + .../postgresql/PostgresIdAccessor.java | 9 +++ .../database/postgresql/type/ItemDao.java | 65 ++++++++++++++----- 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java index fe656e54..6b3f16da 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresConnector.java +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -57,6 +57,8 @@ public void initialize() { giftAccessor = new PostgresGiftAccessor(dataSource); memoAccessor = new PostgresMemoAccessor(dataSource); + + } catch (Exception e) { e.printStackTrace(); throw new RuntimeException("Failed to initialize PostgresConnector", e); diff --git a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java index d8fc3b99..8200cdec 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java @@ -2,13 +2,22 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.IdAccessor; +import kinoko.database.postgresql.type.ItemDao; +import java.sql.Connection; +import java.sql.SQLException; import java.util.Optional; public final class PostgresIdAccessor extends PostgresAccessor implements IdAccessor { public PostgresIdAccessor(HikariDataSource dataSource) { super(dataSource); + + try (Connection conn = getConnection()){ + ItemDao.cleanupInvalidItems(conn); + } catch (SQLException e) { + e.printStackTrace(); + } } private Optional getNextId(String type) { diff --git a/src/main/java/kinoko/database/postgresql/type/ItemDao.java b/src/main/java/kinoko/database/postgresql/type/ItemDao.java index 572e8cc6..ef669ddb 100644 --- a/src/main/java/kinoko/database/postgresql/type/ItemDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ItemDao.java @@ -1,19 +1,21 @@ package kinoko.database.postgresql.type; - import kinoko.world.item.EquipData; import kinoko.world.item.Item; import kinoko.world.item.PetData; import kinoko.world.item.RingData; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import java.sql.*; import java.util.Collection; public class ItemDao { + private static final Logger log = LogManager.getLogger(ItemDao.class); /** * Inserts a new item into the `item.items` table and returns its generated item_sn. - * + *

* If the insertion is successful, the auto-generated item_sn is also set in the provided Item object. * This method is useful when creating a new item that does not yet have an item_sn. * @@ -25,10 +27,10 @@ public class ItemDao { public static long createNewItem(Connection conn, Item item) throws SQLException { String sql = """ - INSERT INTO item.items (item_id, quantity, attribute, title, date_expire) - VALUES (?, ?, ?, ?, ?) - RETURNING item_sn - """; + INSERT INTO item.items (item_id, quantity, attribute, title, date_expire) + VALUES (?, ?, ?, ?, ?) + RETURNING item_sn + """; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, item.getItemId()); @@ -51,11 +53,11 @@ public static long createNewItem(Connection conn, Item item) throws SQLException /** * Saves a collection of items to the database in batch. - * + *

* For each item, this method checks if it already has an item_sn: * - If the item_sn is missing (<=0), a new one is generated and the item is inserted. * - If the item_sn exists, the item is updated with the latest quantity, attributes, title, and expiration date. - * + *

* This approach ensures efficient batch inserts for new items while keeping existing items up to date. * * @param conn the active database connection @@ -66,15 +68,15 @@ public static void saveItemsBatch(Connection conn, Collection items) throw if (items.isEmpty()) return; String sqlInsert = """ - INSERT INTO item.items (item_sn, item_id, quantity, attribute, title, date_expire) - VALUES (?, ?, ?, ?, ?, ?) - """; + INSERT INTO item.items (item_sn, item_id, quantity, attribute, title, date_expire) + VALUES (?, ?, ?, ?, ?, ?) + """; String sqlUpdate = """ - UPDATE item.items - SET quantity = ?, attribute = ?, title = ?, date_expire = ? - WHERE item_sn = ? - """; + UPDATE item.items + SET quantity = ?, attribute = ?, title = ?, date_expire = ? + WHERE item_sn = ? + """; try (PreparedStatement stmtInsert = conn.prepareStatement(sqlInsert)) { for (Item item : items) { @@ -191,4 +193,37 @@ public static Item from(ResultSet rs) throws SQLException { ); return item; } + + /** + * Cleans up invalid items from the database that no longer have valid references. + *

+ * In the PostgreSQL implementation, all items are stored in {@code item.Items}, regardless of + * whether they are currently held by a player (inventory, trunk, locker, wishlist, gifted) + * or not. This can lead to orphaned item records when items are dropped, since dropped + * items are not tracked by the database. + *

+ * This method queries and removes items that are not referenced anywhere else, ensuring + * synchronization between the in-game state and the persistent database state. This function + * typically called during server initialization, when no dropped items exist. + * BE CAREFUL to run this in any other situation. + * + * @param conn the active SQL connection used to perform cleanup operations + */ + public static void cleanupInvalidItems(Connection conn) throws SQLException { + String sql = """ + DELETE FROM item.items i + WHERE NOT EXISTS (SELECT 1 FROM item.equip_data e WHERE e.item_sn = i.item_sn) + AND NOT EXISTS (SELECT 1 FROM item.pet_data p WHERE p.item_sn = i.item_sn) + AND NOT EXISTS (SELECT 1 FROM item.ring_data r WHERE r.item_sn = i.item_sn) + AND NOT EXISTS (SELECT 1 FROM player.inventory inv WHERE inv.item_sn = i.item_sn) + AND NOT EXISTS (SELECT 1 FROM account.trunk_item t WHERE t.item_sn = i.item_sn) + AND NOT EXISTS (SELECT 1 FROM account.locker_item l WHERE l.item_sn = i.item_sn) + AND NOT EXISTS (SELECT 1 FROM gift.gifts g WHERE g.item_sn = i.item_sn); + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int rowsDeleted = stmt.executeUpdate(); + log.info("Cleaned up {} items with no references.", rowsDeleted); + } + } } \ No newline at end of file From d57363476b92436d1bd7a774314bca627c1e9d24 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 23:37:38 -0400 Subject: [PATCH 12/83] Added hikari logging + better log4j logging + fixed connection leaks --- .../postgresql/PostgresAccountAccessor.java | 8 +- .../postgresql/PostgresCharacterAccessor.java | 4 +- .../postgresql/PostgresConnector.java | 7 +- .../postgresql/PostgresGuildAccessor.java | 3 +- .../postgresql/type/EquipDataDao.java | 202 ++++++++++++++++++ .../postgresql/type/InventoryDao.java | 1 - .../database/postgresql/type/ItemDao.java | 3 + src/main/resources/log4j2.xml | 9 +- .../world/item/InventoryManagerTest.java | 10 +- 9 files changed, 231 insertions(+), 16 deletions(-) create mode 100644 src/main/java/kinoko/database/postgresql/type/EquipDataDao.java diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java index 495819ee..4a77efe6 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java @@ -160,13 +160,14 @@ public boolean savePassword(Account account, String oldPassword, String newPassw String column = secondary ? "secondary_password" : "password"; String sqlSelect = "SELECT " + column + " FROM account.accounts WHERE id = ?"; String sqlUpdate = "UPDATE account.accounts SET " + column + " = ? WHERE id = ?"; - try (PreparedStatement selectStmt = getConnection().prepareStatement(sqlSelect)) { + try (Connection conn = getConnection(); + PreparedStatement selectStmt = conn.prepareStatement(sqlSelect)) { selectStmt.setInt(1, account.getId()); try (ResultSet rs = selectStmt.executeQuery()) { if (rs.next()) { String hashedOld = rs.getString(column); if (hashedOld == null || checkHashedPassword(oldPassword, hashedOld)) { - try (PreparedStatement updateStmt = getConnection().prepareStatement(sqlUpdate)) { + try (PreparedStatement updateStmt = conn.prepareStatement(sqlUpdate)) { updateStmt.setString(1, hashPassword(newPassword)); updateStmt.setInt(2, account.getId()); return updateStmt.executeUpdate() > 0; @@ -210,6 +211,7 @@ public synchronized boolean newAccount(String username, String password) { } } + // WARNING CODED AS A LEAK RN // Initialize a base trunk, locker, wishlist // for (int i = 0; i < ServerConfig.TRUNK_BASE_SLOTS; i++) { // try (PreparedStatement tStmt = getConnection().prepareStatement( @@ -220,7 +222,7 @@ public synchronized boolean newAccount(String username, String password) { // tStmt.executeUpdate(); // } // } - +// WARNING CODED AS A LEAK RN // for (int i = 0; i < 10; i++) { // try (PreparedStatement wStmt = getConnection().prepareStatement( // "INSERT INTO account.wishlist (account_id, slot, item_sn) VALUES (?, ?, ?)")) { diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java index a23dc0e2..3ed7d4f9 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -169,9 +169,9 @@ private WildHunterInfo loadWildHunterInfo(int characterId) throws SQLException { private MapTransferInfo loadMapTransferInfo(int characterId) throws SQLException { MapTransferInfo mti = new MapTransferInfo(); - // Query the new table for this character String sql = "SELECT map_id, old_map_id FROM player.map_transfer WHERE character_id = ?"; - try (PreparedStatement stmt = dataSource.getConnection().prepareStatement(sql)) { + try (Connection con = dataSource.getConnection(); + PreparedStatement stmt = con.prepareStatement(sql)) { stmt.setInt(1, characterId); try (ResultSet mapRs = stmt.executeQuery()) { if (mapRs.next()) { diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java index 6b3f16da..6b9c736d 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresConnector.java +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -1,6 +1,8 @@ package kinoko.database.postgresql; import kinoko.database.*; + +import java.sql.Connection; import java.util.TimeZone; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; @@ -31,15 +33,14 @@ public void initialize() { config.setJdbcUrl(DATABASE_URL); config.setUsername(ServerConstants.DATABASE_USER); config.setPassword(ServerConstants.DATABASE_PASSWORD); - config.setMaximumPoolSize(50); // Adjust as needed + config.setMaximumPoolSize(10); // Adjust as needed config.setConnectionTimeout(5000); // 5s config.setIdleTimeout(60000); // 60s config.setMaxLifetime(1800000); // 30min - config.setLeakDetectionThreshold(5000); + config.setLeakDetectionThreshold(5000L); dataSource = new HikariDataSource(config); - // Run init.sql if needed // Path initPath = Path.of("src/main/java/kinoko/database/postgresql/setup/init.sql"); // if (Files.exists(initPath)) { // String sql = Files.readString(initPath); diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java index f9abedf7..94454b16 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -345,7 +345,8 @@ public boolean deleteGuild(int guildId) { public List getGuildRankings() { List rankings = new ArrayList<>(); String sql = "SELECT name, points, mark, mark_color, mark_bg, mark_bg_color FROM guild.guilds ORDER BY points DESC"; - try (Statement stmt = getConnection().createStatement(); + try (Connection conn = getConnection(); + Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql)) { while (rs.next()) { rankings.add(new GuildRanking( diff --git a/src/main/java/kinoko/database/postgresql/type/EquipDataDao.java b/src/main/java/kinoko/database/postgresql/type/EquipDataDao.java new file mode 100644 index 00000000..a83be181 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/EquipDataDao.java @@ -0,0 +1,202 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.item.EquipData; +import kinoko.world.item.Item; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.Collection; + +public class EquipDataDao { + /** + * Inserts or updates (upserts) an EquipData record into the item.equip_data table. + *

+ * If the given item_sn already exists, the existing record will be updated + * with the latest equip data values. Otherwise, a new record will be inserted. + * + * @param conn the active SQL connection + * @param itemSn the unique item_sn associated with the equip + * @param equipData the EquipData instance containing the stats to insert or update + * @throws SQLException if any SQL error occurs + */ + public static void upsertEquipData(Connection conn, long itemSn, EquipData equipData) throws SQLException { + if (equipData == null) return; + + String sql = """ + INSERT INTO item.equip_data ( + item_sn, inc_str, inc_dex, inc_int, inc_luk, inc_max_hp, inc_max_mp, + inc_pad, inc_mad, inc_pdd, inc_mdd, inc_acc, inc_eva, inc_craft, + inc_speed, inc_jump, ruc, cuc, iuc, chuc, grade, + option_1, option_2, option_3, socket_1, socket_2, + level_up_type, level, exp, durability + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (item_sn) DO UPDATE SET + inc_str = EXCLUDED.inc_str, + inc_dex = EXCLUDED.inc_dex, + inc_int = EXCLUDED.inc_int, + inc_luk = EXCLUDED.inc_luk, + inc_max_hp = EXCLUDED.inc_max_hp, + inc_max_mp = EXCLUDED.inc_max_mp, + inc_pad = EXCLUDED.inc_pad, + inc_mad = EXCLUDED.inc_mad, + inc_pdd = EXCLUDED.inc_pdd, + inc_mdd = EXCLUDED.inc_mdd, + inc_acc = EXCLUDED.inc_acc, + inc_eva = EXCLUDED.inc_eva, + inc_craft = EXCLUDED.inc_craft, + inc_speed = EXCLUDED.inc_speed, + inc_jump = EXCLUDED.inc_jump, + ruc = EXCLUDED.ruc, + cuc = EXCLUDED.cuc, + iuc = EXCLUDED.iuc, + chuc = EXCLUDED.chuc, + grade = EXCLUDED.grade, + option_1 = EXCLUDED.option_1, + option_2 = EXCLUDED.option_2, + option_3 = EXCLUDED.option_3, + socket_1 = EXCLUDED.socket_1, + socket_2 = EXCLUDED.socket_2, + level_up_type = EXCLUDED.level_up_type, + level = EXCLUDED.level, + exp = EXCLUDED.exp, + durability = EXCLUDED.durability + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int idx = 1; + stmt.setLong(idx++, itemSn); + stmt.setShort(idx++, equipData.getIncStr()); + stmt.setShort(idx++, equipData.getIncDex()); + stmt.setShort(idx++, equipData.getIncInt()); + stmt.setShort(idx++, equipData.getIncLuk()); + stmt.setShort(idx++, equipData.getIncMaxHp()); + stmt.setShort(idx++, equipData.getIncMaxMp()); + stmt.setShort(idx++, equipData.getIncPad()); + stmt.setShort(idx++, equipData.getIncMad()); + stmt.setShort(idx++, equipData.getIncPdd()); + stmt.setShort(idx++, equipData.getIncMdd()); + stmt.setShort(idx++, equipData.getIncAcc()); + stmt.setShort(idx++, equipData.getIncEva()); + stmt.setShort(idx++, equipData.getIncCraft()); + stmt.setShort(idx++, equipData.getIncSpeed()); + stmt.setShort(idx++, equipData.getIncJump()); + stmt.setByte(idx++, equipData.getRuc()); + stmt.setByte(idx++, equipData.getCuc()); + stmt.setInt(idx++, equipData.getIuc()); + stmt.setByte(idx++, equipData.getChuc()); + stmt.setByte(idx++, equipData.getGrade()); + stmt.setShort(idx++, equipData.getOption1()); + stmt.setShort(idx++, equipData.getOption2()); + stmt.setShort(idx++, equipData.getOption3()); + stmt.setShort(idx++, equipData.getSocket1()); + stmt.setShort(idx++, equipData.getSocket2()); + stmt.setByte(idx++, equipData.getLevelUpType()); + stmt.setByte(idx++, equipData.getLevel()); + stmt.setInt(idx++, equipData.getExp()); + stmt.setInt(idx, equipData.getDurability()); + + stmt.executeUpdate(); + } + } + + /** + * Batch upserts equip data for multiple items. + *

+ * For each item, if the item has EquipData, it will be inserted or updated. + * Existing equip_data rows are updated; missing ones are inserted. + * Uses a single PreparedStatement batch for efficiency. + * + * @param conn active SQL connection + * @param items collection of items that may contain equip data + * @throws SQLException if any SQL error occurs + */ + public static void saveEquipDataBatch(Connection conn, Collection items) throws SQLException { + if (items == null || items.isEmpty()) return; + + String sql = """ + INSERT INTO item.equip_data ( + item_sn, inc_str, inc_dex, inc_int, inc_luk, inc_max_hp, inc_max_mp, + inc_pad, inc_mad, inc_pdd, inc_mdd, inc_acc, inc_eva, inc_craft, + inc_speed, inc_jump, ruc, cuc, iuc, chuc, grade, + option_1, option_2, option_3, socket_1, socket_2, + level_up_type, level, exp, durability + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (item_sn) DO UPDATE SET + inc_str = EXCLUDED.inc_str, + inc_dex = EXCLUDED.inc_dex, + inc_int = EXCLUDED.inc_int, + inc_luk = EXCLUDED.inc_luk, + inc_max_hp = EXCLUDED.inc_max_hp, + inc_max_mp = EXCLUDED.inc_max_mp, + inc_pad = EXCLUDED.inc_pad, + inc_mad = EXCLUDED.inc_mad, + inc_pdd = EXCLUDED.inc_pdd, + inc_mdd = EXCLUDED.inc_mdd, + inc_acc = EXCLUDED.inc_acc, + inc_eva = EXCLUDED.inc_eva, + inc_craft = EXCLUDED.inc_craft, + inc_speed = EXCLUDED.inc_speed, + inc_jump = EXCLUDED.inc_jump, + ruc = EXCLUDED.ruc, + cuc = EXCLUDED.cuc, + iuc = EXCLUDED.iuc, + chuc = EXCLUDED.chuc, + grade = EXCLUDED.grade, + option_1 = EXCLUDED.option_1, + option_2 = EXCLUDED.option_2, + option_3 = EXCLUDED.option_3, + socket_1 = EXCLUDED.socket_1, + socket_2 = EXCLUDED.socket_2, + level_up_type = EXCLUDED.level_up_type, + level = EXCLUDED.level, + exp = EXCLUDED.exp, + durability = EXCLUDED.durability + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (Item item : items) { + EquipData e = item.getEquipData(); + if (e == null) continue; + + int idx = 1; + stmt.setLong(idx++, item.getItemSn()); + stmt.setShort(idx++, e.getIncStr()); + stmt.setShort(idx++, e.getIncDex()); + stmt.setShort(idx++, e.getIncInt()); + stmt.setShort(idx++, e.getIncLuk()); + stmt.setShort(idx++, e.getIncMaxHp()); + stmt.setShort(idx++, e.getIncMaxMp()); + stmt.setShort(idx++, e.getIncPad()); + stmt.setShort(idx++, e.getIncMad()); + stmt.setShort(idx++, e.getIncPdd()); + stmt.setShort(idx++, e.getIncMdd()); + stmt.setShort(idx++, e.getIncAcc()); + stmt.setShort(idx++, e.getIncEva()); + stmt.setShort(idx++, e.getIncCraft()); + stmt.setShort(idx++, e.getIncSpeed()); + stmt.setShort(idx++, e.getIncJump()); + stmt.setByte(idx++, e.getRuc()); + stmt.setByte(idx++, e.getCuc()); + stmt.setInt(idx++, e.getIuc()); + stmt.setByte(idx++, e.getChuc()); + stmt.setByte(idx++, e.getGrade()); + stmt.setShort(idx++, e.getOption1()); + stmt.setShort(idx++, e.getOption2()); + stmt.setShort(idx++, e.getOption3()); + stmt.setShort(idx++, e.getSocket1()); + stmt.setShort(idx++, e.getSocket2()); + stmt.setByte(idx++, e.getLevelUpType()); + stmt.setByte(idx++, e.getLevel()); + stmt.setInt(idx++, e.getExp()); + stmt.setInt(idx, e.getDurability()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java index 68658389..54fa6059 100644 --- a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java +++ b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java @@ -114,7 +114,6 @@ private static void deleteUnusedItems(Connection conn, int charId, Collection ALL (?)")) { - System.out.println("DELETING INVENTORY"); deleteStmt.setInt(1, charId); Array sqlArray = conn.createArrayOf("bigint", itemSnArray); deleteStmt.setArray(2, sqlArray); diff --git a/src/main/java/kinoko/database/postgresql/type/ItemDao.java b/src/main/java/kinoko/database/postgresql/type/ItemDao.java index ef669ddb..8f30cd67 100644 --- a/src/main/java/kinoko/database/postgresql/type/ItemDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ItemDao.java @@ -43,6 +43,7 @@ public static long createNewItem(Connection conn, Item item) throws SQLException if (rs.next()) { long generatedSn = rs.getLong("item_sn"); item.setItemSn(generatedSn); // store it in the item object + EquipDataDao.upsertEquipData(conn, generatedSn, item.getEquipData()); return generatedSn; } else { throw new SQLException("Failed to generate item_sn for new item"); @@ -113,6 +114,8 @@ public static void saveItemsBatch(Connection conn, Collection items) throw // Execute all insert batches stmtInsert.executeBatch(); + // Update all EquipData + EquipDataDao.saveEquipDataBatch(conn, items); } } diff --git a/src/main/resources/log4j2.xml b/src/main/resources/log4j2.xml index f69bb25e..796846f8 100644 --- a/src/main/resources/log4j2.xml +++ b/src/main/resources/log4j2.xml @@ -1,8 +1,11 @@ + + + - + @@ -15,6 +18,10 @@ + + + + diff --git a/src/test/java/kinoko/world/item/InventoryManagerTest.java b/src/test/java/kinoko/world/item/InventoryManagerTest.java index 30176cac..7cd89a40 100644 --- a/src/test/java/kinoko/world/item/InventoryManagerTest.java +++ b/src/test/java/kinoko/world/item/InventoryManagerTest.java @@ -42,7 +42,7 @@ public void testMoney() { @Test public void testItemCount() { final InventoryManager im = new InventoryManager(); - im.setConsumeInventory(new Inventory(5)); + im.setConsumeInventory(new Inventory(5, InventoryType.CONSUME)); Assertions.assertEquals(0, im.getItemCount(RED_POTION)); @@ -62,7 +62,7 @@ public void testItemCount() { @Test public void testRemoveItem() { final InventoryManager im = new InventoryManager(); - im.setConsumeInventory(new Inventory(5)); + im.setConsumeInventory(new Inventory(5, InventoryType.CONSUME)); im.getConsumeInventory().putItem(1, createItem(RED_POTION, 5)); Assertions.assertEquals(5, im.getItemCount(RED_POTION)); @@ -83,7 +83,7 @@ public void testRemoveItem() { @Test public void testAddItem() { final InventoryManager im = new InventoryManager(); - im.setConsumeInventory(new Inventory(3)); + im.setConsumeInventory(new Inventory(3, InventoryType.CONSUME)); Assertions.assertTrue(im.addItem(createItem(RED_POTION, 5)).isPresent()); Assertions.assertNotNull(im.getConsumeInventory().getItem(1)); @@ -106,7 +106,7 @@ public void testAddItem() { @Test public void testCanAddItems() { final InventoryManager im = new InventoryManager(); - im.setConsumeInventory(new Inventory(5)); + im.setConsumeInventory(new Inventory(5, InventoryType.CONSUME)); Assertions.assertTrue(im.canAddItem(createItem(RED_POTION, 5))); Assertions.assertTrue(im.canAddItems(Set.of(createItem(RED_POTION, 5), createItem(ORANGE_POTION, 5)))); @@ -132,7 +132,7 @@ public void testCanAddItems() { @Test public void testRechargeableItems() { final InventoryManager im = new InventoryManager(); - im.setConsumeInventory(new Inventory(2)); + im.setConsumeInventory(new Inventory(2, InventoryType.CONSUME)); Assertions.assertTrue(im.canAddItem(createItem(SUBI_THROWING_STARS, 400))); Assertions.assertTrue(im.addItem(createItem(SUBI_THROWING_STARS, 400)).isPresent()); From e5fc3a84c25e33a7f17cdf1ea5876fee4d024df9 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Fri, 10 Oct 2025 00:24:32 -0400 Subject: [PATCH 13/83] Added whereami command --- src/main/java/kinoko/server/command/AdminCommands.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/kinoko/server/command/AdminCommands.java b/src/main/java/kinoko/server/command/AdminCommands.java index 8b92879d..2d11a793 100644 --- a/src/main/java/kinoko/server/command/AdminCommands.java +++ b/src/main/java/kinoko/server/command/AdminCommands.java @@ -415,6 +415,11 @@ public static void map(User user, String[] args) { user.warp(targetField, portalResult.get(), false, false); } + @Command("whereami") + public static void whereAmI(User user, String[] args) { + user.write(MessagePacket.system("You are in map: %d", user.getField().getFieldId())); + } + @Command("reactor") @Arguments("reactor template ID") public static void reactor(User user, String[] args) { From fd7302fe7754a9f4771a410287853c65cd3ce0cb Mon Sep 17 00:00:00 2001 From: MujyKun Date: Fri, 10 Oct 2025 16:53:54 -0400 Subject: [PATCH 14/83] Refactored DAOs, fixed guilds, notices, and entries. --- .../database/postgresql/PostgresAccessor.java | 139 +++++++ .../postgresql/PostgresAccountAccessor.java | 184 ++------- .../postgresql/PostgresGuildAccessor.java | 362 ++++-------------- .../kinoko/database/postgresql/setup/init.sql | 29 +- .../database/postgresql/type/AccountDao.java | 76 ++++ .../postgresql/type/BoardEntryCommentDao.java | 121 ++++++ .../postgresql/type/BoardEntryDao.java | 209 ++++++++++ .../postgresql/type/BoardNoticeDao.java | 104 +++++ .../postgresql/type/EquipDataDao.java | 2 - .../database/postgresql/type/GuildDao.java | 212 ++++++++++ .../postgresql/type/GuildMemberDao.java | 169 ++++++++ .../database/postgresql/type/LockerDao.java | 40 ++ .../database/postgresql/type/TrunkDao.java | 1 - .../database/postgresql/type/WishlistDao.java | 34 ++ .../database/postgresql/util/SQLAction.java | 9 + .../postgresql/util/SQLBooleanAction.java | 9 + src/main/java/kinoko/server/guild/Guild.java | 6 + .../server/guild/GuildBoardComment.java | 19 +- .../kinoko/server/guild/GuildBoardEntry.java | 25 +- 19 files changed, 1288 insertions(+), 462 deletions(-) create mode 100644 src/main/java/kinoko/database/postgresql/type/AccountDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/GuildDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java create mode 100644 src/main/java/kinoko/database/postgresql/util/SQLAction.java create mode 100644 src/main/java/kinoko/database/postgresql/util/SQLBooleanAction.java diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java index 8afa32b6..8dbea1e2 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java @@ -1,9 +1,14 @@ package kinoko.database.postgresql; import com.zaxxer.hikari.HikariDataSource; +import kinoko.database.postgresql.util.SQLAction; +import kinoko.database.postgresql.util.SQLBooleanAction; import java.sql.Connection; import java.sql.SQLException; +import java.util.function.Supplier; + + public abstract class PostgresAccessor { private final HikariDataSource dataSource; @@ -26,4 +31,138 @@ protected final Connection getConnection() throws SQLException { protected final String lowerName(String name) { return name.toLowerCase(); } + + /** + * Executes the given action within a database transaction using a connection from the instance's data source. + * Sets auto-commit to false, runs the action, commits the transaction if successful, and rolls back if a SQLException occurs. + * Finally, restores auto-commit to true and closes the connection. + * + * @param action The action to execute inside the transaction. Can throw SQLException. + * @return true if the transaction committed successfully; false if an exception occurred and rollback was performed. + */ + public boolean withTransaction(SQLAction action) { + Connection conn = null; + try { + conn = dataSource.getConnection(); + conn.setAutoCommit(false); + + action.apply(conn); + + conn.commit(); + return true; + + } catch (SQLException e) { + if (conn != null) { + try { + conn.rollback(); + } + catch (SQLException rollbackEx) + { + rollbackEx.printStackTrace(); + } + } + e.printStackTrace(); + return false; + } finally { + if (conn != null) { + try { + conn.setAutoCommit(true); + conn.close(); + } catch + (SQLException ignored) { + ignored.printStackTrace(); + } + } + } + } + + /** + * Executes the given action within a database transaction using the provided connection. + * Sets auto-commit to false on the connection, executes the action, commits the transaction + * if successful, and rolls back if a SQLException occurs. Restores auto-commit to true and closes + * the connection in the finally block. + * + * @param conn The database connection to use for the transaction. This connection will be closed by this method. + * @param action The action to execute inside the transaction. Can throw SQLException. + * @return true if the transaction committed successfully; false if an exception occurred and rollback was performed. + */ + public static boolean withTransaction(Connection conn, + SQLAction action) { + try { + conn.setAutoCommit(false); + action.apply(conn); + conn.commit(); + return true; + } catch (SQLException e) { + if (conn != null) { + try { + conn.rollback(); + } + catch (SQLException rollbackEx) + { + rollbackEx.printStackTrace(); + } + } + e.printStackTrace(); + return false; + } finally { + if (conn != null) { + try { + conn.setAutoCommit(true); + conn.close(); + } catch (SQLException ignored) { + ignored.printStackTrace(); + } + } + } + } + + /** + * Executes the given action within a database transaction using a connection from the instance's data source. + * Sets auto-commit to false, runs the action, commits the transaction if successful, and rolls back if a SQLException occurs. + * Finally, restores auto-commit to true and closes the connection. + * + * @param action The action to execute inside the transaction. Can throw SQLException. + * @return true if the transaction committed successfully; false if an exception occurred and rollback was performed. + */ + public boolean withTransaction(SQLBooleanAction action) { + Connection conn = null; + try { + conn = dataSource.getConnection(); + conn.setAutoCommit(false); + + boolean logicalSuccess = action.apply(conn); + if (!logicalSuccess){ + conn.rollback(); + return false; + } + + conn.commit(); + return true; + + } catch (SQLException e) { + if (conn != null) { + try { + conn.rollback(); + } + catch (SQLException rollbackEx) + { + rollbackEx.printStackTrace(); + } + } + e.printStackTrace(); + return false; + } finally { + if (conn != null) { + try { + conn.setAutoCommit(true); + conn.close(); + } catch + (SQLException ignored) { + ignored.printStackTrace(); + } + } + } + } } + diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java index 4a77efe6..8efa91b4 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java @@ -3,21 +3,13 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.AccountAccessor; import kinoko.database.DatabaseManager; -import kinoko.database.postgresql.type.LockerDao; -import kinoko.database.postgresql.type.TrunkDao; -import kinoko.database.postgresql.type.WishlistDao; +import kinoko.database.postgresql.type.AccountDao; import kinoko.server.ServerConfig; -import kinoko.server.cashshop.CashItemInfo; -import kinoko.world.item.Item; -import kinoko.world.item.Trunk; import kinoko.world.user.Account; -import kinoko.world.user.Locker; import org.mindrot.jbcrypt.BCrypt; import java.sql.*; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; + import java.util.Optional; public final class PostgresAccountAccessor extends PostgresAccessor implements AccountAccessor { @@ -26,70 +18,6 @@ public PostgresAccountAccessor(HikariDataSource dataSource) { super(dataSource); } - private Account loadAccount(Connection conn, ResultSet rs) throws SQLException { - final int accountId = rs.getInt("id"); - final String username = rs.getString("username"); - final String secondaryPassword = rs.getString("secondary_password"); - - final Account account = new Account(accountId, username); - account.setHasSecondaryPassword(secondaryPassword != null && !secondaryPassword.isEmpty()); - account.setSlotCount(rs.getInt("character_slots")); - account.setNxCredit(rs.getInt("nx_credit")); - account.setNxPrepaid(rs.getInt("nx_prepaid")); - account.setMaplePoint(rs.getInt("maple_point")); - - account.setTrunk(TrunkDao.load(conn, accountId)); - - account.setLocker(loadLocker(accountId)); - account.setWishlist(loadWishlist(accountId)); - - return account; - } - - - private Locker loadLocker(int accountId) throws SQLException { - Locker locker = new Locker(); - String sql = "SELECT li.slot, li.item_sn, li.commodity_id, i.item_id, i.quantity " + - "FROM account.locker_item li " + - "JOIN item.items i ON li.item_sn = i.item_sn " + - "WHERE li.account_id = ? ORDER BY li.slot"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, accountId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - Item item = new Item(rs.getInt("item_id"), (short) rs.getInt("quantity")); - CashItemInfo info = new CashItemInfo( - item, - rs.getInt("commodity_id"), - accountId, // account owner - -1, // character owner unknown at this point - null - ); - locker.addCashItem(info); - } - } - } - return locker; - } - - private List loadWishlist(int accountId) throws SQLException { - List wishlist = new ArrayList<>(); - String sql = "SELECT w.item_id FROM account.wishlist w " + - "WHERE w.account_id = ? ORDER BY w.slot"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, accountId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - wishlist.add(rs.getInt("item_id")); - } - } - } - while (wishlist.size() < 10) wishlist.add(0); - return Collections.unmodifiableList(wishlist); - } - private String lowerUsername(String username) { return username.toLowerCase(); } @@ -106,13 +34,16 @@ private boolean checkHashedPassword(String password, String hashedPassword) { public Optional getAccountById(int accountId) { String sql = "SELECT * FROM account.accounts WHERE id = ?"; try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - return Optional.of(loadAccount(conn, rs)); + return Optional.of(AccountDao.load(conn, rs)); } } + } catch (SQLException e) { e.printStackTrace(); } @@ -123,13 +54,16 @@ public Optional getAccountById(int accountId) { public Optional getAccountByUsername(String username) { String sql = "SELECT * FROM account.accounts WHERE username = ?"; try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, lowerUsername(username)); + try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - return Optional.of(loadAccount(conn, rs)); + return Optional.of(AccountDao.load(conn, rs)); } } + } catch (SQLException e) { e.printStackTrace(); } @@ -138,41 +72,27 @@ public Optional getAccountByUsername(String username) { @Override public boolean checkPassword(Account account, String password, boolean secondary) { - String column = secondary ? "secondary_password" : "password"; - String sql = "SELECT " + column + " FROM account.accounts WHERE id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, account.getId()); - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - String hashed = rs.getString(column); - return hashed != null && checkHashedPassword(password, hashed); - } - } + try (Connection conn = getConnection()) { + String hashed = AccountDao.getHashedPassword(conn, account, secondary); + return hashed != null && checkHashedPassword(password, hashed); } catch (SQLException e) { e.printStackTrace(); + return false; } - return false; } @Override public boolean savePassword(Account account, String oldPassword, String newPassword, boolean secondary) { - String column = secondary ? "secondary_password" : "password"; - String sqlSelect = "SELECT " + column + " FROM account.accounts WHERE id = ?"; - String sqlUpdate = "UPDATE account.accounts SET " + column + " = ? WHERE id = ?"; - try (Connection conn = getConnection(); - PreparedStatement selectStmt = conn.prepareStatement(sqlSelect)) { - selectStmt.setInt(1, account.getId()); - try (ResultSet rs = selectStmt.executeQuery()) { - if (rs.next()) { - String hashedOld = rs.getString(column); - if (hashedOld == null || checkHashedPassword(oldPassword, hashedOld)) { - try (PreparedStatement updateStmt = conn.prepareStatement(sqlUpdate)) { - updateStmt.setString(1, hashPassword(newPassword)); - updateStmt.setInt(2, account.getId()); - return updateStmt.executeUpdate() > 0; - } - } + String sqlUpdate = "UPDATE account.accounts SET " + + (secondary ? "secondary_password" : "password") + + " = ? WHERE id = ?"; + try (Connection conn = getConnection()) { + String hashedOld = AccountDao.getHashedPassword(conn, account, secondary); + if (hashedOld == null || checkHashedPassword(oldPassword, hashedOld)) { + try (PreparedStatement updateStmt = conn.prepareStatement(sqlUpdate)) { + updateStmt.setString(1, hashPassword(newPassword)); + updateStmt.setInt(2, account.getId()); + return updateStmt.executeUpdate() > 0; } } } catch (SQLException e) { @@ -192,7 +112,6 @@ public synchronized boolean newAccount(String username, String password) { String sql = "INSERT INTO account.accounts (username, password, character_slots, nx_credit, nx_prepaid, maple_point, trunk_size, trunk_money) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + "RETURNING ID"; - System.out.println("Creating account."); try (Connection conn = getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, lowerUsername(username)); @@ -210,30 +129,6 @@ public synchronized boolean newAccount(String username, String password) { throw new SQLException("Failed to retrieve account ID after insert."); } } - - // WARNING CODED AS A LEAK RN - // Initialize a base trunk, locker, wishlist -// for (int i = 0; i < ServerConfig.TRUNK_BASE_SLOTS; i++) { -// try (PreparedStatement tStmt = getConnection().prepareStatement( -// "INSERT INTO account.trunk_item (account_id, slot, item_sn) VALUES (?, ?, ?)")) { -// tStmt.setInt(1, accountId); -// tStmt.setInt(2, i); -// tStmt.setLong(3, itemSn); -// tStmt.executeUpdate(); -// } -// } -// WARNING CODED AS A LEAK RN -// for (int i = 0; i < 10; i++) { -// try (PreparedStatement wStmt = getConnection().prepareStatement( -// "INSERT INTO account.wishlist (account_id, slot, item_sn) VALUES (?, ?, ?)")) { -// wStmt.setInt(1, accountId); -// wStmt.setInt(2, i); -// wStmt.setLong(3, itemSn); -// wStmt.executeUpdate(); -// } -// } - - // Locker starts empty, no rows needed initially return true; } catch (SQLException e) { e.printStackTrace(); @@ -243,32 +138,11 @@ public synchronized boolean newAccount(String username, String password) { @Override public boolean saveAccount(Account account) { - try (Connection conn = getConnection()) { - conn.setAutoCommit(false); - - try (PreparedStatement stmt = conn.prepareStatement( - "UPDATE account.accounts SET character_slots = ?, nx_credit = ?, nx_prepaid = ?, maple_point = ?, trunk_size = ?, trunk_money = ? WHERE id = ?")) { - stmt.setInt(1, account.getSlotCount()); - stmt.setInt(2, account.getNxCredit()); - stmt.setInt(3, account.getNxPrepaid()); - stmt.setInt(4, account.getMaplePoint()); - stmt.setInt(5, account.getTrunk().getSize()); - stmt.setInt(6, account.getTrunk().getMoney()); - stmt.setInt(7, account.getId()); - stmt.executeUpdate(); - } - - int accountId = account.getId(); - TrunkDao.save(conn, accountId, account.getTrunk()); - WishlistDao.save(conn, accountId, account.getWishlist()); - LockerDao.save(conn, accountId, account.getLocker()); - - conn.commit(); - conn.setAutoCommit(true); - return true; + try { + return withTransaction(getConnection(), c -> AccountDao.save(c, account)); } catch (SQLException e) { e.printStackTrace(); + return false; } - return false; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java index 94454b16..66eabb5c 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -2,11 +2,9 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.GuildAccessor; +import kinoko.database.postgresql.type.GuildDao; import kinoko.server.guild.Guild; -import kinoko.server.guild.GuildBoardEntry; -import kinoko.server.guild.GuildMember; import kinoko.server.guild.GuildRanking; -import kinoko.server.guild.GuildRank; import java.sql.*; import java.util.*; @@ -17,38 +15,17 @@ public PostgresGuildAccessor(HikariDataSource dataSource) { super(dataSource); } - // --------------------------------------------- - // LOAD A GUILD - // --------------------------------------------- - private Guild loadGuild(ResultSet rs) throws SQLException { - final int guildId = rs.getInt("guild_id"); - final String guildName = rs.getString("guild_name"); - Guild guild = new Guild(guildId, guildName); - - guild.setMemberMax(rs.getInt("member_max")); - guild.setMarkBg(rs.getShort("mark_bg")); - guild.setMarkBgColor(rs.getByte("mark_bg_color")); - guild.setMark(rs.getShort("mark")); - guild.setMarkColor(rs.getByte("mark_color")); - guild.setNotice(rs.getString("notice")); - guild.setPoints(rs.getInt("points")); - guild.setLevel(rs.getByte("level")); - - final List members = loadMembers(guildId); - for (GuildMember member : members) { - guild.addMember(member); - } - - guild.setGradeNames(loadGrades(guild.getGuildId())); - - final List boardEntries = loadBoardEntries(guildId); - guild.getBoardEntries().addAll(boardEntries); - - guild.setBoardNoticeEntry(loadBoardNotice(guildId)); - - return guild; - } - + /** + * Retrieves a guild from the database by its ID. + * + * Executes a query to fetch the guild record corresponding to the + * provided guild ID. If a matching guild is found, it is loaded + * into a {@link Guild} object using {@link GuildDao#loadGuild}. + * + * @param guildId the ID of the guild to retrieve + * @return an {@link Optional} containing the guild if found, or + * {@link Optional#empty()} if no guild exists with the given ID + */ @Override public Optional getGuildById(int guildId) { String sql = "SELECT * FROM guild.guilds WHERE guild_id = ?"; @@ -57,7 +34,7 @@ public Optional getGuildById(int guildId) { stmt.setInt(1, guildId); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - return Optional.of(loadGuild(rs)); + return Optional.of(GuildDao.loadGuild(conn, rs)); } } } catch (SQLException e) { @@ -66,281 +43,84 @@ public Optional getGuildById(int guildId) { return Optional.empty(); } - - private List loadGrades(int guildId) throws SQLException { - List grades = new ArrayList<>(); - String sql = "SELECT grade_name FROM guild.grade WHERE guild_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, guildId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - grades.add(rs.getString("grade_name")); - } - } - } - return grades; - } - - // --------------------------------------------- - // MEMBERS - // --------------------------------------------- - private List loadMembers(int guildId) { - List members = new ArrayList<>(); - String sql = """ - SELECT c.character_id, c.character_name, s.job, s.level, - m.grade AS guildRank, NULL AS allianceRank, c.online - FROM guild.member m - JOIN player.characters c ON c.character_id = m.character_id - JOIN character.stats s ON s.character_id = c.character_id - WHERE m.guild_id = ? - """; - - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, guildId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - int charId = rs.getInt("character_id"); - String charName = rs.getString("character_name"); - int job = rs.getInt("job"); - int level = rs.getInt("level"); - boolean online = rs.getBoolean("online"); // now works with the new column - int guildRankInt = rs.getInt("guildRank"); - Integer allianceRankInt = null; // no alliance rank yet - - members.add(new GuildMember( - charId, - charName, - job, - level, - online, - GuildRank.getByValue(guildRankInt), - allianceRankInt != null ? GuildRank.getByValue(allianceRankInt) : null // setting to null for now. - )); - } - } - } catch (SQLException e) { - e.printStackTrace(); - } - - return members; - } - - - - - // --------------------------------------------- - // BOARD ENTRIES - // --------------------------------------------- - private List loadBoardEntries(int guildId) { - List entries = new ArrayList<>(); - String sql = "SELECT entry_id, character_id, title, message, timestamp, 0 AS emoticon " + - "FROM guild.board_entry WHERE guild_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, guildId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - entries.add(new GuildBoardEntry( - rs.getInt("entry_id"), - rs.getInt("character_id"), - rs.getString("title"), - rs.getString("message"), - rs.getTimestamp("timestamp").toInstant(), - rs.getInt("emoticon") - )); - } - } - } catch (SQLException e) { - e.printStackTrace(); - } - return entries; - } - - private GuildBoardEntry loadBoardNotice(int guildId) { - String sql = "SELECT entry_id FROM guild.notice WHERE guild_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, guildId); - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - int entryId = rs.getInt("entry_id"); - // Load full entry - String entrySql = "SELECT entry_id, character_id, title, message, timestamp, 0 AS emoticon " + - "FROM guild.board_entry WHERE entry_id = ?"; - try (PreparedStatement entryStmt = getConnection().prepareStatement(entrySql)) { - entryStmt.setInt(1, entryId); - try (ResultSet ers = entryStmt.executeQuery()) { - if (ers.next()) { - return new GuildBoardEntry( - ers.getInt("entry_id"), - ers.getInt("character_id"), - ers.getString("title"), - ers.getString("message"), - ers.getTimestamp("timestamp").toInstant(), - ers.getInt("emoticon") - ); - } - } - } - } - } - } catch (SQLException e) { - e.printStackTrace(); - } - return null; - } - - // --------------------------------------------- - // CHECK NAME - // --------------------------------------------- + /** + * Checks if a guild name is available for use. + * + * Queries the database to determine whether the given guild name + * already exists. Returns true if the name is not taken, false otherwise. + * + * @param name the guild name to check + * @return true if the name is available, false if it is already in use + */ @Override public boolean checkGuildNameAvailable(String name) { - String sql = "SELECT 1 FROM guild.guilds WHERE LOWER(guild_name) = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setString(1, name.toLowerCase()); - try (ResultSet rs = stmt.executeQuery()) { - return !rs.next(); - } + try (Connection conn = getConnection();) { + return GuildDao.checkGuildNameAvailable(conn, name); } catch (SQLException e) { e.printStackTrace(); } return false; } - // --------------------------------------------- - // SAVE / CREATE - // --------------------------------------------- + /** + * Creates a new guild in the database within a transaction. + * + * This method wraps the insertion in a transaction to ensure atomicity. + * It delegates the actual insertion to GuildDao.insertGuild. + * + * @param guild the Guild object to be inserted + * @return true if the guild was successfully created, false otherwise + */ @Override public synchronized boolean newGuild(Guild guild) { - if (!checkGuildNameAvailable(guild.getGuildName())) return false; - return saveGuild(guild); + return withTransaction(conn -> { + return GuildDao.insertGuild(conn, guild); + }); } + /** + * Saves (updates) an existing guild in the database within a transaction. + * + * This method wraps the update in a transaction to ensure atomicity. + * It delegates the actual update to GuildDao.updateGuild. + * + * @param guild the Guild object with updated data + * @return true if the guild was successfully updated, false otherwise + */ @Override public boolean saveGuild(Guild guild) { - String sql = "INSERT INTO guild.guilds (guild_id, guild_name, grade_names, member_max, mark_bg, mark_bg_color, mark, mark_color, notice, points, level, board_entry_counter) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + - "ON CONFLICT (guild_id) DO UPDATE SET " + - "guild_name = EXCLUDED.guild_name, " + - "grade_names = EXCLUDED.grade_names, " + - "member_max = EXCLUDED.member_max, " + - "mark_bg = EXCLUDED.mark_bg, " + - "mark_bg_color = EXCLUDED.mark_bg_color, " + - "mark = EXCLUDED.mark, " + - "mark_color = EXCLUDED.mark_color, " + - "notice = EXCLUDED.notice, " + - "points = EXCLUDED.points, " + - "level = EXCLUDED.level, " + - "board_entry_counter = EXCLUDED.board_entry_counter"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, guild.getGuildId()); - stmt.setString(2, guild.getGuildName()); - stmt.setArray(3, getConnection().createArrayOf("text", guild.getGradeNames().toArray())); - stmt.setInt(4, guild.getMemberMax()); - stmt.setShort(5, guild.getMarkBg()); - stmt.setByte(6, guild.getMarkBgColor()); - stmt.setShort(7, guild.getMark()); - stmt.setByte(8, guild.getMarkColor()); - stmt.setString(9, guild.getNotice()); - stmt.setInt(10, guild.getPoints()); - stmt.setByte(11, guild.getLevel()); - stmt.setInt(12, guild.getBoardEntryCounter().get()); - stmt.executeUpdate(); - - saveMembers(guild); - saveBoardEntries(guild); - saveBoardNotice(guild); - - return true; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; - } - - private void saveMembers(Guild guild) throws SQLException { - String deleteSql = "DELETE FROM guild.member WHERE guild_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(deleteSql)) { - stmt.setInt(1, guild.getGuildId()); - stmt.executeUpdate(); - } - - String insertSql = "INSERT INTO guild.member (guild_id, character_id, grade) VALUES (?, ?, ?)"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(insertSql)) { - for (GuildMember member : guild.getGuildMembers()) { - stmt.setInt(1, guild.getGuildId()); - stmt.setInt(2, member.getCharacterId()); - stmt.setShort(3, (short) member.getGuildRank().getValue()); - stmt.addBatch(); - } - stmt.executeBatch(); - } + return withTransaction(conn -> { + return GuildDao.updateGuild(conn, guild); + }); } - private void saveBoardEntries(Guild guild) throws SQLException { - String deleteSql = "DELETE FROM guild.board_entry WHERE guild_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(deleteSql)) { - stmt.setInt(1, guild.getGuildId()); - stmt.executeUpdate(); - } - - String insertSql = "INSERT INTO guild.board_entry (entry_id, guild_id, character_id, title, message, timestamp, emoticon) VALUES (?, ?, ?, ?, ?, ?, ?)"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(insertSql)) { - for (GuildBoardEntry entry : guild.getBoardEntries()) { - stmt.setInt(1, entry.getEntryId()); - stmt.setInt(2, guild.getGuildId()); - stmt.setInt(3, entry.getCharacterId()); - stmt.setString(4, entry.getTitle()); - stmt.setString(5, entry.getText()); - stmt.setTimestamp(6, Timestamp.from(entry.getDate())); - stmt.setInt(7, entry.getEmoticon()); - stmt.addBatch(); - } - stmt.executeBatch(); - } - } - - private void saveBoardNotice(Guild guild) throws SQLException { - String sql = "INSERT INTO guild.notice (guild_id, entry_id) VALUES (?, ?) " + - "ON CONFLICT (guild_id) DO UPDATE SET entry_id = EXCLUDED.entry_id"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - GuildBoardEntry notice = guild.getBoardNoticeEntry(); - if (notice != null) { - stmt.setInt(1, guild.getGuildId()); - stmt.setInt(2, notice.getEntryId()); - stmt.executeUpdate(); - } - } - } - - // --------------------------------------------- - // DELETE - // --------------------------------------------- + /** + * Deletes a guild from the database within a transaction. + * + * This method wraps the deletion in a transaction to ensure atomicity. + * It delegates the actual deletion to GuildDao.deleteGuild. + * + * @param guildId the ID of the guild to delete + * @return true if the guild was successfully deleted, false otherwise + */ @Override public boolean deleteGuild(int guildId) { - String sql = "DELETE FROM guild.guilds WHERE guild_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, guildId); - return stmt.executeUpdate() > 0; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return GuildDao.deleteGuild(conn, guildId); + }); } - // --------------------------------------------- - // RANKINGS - // --------------------------------------------- + /** + * Retrieves a list of guild rankings from the database. + * + * Guilds are ordered by their points in descending order, + * so the guild with the highest points appears first. + * + * Each GuildRanking object contains the guild's name, points, + * and visual mark information (mark, mark color, background, background color). + * + * @return a list of GuildRanking objects representing all guilds ordered by points + */ @Override public List getGuildRankings() { List rankings = new ArrayList<>(); diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index 77b5ec57..b2eebea4 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -407,22 +407,29 @@ CREATE TABLE IF NOT EXISTS guild.member ( PRIMARY KEY (guild_id, character_id) ); + CREATE TABLE IF NOT EXISTS guild.board_entry ( + id SERIAL PRIMARY KEY, guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, - entry_id SERIAL PRIMARY KEY, - poster_id INT NOT NULL, - poster_name TEXT, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + title TEXT, message TEXT NOT NULL, - timestamp TIMESTAMP NOT NULL + emoticon INT, + timestamp TIMESTAMP NOT NULL DEFAULT UTC_NOW(), + notice boolean DEFAULT false ); -CREATE TABLE IF NOT EXISTS guild.notice ( - guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, - poster_id INT NOT NULL, - poster_name TEXT, - message TEXT NOT NULL, - timestamp TIMESTAMP NOT NULL, - PRIMARY KEY (guild_id) +-- enforce only one TRUE notice per guild +CREATE UNIQUE INDEX IF NOT EXISTS unique_guild_notice +ON guild.board_entry(guild_id) +WHERE notice = TRUE; + +CREATE TABLE IF NOT EXISTS guild.board_entry_comment ( + id SERIAL PRIMARY KEY, + entry_id INT NOT NULL REFERENCES guild.board_entry(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + text TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL DEFAULT UTC_NOW() ); diff --git a/src/main/java/kinoko/database/postgresql/type/AccountDao.java b/src/main/java/kinoko/database/postgresql/type/AccountDao.java new file mode 100644 index 00000000..714eda69 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/AccountDao.java @@ -0,0 +1,76 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.user.Account; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +public class AccountDao { + + /** + * Saves the given account and all related data to the database. + * Updates the main account fields (character slots, NX credit, prepaid, Maple points, trunk size, trunk money) + * in the accounts table and saves the associated trunk, wishlist, and locker data. + * All operations should be executed inside a transaction. If any database operation fails, + * an SQLException is thrown and the transaction can be rolled back by the caller. + * + * @param conn the database connection to use + * @param account the account object containing updated data to save + * @throws SQLException if any database operation fails + */ + public static void save(Connection conn, Account account) throws SQLException { + try (PreparedStatement stmt = conn.prepareStatement( + "UPDATE account.accounts SET character_slots = ?, nx_credit = ?, nx_prepaid = ?, maple_point = ?, trunk_size = ?, trunk_money = ? WHERE id = ?" + )) { + stmt.setInt(1, account.getSlotCount()); + stmt.setInt(2, account.getNxCredit()); + stmt.setInt(3, account.getNxPrepaid()); + stmt.setInt(4, account.getMaplePoint()); + stmt.setInt(5, account.getTrunk().getSize()); + stmt.setInt(6, account.getTrunk().getMoney()); + stmt.setInt(7, account.getId()); + stmt.executeUpdate(); + } + + // Save related tables + int accountId = account.getId(); + TrunkDao.save(conn, accountId, account.getTrunk()); + WishlistDao.save(conn, accountId, account.getWishlist()); + LockerDao.save(conn, accountId, account.getLocker()); + } + + public static String getHashedPassword(Connection conn, Account account, boolean secondary) throws SQLException { + String column = secondary ? "secondary_password" : "password"; + String sql = "SELECT " + column + " FROM account.accounts WHERE id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, account.getId()); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return rs.getString(column); + } + } + } + return null; + } + + public static Account load(Connection conn, ResultSet rs) throws SQLException { + final int accountId = rs.getInt("id"); + final String username = rs.getString("username"); + final String secondaryPassword = rs.getString("secondary_password"); + + final Account account = new Account(accountId, username); + account.setHasSecondaryPassword(secondaryPassword != null && !secondaryPassword.isEmpty()); + account.setSlotCount(rs.getInt("character_slots")); + account.setNxCredit(rs.getInt("nx_credit")); + account.setNxPrepaid(rs.getInt("nx_prepaid")); + account.setMaplePoint(rs.getInt("maple_point")); + + account.setTrunk(TrunkDao.load(conn, accountId)); + account.setLocker(LockerDao.load(conn, accountId)); + account.setWishlist(WishlistDao.load(conn, accountId)); + + return account; + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java b/src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java new file mode 100644 index 00000000..bf26794c --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java @@ -0,0 +1,121 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.guild.GuildBoardComment; +import kinoko.server.guild.GuildBoardEntry; + +import java.sql.*; +import java.util.List; +import java.util.stream.Collectors; + +public class BoardEntryCommentDao { + + /** + * Synchronizes the comments of a given guild board entry in the database + * with the comments currently in memory. + * + * Deletes comments that have been removed, inserts new comments, + * and ensures that the database matches the in-memory list. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param entry the board entry whose comments should be synchronized + * @throws SQLException if a database access error occurs + */ + public static void saveComments(Connection conn, GuildBoardEntry entry) throws SQLException { + List comments = entry.getComments(); + + if (comments == null || comments.isEmpty()) { + deleteAllComments(conn, entry.getEntryId()); + return; + } + + deleteRemovedComments(conn, entry, comments); + insertNewComments(conn, entry, comments); + } + + /** + * Deletes all comments for a given board entry from the database. + * + * This is typically used when there are no comments in memory, + * and the database should be cleared accordingly. + * + * @param conn the active SQL connection + * @param entryId the ID of the board entry whose comments should be deleted + * @throws SQLException if a database access error occurs + */ + private static void deleteAllComments(Connection conn, int entryId) throws SQLException { + try (PreparedStatement stmt = conn.prepareStatement( + "DELETE FROM guild.board_entry_comment WHERE entry_id = ?")) { + stmt.setInt(1, entryId); + stmt.executeUpdate(); + } + } + + /** + * Deletes comments that exist in the database but no longer exist + * in the current in-memory list of comments for a given entry. + * + * @param conn the active SQL connection + * @param entry the board entry whose comments are being synchronized + * @param comments the current list of comments that should remain + * @throws SQLException if a database access error occurs + */ + private static void deleteRemovedComments(Connection conn, GuildBoardEntry entry, List comments) throws SQLException { + List currentIds = comments.stream() + .filter(c -> !c.hasNoSN()) + .map(GuildBoardComment::getCommentSn) + .toList(); + + if (currentIds.isEmpty()) return; + + String placeholders = currentIds.stream().map(id -> "?").collect(Collectors.joining(",")); + String sql = "DELETE FROM guild.board_entry_comment WHERE entry_id = ? AND id NOT IN (" + placeholders + ")"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int idx = 1; + stmt.setInt(idx++, entry.getEntryId()); + for (Integer id : currentIds) stmt.setInt(idx++, id); + stmt.executeUpdate(); + } + } + + /** + * Inserts new comments for a given board entry into the database + * and sets their generated IDs. + * + * Only comments without a serial number (new comments) are inserted. + * + * @param conn the active SQL connection + * @param entry the board entry to which the comments belong + * @param comments the list of comments to insert + * @throws SQLException if a database access error occurs or + * the generated keys cannot be retrieved + */ + private static void insertNewComments(Connection conn, GuildBoardEntry entry, List comments) throws SQLException { + String insertSql = """ + INSERT INTO guild.board_entry_comment (entry_id, character_id, text, timestamp) + VALUES (?, ?, ?, ?) + """; + + try (PreparedStatement stmt = conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS)) { + List newComments = comments.stream().filter(GuildBoardComment::hasNoSN).toList(); + for (GuildBoardComment comment : newComments) { + stmt.setInt(1, entry.getEntryId()); + stmt.setInt(2, comment.getCharacterId()); + stmt.setString(3, comment.getText()); + stmt.setTimestamp(4, Timestamp.from(comment.getDate())); + stmt.addBatch(); + } + + stmt.executeBatch(); + + try (ResultSet generatedKeys = stmt.getGeneratedKeys()) { + for (GuildBoardComment comment : newComments) { + if (generatedKeys.next()) { + comment.setCommentSn(generatedKeys.getInt(1)); + } else { + throw new SQLException("Failed to retrieve generated commentSn for a new comment."); + } + } + } + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java b/src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java new file mode 100644 index 00000000..b06f4978 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java @@ -0,0 +1,209 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildBoardEntry; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class BoardEntryDao { + + /** + * Synchronizes the guild's board entries in the database with the entries in memory. + * + * Deletes entries that are no longer present and inserts or updates + * existing ones, minimizing redundant operations. + * + * Also synchronizes comments for each board entry via BoardEntryCommentDao. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param guild the guild whose board entries should be synchronized + * @throws SQLException if a database access error occurs + */ + public static void saveBoardEntries(Connection conn, Guild guild) throws SQLException { + List entries = guild.getBoardEntries(); + + if (entries == null || entries.isEmpty()) { + deleteAllEntries(conn, guild.getGuildId()); + return; + } + + // Delete board entries that have been removed. + deleteRemovedBoardEntries(conn, guild, entries); + + // Split entries into new inserts and existing updates + List newEntries = entries.stream() + .filter(GuildBoardEntry::hasNoSN) + .toList(); + + List existingEntries = entries.stream() + .filter(entry -> !entry.hasNoSN()) + .toList(); + + // Insert new entries and assign generated SNs + insertBoardEntries(conn, guild.getGuildId(), newEntries); + + // Update existing entries + updateBoardEntries(conn, guild.getGuildId(), existingEntries); + + // Synchronize comments for each entry + for (GuildBoardEntry entry : entries) { + BoardEntryCommentDao.saveComments(conn, entry); + } + } + + /** + * Deletes all board entries for a given guild from the database. + * + * This is typically used when the guild has no entries in memory + * and we want to remove all corresponding database records. + * + * @param conn the active SQL connection + * @param guildId the ID of the guild whose entries should be deleted + * @throws SQLException if a database access error occurs + */ + private static void deleteAllEntries(Connection conn, int guildId) throws SQLException { + try (PreparedStatement stmt = conn.prepareStatement( + "DELETE FROM guild.board_entry WHERE guild_id = ?")) { + stmt.setInt(1, guildId); + stmt.executeUpdate(); + } + } + + /** + * Deletes board entries from the database that are no longer present + * in the provided in-memory list of entries. + * + * Only entries that exist in the database but not in the current list + * will be deleted. If the list is empty, all entries for the guild + * will be removed. + * + * @param conn the active SQL connection + * @param guild the guild whose entries are being synchronized + * @param entries the current list of board entries that should remain + * @throws SQLException if a database access error occurs + */ + private static void deleteRemovedBoardEntries(Connection conn, Guild guild, List entries) throws SQLException { + List currentIds = entries.stream() + .map(GuildBoardEntry::getEntryId) + .toList(); + + if (currentIds.isEmpty()) { + deleteAllEntries(conn, guild.getGuildId()); + return; + } + + String placeholders = currentIds.stream().map(id -> "?").collect(Collectors.joining(",")); + String sql = "DELETE FROM guild.board_entry WHERE guild_id = ? AND id NOT IN (" + placeholders + ")"; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int idx = 1; + stmt.setInt(idx++, guild.getGuildId()); + for (Integer id : currentIds) stmt.setInt(idx++, id); + stmt.executeUpdate(); + } + } + + /** + * Inserts new board entries into the database and sets their generated IDs. + * + * @param conn the SQL connection + * @param guildId the guild ID + * @param newEntries list of entries to insert + * @throws SQLException if a database error occurs + */ + private static void insertBoardEntries(Connection conn, int guildId, List newEntries) throws SQLException { + if (newEntries.isEmpty()) return; + + String sql = """ + INSERT INTO guild.board_entry (guild_id, character_id, title, message, emoticon) + VALUES (?, ?, ?, ?, ?) + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + for (GuildBoardEntry entry : newEntries) { + stmt.setInt(1, guildId); + stmt.setInt(2, entry.getCharacterId()); + stmt.setString(3, entry.getTitle()); + stmt.setString(4, entry.getText()); + stmt.setInt(5, entry.getEmoticon()); + stmt.addBatch(); + } + + stmt.executeBatch(); + + try (ResultSet rs = stmt.getGeneratedKeys()) { + int i = 0; + while (rs.next()) { + newEntries.get(i++).setEntryId(rs.getInt(1)); + } + } + } + } + + /** + * Updates existing board entries in the database. + * + * @param conn the SQL connection + * @param guildId the guild ID + * @param existingEntries list of entries to update + * @throws SQLException if a database error occurs + */ + private static void updateBoardEntries(Connection conn, int guildId, List existingEntries) throws SQLException { + if (existingEntries.isEmpty()) return; + + String sql = """ + UPDATE guild.board_entry + SET character_id = ?, title = ?, message = ?, emoticon = ? + WHERE guild_id = ? AND id = ? + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (GuildBoardEntry entry : existingEntries) { + stmt.setInt(1, entry.getCharacterId()); + stmt.setString(2, entry.getTitle()); + stmt.setString(3, entry.getText()); + stmt.setInt(4, entry.getEmoticon()); + stmt.setInt(5, guildId); + stmt.setInt(6, entry.getEntryId()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Loads all board entries for a given guild from the database. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param guildId the guild ID whose board entries should be loaded + * @return a list of GuildBoardEntry objects + * @throws SQLException if a database access error occurs + */ + public static List loadBoardEntries(Connection conn, int guildId) throws SQLException { + List entries = new ArrayList<>(); + String sql = "SELECT id, character_id, title, message, timestamp, emoticon, notice " + + "FROM guild.board_entry WHERE guild_id = ?"; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + GuildBoardEntry entry = new GuildBoardEntry( + rs.getInt("id"), + rs.getInt("character_id"), + rs.getString("title"), + rs.getString("message"), + rs.getTimestamp("timestamp").toInstant(), + rs.getInt("emoticon") + ); + entries.add(entry); + } + } + } + + return entries; + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java b/src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java new file mode 100644 index 00000000..f215d584 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java @@ -0,0 +1,104 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildBoardEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + + +public class BoardNoticeDao { + + /** + * Saves or updates the guild's notice entry in the database. + * + * Sets the `notice` column to TRUE for the given board entry and + * automatically ensures that all other entries for the guild are set to FALSE. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param guild the guild whose board notice should be saved + * @throws SQLException if a database access error occurs + */ + public static void saveBoardNotice(Connection conn, Guild guild) throws SQLException { + GuildBoardEntry notice = guild.getBoardNoticeEntry(); + + // Always unset the notice flag for all entries first + try (PreparedStatement unsetStmt = conn.prepareStatement( + "UPDATE guild.board_entry SET notice = FALSE WHERE guild_id = ?" + )) { + unsetStmt.setInt(1, guild.getGuildId()); + unsetStmt.executeUpdate(); + } + + // If there’s no notice to save, we’re done + if (notice == null) return; + + String sql = """ + INSERT INTO guild.board_entry (guild_id, character_id, title, message, emoticon, notice) + VALUES (?, ?, ?, ?, ?, TRUE) + ON CONFLICT (guild_id) WHERE notice = TRUE DO UPDATE SET + character_id = EXCLUDED.character_id, + title = EXCLUDED.title, + message = EXCLUDED.message, + emoticon = EXCLUDED.emoticon, + notice = TRUE + RETURNING id + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guild.getGuildId()); + stmt.setInt(2, notice.getCharacterId()); + stmt.setString(3, notice.getTitle()); + stmt.setString(4, notice.getText()); + stmt.setInt(5, notice.getEmoticon()); + + try (var rs = stmt.executeQuery()) { + if (rs.next()) { + notice.setEntryId(rs.getInt("id")); + } else { + throw new SQLException("Failed to retrieve generated entry_id for guild notice."); + } + } + } + } + + /** + * Loads the guild's current board notice from the database. + * + * Queries the `guild.board_entry` table for the entry marked as a notice. + * Assumes there is at most one notice per guild. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param guildId the guild ID whose notice should be loaded + * @return the GuildBoardEntry marked as notice, or null if none exists + * @throws SQLException if a database access error occurs + */ + public static GuildBoardEntry loadBoardNotice(Connection conn, int guildId) throws SQLException { + String sql = """ + SELECT id, character_id, title, message, timestamp, emoticon + FROM guild.board_entry + WHERE guild_id = ? AND notice = TRUE + LIMIT 1 + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return new GuildBoardEntry( + rs.getInt("id"), + rs.getInt("character_id"), + rs.getString("title"), + rs.getString("message"), + rs.getTimestamp("timestamp").toInstant(), + rs.getInt("emoticon") + ); + } + } + } + + return null; + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/EquipDataDao.java b/src/main/java/kinoko/database/postgresql/type/EquipDataDao.java index a83be181..2c3ddd17 100644 --- a/src/main/java/kinoko/database/postgresql/type/EquipDataDao.java +++ b/src/main/java/kinoko/database/postgresql/type/EquipDataDao.java @@ -2,8 +2,6 @@ import kinoko.world.item.EquipData; import kinoko.world.item.Item; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import java.sql.Connection; import java.sql.PreparedStatement; diff --git a/src/main/java/kinoko/database/postgresql/type/GuildDao.java b/src/main/java/kinoko/database/postgresql/type/GuildDao.java new file mode 100644 index 00000000..ca364837 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/GuildDao.java @@ -0,0 +1,212 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildBoardEntry; +import kinoko.server.guild.GuildMember; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +public class GuildDao { + + /** + * Checks if a guild name is available for use by verifying that it does not already exist in the database. + * + * Performs a case-insensitive lookup in the `guild.guilds` table using the provided connection. + * Returns true if no guild with the same name exists, false otherwise. + * + * @param conn the active SQL connection to use for the query + * @param name the guild name to check for availability + * @return true if the guild name is available; false if it already exists or an error occurs + */ + public static boolean checkGuildNameAvailable(Connection conn, String name) throws SQLException { + String sql = "SELECT 1 FROM guild.guilds WHERE LOWER(name) = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, name.toLowerCase()); + try (ResultSet rs = stmt.executeQuery()) { + return !rs.next(); + } + } + } + + /** + * Inserts a new guild record and all related data into the database within the provided transaction connection. + * + * This method first verifies that the guild name is available before inserting the guild into the `guild.guilds` table. + * After the main guild record is inserted, it saves the associated members, board entries, and notice using the same connection. + * If the guild name is already taken, the method returns false without modifying the database. + * + * @param conn the active SQL connection used for the transaction + * @param guild the guild object containing all information to insert + * @return true if the guild was successfully inserted; false if the guild name was unavailable + * @throws SQLException if a database error occurs during insertion or related saves + */ + public static synchronized boolean insertGuild(Connection conn, Guild guild) throws SQLException { + if (!checkGuildNameAvailable(conn, guild.getGuildName())) return false; + + String sql = "INSERT INTO guild.guilds (name, grade_names, member_max, mark_bg, mark_bg_color, mark, mark_color, notice, points, level, board_entry_counter) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, guild.getGuildName()); + stmt.setArray(2, conn.createArrayOf("text", guild.getGradeNames().toArray())); + stmt.setInt(3, guild.getMemberMax()); + stmt.setShort(4, guild.getMarkBg()); + stmt.setByte(5, guild.getMarkBgColor()); + stmt.setShort(6, guild.getMark()); + stmt.setByte(7, guild.getMarkColor()); + stmt.setString(8, guild.getNotice()); + stmt.setInt(9, guild.getPoints()); + stmt.setByte(10, guild.getLevel()); + stmt.setInt(11, guild.getBoardEntryCounter().get()); + + stmt.executeUpdate(); + + GuildMemberDao.saveMembers(conn, guild); + BoardEntryDao.saveBoardEntries(conn, guild); + BoardNoticeDao.saveBoardNotice(conn, guild); + return true; + } + } + + /** + * Updates an existing guild's information in the database using the provided connection. + * + * Modifies all relevant guild fields such as name, grade names, emblems, notice, points, and level. + * Also updates related members, board entries, and board notice after the main record update. + * + * @param conn the active SQL connection to use for the update + * @param guild the guild object containing updated data + * @return true if the update affected at least one row; false otherwise + * @throws SQLException if a database error occurs during the update + */ + public static boolean updateGuild(Connection conn, Guild guild) throws SQLException { + String sql = "UPDATE guild.guilds SET " + + "name = ?, " + + "grade_names = ?, " + + "member_max = ?, " + + "mark_bg = ?, " + + "mark_bg_color = ?, " + + "mark = ?, " + + "mark_color = ?, " + + "notice = ?, " + + "points = ?, " + + "level = ?, " + + "board_entry_counter = ? " + + "WHERE id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setString(1, guild.getGuildName()); + stmt.setArray(2, conn.createArrayOf("text", guild.getGradeNames().toArray())); + stmt.setInt(3, guild.getMemberMax()); + stmt.setShort(4, guild.getMarkBg()); + stmt.setByte(5, guild.getMarkBgColor()); + stmt.setShort(6, guild.getMark()); + stmt.setByte(7, guild.getMarkColor()); + stmt.setString(8, guild.getNotice()); + stmt.setInt(9, guild.getPoints()); + stmt.setByte(10, guild.getLevel()); + stmt.setInt(11, guild.getBoardEntryCounter().get()); + stmt.setInt(12, guild.getGuildId()); + + int rows = stmt.executeUpdate(); + + GuildMemberDao.saveMembers(conn, guild); + BoardEntryDao.saveBoardEntries(conn, guild); + BoardNoticeDao.saveBoardNotice(conn, guild); + + return rows > 0; + } + } + + /** + * Loads a Guild object from the provided ResultSet. + * + * Populates the guild's basic info, members, grades, board entries, + * and the board notice entry. Assumes the ResultSet is already positioned + * at the correct row. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param rs the ResultSet containing guild data + * @return a fully populated Guild object + * @throws SQLException if a database access error occurs + */ + public static Guild loadGuild(Connection conn, ResultSet rs) throws SQLException { + final int guildId = rs.getInt("id"); + final String guildName = rs.getString("name"); + Guild guild = new Guild(guildId, guildName); + + guild.setMemberMax(rs.getInt("member_max")); + guild.setMarkBg(rs.getShort("mark_bg")); + guild.setMarkBgColor(rs.getByte("mark_bg_color")); + guild.setMark(rs.getShort("mark")); + guild.setMarkColor(rs.getByte("mark_color")); + guild.setNotice(rs.getString("notice")); + guild.setPoints(rs.getInt("points")); + guild.setLevel(rs.getByte("level")); + + // Load members + List members = GuildMemberDao.loadMembers(conn, guildId); + for (GuildMember member : members) { + guild.addMember(member); + } + + // Load grade names + guild.setGradeNames(loadGrades(conn, guildId)); + + // Load board entries + List boardEntries = BoardEntryDao.loadBoardEntries(conn, guildId); + guild.getBoardEntries().addAll(boardEntries); + + // Load board notice entry + GuildBoardEntry noticeEntry = BoardNoticeDao.loadBoardNotice(conn, guildId); + guild.setBoardNoticeEntry(noticeEntry); + + return guild; + } + + /** + * Deletes a guild from the database along with all related data. + * + * This method expects an active SQL connection, allowing it to be part + * of a larger transaction. + * + * @param conn the active SQL connection (part of a transaction) + * @param guildId the ID of the guild to delete + * @return true if the guild was deleted, false otherwise + * @throws SQLException if a database access error occurs + */ + public static boolean deleteGuild(Connection conn, int guildId) throws SQLException { + String sql = "DELETE FROM guild.guilds WHERE guild_id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guildId); + return stmt.executeUpdate() > 0; + } + } + + /** + * Loads the grade names for a specific guild from the database. + * + * Retrieves all grade names associated with the given guild ID + * and returns them as a list of strings. The order of the grades + * is determined by the database query. + * + * @param conn the active SQL connection to use + * @param guildId the ID of the guild whose grades should be loaded + * @return a list of grade names for the specified guild + * @throws SQLException if a database access error occurs + */ + private static List loadGrades(Connection conn, int guildId) throws SQLException { + List grades = new ArrayList<>(); + String sql = "SELECT grade_name FROM guild.grade WHERE guild_id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + grades.add(rs.getString("grade_name")); + } + } + } + return grades; + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java b/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java new file mode 100644 index 00000000..6a8f9b4e --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java @@ -0,0 +1,169 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildMember; +import kinoko.server.guild.GuildRank; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + + +public class GuildMemberDao { + + /** + * Synchronizes the members of a guild in the database with the current list in memory. + * + * Deletes members that are no longer in the guild and inserts or updates members + * that exist in the current guild member list, minimizing redundant database operations. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param guild the guild whose members should be synchronized + * @throws SQLException if a database access error occurs + */ + public static void saveMembers(Connection conn, Guild guild) throws SQLException { + List members = guild.getGuildMembers(); + + if (members == null || members.isEmpty()) { + // No members at all — clear any remaining entries + deleteAllMembers(conn, guild.getGuildId()); + return; + } + + deleteRemovedMembers(conn, guild, members); + upsertMembers(conn, guild, members); + } + + /** + * Deletes all members for a given guild from the database. + * + * @param conn the active SQL connection + * @param guildId the guild ID whose members should be deleted + * @throws SQLException if a database access error occurs + */ + public static void deleteAllMembers(Connection conn, int guildId) throws SQLException { + try (PreparedStatement stmt = conn.prepareStatement( + "DELETE FROM guild.member WHERE guild_id = ?")) { + stmt.setInt(1, guildId); + stmt.executeUpdate(); + } + } + + /** + * Deletes all guild members from the database that are no longer present + * in the provided in-memory guild member list. + * + * Only members that exist in the database but not in the current list + * will be deleted. + * + * @param conn the SQL connection + * @param guild the guild whose members are being synchronized + * @param members the current list of guild members that should remain + * @throws SQLException if a database access error occurs + */ + public static void deleteRemovedMembers(Connection conn, Guild guild, List members) throws SQLException { + List currentIds = members.stream() + .map(GuildMember::getCharacterId) + .toList(); + + // If there are no current members, remove all from DB + if (currentIds.isEmpty()) { + deleteAllMembers(conn, guild.getGuildId()); + return; + } + + String placeholders = currentIds.stream().map(id -> "?").collect(Collectors.joining(",")); + String sql = "DELETE FROM guild.member WHERE guild_id = ? AND character_id NOT IN (" + placeholders + ")"; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int idx = 1; + stmt.setInt(idx++, guild.getGuildId()); + for (Integer id : currentIds) { + stmt.setInt(idx++, id); + } + stmt.executeUpdate(); + } + } + + /** + * Inserts or updates guild members in the database to match the provided in-memory list. + * Existing members are updated with their new grade; new members are inserted. + * + * @param conn the SQL connection + * @param guild the guild whose members are being synchronized + * @param members the list of members to insert or update + * @throws SQLException if a database access error occurs + */ + public static void upsertMembers(Connection conn, Guild guild, List members) throws SQLException { + String sql = """ + INSERT INTO guild.member (guild_id, character_id, grade) + VALUES (?, ?, ?) + ON CONFLICT (guild_id, character_id) DO UPDATE SET grade = EXCLUDED.grade + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (GuildMember member : members) { + stmt.setInt(1, guild.getGuildId()); + stmt.setInt(2, member.getCharacterId()); + stmt.setShort(3, (short) member.getGuildRank().getValue()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Loads all members of a given guild from the database. + * + * Retrieves character information, stats, guild rank, and online status + * for each member of the specified guild. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param guildId the ID of the guild whose members should be loaded + * @return a list of GuildMember objects representing the current members + * @throws SQLException if a database access error occurs + */ + public static List loadMembers(Connection conn, int guildId) throws SQLException { + List members = new ArrayList<>(); + String sql = """ + SELECT c.character_id, c.character_name, s.job, s.level, + m.grade AS guildRank, NULL AS allianceRank, c.online + FROM guild.member m + JOIN player.characters c ON c.character_id = m.character_id + JOIN character.stats s ON s.character_id = c.character_id + WHERE m.guild_id = ? + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int charId = rs.getInt("character_id"); + String charName = rs.getString("character_name"); + int job = rs.getInt("job"); + int level = rs.getInt("level"); + boolean online = rs.getBoolean("online"); + int guildRankInt = rs.getInt("guildRank"); + Integer allianceRankInt = null; // no alliance rank yet + + members.add(new GuildMember( + charId, + charName, + job, + level, + online, + GuildRank.getByValue(guildRankInt), + allianceRankInt != null ? GuildRank.getByValue(allianceRankInt) : null + )); + } + } + } + + return members; + } + +} diff --git a/src/main/java/kinoko/database/postgresql/type/LockerDao.java b/src/main/java/kinoko/database/postgresql/type/LockerDao.java index c6e9bd22..86fd3616 100644 --- a/src/main/java/kinoko/database/postgresql/type/LockerDao.java +++ b/src/main/java/kinoko/database/postgresql/type/LockerDao.java @@ -89,4 +89,44 @@ private static void deleteUnusedItems(Connection conn, int accountId, Collection } } } + + /** + * Loads the Locker for a specific account. + * + * Retrieves all locker items joined with the item table. Constructs CashItemInfo + * objects and adds them to a Locker. The resulting Locker contains all saved items. + * + * @param conn the active database connection + * @param accountId the ID of the account whose locker should be loaded + * @return the Locker with all items for the account + * @throws SQLException if a database error occurs + */ + public static Locker load(Connection conn, int accountId) throws SQLException { + Locker locker = new Locker(); + String sql = """ + SELECT li.slot, li.item_sn, li.commodity_id, i.item_id, i.quantity + FROM account.locker_item li + JOIN item.items i ON li.item_sn = i.item_sn + WHERE li.account_id = ? ORDER BY li.slot + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + Item item = new Item(rs.getInt("item_id"), (short) rs.getInt("quantity")); + CashItemInfo info = new CashItemInfo( + item, + rs.getInt("commodity_id"), + accountId, // account owner + -1, // character owner unknown at this point + null + ); + locker.addCashItem(info); + } + } + } + + return locker; + } } diff --git a/src/main/java/kinoko/database/postgresql/type/TrunkDao.java b/src/main/java/kinoko/database/postgresql/type/TrunkDao.java index 3fd5cb86..e5cc8ea9 100644 --- a/src/main/java/kinoko/database/postgresql/type/TrunkDao.java +++ b/src/main/java/kinoko/database/postgresql/type/TrunkDao.java @@ -1,7 +1,6 @@ package kinoko.database.postgresql.type; -import kinoko.provider.item.ItemInfo; import kinoko.server.ServerConfig; import kinoko.world.item.*; diff --git a/src/main/java/kinoko/database/postgresql/type/WishlistDao.java b/src/main/java/kinoko/database/postgresql/type/WishlistDao.java index 2751f7ef..8e927a65 100644 --- a/src/main/java/kinoko/database/postgresql/type/WishlistDao.java +++ b/src/main/java/kinoko/database/postgresql/type/WishlistDao.java @@ -1,6 +1,8 @@ package kinoko.database.postgresql.type; import java.sql.*; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; @@ -72,4 +74,36 @@ public static void deleteUnusedItems(Connection conn, int accountId, List load(Connection conn, int accountId) throws SQLException { + List wishlist = new ArrayList<>(); + String sql = "SELECT w.item_id FROM account.wishlist w WHERE w.account_id = ? ORDER BY w.slot"; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + wishlist.add(rs.getInt("item_id")); + } + } + } + + // Pad with zeros to always return 10 items + while (wishlist.size() < 10) { + wishlist.add(0); + } + + return Collections.unmodifiableList(wishlist); + } } diff --git a/src/main/java/kinoko/database/postgresql/util/SQLAction.java b/src/main/java/kinoko/database/postgresql/util/SQLAction.java new file mode 100644 index 00000000..2eb4f2d5 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/util/SQLAction.java @@ -0,0 +1,9 @@ +package kinoko.database.postgresql.util; + +import java.sql.Connection; +import java.sql.SQLException; + +@FunctionalInterface +public interface SQLAction { + void apply(Connection conn) throws SQLException; +} diff --git a/src/main/java/kinoko/database/postgresql/util/SQLBooleanAction.java b/src/main/java/kinoko/database/postgresql/util/SQLBooleanAction.java new file mode 100644 index 00000000..2f39b3e0 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/util/SQLBooleanAction.java @@ -0,0 +1,9 @@ +package kinoko.database.postgresql.util; + +import java.sql.Connection; +import java.sql.SQLException; + +@FunctionalInterface +public interface SQLBooleanAction { + boolean apply(Connection conn) throws SQLException; +} diff --git a/src/main/java/kinoko/server/guild/Guild.java b/src/main/java/kinoko/server/guild/Guild.java index 1f0d176b..2739a06a 100644 --- a/src/main/java/kinoko/server/guild/Guild.java +++ b/src/main/java/kinoko/server/guild/Guild.java @@ -1,5 +1,6 @@ package kinoko.server.guild; +import kinoko.database.DatabaseManager; import kinoko.server.packet.OutPacket; import kinoko.server.user.RemoteUser; import kinoko.util.Encodable; @@ -215,6 +216,11 @@ public List getBoardEntryList(int page) { } public int getNextBoardEntryId() { + if (DatabaseManager.isRelational()) { + // Let the relational database handle SN generation; return placeholder + return -1; + } + return boardEntryCounter.getAndIncrement(); } diff --git a/src/main/java/kinoko/server/guild/GuildBoardComment.java b/src/main/java/kinoko/server/guild/GuildBoardComment.java index 31c157e5..50fe1b74 100644 --- a/src/main/java/kinoko/server/guild/GuildBoardComment.java +++ b/src/main/java/kinoko/server/guild/GuildBoardComment.java @@ -6,7 +6,7 @@ import java.time.Instant; public final class GuildBoardComment implements Encodable { - private final int commentSn; + private int commentSn; private final int characterId; private String text; private Instant date; @@ -22,6 +22,10 @@ public int getCommentSn() { return commentSn; } + public void setCommentSn(int newCommentSn) { + this.commentSn = newCommentSn; + } + public int getCharacterId() { return characterId; } @@ -50,4 +54,17 @@ public void encode(OutPacket outPacket) { outPacket.encodeFT(date); // ftDate outPacket.encodeString(text); // sComment } + + /** + * Checks whether this GuildBoardComment has a valid comment serial number (SN). + * + * In relational databases, comment SNs are typically automatically generated. + * Returns true if the comment has no SN and therefore needs to be inserted + * into the database to obtain one. + * + * @return true if the comment SN is zero or negative, false otherwise + */ + public boolean hasNoSN() { + return getCommentSn() <= 0; + } } diff --git a/src/main/java/kinoko/server/guild/GuildBoardEntry.java b/src/main/java/kinoko/server/guild/GuildBoardEntry.java index df372be0..ea72e450 100644 --- a/src/main/java/kinoko/server/guild/GuildBoardEntry.java +++ b/src/main/java/kinoko/server/guild/GuildBoardEntry.java @@ -1,5 +1,6 @@ package kinoko.server.guild; +import kinoko.database.DatabaseManager; import kinoko.server.packet.OutPacket; import java.time.Instant; @@ -9,7 +10,7 @@ import java.util.concurrent.atomic.AtomicInteger; public final class GuildBoardEntry { - private final int entryId; + private int entryId; private final int characterId; private String title; private String text; @@ -33,6 +34,10 @@ public int getEntryId() { return entryId; } + public void setEntryId(int newEntryId) { + this.entryId = newEntryId; + } + public int getCharacterId() { return characterId; } @@ -89,6 +94,11 @@ public void setCommentSnCounter(AtomicInteger commentSnCounter) { // HELPER METHODS -------------------------------------------------------------------------------------------------- public int getNextCommentSn() { + if (DatabaseManager.isRelational()) { + // Let the relational database handle SN generation; return placeholder + return -1; + } + return commentSnCounter.getAndIncrement(); } @@ -132,4 +142,17 @@ public void encodeCurrent(OutPacket outPacket) { comment.encode(outPacket); // CUIGuildBBS::COMMENT } } + + /** + * Checks whether this GuildBoardEntry has a valid entry ID. + * + * In relational databases, entry IDs are typically automatically generated. + * Returns true if the entry has no ID and therefore needs to be inserted + * into the database to obtain one. + * + * @return true if the entry ID is zero or negative, false otherwise + */ + public boolean hasNoSN() { + return getEntryId() <= 0; + } } From cf52f2696e58b867ac978c7aa80b3f0a8ea6d017 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Fri, 10 Oct 2025 20:38:40 -0400 Subject: [PATCH 15/83] Added which port a channel fails to bind to --- .../java/kinoko/server/node/ChannelServerNode.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/kinoko/server/node/ChannelServerNode.java b/src/main/java/kinoko/server/node/ChannelServerNode.java index aa6b9340..5078042b 100644 --- a/src/main/java/kinoko/server/node/ChannelServerNode.java +++ b/src/main/java/kinoko/server/node/ChannelServerNode.java @@ -257,8 +257,14 @@ public void initialize() throws InterruptedException, UnknownHostException { // Start channel server final ChannelServerNode self = this; - channelServerFuture = startServer(new PacketChannelInitializer(new ChannelPacketHandler(), self), channelPort); - channelServerFuture.sync(); + try { + channelServerFuture = startServer(new PacketChannelInitializer(new ChannelPacketHandler(), self), channelPort); + channelServerFuture.sync(); + } + catch(Exception e){ + log.error("Channel {} failed to bind to port {}", channelId + 1, channelPort); + throw e; + } log.info("Channel {} listening on port {}", channelId + 1, channelPort); // Start central client From 28e6086c20610ca7fdfbc186f2b74850c1d72a3b Mon Sep 17 00:00:00 2001 From: MujyKun Date: Fri, 10 Oct 2025 21:26:02 -0400 Subject: [PATCH 16/83] Added batch file to kill ports --- utility/kill_ports.bat | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 utility/kill_ports.bat diff --git a/utility/kill_ports.bat b/utility/kill_ports.bat new file mode 100644 index 00000000..3394e2b5 --- /dev/null +++ b/utility/kill_ports.bat @@ -0,0 +1,18 @@ +@echo off +setlocal + +REM Kills the default ports on Windows cuz wallahi I'm done trying to find out which processes are taking up my ports. + +REM List of ports +set PORTS=8282 8585 8586 8587 8588 8589 + +for %%P in (%PORTS%) do ( + echo Checking port %%P... + for /f "tokens=5" %%A in ('netstat -aon ^| findstr :%%P') do ( + echo Killing PID %%A on port %%P + taskkill /F /PID %%A + ) +) + +echo Done! +pause From d53ff96b226bbe007d38ba2f68a04d07baaca970 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sat, 11 Oct 2025 02:20:15 -0400 Subject: [PATCH 17/83] Added Save to Guilds on server shutdown | Added Guild Roles | Added Boards/Notices | Added Board Comments --- .../java/kinoko/database/DatabaseManager.java | 7 ++ .../java/kinoko/database/GuildAccessor.java | 10 ++ .../postgresql/PostgresCharacterAccessor.java | 3 +- .../postgresql/PostgresGuildAccessor.java | 5 +- .../kinoko/database/postgresql/setup/init.sql | 12 +- .../postgresql/type/BoardEntryCommentDao.java | 70 ++++++++--- .../postgresql/type/BoardEntryDao.java | 111 +++++++----------- .../postgresql/type/BoardNoticeDao.java | 28 ++--- .../database/postgresql/type/GuildDao.java | 100 +++++++++++----- .../postgresql/type/GuildMemberDao.java | 14 +-- src/main/java/kinoko/server/ServerConfig.java | 2 +- src/main/java/kinoko/server/guild/Guild.java | 12 +- .../kinoko/server/guild/GuildBoardEntry.java | 13 -- .../kinoko/server/guild/GuildStorage.java | 11 ++ .../kinoko/server/node/CentralServerNode.java | 4 + 15 files changed, 239 insertions(+), 163 deletions(-) diff --git a/src/main/java/kinoko/database/DatabaseManager.java b/src/main/java/kinoko/database/DatabaseManager.java index baba218e..9d8ff108 100644 --- a/src/main/java/kinoko/database/DatabaseManager.java +++ b/src/main/java/kinoko/database/DatabaseManager.java @@ -3,6 +3,7 @@ import kinoko.database.postgresql.PostgresConnector; import kinoko.database.cassandra.CassandraConnector; import kinoko.server.ServerConstants; +import kinoko.server.guild.GuildStorage; import java.util.List; import java.util.Objects; @@ -65,6 +66,12 @@ else if (Objects.equals(ServerConstants.DATABASE_HOST, "postgres_kinoko") connector.initialize(); } + public static void saveAll() { + if (connector != null) { + + } + } + public static void shutdown() { if (connector != null) { connector.shutdown(); diff --git a/src/main/java/kinoko/database/GuildAccessor.java b/src/main/java/kinoko/database/GuildAccessor.java index dc326191..122ea49b 100644 --- a/src/main/java/kinoko/database/GuildAccessor.java +++ b/src/main/java/kinoko/database/GuildAccessor.java @@ -1,8 +1,10 @@ package kinoko.database; import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildBoardEntry; import kinoko.server.guild.GuildRanking; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -18,4 +20,12 @@ public interface GuildAccessor { boolean deleteGuild(int guildId); List getGuildRankings(); + + default void saveAll(Collection guilds){ + if (guilds == null || guilds.isEmpty()) return; + + for (Guild guild : guilds) { + saveGuild(guild); + } + } } \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java index 3ed7d4f9..091cb5c2 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -399,9 +399,10 @@ public boolean checkCharacterNameAvailable(String name) { @Override public Optional getCharacterById(int characterId) { String sql = """ - SELECT c.*, s.* + SELECT c.*, s.*, m.guild_id, m.grade FROM player.characters c LEFT JOIN player.stats s ON c.id = s.character_id + LEFT JOIN guild.member m ON m.character_id = c.id WHERE c.id = ? """; try (Connection conn = dataSource.getConnection(); diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java index 66eabb5c..f9029fb2 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -2,8 +2,11 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.GuildAccessor; +import kinoko.database.postgresql.type.BoardEntryDao; +import kinoko.database.postgresql.type.BoardNoticeDao; import kinoko.database.postgresql.type.GuildDao; import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildBoardEntry; import kinoko.server.guild.GuildRanking; import java.sql.*; @@ -28,7 +31,7 @@ public PostgresGuildAccessor(HikariDataSource dataSource) { */ @Override public Optional getGuildById(int guildId) { - String sql = "SELECT * FROM guild.guilds WHERE guild_id = ?"; + String sql = "SELECT * FROM guild.guilds WHERE id = ?"; try (Connection conn = getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index b2eebea4..ad435636 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -402,21 +402,22 @@ CREATE TABLE IF NOT EXISTS guild.member ( guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, grade SMALLINT NOT NULL, - join_date TIMESTAMP NOT NULL, + join_date TIMESTAMP NOT NULL DEFAULT UTC_NOW(), last_login TIMESTAMP, - PRIMARY KEY (guild_id, character_id) + PRIMARY KEY (character_id) ); CREATE TABLE IF NOT EXISTS guild.board_entry ( - id SERIAL PRIMARY KEY, + id INT NOT NULL, guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, title TEXT, message TEXT NOT NULL, emoticon INT, timestamp TIMESTAMP NOT NULL DEFAULT UTC_NOW(), - notice boolean DEFAULT false + notice boolean DEFAULT false, + PRIMARY KEY (guild_id, id) ); -- enforce only one TRUE notice per guild @@ -426,7 +427,8 @@ WHERE notice = TRUE; CREATE TABLE IF NOT EXISTS guild.board_entry_comment ( id SERIAL PRIMARY KEY, - entry_id INT NOT NULL REFERENCES guild.board_entry(id) ON DELETE CASCADE, + entry_id INT NOT NULL, + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, text TEXT NOT NULL, timestamp TIMESTAMP NOT NULL DEFAULT UTC_NOW() diff --git a/src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java b/src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java index bf26794c..97b1f63c 100644 --- a/src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java +++ b/src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java @@ -1,5 +1,6 @@ package kinoko.database.postgresql.type; +import kinoko.server.guild.Guild; import kinoko.server.guild.GuildBoardComment; import kinoko.server.guild.GuildBoardEntry; @@ -20,16 +21,16 @@ public class BoardEntryCommentDao { * @param entry the board entry whose comments should be synchronized * @throws SQLException if a database access error occurs */ - public static void saveComments(Connection conn, GuildBoardEntry entry) throws SQLException { + public static void saveComments(Connection conn, GuildBoardEntry entry, Guild guild) throws SQLException { List comments = entry.getComments(); if (comments == null || comments.isEmpty()) { - deleteAllComments(conn, entry.getEntryId()); + deleteAllComments(conn, entry.getEntryId(), guild.getGuildId()); return; } - deleteRemovedComments(conn, entry, comments); - insertNewComments(conn, entry, comments); + deleteRemovedComments(conn, entry, comments, guild.getGuildId()); + insertNewComments(conn, entry, comments, guild.getGuildId()); } /** @@ -42,10 +43,11 @@ public static void saveComments(Connection conn, GuildBoardEntry entry) throws S * @param entryId the ID of the board entry whose comments should be deleted * @throws SQLException if a database access error occurs */ - private static void deleteAllComments(Connection conn, int entryId) throws SQLException { + private static void deleteAllComments(Connection conn, int entryId, int guildId) throws SQLException { try (PreparedStatement stmt = conn.prepareStatement( - "DELETE FROM guild.board_entry_comment WHERE entry_id = ?")) { + "DELETE FROM guild.board_entry_comment WHERE entry_id = ? AND guild_id = ?")) { stmt.setInt(1, entryId); + stmt.setInt(2, guildId); stmt.executeUpdate(); } } @@ -59,7 +61,7 @@ private static void deleteAllComments(Connection conn, int entryId) throws SQLEx * @param comments the current list of comments that should remain * @throws SQLException if a database access error occurs */ - private static void deleteRemovedComments(Connection conn, GuildBoardEntry entry, List comments) throws SQLException { + private static void deleteRemovedComments(Connection conn, GuildBoardEntry entry, List comments, int guildId) throws SQLException { List currentIds = comments.stream() .filter(c -> !c.hasNoSN()) .map(GuildBoardComment::getCommentSn) @@ -68,10 +70,11 @@ private static void deleteRemovedComments(Connection conn, GuildBoardEntry entry if (currentIds.isEmpty()) return; String placeholders = currentIds.stream().map(id -> "?").collect(Collectors.joining(",")); - String sql = "DELETE FROM guild.board_entry_comment WHERE entry_id = ? AND id NOT IN (" + placeholders + ")"; + String sql = "DELETE FROM guild.board_entry_comment WHERE entry_id = ? AND guild_id = ? AND id NOT IN (" + placeholders + ")"; try (PreparedStatement stmt = conn.prepareStatement(sql)) { int idx = 1; stmt.setInt(idx++, entry.getEntryId()); + stmt.setInt(idx++, guildId); for (Integer id : currentIds) stmt.setInt(idx++, id); stmt.executeUpdate(); } @@ -89,19 +92,20 @@ private static void deleteRemovedComments(Connection conn, GuildBoardEntry entry * @throws SQLException if a database access error occurs or * the generated keys cannot be retrieved */ - private static void insertNewComments(Connection conn, GuildBoardEntry entry, List comments) throws SQLException { + private static void insertNewComments(Connection conn, GuildBoardEntry entry, List comments, int guildId) throws SQLException { String insertSql = """ - INSERT INTO guild.board_entry_comment (entry_id, character_id, text, timestamp) - VALUES (?, ?, ?, ?) + INSERT INTO guild.board_entry_comment (entry_id, guild_id, character_id, text, timestamp) + VALUES (?, ?, ?, ?, ?) """; try (PreparedStatement stmt = conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS)) { List newComments = comments.stream().filter(GuildBoardComment::hasNoSN).toList(); for (GuildBoardComment comment : newComments) { stmt.setInt(1, entry.getEntryId()); - stmt.setInt(2, comment.getCharacterId()); - stmt.setString(3, comment.getText()); - stmt.setTimestamp(4, Timestamp.from(comment.getDate())); + stmt.setInt(2, guildId); + stmt.setInt(3, comment.getCharacterId()); + stmt.setString(4, comment.getText()); + stmt.setTimestamp(5, Timestamp.from(comment.getDate())); stmt.addBatch(); } @@ -118,4 +122,42 @@ private static void insertNewComments(Connection conn, GuildBoardEntry entry, Li } } } + + /** + * Loads all comments for a given guild from the database, grouped by board entry. + * + * @param conn the active SQL connection + * @param guildId the ID of the guild whose comments should be loaded + * @return a mapping from entryId to a list of GuildBoardComment objects + * @throws SQLException if a database access error occurs + */ + public static java.util.Map> loadComments(Connection conn, int guildId) throws SQLException { + String sql = """ + SELECT id, entry_id, guild_id, character_id, text, timestamp + FROM guild.board_entry_comment + WHERE guild_id = ? + ORDER BY entry_id ASC, timestamp ASC + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + java.util.Map> commentsByEntry = new java.util.HashMap<>(); + + while (rs.next()) { + int entryId = rs.getInt("entry_id"); + GuildBoardComment comment = new GuildBoardComment( + rs.getInt("id"), + rs.getInt("character_id"), + rs.getString("text"), + rs.getTimestamp("timestamp").toInstant() + ); + + commentsByEntry.computeIfAbsent(entryId, k -> new java.util.ArrayList<>()).add(comment); + } + + return commentsByEntry; + } + } + } } diff --git a/src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java b/src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java index b06f4978..ed80c739 100644 --- a/src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java +++ b/src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java @@ -1,9 +1,11 @@ package kinoko.database.postgresql.type; import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildBoardComment; import kinoko.server.guild.GuildBoardEntry; import java.sql.*; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -33,24 +35,12 @@ public static void saveBoardEntries(Connection conn, Guild guild) throws SQLExce // Delete board entries that have been removed. deleteRemovedBoardEntries(conn, guild, entries); - // Split entries into new inserts and existing updates - List newEntries = entries.stream() - .filter(GuildBoardEntry::hasNoSN) - .toList(); - - List existingEntries = entries.stream() - .filter(entry -> !entry.hasNoSN()) - .toList(); - - // Insert new entries and assign generated SNs - insertBoardEntries(conn, guild.getGuildId(), newEntries); - // Update existing entries - updateBoardEntries(conn, guild.getGuildId(), existingEntries); + upsertBoardEntries(conn, guild.getGuildId(), entries); // Synchronize comments for each entry for (GuildBoardEntry entry : entries) { - BoardEntryCommentDao.saveComments(conn, entry); + BoardEntryCommentDao.saveComments(conn, entry, guild); } } @@ -107,67 +97,41 @@ private static void deleteRemovedBoardEntries(Connection conn, Guild guild, List } /** - * Inserts new board entries into the database and sets their generated IDs. + * Inserts or updates guild board entries in the database. * - * @param conn the SQL connection - * @param guildId the guild ID - * @param newEntries list of entries to insert - * @throws SQLException if a database error occurs - */ - private static void insertBoardEntries(Connection conn, int guildId, List newEntries) throws SQLException { - if (newEntries.isEmpty()) return; - - String sql = """ - INSERT INTO guild.board_entry (guild_id, character_id, title, message, emoticon) - VALUES (?, ?, ?, ?, ?) - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { - for (GuildBoardEntry entry : newEntries) { - stmt.setInt(1, guildId); - stmt.setInt(2, entry.getCharacterId()); - stmt.setString(3, entry.getTitle()); - stmt.setString(4, entry.getText()); - stmt.setInt(5, entry.getEmoticon()); - stmt.addBatch(); - } - - stmt.executeBatch(); - - try (ResultSet rs = stmt.getGeneratedKeys()) { - int i = 0; - while (rs.next()) { - newEntries.get(i++).setEntryId(rs.getInt(1)); - } - } - } - } - - /** - * Updates existing board entries in the database. + * If a board entry with the same guild_id and id already exists, this method updates + * its character_id, title, message, and emoticon fields. Otherwise, a new row is inserted. + * + * Because id is a per-guild serial, callers should ensure that new entries have a valid + * or newly assigned id value before invoking this method. * - * @param conn the SQL connection - * @param guildId the guild ID - * @param existingEntries list of entries to update + * @param conn the SQL connection + * @param guildId the guild ID associated with the board entries + * @param entries list of board entries to insert or update * @throws SQLException if a database error occurs */ - private static void updateBoardEntries(Connection conn, int guildId, List existingEntries) throws SQLException { - if (existingEntries.isEmpty()) return; + private static void upsertBoardEntries(Connection conn, int guildId, List entries) throws SQLException { + if (entries.isEmpty()) return; String sql = """ - UPDATE guild.board_entry - SET character_id = ?, title = ?, message = ?, emoticon = ? - WHERE guild_id = ? AND id = ? + INSERT INTO guild.board_entry (guild_id, id, character_id, title, message, emoticon) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (guild_id, id) + DO UPDATE SET + character_id = EXCLUDED.character_id, + title = EXCLUDED.title, + message = EXCLUDED.message, + emoticon = EXCLUDED.emoticon """; try (PreparedStatement stmt = conn.prepareStatement(sql)) { - for (GuildBoardEntry entry : existingEntries) { - stmt.setInt(1, entry.getCharacterId()); - stmt.setString(2, entry.getTitle()); - stmt.setString(3, entry.getText()); - stmt.setInt(4, entry.getEmoticon()); - stmt.setInt(5, guildId); - stmt.setInt(6, entry.getEntryId()); + for (GuildBoardEntry entry : entries) { + stmt.setInt(1, guildId); + stmt.setInt(2, entry.getEntryId()); // must already be set or generated + stmt.setInt(3, entry.getCharacterId()); + stmt.setString(4, entry.getTitle()); + stmt.setString(5, entry.getText()); + stmt.setInt(6, entry.getEmoticon()); stmt.addBatch(); } stmt.executeBatch(); @@ -185,7 +149,7 @@ private static void updateBoardEntries(Connection conn, int guildId, List loadBoardEntries(Connection conn, int guildId) throws SQLException { List entries = new ArrayList<>(); String sql = "SELECT id, character_id, title, message, timestamp, emoticon, notice " + - "FROM guild.board_entry WHERE guild_id = ?"; + "FROM guild.board_entry WHERE guild_id = ? AND notice IS FALSE"; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); @@ -204,6 +168,19 @@ public static List loadBoardEntries(Connection conn, int guildI } } + + java.util.Map> entryComments = BoardEntryCommentDao.loadComments(conn, guildId); + + // Attach comments to the corresponding entries + for (GuildBoardEntry entry : entries) { + List comments = entryComments.get(entry.getEntryId()); + if (comments != null) { + for (GuildBoardComment comment : comments) { + entry.addComment(comment); + } + } + } + return entries; } } diff --git a/src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java b/src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java index f215d584..67845b82 100644 --- a/src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java +++ b/src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java @@ -36,31 +36,25 @@ public static void saveBoardNotice(Connection conn, Guild guild) throws SQLExcep if (notice == null) return; String sql = """ - INSERT INTO guild.board_entry (guild_id, character_id, title, message, emoticon, notice) - VALUES (?, ?, ?, ?, ?, TRUE) - ON CONFLICT (guild_id) WHERE notice = TRUE DO UPDATE SET + INSERT INTO guild.board_entry (guild_id, id, character_id, title, message, emoticon, notice) + VALUES (?, ?, ?, ?, ?, ?, TRUE) + ON CONFLICT (guild_id, id) + DO UPDATE SET character_id = EXCLUDED.character_id, title = EXCLUDED.title, message = EXCLUDED.message, emoticon = EXCLUDED.emoticon, notice = TRUE - RETURNING id """; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guild.getGuildId()); - stmt.setInt(2, notice.getCharacterId()); - stmt.setString(3, notice.getTitle()); - stmt.setString(4, notice.getText()); - stmt.setInt(5, notice.getEmoticon()); - - try (var rs = stmt.executeQuery()) { - if (rs.next()) { - notice.setEntryId(rs.getInt("id")); - } else { - throw new SQLException("Failed to retrieve generated entry_id for guild notice."); - } - } + stmt.setInt(2, notice.getEntryId()); + stmt.setInt(3, notice.getCharacterId()); + stmt.setString(4, notice.getTitle()); + stmt.setString(5, notice.getText()); + stmt.setInt(6, notice.getEmoticon()); + stmt.executeUpdate(); } } @@ -79,7 +73,7 @@ public static GuildBoardEntry loadBoardNotice(Connection conn, int guildId) thro String sql = """ SELECT id, character_id, title, message, timestamp, emoticon FROM guild.board_entry - WHERE guild_id = ? AND notice = TRUE + WHERE guild_id = ? AND notice IS TRUE LIMIT 1 """; diff --git a/src/main/java/kinoko/database/postgresql/type/GuildDao.java b/src/main/java/kinoko/database/postgresql/type/GuildDao.java index ca364837..f53fad84 100644 --- a/src/main/java/kinoko/database/postgresql/type/GuildDao.java +++ b/src/main/java/kinoko/database/postgresql/type/GuildDao.java @@ -6,6 +6,7 @@ import java.sql.*; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; public class GuildDao { @@ -45,26 +46,35 @@ public static boolean checkGuildNameAvailable(Connection conn, String name) thro public static synchronized boolean insertGuild(Connection conn, Guild guild) throws SQLException { if (!checkGuildNameAvailable(conn, guild.getGuildName())) return false; - String sql = "INSERT INTO guild.guilds (name, grade_names, member_max, mark_bg, mark_bg_color, mark, mark_color, notice, points, level, board_entry_counter) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + String sql = "INSERT INTO guild.guilds (name, member_max, mark_bg, mark_bg_color, mark, mark_color, notice, points, level) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + + "RETURNING id"; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, guild.getGuildName()); - stmt.setArray(2, conn.createArrayOf("text", guild.getGradeNames().toArray())); - stmt.setInt(3, guild.getMemberMax()); - stmt.setShort(4, guild.getMarkBg()); - stmt.setByte(5, guild.getMarkBgColor()); - stmt.setShort(6, guild.getMark()); - stmt.setByte(7, guild.getMarkColor()); - stmt.setString(8, guild.getNotice()); - stmt.setInt(9, guild.getPoints()); - stmt.setByte(10, guild.getLevel()); - stmt.setInt(11, guild.getBoardEntryCounter().get()); - - stmt.executeUpdate(); + stmt.setInt(2, guild.getMemberMax()); + stmt.setShort(3, guild.getMarkBg()); + stmt.setByte(4, guild.getMarkBgColor()); + stmt.setShort(5, guild.getMark()); + stmt.setByte(6, guild.getMarkColor()); + stmt.setString(7, guild.getNotice()); + stmt.setInt(8, guild.getPoints()); + stmt.setByte(9, guild.getLevel()); + + try (ResultSet rs = stmt.executeQuery()) { // executeQuery because RETURNING returns a result set + if (rs.next()) { + int guildId = rs.getInt(1); // get the generated id + guild.setGuildId(guildId); + } + } + GuildMemberDao.saveMembers(conn, guild); - BoardEntryDao.saveBoardEntries(conn, guild); - BoardNoticeDao.saveBoardNotice(conn, guild); + BoardEntryDao.saveBoardEntries(conn, guild); // none should exist, but maybe we added some to the guild object. + BoardNoticeDao.saveBoardNotice(conn, guild); // it shouldn't exist, but maybe we added it to the guild object. + + List defaultGrades = Arrays.asList("Master", "Jr.Master", "Test1", "test2", "Member"); + guild.setGradeNames(defaultGrades); + upsertGrades(conn, guild.getGuildId(), defaultGrades); return true; } } @@ -83,7 +93,6 @@ public static synchronized boolean insertGuild(Connection conn, Guild guild) thr public static boolean updateGuild(Connection conn, Guild guild) throws SQLException { String sql = "UPDATE guild.guilds SET " + "name = ?, " + - "grade_names = ?, " + "member_max = ?, " + "mark_bg = ?, " + "mark_bg_color = ?, " + @@ -91,29 +100,27 @@ public static boolean updateGuild(Connection conn, Guild guild) throws SQLExcept "mark_color = ?, " + "notice = ?, " + "points = ?, " + - "level = ?, " + - "board_entry_counter = ? " + + "level = ? " + "WHERE id = ?"; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, guild.getGuildName()); - stmt.setArray(2, conn.createArrayOf("text", guild.getGradeNames().toArray())); - stmt.setInt(3, guild.getMemberMax()); - stmt.setShort(4, guild.getMarkBg()); - stmt.setByte(5, guild.getMarkBgColor()); - stmt.setShort(6, guild.getMark()); - stmt.setByte(7, guild.getMarkColor()); - stmt.setString(8, guild.getNotice()); - stmt.setInt(9, guild.getPoints()); - stmt.setByte(10, guild.getLevel()); - stmt.setInt(11, guild.getBoardEntryCounter().get()); - stmt.setInt(12, guild.getGuildId()); + stmt.setInt(2, guild.getMemberMax()); + stmt.setShort(3, guild.getMarkBg()); + stmt.setByte(4, guild.getMarkBgColor()); + stmt.setShort(5, guild.getMark()); + stmt.setByte(6, guild.getMarkColor()); + stmt.setString(7, guild.getNotice()); + stmt.setInt(8, guild.getPoints()); + stmt.setByte(9, guild.getLevel()); + stmt.setInt(10, guild.getGuildId()); int rows = stmt.executeUpdate(); GuildMemberDao.saveMembers(conn, guild); BoardEntryDao.saveBoardEntries(conn, guild); BoardNoticeDao.saveBoardNotice(conn, guild); + upsertGrades(conn, guild.getGuildId(), guild.getGradeNames()); return rows > 0; } @@ -177,13 +184,44 @@ public static Guild loadGuild(Connection conn, ResultSet rs) throws SQLException * @throws SQLException if a database access error occurs */ public static boolean deleteGuild(Connection conn, int guildId) throws SQLException { - String sql = "DELETE FROM guild.guilds WHERE guild_id = ?"; + String sql = "DELETE FROM guild.guilds WHERE id = ?"; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); return stmt.executeUpdate() > 0; } } + /** + * Upsert a list of grades for a specific guild into the database. + * + * Each grade in the list is inserted with its corresponding index in the list. + * If a grade with the same guild_id and grade_index already exists, it will + * be replaced with the new grade_name. + * + * @param conn the active SQL connection to use + * @param guildId the ID of the guild for which grades should be inserted + * @param grades the list of grade names to insert + * @throws SQLException if a database access error occurs + */ + private static void upsertGrades(Connection conn, int guildId, List grades) throws SQLException { + String sql = """ + INSERT INTO guild.grade (guild_id, grade_index, grade_name) + VALUES (?, ?, ?) + ON CONFLICT (guild_id, grade_index) DO UPDATE + SET grade_name = EXCLUDED.grade_name + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (int i = 0; i < grades.size(); i++) { + stmt.setInt(1, guildId); + stmt.setInt(2, i); // grade_index corresponds to the list index + stmt.setString(3, grades.get(i)); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + /** * Loads the grade names for a specific guild from the database. * diff --git a/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java b/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java index 6a8f9b4e..bcb83161 100644 --- a/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java +++ b/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java @@ -102,7 +102,7 @@ public static void upsertMembers(Connection conn, Guild guild, List String sql = """ INSERT INTO guild.member (guild_id, character_id, grade) VALUES (?, ?, ?) - ON CONFLICT (guild_id, character_id) DO UPDATE SET grade = EXCLUDED.grade + ON CONFLICT (character_id) DO UPDATE SET grade = EXCLUDED.grade """; try (PreparedStatement stmt = conn.prepareStatement(sql)) { @@ -130,11 +130,11 @@ ON CONFLICT (guild_id, character_id) DO UPDATE SET grade = EXCLUDED.grade public static List loadMembers(Connection conn, int guildId) throws SQLException { List members = new ArrayList<>(); String sql = """ - SELECT c.character_id, c.character_name, s.job, s.level, + SELECT c.id, c.name, s.job, s.level, m.grade AS guildRank, NULL AS allianceRank, c.online FROM guild.member m - JOIN player.characters c ON c.character_id = m.character_id - JOIN character.stats s ON s.character_id = c.character_id + JOIN player.characters c ON c.id = m.character_id + JOIN player.stats s ON s.character_id = c.id WHERE m.guild_id = ? """; @@ -142,8 +142,8 @@ public static List loadMembers(Connection conn, int guildId) throws stmt.setInt(1, guildId); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { - int charId = rs.getInt("character_id"); - String charName = rs.getString("character_name"); + int charId = rs.getInt("id"); + String charName = rs.getString("name"); int job = rs.getInt("job"); int level = rs.getInt("level"); boolean online = rs.getBoolean("online"); @@ -157,7 +157,7 @@ public static List loadMembers(Connection conn, int guildId) throws level, online, GuildRank.getByValue(guildRankInt), - allianceRankInt != null ? GuildRank.getByValue(allianceRankInt) : null + allianceRankInt != null ? GuildRank.getByValue(allianceRankInt) : GuildRank.NONE )); } } diff --git a/src/main/java/kinoko/server/ServerConfig.java b/src/main/java/kinoko/server/ServerConfig.java index 99c9b974..4178ebb7 100644 --- a/src/main/java/kinoko/server/ServerConfig.java +++ b/src/main/java/kinoko/server/ServerConfig.java @@ -15,7 +15,7 @@ public final class ServerConfig { public static final int SHUTDOWN_TIMEOUT = 30; public static final boolean AUTO_CREATE_ACCOUNT = Util.getEnv("AUTO_CREATE_ACCOUNT", true); - public static final boolean REQUIRE_SECONDARY_PASSWORD = Util.getEnv("REQUIRE_SECONDARY_PASSWORD", true); + public static final boolean REQUIRE_SECONDARY_PASSWORD = Util.getEnv("REQUIRE_SECONDARY_PASSWORD", false); public static final String WZ_DIRECTORY = Util.getEnv("WZ_DIRECTORY", "wz"); public static final String DATA_DIRECTORY = Util.getEnv("DATA_DIRECTORY", "data"); diff --git a/src/main/java/kinoko/server/guild/Guild.java b/src/main/java/kinoko/server/guild/Guild.java index 2739a06a..5c2cc2b9 100644 --- a/src/main/java/kinoko/server/guild/Guild.java +++ b/src/main/java/kinoko/server/guild/Guild.java @@ -21,7 +21,7 @@ public final class Guild implements Encodable, Lockable { .thenComparing(Comparator.comparing(GuildMember::getLevel).reversed()); private final Lock lock = new ReentrantLock(); - private final int guildId; + private int guildId; private final String guildName; private final List gradeNames; private final Map guildMembers; // character ID -> guild member @@ -60,6 +60,10 @@ public int getGuildId() { return guildId; } + public void setGuildId(int newGuildId) { + this.guildId = newGuildId; + } + public String getGuildName() { return guildName; } @@ -216,11 +220,7 @@ public List getBoardEntryList(int page) { } public int getNextBoardEntryId() { - if (DatabaseManager.isRelational()) { - // Let the relational database handle SN generation; return placeholder - return -1; - } - + // Board Entries SN are composite keys with Guild IDs, so we don't need to return -1 for Relational DBs. return boardEntryCounter.getAndIncrement(); } diff --git a/src/main/java/kinoko/server/guild/GuildBoardEntry.java b/src/main/java/kinoko/server/guild/GuildBoardEntry.java index ea72e450..16c60881 100644 --- a/src/main/java/kinoko/server/guild/GuildBoardEntry.java +++ b/src/main/java/kinoko/server/guild/GuildBoardEntry.java @@ -142,17 +142,4 @@ public void encodeCurrent(OutPacket outPacket) { comment.encode(outPacket); // CUIGuildBBS::COMMENT } } - - /** - * Checks whether this GuildBoardEntry has a valid entry ID. - * - * In relational databases, entry IDs are typically automatically generated. - * Returns true if the entry has no ID and therefore needs to be inserted - * into the database to obtain one. - * - * @return true if the entry ID is zero or negative, false otherwise - */ - public boolean hasNoSN() { - return getEntryId() <= 0; - } } diff --git a/src/main/java/kinoko/server/guild/GuildStorage.java b/src/main/java/kinoko/server/guild/GuildStorage.java index 6ec8d53c..f069edb7 100644 --- a/src/main/java/kinoko/server/guild/GuildStorage.java +++ b/src/main/java/kinoko/server/guild/GuildStorage.java @@ -2,6 +2,7 @@ import kinoko.database.DatabaseManager; +import java.util.Collection; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -38,4 +39,14 @@ public Optional getGuildById(int guildId) { guildResult.ifPresent(guild -> guildMap.put(guildId, guild)); return guildResult; } + + /** + * Retrieves all guilds currently stored in memory. + * + * @return a collection of all guilds in the cache + */ + public Collection getAllGuilds() { + return guildMap.values(); + } + } diff --git a/src/main/java/kinoko/server/node/CentralServerNode.java b/src/main/java/kinoko/server/node/CentralServerNode.java index 5a620ede..a0360ed6 100644 --- a/src/main/java/kinoko/server/node/CentralServerNode.java +++ b/src/main/java/kinoko/server/node/CentralServerNode.java @@ -3,6 +3,7 @@ import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelInitializer; import io.netty.channel.socket.SocketChannel; +import kinoko.database.DatabaseManager; import kinoko.packet.CentralPacket; import kinoko.server.guild.Guild; import kinoko.server.guild.GuildMember; @@ -221,6 +222,9 @@ protected void initChannel(SocketChannel ch) { @Override public void shutdown() throws InterruptedException { + // Save All Guilds + DatabaseManager.guildAccessor().saveAll(guildStorage.getAllGuilds()); + // Shutdown login server node final Instant start = Instant.now(); serverStorage.getLoginServerNode().ifPresent((serverNode) -> serverNode.write(CentralPacket.shutdownRequest())); From 2f66f5e004ea622a27af9f1a288a98d8e5ac7d5f Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sat, 11 Oct 2025 02:22:39 -0400 Subject: [PATCH 18/83] put default value back --- src/main/java/kinoko/server/ServerConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/kinoko/server/ServerConfig.java b/src/main/java/kinoko/server/ServerConfig.java index 4178ebb7..99c9b974 100644 --- a/src/main/java/kinoko/server/ServerConfig.java +++ b/src/main/java/kinoko/server/ServerConfig.java @@ -15,7 +15,7 @@ public final class ServerConfig { public static final int SHUTDOWN_TIMEOUT = 30; public static final boolean AUTO_CREATE_ACCOUNT = Util.getEnv("AUTO_CREATE_ACCOUNT", true); - public static final boolean REQUIRE_SECONDARY_PASSWORD = Util.getEnv("REQUIRE_SECONDARY_PASSWORD", false); + public static final boolean REQUIRE_SECONDARY_PASSWORD = Util.getEnv("REQUIRE_SECONDARY_PASSWORD", true); public static final String WZ_DIRECTORY = Util.getEnv("WZ_DIRECTORY", "wz"); public static final String DATA_DIRECTORY = Util.getEnv("DATA_DIRECTORY", "data"); From aaaadad1c4204805369380ee4fa0ea202b580646 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sat, 11 Oct 2025 02:32:42 -0400 Subject: [PATCH 19/83] . --- wz/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 wz/.gitkeep diff --git a/wz/.gitkeep b/wz/.gitkeep deleted file mode 100644 index e69de29b..00000000 From b5d26a3e10e2632c1e2a44021927b902dbf1b1aa Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sun, 12 Oct 2025 02:05:24 -0400 Subject: [PATCH 20/83] modifications to player shop & extendsp --- .../kinoko/database/postgresql/setup/init.sql | 6 ++---- .../postgresql/type/GuildMemberDao.java | 5 ++--- .../database/postgresql/type/InventoryDao.java | 4 ++-- src/main/java/kinoko/server/Server.java | 4 ++++ .../server/dialog/miniroom/PersonalShop.java | 6 ++++-- .../server/netty/CentralServerHandler.java | 1 + src/main/java/kinoko/world/item/Item.java | 18 ++++++++++++++++++ .../kinoko/world/user/stat/CharacterStat.java | 3 --- 8 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index ad435636..658f6bdf 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -177,8 +177,7 @@ CREATE TABLE IF NOT EXISTS player.characters ( party_id INT, guild_id INT, creation_time TIMESTAMP NOT NULL DEFAULT UTC_NOW(), - max_level_time TIMESTAMP, - online BOOLEAN NOT NULL DEFAULT FALSE + max_level_time TIMESTAMP ); CREATE TABLE IF NOT EXISTS player.stats ( @@ -234,7 +233,7 @@ CREATE TABLE IF NOT EXISTS player.inventory ( item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, inventory_type inventory_type_enum NOT NULL, slot INT NOT NULL, - PRIMARY KEY (character_id, item_sn) + PRIMARY KEY (item_sn) ); CREATE INDEX IF NOT EXISTS idx_inventory_item_sn @@ -403,7 +402,6 @@ CREATE TABLE IF NOT EXISTS guild.member ( character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, grade SMALLINT NOT NULL, join_date TIMESTAMP NOT NULL DEFAULT UTC_NOW(), - last_login TIMESTAMP, PRIMARY KEY (character_id) ); diff --git a/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java b/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java index bcb83161..b1bd949c 100644 --- a/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java +++ b/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java @@ -131,7 +131,7 @@ public static List loadMembers(Connection conn, int guildId) throws List members = new ArrayList<>(); String sql = """ SELECT c.id, c.name, s.job, s.level, - m.grade AS guildRank, NULL AS allianceRank, c.online + m.grade AS guildRank, NULL AS allianceRank -- waiting to implement alliances FROM guild.member m JOIN player.characters c ON c.id = m.character_id JOIN player.stats s ON s.character_id = c.id @@ -146,7 +146,6 @@ public static List loadMembers(Connection conn, int guildId) throws String charName = rs.getString("name"); int job = rs.getInt("job"); int level = rs.getInt("level"); - boolean online = rs.getBoolean("online"); int guildRankInt = rs.getInt("guildRank"); Integer allianceRankInt = null; // no alliance rank yet @@ -155,7 +154,7 @@ public static List loadMembers(Connection conn, int guildId) throws charName, job, level, - online, + false, // CentralServerHandler will deal with this value. GuildRank.getByValue(guildRankInt), allianceRankInt != null ? GuildRank.getByValue(allianceRankInt) : GuildRank.NONE )); diff --git a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java index 54fa6059..f4760582 100644 --- a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java +++ b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java @@ -30,8 +30,8 @@ public static void saveCharacter(Connection conn, CharacterData characterData) t String sqlInventory = """ INSERT INTO player.inventory (character_id, inventory_type, slot, item_sn) VALUES (?, ?, ?, ?) - ON CONFLICT (character_id, item_sn) - DO UPDATE SET slot = EXCLUDED.slot, inventory_type = EXCLUDED.inventory_type + ON CONFLICT (item_sn) + DO UPDATE SET slot = EXCLUDED.slot, inventory_type = EXCLUDED.inventory_type, character_id = EXCLUDED.character_id """; int characterId = characterData.getCharacterId(); diff --git a/src/main/java/kinoko/server/Server.java b/src/main/java/kinoko/server/Server.java index 2054053a..96b21950 100644 --- a/src/main/java/kinoko/server/Server.java +++ b/src/main/java/kinoko/server/Server.java @@ -25,6 +25,10 @@ public static void main(String[] args) throws Exception { Server.initialize(); } + public static CentralServerNode getCentralServerNode() { + return centralServerNode; + } + private static void initialize() throws Exception { // Initialize providers Instant start = Instant.now(); diff --git a/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java b/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java index 0cb3a45a..ef416a88 100644 --- a/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java +++ b/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java @@ -103,12 +103,14 @@ public void handlePacket(User user, MiniRoomProtocol mrp, InPacket inPacket) { user.dispose(); return; } + + int originalInventoryQuantity = item.getQuantity(); // Move item from inventory to shop - final Optional removeItemResult = user.getInventoryManager().removeItem(targetPosition, item, totalCount); + final Optional removeItemResult = user.getInventoryManager().removeItem(targetPosition, item, totalCount); // updates player inventory item quantity if (removeItemResult.isEmpty()) { throw new IllegalStateException("Could not remove item from inventory"); } - if (item.getQuantity() > totalCount) { + if (originalInventoryQuantity > totalCount) { final Item partialItem = new Item(item); partialItem.setItemSn(user.getNextItemSn()); partialItem.setQuantity((short) totalCount); diff --git a/src/main/java/kinoko/server/netty/CentralServerHandler.java b/src/main/java/kinoko/server/netty/CentralServerHandler.java index fade8137..5352077b 100644 --- a/src/main/java/kinoko/server/netty/CentralServerHandler.java +++ b/src/main/java/kinoko/server/netty/CentralServerHandler.java @@ -515,6 +515,7 @@ private void handlePartyRequest(RemoteServerNode remoteServerNode, InPacket inPa final int inviterId = partyRequest.getCharacterId(); final Optional inviterResult = centralServerNode.getUserByCharacterId(inviterId); if (inviterResult.isEmpty()) { + // The inviter is not online. remoteServerNode.write(CentralPacket.userPacketReceive(remoteUser.getCharacterId(), PartyPacket.of(PartyResultType.JoinParty_Unknown))); // Your request for a party didn't work due to an unexpected error. return; } diff --git a/src/main/java/kinoko/world/item/Item.java b/src/main/java/kinoko/world/item/Item.java index 40dc045c..552a4f10 100644 --- a/src/main/java/kinoko/world/item/Item.java +++ b/src/main/java/kinoko/world/item/Item.java @@ -1,5 +1,6 @@ package kinoko.world.item; +import kinoko.database.DatabaseManager; import kinoko.server.packet.OutPacket; import kinoko.util.Encodable; @@ -226,4 +227,21 @@ public void setPossibleTrading(boolean set) { public boolean hasNoSN() { return getItemSn() <= 0; } + + /** + * Resets the item's serial number (SN) to -1. + * + * If checkIfRelational is true, the SN is reset only if the underlying + * database is relational. If false, the SN is always reset regardless of database type. + * + * This can be used to mark the item as needing a new SN before inserting it into + * a database. Especially useful when dropping/trading items in bulk that split an item. + * + * @param checkIfRelational whether to check if the database is relational before resetting + */ + public void resetSN(boolean checkIfRelational) { + if (!checkIfRelational || DatabaseManager.isRelational()) { + setItemSn(-1); + } + } } diff --git a/src/main/java/kinoko/world/user/stat/CharacterStat.java b/src/main/java/kinoko/world/user/stat/CharacterStat.java index 0dd20bd7..499b4a83 100644 --- a/src/main/java/kinoko/world/user/stat/CharacterStat.java +++ b/src/main/java/kinoko/world/user/stat/CharacterStat.java @@ -7,7 +7,6 @@ import kinoko.world.job.JobConstants; import java.util.EnumMap; -import java.util.HashMap; import java.util.Map; public final class CharacterStat implements Encodable { @@ -73,8 +72,6 @@ public CharacterStat(int id, String name, byte gender, byte skin, int face, int this.petSn1 = petSn1; this.petSn2 = petSn2; this.petSn3 = petSn3; - // TODO: give this a legit value. - this.sp = new ExtendSp(new HashMap<>()); } public int getId() { From 9dab7bb1b35b821755712da51d4c20436d768e5b Mon Sep 17 00:00:00 2001 From: MujyKun Date: Mon, 13 Oct 2025 17:49:10 -0400 Subject: [PATCH 21/83] Fixed Pets, Cash Shop Locker --- src/main/java/kinoko/database/IdAccessor.java | 9 + .../postgresql/PostgresAccountAccessor.java | 1 + .../postgresql/PostgresCharacterAccessor.java | 172 +++++++++++++----- .../postgresql/PostgresIdAccessor.java | 16 ++ .../kinoko/database/postgresql/setup/init.sql | 92 ++++++---- .../database/postgresql/type/ExtendSpDao.java | 60 ++++++ .../database/postgresql/type/ItemDao.java | 31 ++-- .../database/postgresql/type/PetDataDao.java | 47 +++++ .../database/postgresql/type/RingDataDao.java | 39 ++++ .../postgresql/type/SkillMacrosDao.java | 73 ++++++++ .../kinoko/handler/stage/CashShopHandler.java | 9 + .../handler/stage/MigrationHandler.java | 2 + .../java/kinoko/handler/user/PetHandler.java | 6 + src/main/java/kinoko/server/Server.java | 3 - src/main/java/kinoko/world/user/Locker.java | 3 + src/main/java/kinoko/world/user/User.java | 13 +- .../kinoko/world/user/stat/CharacterStat.java | 5 + 17 files changed, 480 insertions(+), 101 deletions(-) create mode 100644 src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/PetDataDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/RingDataDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/SkillMacrosDao.java diff --git a/src/main/java/kinoko/database/IdAccessor.java b/src/main/java/kinoko/database/IdAccessor.java index 399887c9..c4a930d2 100644 --- a/src/main/java/kinoko/database/IdAccessor.java +++ b/src/main/java/kinoko/database/IdAccessor.java @@ -1,5 +1,7 @@ package kinoko.database; +import kinoko.world.item.Item; + import java.util.Optional; public interface IdAccessor { @@ -12,4 +14,11 @@ public interface IdAccessor { Optional nextGuildId(); Optional nextMemoId(); + + default public boolean generateItemSn(Item item){ + if (DatabaseManager.isRelational()){ + throw new UnsupportedOperationException("generateItemSn() needs to be implemented for this database."); + } + return true; + } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java index 8efa91b4..a351229f 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java @@ -4,6 +4,7 @@ import kinoko.database.AccountAccessor; import kinoko.database.DatabaseManager; import kinoko.database.postgresql.type.AccountDao; +import kinoko.database.postgresql.type.LockerDao; import kinoko.server.ServerConfig; import kinoko.world.user.Account; import org.mindrot.jbcrypt.BCrypt; diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java index 091cb5c2..863c1159 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -3,7 +3,9 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.CharacterAccessor; import kinoko.database.CharacterInfo; +import kinoko.database.postgresql.type.ExtendSpDao; import kinoko.database.postgresql.type.InventoryDao; +import kinoko.database.postgresql.type.SkillMacrosDao; import kinoko.server.rank.CharacterRank; import kinoko.world.GameConstants; import kinoko.world.item.*; @@ -17,6 +19,7 @@ import kinoko.world.user.CharacterData; import kinoko.world.user.data.*; import kinoko.world.user.stat.CharacterStat; +import kinoko.world.user.stat.ExtendSp; import org.postgresql.util.PGobject; import java.io.*; @@ -78,9 +81,13 @@ private CharacterData loadCharacterData(ResultSet rs) throws SQLException, IOExc rs.getLong("pet_2"), rs.getLong("pet_3") ); + cd.setCharacterStat(cs); try (Connection conn = dataSource.getConnection()) { + cs.setSp(ExtendSpDao.loadExtendSp(conn, characterID)); // TODO: put this in a proper connection block. + + InventoryManager im = InventoryDao.loadInventoryManager(conn, characterID); cd.setInventoryManager(im); @@ -301,7 +308,9 @@ private ConfigManager loadConfig(int characterId) throws SQLException { quickslotKeyMap = Arrays.copyOf(GameConstants.DEFAULT_QUICKSLOT_KEY_MAP, GameConstants.QUICKSLOT_KEY_MAP_SIZE); } - return new ConfigManager(petConsumeItem, petConsumeMpItem, petExceptionList, funcKeyMap, quickslotKeyMap); + ConfigManager cm = new ConfigManager(petConsumeItem, petConsumeMpItem, petExceptionList, funcKeyMap, quickslotKeyMap); + cm.updateMacroSysData(SkillMacrosDao.loadMacros(conn, characterId)); + return cm; } } } @@ -538,6 +547,7 @@ public List getAvatarDataByAccountId(int accountId) { // For inventory, query normalized player.inventory table separately Inventory equipped = loadEquippedInventory(cs.getId()); +// Inventory cash = loadCashInventory(cs.getId()); list.add(AvatarData.from(cs, equipped)); } @@ -549,8 +559,8 @@ public List getAvatarDataByAccountId(int accountId) { return list; } - private Inventory loadEquippedInventory(int characterId) throws SQLException { - Inventory equipped = new Inventory(24, InventoryType.EQUIPPED); // default equipped size + private Inventory loadCashInventory(int characterId) throws SQLException { + Inventory equipped = new Inventory(24, InventoryType.CASH); // default cash size String sql = """ SELECT f.*, i.slot @@ -559,6 +569,117 @@ private Inventory loadEquippedInventory(int characterId) throws SQLException { WHERE i.character_id = ? AND i.inventory_type = ? """; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + + PGobject enumValue = new PGobject(); + enumValue.setType("inventory_type_enum"); + enumValue.setValue(InventoryType.CASH.name()); + stmt.setObject(2, enumValue); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + long itemSn = rs.getLong("item_sn"); + int slot = rs.getInt("slot"); + int itemId = rs.getInt("item_id"); + short quantity = rs.getShort("quantity"); + short attribute = rs.getShort("attribute"); + String title = rs.getString("title"); + Timestamp dateExpireTs = rs.getTimestamp("date_expire"); + + // Build EquipData if applicable + EquipData equipData = null; + if (rs.getObject("inc_str") != null) { + equipData = new EquipData( + rs.getShort("inc_str"), + rs.getShort("inc_dex"), + rs.getShort("inc_int"), + rs.getShort("inc_luk"), + rs.getShort("inc_max_hp"), + rs.getShort("inc_max_mp"), + rs.getShort("inc_pad"), + rs.getShort("inc_mad"), + rs.getShort("inc_pdd"), + rs.getShort("inc_mdd"), + rs.getShort("inc_acc"), + rs.getShort("inc_eva"), + rs.getShort("inc_craft"), + rs.getShort("inc_speed"), + rs.getShort("inc_jump"), + rs.getByte("ruc"), + rs.getByte("cuc"), + rs.getInt("iuc"), + rs.getByte("chuc"), + rs.getByte("grade"), + rs.getShort("option_1"), + rs.getShort("option_2"), + rs.getShort("option_3"), + rs.getShort("socket_1"), + rs.getShort("socket_2"), + rs.getByte("level_up_type"), + rs.getByte("level"), + rs.getInt("exp"), + rs.getInt("durability") + ); + } + + // Build PetData if applicable + PetData petData = null; + if (rs.getObject("pet_name") != null) { + petData = new PetData( + rs.getString("pet_name"), + rs.getByte("pet_level"), + rs.getByte("fullness"), + rs.getShort("tameness"), + rs.getShort("pet_skill"), + rs.getShort("pet_attribute"), + rs.getInt("remain_life") + ); + } + + // Build RingData if applicable + RingData ringData = null; + if (rs.getObject("pair_character_id") != null) { + ringData = new RingData( + rs.getInt("pair_character_id"), + rs.getString("pair_character_name"), + rs.getLong("pair_item_sn") + ); + } + + Item item = new Item( + itemId, + quantity, + itemSn, + false, // cash flag, adjust if you have it + attribute, + title, + dateExpireTs != null ? dateExpireTs.toInstant() : null, + equipData, + petData, + ringData + ); + + equipped.putItem(slot, item); + } + } + } + + return equipped; + } + + + private Inventory loadEquippedInventory(int characterId) throws SQLException { + Inventory equipped = new Inventory(24, InventoryType.EQUIPPED); // default equipped size + + String sql = """ + SELECT f.*, i.slot + FROM player.inventory i + JOIN item.full_item f ON i.item_sn = f.item_sn + WHERE i.character_id = ? AND i.inventory_type = ? + """; + try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, characterId); @@ -705,8 +826,8 @@ public synchronized boolean newCharacter(CharacterData characterData) { saveCharacterSkills(conn, characterData); saveCharacterQuests(conn, characterData); saveCharacterConfig(conn, characterData); - saveCharacterMacros(conn, characterData); saveCharacterPopularity(conn, characterData); + ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); conn.commit(); success = true; @@ -776,43 +897,10 @@ ON CONFLICT (character_id) DO UPDATE stmt.executeUpdate(); } + // save skill macros + SkillMacrosDao.upsertMacros(conn, characterData.getCharacterId(), config.getMacroSysData()); } - - - private void saveCharacterMacros(Connection conn, CharacterData characterData) throws SQLException { - List macros = characterData.getConfigManager().getMacroSysData(); - - String sql = """ - INSERT INTO player.character_macro - (character_id, macro_index, name, mute, skills) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT (character_id, macro_index) DO UPDATE - SET name = EXCLUDED.name, - mute = EXCLUDED.mute, - skills = EXCLUDED.skills - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - for (int i = 0; i < macros.size(); i++) { - SingleMacro macro = macros.get(i); - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, i); // macro_index - stmt.setString(3, macro.getName()); - stmt.setBoolean(4, macro.isMute()); - - // skills array -> Integer[] - int[] skills = macro.getSkills(); - Integer[] skillArray = Arrays.stream(skills).boxed().toArray(Integer[]::new); - stmt.setArray(5, conn.createArrayOf("INT", skillArray)); - - stmt.addBatch(); - } - stmt.executeBatch(); - } - } - - private void saveCharacterPopularity(Connection conn, CharacterData characterData) throws SQLException { String sql = """ INSERT INTO player.popularity (character_id, other_character_id, timestamp) @@ -851,11 +939,7 @@ ON CONFLICT (character_id, skill_id) stmt.setInt(1, characterData.getCharacterId()); stmt.setInt(2, sr.getSkillId()); stmt.setInt(3, sr.getSkillLevel()); - if (sr.getMasterLevel() > 0) { stmt.setInt(4, sr.getMasterLevel()); - } else { - stmt.setNull(4, java.sql.Types.INTEGER); - } stmt.addBatch(); } stmt.executeBatch(); @@ -946,8 +1030,8 @@ public boolean saveCharacter(CharacterData characterData) { saveCharacterSkills(conn, characterData); saveCharacterQuests(conn, characterData); saveCharacterConfig(conn, characterData); - saveCharacterMacros(conn, characterData); saveCharacterPopularity(conn, characterData); + ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); conn.commit(); return true; diff --git a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java index 8200cdec..16975ffd 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java @@ -2,7 +2,9 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.IdAccessor; +import kinoko.database.postgresql.type.AccountDao; import kinoko.database.postgresql.type.ItemDao; +import kinoko.world.item.Item; import java.sql.Connection; import java.sql.SQLException; @@ -47,4 +49,18 @@ public synchronized Optional nextGuildId() { public synchronized Optional nextMemoId() { return getNextId("memo_id"); } + + @Override + public boolean generateItemSn(Item item) { + if (!item.hasNoSN()){ + return true; + } + + try { + return withTransaction(getConnection(), c -> ItemDao.createNewItem(c, item)); + } catch (SQLException e) { + e.printStackTrace(); + return false; + } + } } diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index 658f6bdf..6a763f94 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -28,6 +28,7 @@ $function$; CREATE TABLE item.items ( item_sn BIGSERIAL PRIMARY KEY, -- auto-increment unique ID for every item instance item_id INT NOT NULL, + cash BOOLEAN NOT NULL DEFAULT FALSE, quantity INT NOT NULL DEFAULT 1, attribute SMALLINT DEFAULT 0, title TEXT DEFAULT '', @@ -41,7 +42,7 @@ CREATE INDEX IF NOT EXISTS idx_items_date_expire ON item.items(date_expire); CREATE TABLE IF NOT EXISTS item.equip_data ( - item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE, -- unique for every equip item instance + item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE ON UPDATE CASCADE, -- unique for every equip item instance inc_str SMALLINT DEFAULT 0, inc_dex SMALLINT DEFAULT 0, inc_int SMALLINT DEFAULT 0, @@ -78,7 +79,7 @@ CREATE INDEX IF NOT EXISTS idx_equip_data_item_sn CREATE TABLE IF NOT EXISTS item.pet_data ( - item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE, + item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE ON UPDATE CASCADE , pet_name TEXT, level SMALLINT DEFAULT 0, fullness SMALLINT DEFAULT 0, @@ -93,7 +94,7 @@ CREATE INDEX IF NOT EXISTS idx_pet_data_item_sn CREATE TABLE IF NOT EXISTS item.ring_data ( - item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE, + item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE ON UPDATE CASCADE , pair_character_id INT, pair_character_name TEXT, pair_item_sn BIGINT @@ -121,8 +122,8 @@ CREATE TABLE IF NOT EXISTS account.accounts ( ); CREATE TABLE IF NOT EXISTS account.trunk_item ( - account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, - item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE ON UPDATE CASCADE , + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE ON UPDATE CASCADE , slot INT NOT NULL, PRIMARY KEY (account_id, slot) ); @@ -135,8 +136,8 @@ CREATE INDEX IF NOT EXISTS idx_trunk_item_account_item CREATE TABLE IF NOT EXISTS account.locker_item ( - account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, - item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE ON UPDATE CASCADE , + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE ON UPDATE CASCADE , slot INT NOT NULL, commodity_id INT, PRIMARY KEY (account_id, slot) @@ -150,7 +151,7 @@ CREATE INDEX IF NOT EXISTS idx_locker_item_account_item CREATE TABLE IF NOT EXISTS account.wishlist ( - account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE ON UPDATE CASCADE , item_id BIGINT NOT NULL, slot INT NOT NULL, PRIMARY KEY (account_id, slot) @@ -169,7 +170,7 @@ CREATE INDEX IF NOT EXISTS idx_wishlist_account_item CREATE TABLE IF NOT EXISTS player.characters ( id SERIAL PRIMARY KEY, - account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE ON UPDATE CASCADE , name TEXT NOT NULL, money INT NOT NULL DEFAULT 0, ext_slot_expire TIMESTAMP, @@ -181,7 +182,7 @@ CREATE TABLE IF NOT EXISTS player.characters ( ); CREATE TABLE IF NOT EXISTS player.stats ( - character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , gender SMALLINT NOT NULL, skin SMALLINT NOT NULL, face INT NOT NULL, @@ -207,11 +208,11 @@ CREATE TABLE IF NOT EXISTS player.stats ( pet_3 BIGINT ); -CREATE TABLE IF NOT EXISTS player.skill_points ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, - skill_id INT NOT NULL, - points INT NOT NULL DEFAULT 0, - PRIMARY KEY (character_id, skill_id) +CREATE TABLE IF NOT EXISTS player.extend_sp ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , + job_level SMALLINT NOT NULL, + sp SMALLINT DEFAULT 0, + PRIMARY KEY (character_id, job_level) ); DO $$ @@ -229,8 +230,8 @@ BEGIN END$$; CREATE TABLE IF NOT EXISTS player.inventory ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, - item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE ON UPDATE CASCADE , inventory_type inventory_type_enum NOT NULL, slot INT NOT NULL, PRIMARY KEY (item_sn) @@ -244,7 +245,7 @@ CREATE INDEX IF NOT EXISTS idx_inventory_char_item CREATE TABLE IF NOT EXISTS player.skill_cooltime ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , skill_id INT NOT NULL, cooldown_end TIMESTAMP NOT NULL, PRIMARY KEY (character_id, skill_id) @@ -252,7 +253,7 @@ CREATE TABLE IF NOT EXISTS player.skill_cooltime ( CREATE TABLE IF NOT EXISTS player.skill_record ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , skill_id INT NOT NULL, level INT NOT NULL, master_level INT, @@ -260,7 +261,7 @@ CREATE TABLE IF NOT EXISTS player.skill_record ( ); CREATE TABLE IF NOT EXISTS player.quest_record ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , quest_id INT NOT NULL, status INT NOT NULL, progress TEXT, @@ -269,7 +270,7 @@ CREATE TABLE IF NOT EXISTS player.quest_record ( ); CREATE TABLE IF NOT EXISTS player.config ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , pet_consume_item INT NOT NULL DEFAULT 0, pet_consume_mp_item INT NOT NULL DEFAULT 0, pet_exception_list INT[] NOT NULL DEFAULT '{}', @@ -279,8 +280,18 @@ CREATE TABLE IF NOT EXISTS player.config ( PRIMARY KEY (character_id) ); +CREATE TABLE IF NOT EXISTS player.skill_macros ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , + macro_index SMALLINT NOT NULL, -- 0–4 + name TEXT NOT NULL, + mute BOOLEAN NOT NULL DEFAULT FALSE, + skills INT[] NOT NULL, + PRIMARY KEY (character_id, macro_index) +); + + CREATE TABLE IF NOT EXISTS player.character_macro ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , macro_index INT NOT NULL, -- index/order of the macro name TEXT NOT NULL, mute BOOLEAN NOT NULL DEFAULT FALSE, @@ -289,14 +300,14 @@ CREATE TABLE IF NOT EXISTS player.character_macro ( ); CREATE TABLE IF NOT EXISTS player.popularity ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , other_character_id INT NOT NULL, timestamp TIMESTAMP NOT NULL, PRIMARY KEY (character_id, other_character_id) ); CREATE TABLE IF NOT EXISTS player.minigame ( - character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , omok_wins INT NOT NULL DEFAULT 0, omok_ties INT NOT NULL DEFAULT 0, omok_losses INT NOT NULL DEFAULT 0, @@ -308,24 +319,24 @@ CREATE TABLE IF NOT EXISTS player.minigame ( ); CREATE TABLE IF NOT EXISTS player.map_transfer ( - character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , map_id INT NOT NULL, old_map_id INT NOT NULL ); CREATE TABLE IF NOT EXISTS player.wild_hunter ( - character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , riding_type INT NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS player.wild_hunter_mob ( - character_id INT REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , mob_id INT NOT NULL, PRIMARY KEY (character_id, mob_id) ); CREATE TABLE IF NOT EXISTS player.config ( - character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , config_key TEXT NOT NULL, config_value TEXT ); @@ -336,8 +347,8 @@ CREATE TABLE IF NOT EXISTS player.config ( ------------------------------------------ CREATE TABLE IF NOT EXISTS friend.friends ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, - friend_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , + friend_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , friend_name TEXT NOT NULL, friend_group TEXT, friend_status INT NOT NULL DEFAULT 0, @@ -353,8 +364,8 @@ CREATE INDEX IF NOT EXISTS idx_friend_friend_id ------------------------------------------ CREATE TABLE IF NOT EXISTS gift.gifts ( - item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, - receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE ON UPDATE CASCADE , + receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , commodity_id INT, sender_id INT, sender_name TEXT, @@ -391,15 +402,15 @@ CREATE TABLE IF NOT EXISTS guild.guilds ( ); CREATE TABLE IF NOT EXISTS guild.grade ( - guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE ON UPDATE CASCADE , grade_index INT NOT NULL, grade_name TEXT NOT NULL, PRIMARY KEY (guild_id, grade_index) ); CREATE TABLE IF NOT EXISTS guild.member ( - guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE ON UPDATE CASCADE , + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , grade SMALLINT NOT NULL, join_date TIMESTAMP NOT NULL DEFAULT UTC_NOW(), PRIMARY KEY (character_id) @@ -408,8 +419,8 @@ CREATE TABLE IF NOT EXISTS guild.member ( CREATE TABLE IF NOT EXISTS guild.board_entry ( id INT NOT NULL, - guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE ON UPDATE CASCADE , + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , title TEXT, message TEXT NOT NULL, emoticon INT, @@ -426,8 +437,8 @@ WHERE notice = TRUE; CREATE TABLE IF NOT EXISTS guild.board_entry_comment ( id SERIAL PRIMARY KEY, entry_id INT NOT NULL, - guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE ON UPDATE CASCADE , + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , text TEXT NOT NULL, timestamp TIMESTAMP NOT NULL DEFAULT UTC_NOW() ); @@ -439,7 +450,7 @@ CREATE TABLE IF NOT EXISTS guild.board_entry_comment ( CREATE TABLE IF NOT EXISTS memo.memo ( id SERIAL PRIMARY KEY, - receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , memo_type INT NOT NULL, memo_content TEXT NOT NULL, sender_name TEXT, @@ -457,6 +468,7 @@ CREATE OR REPLACE VIEW item.full_item AS SELECT i.item_sn, i.item_id, + i.cash, i.quantity, i.attribute, i.title, diff --git a/src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java b/src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java new file mode 100644 index 00000000..2ec92ba7 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java @@ -0,0 +1,60 @@ +package kinoko.database.postgresql.type; + + +import kinoko.world.user.stat.ExtendSp; + +import java.sql.*; +import java.util.HashMap; +import java.util.Map; + +public final class ExtendSpDao { + /** + * Upserts all entries from ExtendSp into player.extend_sp. + */ + public static void upsertExtendSp(Connection conn, int characterId, ExtendSp extendSp) throws SQLException { + if (extendSp == null || extendSp.getMap().isEmpty()) { + return; + } + + String sql = """ + INSERT INTO player.extend_sp (character_id, job_level, sp) + VALUES (?, ?, ?) + ON CONFLICT (character_id, job_level) + DO UPDATE SET sp = EXCLUDED.sp + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (Map.Entry entry : extendSp.getMap().entrySet()) { + stmt.setInt(1, characterId); + stmt.setInt(2, entry.getKey()); + stmt.setInt(3, entry.getValue()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Loads ExtendSp for a given character. + */ + public static ExtendSp loadExtendSp(Connection conn, int characterId) throws SQLException { + String sql = """ + SELECT job_level, sp + FROM player.extend_sp + WHERE character_id = ? + """; + + Map map = new HashMap<>(); + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + map.put(rs.getInt("job_level"), rs.getInt("sp")); + } + } + } + + return ExtendSp.from(map); + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/ItemDao.java b/src/main/java/kinoko/database/postgresql/type/ItemDao.java index 8f30cd67..93cc497d 100644 --- a/src/main/java/kinoko/database/postgresql/type/ItemDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ItemDao.java @@ -44,6 +44,8 @@ public static long createNewItem(Connection conn, Item item) throws SQLException long generatedSn = rs.getLong("item_sn"); item.setItemSn(generatedSn); // store it in the item object EquipDataDao.upsertEquipData(conn, generatedSn, item.getEquipData()); + PetDataDao.upsertPetData(conn, generatedSn, item.getPetData()); + RingDataDao.upsertRingData(conn, generatedSn, item.getRingData()); return generatedSn; } else { throw new SQLException("Failed to generate item_sn for new item"); @@ -69,13 +71,13 @@ public static void saveItemsBatch(Connection conn, Collection items) throw if (items.isEmpty()) return; String sqlInsert = """ - INSERT INTO item.items (item_sn, item_id, quantity, attribute, title, date_expire) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO item.items (item_sn, item_id, cash, quantity, attribute, title, date_expire) + VALUES (?, ?, ?, ?, ?, ?, ?) """; String sqlUpdate = """ UPDATE item.items - SET quantity = ?, attribute = ?, title = ?, date_expire = ? + SET cash = ?, quantity = ?, attribute = ?, title = ?, date_expire = ? WHERE item_sn = ? """; @@ -95,18 +97,20 @@ public static void saveItemsBatch(Connection conn, Collection items) throw stmtInsert.setLong(1, itemSn); stmtInsert.setInt(2, item.getItemId()); - stmtInsert.setInt(3, item.getQuantity()); - stmtInsert.setShort(4, item.getAttribute()); - stmtInsert.setString(5, item.getTitle()); - stmtInsert.setTimestamp(6, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); + stmtInsert.setBoolean(3, item.isCash()); + stmtInsert.setInt(4, item.getQuantity()); + stmtInsert.setShort(5, item.getAttribute()); + stmtInsert.setString(6, item.getTitle()); + stmtInsert.setTimestamp(7, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); stmtInsert.addBatch(); } else { try (PreparedStatement stmtUpdate = conn.prepareStatement(sqlUpdate)) { - stmtUpdate.setInt(1, item.getQuantity()); - stmtUpdate.setShort(2, item.getAttribute()); - stmtUpdate.setString(3, item.getTitle()); - stmtUpdate.setTimestamp(4, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); - stmtUpdate.setLong(5, itemSn); + stmtUpdate.setBoolean(1, item.isCash()); + stmtUpdate.setInt(2, item.getQuantity()); + stmtUpdate.setShort(3, item.getAttribute()); + stmtUpdate.setString(4, item.getTitle()); + stmtUpdate.setTimestamp(5, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); + stmtUpdate.setLong(6, itemSn); stmtUpdate.executeUpdate(); } } @@ -122,6 +126,7 @@ public static void saveItemsBatch(Connection conn, Collection items) throw public static Item from(ResultSet rs) throws SQLException { long itemSn = rs.getLong("item_sn"); int itemId = rs.getInt("item_id"); + boolean isCashItem = rs.getBoolean("cash"); short quantity = rs.getShort("quantity"); short attribute = rs.getShort("attribute"); String title = rs.getString("title"); @@ -186,7 +191,7 @@ public static Item from(ResultSet rs) throws SQLException { itemId, quantity, itemSn, - false, // cash flag + isCashItem, attribute, title, dateExpireTs != null ? dateExpireTs.toInstant() : null, diff --git a/src/main/java/kinoko/database/postgresql/type/PetDataDao.java b/src/main/java/kinoko/database/postgresql/type/PetDataDao.java new file mode 100644 index 00000000..506969a3 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/PetDataDao.java @@ -0,0 +1,47 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.item.PetData; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +public final class PetDataDao { + /** + * Inserts or updates PetData for a given item_sn. + * If an entry already exists, it will be updated instead. + */ + public static void upsertPetData(Connection conn, long itemSn, PetData petData) throws SQLException { + if (petData == null) { + return; + } + + String sql = """ + INSERT INTO item.pet_data ( + item_sn, pet_name, level, fullness, tameness, pet_skill, pet_attribute, remain_life + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (item_sn) + DO UPDATE SET + pet_name = EXCLUDED.pet_name, + level = EXCLUDED.level, + fullness = EXCLUDED.fullness, + tameness = EXCLUDED.tameness, + pet_skill = EXCLUDED.pet_skill, + pet_attribute = EXCLUDED.pet_attribute, + remain_life = EXCLUDED.remain_life + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, itemSn); + stmt.setString(2, petData.getPetName()); + stmt.setByte(3, petData.getLevel()); + stmt.setByte(4, petData.getFullness()); + stmt.setShort(5, petData.getTameness()); + stmt.setShort(6, petData.getPetSkill()); + stmt.setShort(7, petData.getPetAttribute()); + stmt.setInt(8, petData.getRemainLife()); + stmt.executeUpdate(); + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/RingDataDao.java b/src/main/java/kinoko/database/postgresql/type/RingDataDao.java new file mode 100644 index 00000000..3908dbd9 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/RingDataDao.java @@ -0,0 +1,39 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.item.RingData; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +public final class RingDataDao { + /** + * Inserts or updates RingData for a given item_sn. + * If an entry already exists, it will be updated instead. + */ + public static void upsertRingData(Connection conn, long itemSn, RingData ringData) throws SQLException { + if (ringData == null) { + return; // nothing to save + } + + String sql = """ + INSERT INTO item.ring_data ( + item_sn, pair_character_id, pair_character_name, pair_item_sn + ) + VALUES (?, ?, ?, ?) + ON CONFLICT (item_sn) + DO UPDATE SET + pair_character_id = EXCLUDED.pair_character_id, + pair_character_name = EXCLUDED.pair_character_name, + pair_item_sn = EXCLUDED.pair_item_sn + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, itemSn); + stmt.setInt(2, ringData.getPairCharacterId()); + stmt.setString(3, ringData.getPairCharacterName()); + stmt.setLong(4, ringData.getPairItemSn()); + stmt.executeUpdate(); + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/SkillMacrosDao.java b/src/main/java/kinoko/database/postgresql/type/SkillMacrosDao.java new file mode 100644 index 00000000..3582ae68 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/SkillMacrosDao.java @@ -0,0 +1,73 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.GameConstants; +import kinoko.world.user.data.SingleMacro; + +import java.sql.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public final class SkillMacrosDao { + /** + * Upserts a list of macros for a character. + */ + public static void upsertMacros(Connection conn, int characterId, List macros) throws SQLException { + if (macros == null || macros.isEmpty()) { + return; + } + + String sql = """ + INSERT INTO player.skill_macros (character_id, macro_index, name, mute, skills) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (character_id, macro_index) + DO UPDATE SET name = EXCLUDED.name, + mute = EXCLUDED.mute, + skills = EXCLUDED.skills + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (int i = 0; i < macros.size(); i++) { + SingleMacro macro = macros.get(i); + stmt.setInt(1, characterId); + stmt.setInt(2, i); // macro_index 0-4 + stmt.setString(3, macro.getName()); + stmt.setBoolean(4, macro.isMute()); + stmt.setArray(5, conn.createArrayOf("int", + Arrays.stream(macro.getSkills()).boxed().toArray(Integer[]::new))); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Loads all macros for a character. + */ + public static List loadMacros(Connection conn, int characterId) throws SQLException { + String sql = "SELECT macro_index, name, mute, skills FROM player.skill_macros WHERE character_id = ?"; + SingleMacro[] macros = new SingleMacro[5]; + + // Initialize all slots with default blank macros + for (int i = 0; i < GameConstants.MACRO_SYS_DATA_SIZE; i++) { + macros[i] = new SingleMacro("", false, new int[GameConstants.MACRO_SKILL_COUNT]); + } + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int index = rs.getInt("macro_index"); + if (index < 0 || index >= GameConstants.MACRO_SYS_DATA_SIZE) continue; // safety check + String name = rs.getString("name"); + boolean mute = rs.getBoolean("mute"); + Integer[] skillsArray = (Integer[]) rs.getArray("skills").getArray(); + int[] skills = Arrays.stream(skillsArray).mapToInt(Integer::intValue).toArray(); + macros[index] = new SingleMacro(name, mute, skills); + } + } + } + + return Arrays.asList(macros); + } +} diff --git a/src/main/java/kinoko/handler/stage/CashShopHandler.java b/src/main/java/kinoko/handler/stage/CashShopHandler.java index a1cf47f8..1c57f3da 100644 --- a/src/main/java/kinoko/handler/stage/CashShopHandler.java +++ b/src/main/java/kinoko/handler/stage/CashShopHandler.java @@ -91,10 +91,19 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { throw new IllegalStateException("Could not deduct price for cash item"); } + // Generate an item SN for the locker item - (Relational DBs) - Safe for NoSQL DBs. + if (!DatabaseManager.idAccessor().generateItemSn(cashItemInfo.getItem())){ + user.write(CashShopPacket.fail(CashItemResultType.Buy_Failed, CashItemFailReason.Unknown)); // Due to an unknown error, the request for Cash Shop has failed. + log.error("Could not generate SN for Item ID: {}", cashItemInfo.getItem().getItemId()); + return; + } + // Add to locker and update client account.getLocker().addCashItem(cashItemInfo); user.write(CashShopPacket.queryCashResult(account)); user.write(CashShopPacket.buyDone(cashItemInfo)); + + } case Gift, GiftPackage -> { // CCashShop::SendGiftsPacket diff --git a/src/main/java/kinoko/handler/stage/MigrationHandler.java b/src/main/java/kinoko/handler/stage/MigrationHandler.java index a8a8457c..a07a4d46 100644 --- a/src/main/java/kinoko/handler/stage/MigrationHandler.java +++ b/src/main/java/kinoko/handler/stage/MigrationHandler.java @@ -320,6 +320,7 @@ public static void handleUserMigrateToCashShopRequest(User user, InPacket inPack // Remove user from field user.getField().removeUser(user); + // Load gifts final List gifts = DatabaseManager.giftAccessor().getGiftsByCharacterId(user.getCharacterId()); @@ -330,6 +331,7 @@ public static void handleUserMigrateToCashShopRequest(User user, InPacket inPack user.write(CashShopPacket.loadLockerDone(account)); user.write(CashShopPacket.loadWishDone(account.getWishlist())); user.write(CashShopPacket.queryCashResult(account)); + user.setInCashShop(true); } private static boolean isWhitelistedTransferField(int currentFieldId, int targetFieldId) { diff --git a/src/main/java/kinoko/handler/user/PetHandler.java b/src/main/java/kinoko/handler/user/PetHandler.java index 59617d5d..329e22c8 100644 --- a/src/main/java/kinoko/handler/user/PetHandler.java +++ b/src/main/java/kinoko/handler/user/PetHandler.java @@ -112,6 +112,12 @@ public static void handleUserActivatePetRequest(User user, InPacket inPacket) { @Handler(InHeader.PetMove) public static void handlePetMove(User user, InPacket inPacket) { + if (user.isInCashShop()){ + // If a player attempts to preview a pet, the client for some reason spams this packet that is NOT needed. + // It is attempting to access the character's actual pets. + return; + } + final long petSn = inPacket.decodeLong(); // liPetLockerSN final MovePath movePath = MovePath.decode(inPacket); final Optional petIndexResult = user.getPetIndex(petSn); diff --git a/src/main/java/kinoko/server/Server.java b/src/main/java/kinoko/server/Server.java index 96b21950..ab6e3916 100644 --- a/src/main/java/kinoko/server/Server.java +++ b/src/main/java/kinoko/server/Server.java @@ -25,9 +25,6 @@ public static void main(String[] args) throws Exception { Server.initialize(); } - public static CentralServerNode getCentralServerNode() { - return centralServerNode; - } private static void initialize() throws Exception { // Initialize providers diff --git a/src/main/java/kinoko/world/user/Locker.java b/src/main/java/kinoko/world/user/Locker.java index 6d047b50..c0f1a799 100644 --- a/src/main/java/kinoko/world/user/Locker.java +++ b/src/main/java/kinoko/world/user/Locker.java @@ -1,8 +1,11 @@ package kinoko.world.user; +import kinoko.database.DatabaseManager; import kinoko.server.cashshop.CashItemInfo; import kinoko.world.GameConstants; +import kinoko.world.item.Item; +import java.awt.image.DataBuffer; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/kinoko/world/user/User.java b/src/main/java/kinoko/world/user/User.java index e613cf57..bdda96d1 100644 --- a/src/main/java/kinoko/world/user/User.java +++ b/src/main/java/kinoko/world/user/User.java @@ -68,6 +68,7 @@ public final class User extends Life { private final Map schedules = new HashMap<>(); private final AtomicInteger fieldKey = new AtomicInteger(0); + private int messengerId; private PartyInfo partyInfo; private GuildInfo guildInfo; @@ -78,6 +79,7 @@ public final class User extends Life { private OpenGate openGate; private int effectItemId; private int portableChairId; + private boolean inCashShop = false; private String adBoard; private boolean inTransfer; private Instant nextCheckItemExpire; @@ -807,7 +809,8 @@ public void write(OutPacket outPacket) { } public void dispose() { - write(WvsContext.statChanged(Map.of(), true)); + OutPacket outpacket = WvsContext.statChanged(Map.of(), true); + write(outpacket); } public void logout(boolean disconnect) { @@ -853,6 +856,14 @@ public void logout(boolean disconnect) { } } + public boolean isInCashShop() { + return inCashShop; + } + + public void setInCashShop(boolean inCashShop) { + this.inCashShop = inCashShop; + } + // OVERRIDES ------------------------------------------------------------------------------------------------------- diff --git a/src/main/java/kinoko/world/user/stat/CharacterStat.java b/src/main/java/kinoko/world/user/stat/CharacterStat.java index 499b4a83..fc5c6f7c 100644 --- a/src/main/java/kinoko/world/user/stat/CharacterStat.java +++ b/src/main/java/kinoko/world/user/stat/CharacterStat.java @@ -7,6 +7,10 @@ import kinoko.world.job.JobConstants; import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.sql.*; +import java.util.HashMap; import java.util.Map; public final class CharacterStat implements Encodable { @@ -72,6 +76,7 @@ public CharacterStat(int id, String name, byte gender, byte skin, int face, int this.petSn1 = petSn1; this.petSn2 = petSn2; this.petSn3 = petSn3; + this.sp = ExtendSp.from(new HashMap<>()); // empty on init. } public int getId() { From a7f672b1ec6d7e2b73f7b6735245f3bfd1bd8716 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Mon, 13 Oct 2025 21:27:18 -0400 Subject: [PATCH 22/83] Added marriage/friend ring compatibility to postgresql --- .env.example | 2 + .../kinoko/database/DatabaseConnector.java | 2 + src/main/java/kinoko/database/IdAccessor.java | 37 +++++- .../java/kinoko/database/ItemAccessor.java | 13 +++ .../cassandra/CassandraConnector.java | 8 ++ .../postgresql/PostgresConnector.java | 3 + .../postgresql/PostgresGiftAccessor.java | 23 ++-- .../postgresql/PostgresIdAccessor.java | 8 +- .../postgresql/PostgresItemAccessor.java | 42 +++++++ .../postgresql/PostgresMemoAccessor.java | 98 +++++++--------- .../kinoko/database/postgresql/setup/init.sql | 1 + .../database/postgresql/type/ItemDao.java | 4 + .../database/postgresql/type/MemoDao.java | 110 ++++++++++++++++++ .../database/postgresql/type/PetDataDao.java | 53 +++++++++ .../database/postgresql/type/RingDataDao.java | 45 +++++++ .../kinoko/handler/stage/CashShopHandler.java | 25 +++- .../kinoko/server/cashshop/Commodity.java | 5 + 17 files changed, 396 insertions(+), 83 deletions(-) create mode 100644 src/main/java/kinoko/database/ItemAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/type/MemoDao.java diff --git a/.env.example b/.env.example index a9edac1c..185c09f4 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,5 @@ DB_NAME=kinoko DB_USER=postgres DB_PASS=admin + +# Any other env settings you want to modify: \ No newline at end of file diff --git a/src/main/java/kinoko/database/DatabaseConnector.java b/src/main/java/kinoko/database/DatabaseConnector.java index 9b4bf0e0..35555959 100644 --- a/src/main/java/kinoko/database/DatabaseConnector.java +++ b/src/main/java/kinoko/database/DatabaseConnector.java @@ -15,6 +15,8 @@ public interface DatabaseConnector { MemoAccessor getMemoAccessor(); + ItemAccessor getItemAccessor(); + void initialize(); void shutdown(); diff --git a/src/main/java/kinoko/database/IdAccessor.java b/src/main/java/kinoko/database/IdAccessor.java index c4a930d2..9085e957 100644 --- a/src/main/java/kinoko/database/IdAccessor.java +++ b/src/main/java/kinoko/database/IdAccessor.java @@ -5,17 +5,42 @@ import java.util.Optional; public interface IdAccessor { - Optional nextAccountId(); + default Optional nextAccountId(){ + if (!DatabaseManager.isRelational()){ + throw new UnsupportedOperationException("nextAccountId needs to be implemented for this database."); + } + return Optional.of(-1); + } - Optional nextCharacterId(); + default Optional nextCharacterId(){ + if (!DatabaseManager.isRelational()){ + throw new UnsupportedOperationException("nextCharacterId needs to be implemented for this database."); + } + return Optional.of(-1); + } - Optional nextPartyId(); + default Optional nextPartyId(){ + if (!DatabaseManager.isRelational()){ + throw new UnsupportedOperationException("nextPartyId needs to be implemented for this database."); + } + return Optional.of(-1); + } - Optional nextGuildId(); + default Optional nextGuildId(){ + if (!DatabaseManager.isRelational()){ + throw new UnsupportedOperationException("nextGuildId needs to be implemented for this database."); + } + return Optional.of(-1); + } - Optional nextMemoId(); + default Optional nextMemoId(){ + if (!DatabaseManager.isRelational()){ + throw new UnsupportedOperationException("nextMemoId needs to be implemented for this database."); + } + return Optional.of(-1); + } - default public boolean generateItemSn(Item item){ + default boolean generateItemId(Item item){ if (DatabaseManager.isRelational()){ throw new UnsupportedOperationException("generateItemSn() needs to be implemented for this database."); } diff --git a/src/main/java/kinoko/database/ItemAccessor.java b/src/main/java/kinoko/database/ItemAccessor.java new file mode 100644 index 00000000..f39f08b2 --- /dev/null +++ b/src/main/java/kinoko/database/ItemAccessor.java @@ -0,0 +1,13 @@ +package kinoko.database; + +import kinoko.world.item.Item; + + +public interface ItemAccessor { + default boolean saveItem(Item item){ + if (DatabaseManager.isRelational()){ + throw new UnsupportedOperationException("saveItem() needs to be implemented for this database."); + } + return true; + } +} diff --git a/src/main/java/kinoko/database/cassandra/CassandraConnector.java b/src/main/java/kinoko/database/cassandra/CassandraConnector.java index 9960261e..8aec501a 100644 --- a/src/main/java/kinoko/database/cassandra/CassandraConnector.java +++ b/src/main/java/kinoko/database/cassandra/CassandraConnector.java @@ -49,6 +49,8 @@ public final class CassandraConnector implements DatabaseConnector { private GuildAccessor guildAccessor; private GiftAccessor giftAccessor; private MemoAccessor memoAccessor; + private ItemAccessor itemAccessor; + public boolean createKeyspace(CqlSession session, String keyspace) { try { @@ -112,6 +114,11 @@ public MemoAccessor getMemoAccessor() { return memoAccessor; } + @Override + public ItemAccessor getItemAccessor() { + return itemAccessor; + } + @Override public void initialize() { // Create Config @@ -190,6 +197,7 @@ public void initialize() { guildAccessor = new CassandraGuildAccessor(cqlSession, DATABASE_KEYSPACE); giftAccessor = new CassandraGiftAccessor(cqlSession, DATABASE_KEYSPACE); memoAccessor = new CassandraMemoAccessor(cqlSession, DATABASE_KEYSPACE); + itemAccessor = new ItemAccessor() {}; // Not needed for Cassandra. } @Override diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java index 6b9c736d..91d32ec9 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresConnector.java +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -17,6 +17,7 @@ public final class PostgresConnector implements DatabaseConnector { private GuildAccessor guildAccessor; private GiftAccessor giftAccessor; private MemoAccessor memoAccessor; + private ItemAccessor itemAccessor; @Override public void initialize() { @@ -57,6 +58,7 @@ public void initialize() { guildAccessor = new PostgresGuildAccessor(dataSource); giftAccessor = new PostgresGiftAccessor(dataSource); memoAccessor = new PostgresMemoAccessor(dataSource); + itemAccessor = new PostgresItemAccessor(dataSource); @@ -81,4 +83,5 @@ public void shutdown() { @Override public GuildAccessor getGuildAccessor() { return guildAccessor; } @Override public GiftAccessor getGiftAccessor() { return giftAccessor; } @Override public MemoAccessor getMemoAccessor() { return memoAccessor; } + @Override public ItemAccessor getItemAccessor() {return itemAccessor; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java index 50c938be..8073b429 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java @@ -26,7 +26,7 @@ private Gift loadGift(ResultSet rs) throws SQLException { rs.getInt("sender_id"), rs.getString("sender_name"), rs.getString("sender_message"), - rs.getLong("pair_item_sn") + rs.getLong("pair_gift_sn") ); } @@ -35,7 +35,7 @@ public List getGiftsByCharacterId(int characterId) { List gifts = new ArrayList<>(); String sql = """ SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, - g.sender_name, g.sender_message, fi.pair_item_sn + g.sender_name, g.sender_message, g.pair_gift_sn FROM gift.gifts g JOIN item.full_item fi ON fi.item_sn = g.item_sn WHERE g.receiver_id = ? @@ -57,7 +57,7 @@ public List getGiftsByCharacterId(int characterId) { public Optional getGiftByItemSn(long itemSn) { String sql = """ SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, - g.sender_name, g.sender_message, fi.pair_item_sn FROM gift.gifts g + g.sender_name, g.sender_message, g.pair_gift_sn FROM gift.gifts g JOIN item.full_item fi ON fi.item_sn = g.item_sn WHERE g.item_sn = ? """; @@ -79,24 +79,29 @@ public Optional getGiftByItemSn(long itemSn) { @Override public boolean newGift(Gift gift, int receiverId) { String sql = """ - INSERT INTO gift.gifts (item_sn, receiver_id, commodity_id, sender_id, sender_name, sender_message) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO gift.gifts (item_sn, receiver_id, commodity_id, sender_id, sender_name, sender_message, pair_gift_sn) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (item_sn) DO NOTHING """; try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { - // We need a new item created. - Item basicItem = new Item(gift.getItemId(), (short) 1); - ItemDao.createNewItem(conn, basicItem); + long itemSN = gift.getGiftSn(); + if (itemSN <= 0) { + // We need a new item created. + Item basicItem = new Item(gift.getItemId(), (short) 1); + ItemDao.createNewItem(conn, basicItem); + itemSN = basicItem.getItemSn(); + } - stmt.setLong(1, basicItem.getItemSn()); // item_sn is now the primary key + stmt.setLong(1, itemSN); // item_sn is now the primary key stmt.setInt(2, receiverId); stmt.setInt(3, gift.getCommodityId()); stmt.setInt(4, gift.getSenderId()); stmt.setString(5, gift.getSenderName()); stmt.setString(6, gift.getSenderMessage()); + stmt.setLong(7, gift.getPairItemSn()); return stmt.executeUpdate() > 0; diff --git a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java index 16975ffd..00022cdc 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java @@ -14,12 +14,6 @@ public final class PostgresIdAccessor extends PostgresAccessor implements IdAcce public PostgresIdAccessor(HikariDataSource dataSource) { super(dataSource); - - try (Connection conn = getConnection()){ - ItemDao.cleanupInvalidItems(conn); - } catch (SQLException e) { - e.printStackTrace(); - } } private Optional getNextId(String type) { @@ -51,7 +45,7 @@ public synchronized Optional nextMemoId() { } @Override - public boolean generateItemSn(Item item) { + public boolean generateItemId(Item item) { if (!item.hasNoSN()){ return true; } diff --git a/src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java new file mode 100644 index 00000000..37f1365f --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java @@ -0,0 +1,42 @@ +package kinoko.database.postgresql; + +import com.zaxxer.hikari.HikariDataSource; +import kinoko.database.IdAccessor; +import kinoko.database.ItemAccessor; +import kinoko.database.postgresql.type.GuildDao; +import kinoko.database.postgresql.type.ItemDao; +import kinoko.world.item.Item; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Collections; +import java.util.Optional; + +public final class PostgresItemAccessor extends PostgresAccessor implements ItemAccessor { + public PostgresItemAccessor(HikariDataSource dataSource) { + super(dataSource); + + try (Connection conn = getConnection()){ + ItemDao.cleanupInvalidItems(conn); + } catch (SQLException e) { + e.printStackTrace(); + } + } + + /** + * Saves a single item to the database within a transaction. + * If the item does not already exist, it will be created. + * This method delegates to ItemDao.saveItemsBatch for consistency with batch operations. + * + * @param item the item to be saved or created + * @return true if the transaction completes successfully; false if the transaction fails + */ + @Override + public boolean saveItem(Item item) { + return withTransaction(conn -> { + // will also create any items that don't exist. + ItemDao.saveItemsBatch(conn, Collections.singletonList(item)); + return true; + }); + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java index 3b0e51c8..131092eb 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java @@ -2,11 +2,13 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.MemoAccessor; +import kinoko.database.postgresql.type.MemoDao; import kinoko.server.memo.Memo; import kinoko.server.memo.MemoType; import java.sql.*; import java.util.ArrayList; +import java.util.Collections; import java.util.List; public final class PostgresMemoAccessor extends PostgresAccessor implements MemoAccessor { @@ -15,80 +17,64 @@ public PostgresMemoAccessor(HikariDataSource dataSource) { super(dataSource); } + /** + * Retrieves all memos for a given character ID. + * + * @param characterId the ID of the character + * @return list of memos for the character + */ @Override public List getMemosByCharacterId(int characterId) { - List memos = new ArrayList<>(); - String sql = "SELECT id, memo_type, memo_content, sender_name, date_sent " + - "FROM memo.memo WHERE receiver_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - MemoType type = MemoType.getByValue(rs.getInt("memo_type")); - Memo memo = new Memo( - type != null ? type : MemoType.DEFAULT, - rs.getInt("id"), - rs.getString("sender_name"), - rs.getString("memo_content"), - rs.getTimestamp("date_sent").toInstant() - ); - memos.add(memo); - } - } - } catch (SQLException e) { + try (Connection conn = getConnection()) { + return MemoDao.getMemosByReceiverId(conn, characterId); + } + catch (SQLException e) { e.printStackTrace(); + return Collections.emptyList(); // fallback } - return memos; } + /** + * Checks if a character has any memos. + * + * @param characterId the ID of the character + * @return true if the character has at least one memo, false otherwise + */ @Override public boolean hasMemo(int characterId) { - String sql = "SELECT 1 FROM memo.memo WHERE receiver_id = ? LIMIT 1"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - try (ResultSet rs = stmt.executeQuery()) { - return rs.next(); - } + try (Connection conn = getConnection()) { + return MemoDao.hasMemo(conn, characterId); } catch (SQLException e) { e.printStackTrace(); + return false; // fallback } - return false; } + /** + * Creates a new memo for the given receiver. + * + * @param memo the memo to be created + * @param receiverId the ID of the receiver + * @return true if the memo was successfully created, false otherwise + */ @Override public boolean newMemo(Memo memo, int receiverId) { - // `id` is SERIAL, no need to provide it manually - String sql = "INSERT INTO memo.memo (receiver_id, memo_type, memo_content, sender_name, date_sent) " + - "VALUES (?, ?, ?, ?, ?)"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, receiverId); - stmt.setInt(2, memo.getType().getValue()); - stmt.setString(3, memo.getContent()); - stmt.setString(4, memo.getSender()); - stmt.setTimestamp(5, memo.getDateSent() != null ? Timestamp.from(memo.getDateSent()) : Timestamp.from(java.time.Instant.now())); - stmt.executeUpdate(); - return true; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return MemoDao.insertMemo(conn, memo, receiverId); + }); } + /** + * Deletes a memo by its ID and receiver ID. + * + * @param memoId the ID of the memo + * @param receiverId the ID of the receiver + * @return true if the memo was successfully deleted, false otherwise + */ @Override public boolean deleteMemo(int memoId, int receiverId) { - String sql = "DELETE FROM memo.memo WHERE id = ? AND receiver_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, memoId); - stmt.setInt(2, receiverId); - int affected = stmt.executeUpdate(); - return affected > 0; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return MemoDao.deleteMemo(conn, memoId, receiverId); + }); } } diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index 6a763f94..660a4991 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -370,6 +370,7 @@ CREATE TABLE IF NOT EXISTS gift.gifts ( sender_id INT, sender_name TEXT, sender_message TEXT, + pair_gift_sn BIGINT, PRIMARY KEY (item_sn) ); diff --git a/src/main/java/kinoko/database/postgresql/type/ItemDao.java b/src/main/java/kinoko/database/postgresql/type/ItemDao.java index 93cc497d..ff43c05c 100644 --- a/src/main/java/kinoko/database/postgresql/type/ItemDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ItemDao.java @@ -120,6 +120,10 @@ public static void saveItemsBatch(Connection conn, Collection items) throw stmtInsert.executeBatch(); // Update all EquipData EquipDataDao.saveEquipDataBatch(conn, items); + // Update all PetData + PetDataDao.upsertPetDataBatch(conn, items); + // Update all RingData + RingDataDao.upsertRingDataBatch(conn, items); } } diff --git a/src/main/java/kinoko/database/postgresql/type/MemoDao.java b/src/main/java/kinoko/database/postgresql/type/MemoDao.java new file mode 100644 index 00000000..2981917d --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/MemoDao.java @@ -0,0 +1,110 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.memo.Memo; +import kinoko.server.memo.MemoType; + +import java.sql.*; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + + +public final class MemoDao { + /** + * Inserts a new memo for a specific receiver. + * + * @param conn active SQL connection + * @param memo the memo object containing content and sender info + * @param receiverId the ID of the character receiving the memo + * @throws SQLException if any SQL error occurs + */ + public static boolean insertMemo(Connection conn, Memo memo, int receiverId) throws SQLException { + String sql = """ + INSERT INTO memo.memo (receiver_id, memo_type, memo_content, sender_name, date_sent) + VALUES (?, ?, ?, ?, ?) + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, receiverId); + stmt.setInt(2, memo.getType().getValue()); + stmt.setString(3, memo.getContent()); + stmt.setString(4, memo.getSender()); + stmt.setTimestamp(5, memo.getDateSent() != null ? Timestamp.from(memo.getDateSent()) : Timestamp.from(java.time.Instant.now())); + stmt.executeUpdate(); + } + return true; + } + + + /** + * Retrieves all memos for a given receiver ID. + * + * @param conn active SQL connection + * @param receiverId the character ID to fetch memos for + * @return list of memos belonging to the receiver + * @throws SQLException if any SQL error occurs + */ + public static List getMemosByReceiverId(Connection conn, int receiverId) throws SQLException { + List memos = new ArrayList<>(); + String sql = """ + SELECT id, memo_type, memo_content, sender_name, date_sent + FROM memo.memo + WHERE receiver_id = ? + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, receiverId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + MemoType type = MemoType.getByValue(rs.getInt("memo_type")); + Memo memo = new Memo( + type != null ? type : MemoType.DEFAULT, + rs.getInt("id"), + rs.getString("sender_name"), + rs.getString("memo_content"), + rs.getTimestamp("date_sent").toInstant() + ); + memos.add(memo); + } + } + } + return memos; + } + + /** + * Deletes a memo by its ID and receiver ID. + * + * @param conn active SQL connection + * @param memoId the memo ID to delete + * @param receiverId the receiver ID to verify ownership + * @return true if the memo was deleted; false otherwise + * @throws SQLException if any SQL error occurs + */ + public static boolean deleteMemo(Connection conn, int memoId, int receiverId) throws SQLException { + String sql = "DELETE FROM memo.memo WHERE id = ? AND receiver_id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, memoId); + stmt.setInt(2, receiverId); + return stmt.executeUpdate() > 0; + } + } + + /** + * Checks whether a receiver has at least one memo. + * + * @param conn active SQL connection + * @param receiverId the receiver ID to check + * @return true if at least one memo exists for the receiver; false otherwise + * @throws SQLException if any SQL error occurs + */ + public static boolean hasMemo(Connection conn, int receiverId) throws SQLException { + String sql = "SELECT 1 FROM memo.memo WHERE receiver_id = ? LIMIT 1"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, receiverId); + try (ResultSet rs = stmt.executeQuery()) { + return rs.next(); + } + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/PetDataDao.java b/src/main/java/kinoko/database/postgresql/type/PetDataDao.java index 506969a3..1ba14a33 100644 --- a/src/main/java/kinoko/database/postgresql/type/PetDataDao.java +++ b/src/main/java/kinoko/database/postgresql/type/PetDataDao.java @@ -1,10 +1,12 @@ package kinoko.database.postgresql.type; +import kinoko.world.item.Item; import kinoko.world.item.PetData; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; +import java.util.Collection; public final class PetDataDao { /** @@ -44,4 +46,55 @@ ON CONFLICT (item_sn) stmt.executeUpdate(); } } + + /** + * Batch upserts PetData for multiple items. + * For each item, if PetData exists, it is inserted or updated in the database. + * Existing rows are updated and missing rows are inserted. + * Uses a single PreparedStatement batch for efficiency. + * + * @param conn active SQL connection + * @param items collection of items that may contain PetData + * @throws SQLException if any SQL error occurs + */ + public static void upsertPetDataBatch(Connection conn, Collection items) throws SQLException { + if (items == null || items.isEmpty()) return; + + String sql = """ + INSERT INTO item.pet_data ( + item_sn, pet_name, level, fullness, tameness, pet_skill, pet_attribute, remain_life + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (item_sn) + DO UPDATE SET + pet_name = EXCLUDED.pet_name, + level = EXCLUDED.level, + fullness = EXCLUDED.fullness, + tameness = EXCLUDED.tameness, + pet_skill = EXCLUDED.pet_skill, + pet_attribute = EXCLUDED.pet_attribute, + remain_life = EXCLUDED.remain_life + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (Item item : items) { + PetData petData = item.getPetData(); + if (petData == null) continue; + + int idx = 1; + stmt.setLong(idx++, item.getItemSn()); + stmt.setString(idx++, petData.getPetName()); + stmt.setByte(idx++, petData.getLevel()); + stmt.setByte(idx++, petData.getFullness()); + stmt.setShort(idx++, petData.getTameness()); + stmt.setShort(idx++, petData.getPetSkill()); + stmt.setShort(idx++, petData.getPetAttribute()); + stmt.setInt(idx, petData.getRemainLife()); + + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + } diff --git a/src/main/java/kinoko/database/postgresql/type/RingDataDao.java b/src/main/java/kinoko/database/postgresql/type/RingDataDao.java index 3908dbd9..39e66445 100644 --- a/src/main/java/kinoko/database/postgresql/type/RingDataDao.java +++ b/src/main/java/kinoko/database/postgresql/type/RingDataDao.java @@ -1,10 +1,12 @@ package kinoko.database.postgresql.type; +import kinoko.world.item.Item; import kinoko.world.item.RingData; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; +import java.util.Collection; public final class RingDataDao { /** @@ -36,4 +38,47 @@ ON CONFLICT (item_sn) stmt.executeUpdate(); } } + + + /** + * Batch upserts RingData for multiple items. + * For each item, if RingData exists, it is inserted or updated in the database. + * Existing rows are updated and missing rows are inserted. + * Uses a single PreparedStatement batch for efficiency. + * + * @param conn active SQL connection + * @param items collection of items that may contain RingData + * @throws SQLException if any SQL error occurs + */ + public static void upsertRingDataBatch(Connection conn, Collection items) throws SQLException { + if (items == null || items.isEmpty()) return; + + String sql = """ + INSERT INTO item.ring_data ( + item_sn, pair_character_id, pair_character_name, pair_item_sn + ) + VALUES (?, ?, ?, ?) + ON CONFLICT (item_sn) + DO UPDATE SET + pair_character_id = EXCLUDED.pair_character_id, + pair_character_name = EXCLUDED.pair_character_name, + pair_item_sn = EXCLUDED.pair_item_sn + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (Item item : items) { + RingData ringData = item.getRingData(); + if (ringData == null) continue; + + int idx = 1; + stmt.setLong(idx++, item.getItemSn()); + stmt.setInt(idx++, ringData.getPairCharacterId()); + stmt.setString(idx++, ringData.getPairCharacterName()); + stmt.setLong(idx, ringData.getPairItemSn()); + + stmt.addBatch(); + } + stmt.executeBatch(); + } + } } diff --git a/src/main/java/kinoko/handler/stage/CashShopHandler.java b/src/main/java/kinoko/handler/stage/CashShopHandler.java index 1c57f3da..f8ca7bab 100644 --- a/src/main/java/kinoko/handler/stage/CashShopHandler.java +++ b/src/main/java/kinoko/handler/stage/CashShopHandler.java @@ -8,6 +8,7 @@ import kinoko.packet.world.WvsContext; import kinoko.provider.ItemProvider; import kinoko.provider.item.ItemInfo; +import kinoko.server.ServerConfig; import kinoko.server.cashshop.*; import kinoko.server.header.InHeader; import kinoko.server.memo.Memo; @@ -92,7 +93,7 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { } // Generate an item SN for the locker item - (Relational DBs) - Safe for NoSQL DBs. - if (!DatabaseManager.idAccessor().generateItemSn(cashItemInfo.getItem())){ + if (!DatabaseManager.idAccessor().generateItemId(cashItemInfo.getItem())){ user.write(CashShopPacket.fail(CashItemResultType.Buy_Failed, CashItemFailReason.Unknown)); // Due to an unknown error, the request for Cash Shop has failed. log.error("Could not generate SN for Item ID: {}", cashItemInfo.getItem().getItemId()); return; @@ -120,7 +121,7 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { final String giftMessage = inPacket.decodeString(); // Check secondary password - if (!DatabaseManager.accountAccessor().checkPassword(user.getAccount(), secondaryPassword, true)) { + if (ServerConfig.REQUIRE_SECONDARY_PASSWORD && !DatabaseManager.accountAccessor().checkPassword(user.getAccount(), secondaryPassword, true)) { user.write(CashShopPacket.fail(CashItemResultType.Gift_Failed, CashItemFailReason.InvalidBirthDate)); // Check your PIC password and\r\nplease try again return; } @@ -529,7 +530,7 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { final String giftMessage = inPacket.decodeString(); // Check secondary password - if (!DatabaseManager.accountAccessor().checkPassword(user.getAccount(), secondaryPassword, true)) { + if (ServerConfig.REQUIRE_SECONDARY_PASSWORD && !DatabaseManager.accountAccessor().checkPassword(user.getAccount(), secondaryPassword, true)) { user.write(CashShopPacket.fail(CashItemResultType.Couple_Failed, CashItemFailReason.InvalidBirthDate)); // Check your PIC password and\r\nplease try again return; } @@ -576,8 +577,8 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { } // Generate item SN for both rings - final long selfItemSn = user.getNextItemSn(); - final long pairItemSn = user.getNextItemSn(); + long selfItemSn = user.getNextItemSn(); + long pairItemSn = user.getNextItemSn(); // Create CashItemInfo and set RingData final Optional cashItemInfoResult = commodity.createCashItemInfo(selfItemSn, user.getAccountId(), user.getCharacterId(), ""); @@ -587,6 +588,20 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { return; } final CashItemInfo cashItemInfo = cashItemInfoResult.get(); + + if (DatabaseManager.isRelational()){ + // Generate SN for the pair item (Relational DBs) + selfItemSn = cashItemInfo.getItem().getItemSn(); + Item pairItem = new Item(cashItemInfo.getItem()); + pairItem.resetSN(false); + if (!DatabaseManager.idAccessor().generateItemId(pairItem)){ + user.write(CashShopPacket.fail(CashItemResultType.Couple_Failed, CashItemFailReason.Unknown)); // Due to an unknown error, the request for Cash Shop has failed. + log.error("Could not generate SN for Item ID: {}", cashItemInfo.getItem().getItemId()); + return; + } + pairItemSn = pairItem.getItemSn(); + } + final RingData selfRingData = new RingData(); selfRingData.setPairCharacterId(receiverCharacterId); selfRingData.setPairCharacterName(receiverCharacterName); diff --git a/src/main/java/kinoko/server/cashshop/Commodity.java b/src/main/java/kinoko/server/cashshop/Commodity.java index 41086408..14d42e48 100644 --- a/src/main/java/kinoko/server/cashshop/Commodity.java +++ b/src/main/java/kinoko/server/cashshop/Commodity.java @@ -1,5 +1,6 @@ package kinoko.server.cashshop; +import kinoko.database.DatabaseManager; import kinoko.provider.ItemProvider; import kinoko.provider.item.ItemInfo; import kinoko.provider.item.ItemInfoType; @@ -81,6 +82,10 @@ public Optional createCashItemInfo(long itemSn, int accountId, int item.setDateExpire(Instant.now().plus(getPeriod(), ChronoUnit.DAYS)); } } + + // Generate an item SN for the cash item - (Relational DBs) - Safe for NoSQL DBs. + DatabaseManager.idAccessor().generateItemId(item); + final CashItemInfo cashItemInfo = new CashItemInfo( item, getCommodityId(), From e305abf1a63f0a8a093b8c8b3d5296257b5be61f Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 14 Oct 2025 02:11:18 -0400 Subject: [PATCH 23/83] cleanup --- src/main/java/kinoko/database/IdAccessor.java | 2 +- .../cassandra/CassandraCharacterAccessor.java | 36 +- .../database/postgresql/PostgresAccessor.java | 1 - .../postgresql/PostgresCharacterAccessor.java | 1271 ++--------------- .../postgresql/PostgresConnector.java | 4 +- .../postgresql/PostgresFriendAccessor.java | 111 +- .../postgresql/PostgresGiftAccessor.java | 132 +- .../postgresql/PostgresGuildAccessor.java | 36 +- .../postgresql/PostgresIdAccessor.java | 41 +- .../postgresql/PostgresItemAccessor.java | 3 - .../postgresql/PostgresMemoAccessor.java | 2 - .../database/postgresql/type/AccountDao.java | 43 + .../postgresql/type/AvatarDataDao.java | 98 ++ .../postgresql/type/CharacterDataDao.java | 554 +++++++ .../postgresql/type/CharacterInfoDao.java | 39 + .../postgresql/type/CharacterRankDao.java | 78 + .../postgresql/type/ConfigManagerDao.java | 92 ++ .../database/postgresql/type/FriendDao.java | 124 ++ .../database/postgresql/type/GiftDao.java | 142 ++ .../database/postgresql/type/GuildDao.java | 67 +- .../postgresql/type/InventoryDao.java | 133 ++ .../postgresql/type/MapTransferInfoDao.java | 41 + .../database/postgresql/type/MemoDao.java | 2 - .../postgresql/type/MiniGameRecordDao.java | 51 + .../postgresql/type/PopularityRecordDao.java | 44 + .../postgresql/type/QuestManagerDao.java | 54 + .../postgresql/type/SkillManagerDao.java | 57 + .../database/postgresql/type/UserDao.java | 26 + .../postgresql/type/WildHunterInfoDao.java | 48 + .../database/types/CharacterRankData.java | 11 + .../kinoko/handler/stage/CashShopHandler.java | 4 +- .../kinoko/server/cashshop/Commodity.java | 2 +- 32 files changed, 1917 insertions(+), 1432 deletions(-) create mode 100644 src/main/java/kinoko/database/postgresql/type/AvatarDataDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/CharacterInfoDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/CharacterRankDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/FriendDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/GiftDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/MapTransferInfoDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/MiniGameRecordDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/PopularityRecordDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/QuestManagerDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/SkillManagerDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/UserDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java create mode 100644 src/main/java/kinoko/database/types/CharacterRankData.java diff --git a/src/main/java/kinoko/database/IdAccessor.java b/src/main/java/kinoko/database/IdAccessor.java index 9085e957..f99b7531 100644 --- a/src/main/java/kinoko/database/IdAccessor.java +++ b/src/main/java/kinoko/database/IdAccessor.java @@ -40,7 +40,7 @@ default Optional nextMemoId(){ return Optional.of(-1); } - default boolean generateItemId(Item item){ + default boolean generateItemSn(Item item){ if (DatabaseManager.isRelational()){ throw new UnsupportedOperationException("generateItemSn() needs to be implemented for this database."); } diff --git a/src/main/java/kinoko/database/cassandra/CassandraCharacterAccessor.java b/src/main/java/kinoko/database/cassandra/CassandraCharacterAccessor.java index cb27c8be..e7b1663d 100644 --- a/src/main/java/kinoko/database/cassandra/CassandraCharacterAccessor.java +++ b/src/main/java/kinoko/database/cassandra/CassandraCharacterAccessor.java @@ -7,6 +7,7 @@ import kinoko.database.CharacterAccessor; import kinoko.database.CharacterInfo; import kinoko.database.cassandra.table.CharacterTable; +import kinoko.database.types.CharacterRankData; import kinoko.server.rank.CharacterRank; import kinoko.world.item.Inventory; import kinoko.world.item.InventoryManager; @@ -297,12 +298,12 @@ public Map getCharacterRanks() { )); } // Sort and process rank data - rankDataList.sort(Comparator.comparing(CharacterRankData::getCumulativeExp).reversed().thenComparing(CharacterRankData::getMaxLevelTime)); + rankDataList.sort(Comparator.comparing(CharacterRankData::cumulativeExp).reversed().thenComparing(CharacterRankData::maxLevelTime)); final Map jobRanks = new HashMap<>(); // job rank counter final Map characterRanks = new HashMap<>(); // character id -> character rank for (CharacterRankData rankData : rankDataList) { - final int characterId = rankData.getCharacterId(); - final int jobCategory = rankData.getJobCategory(); + final int characterId = rankData.characterId(); + final int jobCategory = rankData.jobCategory(); final int worldRank = characterRanks.size() + 1; final int jobRank = jobRanks.getOrDefault(jobCategory, 0) + 1; jobRanks.put(jobCategory, jobRank); @@ -315,33 +316,4 @@ public Map getCharacterRanks() { return characterRanks; } - private static class CharacterRankData { - private final int characterId; - private final int jobCategory; - private final long cumulativeExp; - private final Instant maxLevelTime; - - private CharacterRankData(int characterId, int jobCategory, long cumulativeExp, Instant maxLevelTime) { - this.characterId = characterId; - this.jobCategory = jobCategory; - this.cumulativeExp = cumulativeExp; - this.maxLevelTime = maxLevelTime; - } - - public int getCharacterId() { - return characterId; - } - - public int getJobCategory() { - return jobCategory; - } - - public long getCumulativeExp() { - return cumulativeExp; - } - - public Instant getMaxLevelTime() { - return maxLevelTime != null ? maxLevelTime : Instant.MAX; - } - } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java index 8dbea1e2..2f7786c7 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java @@ -6,7 +6,6 @@ import java.sql.Connection; import java.sql.SQLException; -import java.util.function.Supplier; diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java index 863c1159..b43e3a99 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -3,1223 +3,172 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.CharacterAccessor; import kinoko.database.CharacterInfo; -import kinoko.database.postgresql.type.ExtendSpDao; -import kinoko.database.postgresql.type.InventoryDao; -import kinoko.database.postgresql.type.SkillMacrosDao; +import kinoko.database.postgresql.type.*; import kinoko.server.rank.CharacterRank; -import kinoko.world.GameConstants; -import kinoko.world.item.*; -import kinoko.world.job.JobConstants; -import kinoko.world.quest.QuestManager; -import kinoko.world.quest.QuestRecord; -import kinoko.world.quest.QuestState; -import kinoko.world.skill.SkillManager; -import kinoko.world.skill.SkillRecord; import kinoko.world.user.AvatarData; import kinoko.world.user.CharacterData; -import kinoko.world.user.data.*; -import kinoko.world.user.stat.CharacterStat; -import kinoko.world.user.stat.ExtendSp; -import org.postgresql.util.PGobject; -import java.io.*; import java.sql.*; -import java.time.Instant; import java.util.*; -import java.util.concurrent.atomic.AtomicInteger; - -public final class PostgresCharacterAccessor implements CharacterAccessor { - private final HikariDataSource dataSource; +public final class PostgresCharacterAccessor extends PostgresAccessor implements CharacterAccessor { public PostgresCharacterAccessor(HikariDataSource dataSource) { - this.dataSource = dataSource; - } - - private byte[] serialize(Object obj) throws IOException { - try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ObjectOutputStream oos = new ObjectOutputStream(baos)) { - oos.writeObject(obj); - return baos.toByteArray(); - } - } - - private T deserialize(byte[] bytes, Class clazz) throws IOException, ClassNotFoundException { - try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes); - ObjectInputStream ois = new ObjectInputStream(bais)) { - return clazz.cast(ois.readObject()); - } - } - - private CharacterData loadCharacterData(ResultSet rs) throws SQLException, IOException, ClassNotFoundException { - int accountId = rs.getInt("account_id"); - CharacterData cd = new CharacterData(accountId); - int characterID = rs.getInt("id"); - CharacterStat cs = new CharacterStat( - characterID, - rs.getString("name"), - rs.getByte("gender"), - rs.getByte("skin"), - rs.getInt("face"), - rs.getInt("hair"), - rs.getShort("level"), - rs.getShort("job"), - rs.getShort("sub_job"), - rs.getShort("base_str"), - rs.getShort("base_dex"), - rs.getShort("base_int"), - rs.getShort("base_luk"), - rs.getInt("hp"), - rs.getInt("max_hp"), - rs.getInt("mp"), - rs.getInt("max_mp"), - rs.getShort("ap"), - rs.getInt("exp"), - rs.getShort("pop"), - rs.getInt("pos_map"), - rs.getByte("portal"), - rs.getLong("pet_1"), - rs.getLong("pet_2"), - rs.getLong("pet_3") - ); - - cd.setCharacterStat(cs); - - try (Connection conn = dataSource.getConnection()) { - cs.setSp(ExtendSpDao.loadExtendSp(conn, characterID)); // TODO: put this in a proper connection block. - - - InventoryManager im = InventoryDao.loadInventoryManager(conn, characterID); - - cd.setInventoryManager(im); - im.setMoney(rs.getInt("money")); - - - Timestamp extSlotExpireTs = rs.getTimestamp("ext_slot_expire"); - im.setExtSlotExpire(extSlotExpireTs != null ? extSlotExpireTs.toInstant() : null); - cd.setInventoryManager(im); - - cd.setCoupleRecord(CoupleRecord.from( - im.getEquipped(), im.getEquipInventory() - )); - } - - - SkillManager sm = loadSkillCooltimesAndRecords(characterID); - - cd.setSkillManager(sm); - - QuestManager qm = loadQuestRecords(characterID); - - cd.setQuestManager(qm); - - ConfigManager cm = loadConfig(characterID); - cd.setConfigManager(cm); - - PopularityRecord pr = loadPopularityRecord(characterID); - cd.setPopularityRecord(pr); - - MiniGameRecord mgr = loadMiniGameRecord(characterID); - cd.setMiniGameRecord(mgr); - - MapTransferInfo mto = loadMapTransferInfo(characterID); - cd.setMapTransferInfo(mto); - - WildHunterInfo whi = loadWildHunterInfo(characterID); - cd.setWildHunterInfo(whi); - - cd.setItemSnCounter(new AtomicInteger(-1)); // Let Postgres handle item sn - - cd.setFriendMax(rs.getInt("friend_max")); - cd.setPartyId(rs.getInt("party_id")); - cd.setGuildId(rs.getInt("guild_id")); - - Timestamp creationTs = rs.getTimestamp("creation_time"); - cd.setCreationTime(creationTs != null ? creationTs.toInstant() : null); - Timestamp maxLevelTs = rs.getTimestamp("max_level_time"); - cd.setMaxLevelTime(maxLevelTs != null ? maxLevelTs.toInstant() : null); - return cd; - } - - private WildHunterInfo loadWildHunterInfo(int characterId) throws SQLException { - WildHunterInfo wh = new WildHunterInfo(); - - String sqlRiding = "SELECT riding_type FROM player.wild_hunter WHERE character_id = ?"; - String sqlMobs = "SELECT mob_id FROM player.wild_hunter_mob WHERE character_id = ?"; - - try (Connection conn = dataSource.getConnection()) { - // Load riding_type - try (PreparedStatement stmt = conn.prepareStatement(sqlRiding)) { - stmt.setInt(1, characterId); - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - wh.setRidingType(rs.getInt("riding_type")); - } - } - } - - // Load captured mobs - try (PreparedStatement stmt = conn.prepareStatement(sqlMobs)) { - stmt.setInt(1, characterId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - wh.getCapturedMobs().add(rs.getInt("mob_id")); - if (wh.getCapturedMobs().size() >= 5) break; // enforce max 5 - } - } - } - } - - return wh; - } - - - private MapTransferInfo loadMapTransferInfo(int characterId) throws SQLException { - MapTransferInfo mti = new MapTransferInfo(); - - String sql = "SELECT map_id, old_map_id FROM player.map_transfer WHERE character_id = ?"; - try (Connection con = dataSource.getConnection(); - PreparedStatement stmt = con.prepareStatement(sql)) { - stmt.setInt(1, characterId); - try (ResultSet mapRs = stmt.executeQuery()) { - if (mapRs.next()) { - int mapId = mapRs.getInt("map_id"); - int oldMapId = mapRs.getInt("old_map_id"); - - mti.getMapTransfer().add(mapId); // main list - mti.getMapTransferEx().add(oldMapId); // legacy/old map - } - } - } - - return mti; - } - - private MiniGameRecord loadMiniGameRecord(int characterId) throws SQLException { - MiniGameRecord record = new MiniGameRecord(); - - String sql = """ - SELECT omok_wins, omok_ties, omok_losses, omok_score, - memory_wins, memory_ties, memory_losses, memory_score - FROM player.minigame - WHERE character_id = ? - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)){ - stmt.setInt(1, characterId); - - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - record.setOmokGameWins(rs.getInt("omok_wins")); - record.setOmokGameTies(rs.getInt("omok_ties")); - record.setOmokGameLosses(rs.getInt("omok_losses")); - record.setOmokGameScore(rs.getDouble("omok_score")); - - record.setMemoryGameWins(rs.getInt("memory_wins")); - record.setMemoryGameTies(rs.getInt("memory_ties")); - record.setMemoryGameLosses(rs.getInt("memory_losses")); - record.setMemoryGameScore(rs.getDouble("memory_score")); - } - } - } - - return record; - } - - - private PopularityRecord loadPopularityRecord(int characterId) throws SQLException { - PopularityRecord pr = new PopularityRecord(); - - String sql = "SELECT other_character_id, timestamp FROM player.popularity WHERE character_id = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - int otherCharId = rs.getInt("other_character_id"); - Timestamp ts = rs.getTimestamp("timestamp"); - if (ts != null) { - pr.getRecords().put(otherCharId, ts.toInstant()); - } - } - } - } - - return pr; - } - - - private ConfigManager loadConfig(int characterId) throws SQLException { - String sql = """ - SELECT pet_consume_item, pet_consume_mp_item, pet_exception_list, - func_key_types, func_key_ids, quickslot_key_map - FROM player.config - WHERE character_id = ? - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - - try (ResultSet rs = stmt.executeQuery()) { - if (!rs.next()) { - return ConfigManager.defaults(); - } - - int petConsumeItem = rs.getInt("pet_consume_item"); - int petConsumeMpItem = rs.getInt("pet_consume_mp_item"); - - // --- Pet exception list --- - List petExceptionList; - var petExArr = rs.getArray("pet_exception_list"); - if (petExArr != null) { - Integer[] arr = (Integer[]) petExArr.getArray(); - petExceptionList = Arrays.asList(arr); - } else { - petExceptionList = List.of(); - } - - // --- Function key map --- - FuncKeyMapped[] funcKeyMap = new FuncKeyMapped[GameConstants.FUNC_KEY_MAP_SIZE]; - var funcTypeArr = rs.getArray("func_key_types"); - var funcIdArr = rs.getArray("func_key_ids"); - - if (funcTypeArr != null && funcIdArr != null) { - Short[] typeValues = (Short[]) funcTypeArr.getArray(); - Integer[] idValues = (Integer[]) funcIdArr.getArray(); - - for (int i = 0; i < funcKeyMap.length; i++) { - FuncKeyType type = FuncKeyType.getByValue(typeValues[i].byteValue()); - int id = idValues[i]; - funcKeyMap[i] = FuncKeyMapped.of(type, id); - } - } else { - funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); - } - - // --- Quickslot key map --- - int[] quickslotKeyMap; - var quickArr = rs.getArray("quickslot_key_map"); - if (quickArr != null) { - Integer[] arr = (Integer[]) quickArr.getArray(); - quickslotKeyMap = Arrays.stream(arr).mapToInt(Integer::intValue).toArray(); - } else { - quickslotKeyMap = Arrays.copyOf(GameConstants.DEFAULT_QUICKSLOT_KEY_MAP, GameConstants.QUICKSLOT_KEY_MAP_SIZE); - } - - ConfigManager cm = new ConfigManager(petConsumeItem, petConsumeMpItem, petExceptionList, funcKeyMap, quickslotKeyMap); - cm.updateMacroSysData(SkillMacrosDao.loadMacros(conn, characterId)); - return cm; - } - } - } - - - private QuestManager loadQuestRecords(int characterId) throws SQLException { - QuestManager qm = new QuestManager(); - String sql = "SELECT quest_id, status, progress, completed_time FROM player.quest_record WHERE character_id = ?"; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - int questId = rs.getInt("quest_id"); - int statusInt = rs.getInt("status"); - QuestState state = QuestState.getByValue(statusInt); // map int -> QuestState - String value = rs.getString("progress"); - Timestamp completedTs = rs.getTimestamp("completed_time"); - Instant completedTime = completedTs != null ? completedTs.toInstant() : null; - QuestRecord record = new QuestRecord(questId, state, value, completedTime); - qm.addQuestRecord(record); - } - } - } - - return qm; - } - - - private SkillManager loadSkillCooltimesAndRecords(int characterId) throws SQLException { - SkillManager sm = new SkillManager(); - - // Load skill cooldowns - String cooldownSql = "SELECT skill_id, cooldown_end FROM player.skill_cooltime WHERE character_id = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(cooldownSql)) { - stmt.setInt(1, characterId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - int skillId = rs.getInt("skill_id"); - Timestamp cooldownEnd = rs.getTimestamp("cooldown_end"); - if (cooldownEnd != null) { - sm.getSkillCooltimes().put(skillId, cooldownEnd.toInstant()); - } - } - } - } - - // Load skill records - String recordSql = "SELECT skill_id, level, master_level FROM player.skill_record WHERE character_id = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(recordSql)) { - stmt.setInt(1, characterId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - int skillId = rs.getInt("skill_id"); - int level = rs.getInt("level"); - int masterLevel = rs.getInt("master_level"); - SkillRecord record = new SkillRecord(skillId, level, masterLevel); - sm.addSkill(record); - } - } - } - - return sm; - } - - - - private String lowerName(String name) { - return name.toLowerCase(); + super(dataSource); } + /** + * Checks if a character name is available for creation. + * + * @param name the character name to check + * @return true if the name is not already taken, false otherwise + */ @Override public boolean checkCharacterNameAvailable(String name) { - String sql = "SELECT COUNT(*) > 0 AS exists FROM player.characters WHERE name ILIKE ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setString(1, name); // original name - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - boolean exists = rs.getBoolean("exists"); - return !exists; // available if it does NOT exist - } - } - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return CharacterDataDao.checkCharacterNameAvailable(conn, name); + }); } - + /** + * Retrieves a fully populated CharacterData by character ID. + * + * @param characterId the ID of the character + * @return an Optional containing the CharacterData if found, empty otherwise + */ @Override public Optional getCharacterById(int characterId) { - String sql = """ - SELECT c.*, s.*, m.guild_id, m.grade - FROM player.characters c - LEFT JOIN player.stats s ON c.id = s.character_id - LEFT JOIN guild.member m ON m.character_id = c.id - WHERE c.id = ? - """; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - ResultSet rs = stmt.executeQuery(); - if (rs.next()) { - return Optional.of(loadCharacterData(rs)); - } - } catch (Exception e) { + try (Connection conn = getConnection()) { + return CharacterDataDao.getCharacterById(conn, characterId); + } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } + /** + * Retrieves a fully populated CharacterData by character name (case-insensitive). + * + * @param name the name of the character + * @return an Optional containing the CharacterData if found, empty otherwise + */ @Override public Optional getCharacterByName(String name) { - String sql = "SELECT * FROM player.characters WHERE name ILIKE ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setString(1, lowerName(name)); - ResultSet rs = stmt.executeQuery(); - if (rs.next()) { - return Optional.of(loadCharacterData(rs)); - } - } catch (Exception e) { + try (Connection conn = getConnection()) { + return CharacterDataDao.getCharacterByName(conn, name); + } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } + /** + * Retrieves basic CharacterInfo by name (case-insensitive) without loading full data. + * + * @param name the character name + * @return an Optional containing CharacterInfo if found, empty otherwise + */ @Override public Optional getCharacterInfoByName(String name) { - String sql = "SELECT account_id, id, name FROM player.characters WHERE name ILIKE ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setString(1, lowerName(name)); - ResultSet rs = stmt.executeQuery(); - if (rs.next()) { - return Optional.of(new CharacterInfo( - rs.getInt("account_id"), - rs.getInt("id"), - rs.getString("name") - )); - } + try (Connection conn = getConnection()) { + return CharacterInfoDao.getCharacterInfoByName(conn, name); } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } + /** + * Retrieves the account ID associated with a character ID. + * + * @param characterId the character ID + * @return an Optional containing the account ID if found, empty otherwise + */ @Override public Optional getAccountIdByCharacterId(int characterId) { - String sql = "SELECT account_id FROM player.characters WHERE id = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - ResultSet rs = stmt.executeQuery(); - if (rs.next()) return Optional.of(rs.getInt("account_id")); + try (Connection conn = getConnection()) { + return AccountDao.getAccountIdByCharacterId(conn, characterId); } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } + /** + * Retrieves all AvatarData for a specific account ID. + * + * @param accountId the account ID + * @return a list of AvatarData objects; empty if none are found or an error occurs + */ @Override public List getAvatarDataByAccountId(int accountId) { - List list = new ArrayList<>(); - String sql = """ - SELECT c.id AS character_id, - c.name AS character_name, - c.money, - s.gender, - s.skin, - s.face, - s.hair, - s.level, - s.job, - s.sub_job, - s.base_str, - s.base_dex, - s.base_int, - s.base_luk, - s.hp, - s.max_hp, - s.mp, - s.max_mp, - s.ap, - s.exp, - s.pop, - s.pos_map, - s.portal, - s.pet_1, - s.pet_2, - s.pet_3 - FROM player.characters c - JOIN player.stats s ON c.id = s.character_id - WHERE c.account_id = ? - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, accountId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - CharacterStat cs = new CharacterStat( - rs.getInt("character_id"), - rs.getString("character_name"), - rs.getByte("gender"), - rs.getByte("skin"), - rs.getInt("face"), - rs.getInt("hair"), - rs.getShort("level"), - rs.getShort("job"), - rs.getShort("sub_job"), - rs.getShort("base_str"), - rs.getShort("base_dex"), - rs.getShort("base_int"), - rs.getShort("base_luk"), - rs.getInt("hp"), - rs.getInt("max_hp"), - rs.getInt("mp"), - rs.getInt("max_mp"), - rs.getShort("ap"), - rs.getInt("exp"), - rs.getShort("pop"), - rs.getInt("pos_map"), - rs.getByte("portal"), - rs.getLong("pet_1"), - rs.getLong("pet_2"), - rs.getLong("pet_3") - ); - - // For inventory, query normalized player.inventory table separately - Inventory equipped = loadEquippedInventory(cs.getId()); -// Inventory cash = loadCashInventory(cs.getId()); - - list.add(AvatarData.from(cs, equipped)); - } - } + try (Connection conn = getConnection()) { + return AvatarDataDao.getAvatarDataByAccountId(conn, accountId); } catch (SQLException e) { e.printStackTrace(); + return new ArrayList<>(); } - - return list; } - private Inventory loadCashInventory(int characterId) throws SQLException { - Inventory equipped = new Inventory(24, InventoryType.CASH); // default cash size - - String sql = """ - SELECT f.*, i.slot - FROM player.inventory i - JOIN item.full_item f ON i.item_sn = f.item_sn - WHERE i.character_id = ? AND i.inventory_type = ? - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - - PGobject enumValue = new PGobject(); - enumValue.setType("inventory_type_enum"); - enumValue.setValue(InventoryType.CASH.name()); - stmt.setObject(2, enumValue); - - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - long itemSn = rs.getLong("item_sn"); - int slot = rs.getInt("slot"); - int itemId = rs.getInt("item_id"); - short quantity = rs.getShort("quantity"); - short attribute = rs.getShort("attribute"); - String title = rs.getString("title"); - Timestamp dateExpireTs = rs.getTimestamp("date_expire"); - - // Build EquipData if applicable - EquipData equipData = null; - if (rs.getObject("inc_str") != null) { - equipData = new EquipData( - rs.getShort("inc_str"), - rs.getShort("inc_dex"), - rs.getShort("inc_int"), - rs.getShort("inc_luk"), - rs.getShort("inc_max_hp"), - rs.getShort("inc_max_mp"), - rs.getShort("inc_pad"), - rs.getShort("inc_mad"), - rs.getShort("inc_pdd"), - rs.getShort("inc_mdd"), - rs.getShort("inc_acc"), - rs.getShort("inc_eva"), - rs.getShort("inc_craft"), - rs.getShort("inc_speed"), - rs.getShort("inc_jump"), - rs.getByte("ruc"), - rs.getByte("cuc"), - rs.getInt("iuc"), - rs.getByte("chuc"), - rs.getByte("grade"), - rs.getShort("option_1"), - rs.getShort("option_2"), - rs.getShort("option_3"), - rs.getShort("socket_1"), - rs.getShort("socket_2"), - rs.getByte("level_up_type"), - rs.getByte("level"), - rs.getInt("exp"), - rs.getInt("durability") - ); - } - - // Build PetData if applicable - PetData petData = null; - if (rs.getObject("pet_name") != null) { - petData = new PetData( - rs.getString("pet_name"), - rs.getByte("pet_level"), - rs.getByte("fullness"), - rs.getShort("tameness"), - rs.getShort("pet_skill"), - rs.getShort("pet_attribute"), - rs.getInt("remain_life") - ); - } - - // Build RingData if applicable - RingData ringData = null; - if (rs.getObject("pair_character_id") != null) { - ringData = new RingData( - rs.getInt("pair_character_id"), - rs.getString("pair_character_name"), - rs.getLong("pair_item_sn") - ); - } - - Item item = new Item( - itemId, - quantity, - itemSn, - false, // cash flag, adjust if you have it - attribute, - title, - dateExpireTs != null ? dateExpireTs.toInstant() : null, - equipData, - petData, - ringData - ); - - equipped.putItem(slot, item); - } - } - } - - return equipped; - } - - - private Inventory loadEquippedInventory(int characterId) throws SQLException { - Inventory equipped = new Inventory(24, InventoryType.EQUIPPED); // default equipped size - - String sql = """ - SELECT f.*, i.slot - FROM player.inventory i - JOIN item.full_item f ON i.item_sn = f.item_sn - WHERE i.character_id = ? AND i.inventory_type = ? - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - - PGobject enumValue = new PGobject(); - enumValue.setType("inventory_type_enum"); - enumValue.setValue(InventoryType.EQUIPPED.name()); - stmt.setObject(2, enumValue); - - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - long itemSn = rs.getLong("item_sn"); - int slot = rs.getInt("slot"); - int itemId = rs.getInt("item_id"); - short quantity = rs.getShort("quantity"); - short attribute = rs.getShort("attribute"); - String title = rs.getString("title"); - Timestamp dateExpireTs = rs.getTimestamp("date_expire"); - - // Build EquipData if applicable - EquipData equipData = null; - if (rs.getObject("inc_str") != null) { - equipData = new EquipData( - rs.getShort("inc_str"), - rs.getShort("inc_dex"), - rs.getShort("inc_int"), - rs.getShort("inc_luk"), - rs.getShort("inc_max_hp"), - rs.getShort("inc_max_mp"), - rs.getShort("inc_pad"), - rs.getShort("inc_mad"), - rs.getShort("inc_pdd"), - rs.getShort("inc_mdd"), - rs.getShort("inc_acc"), - rs.getShort("inc_eva"), - rs.getShort("inc_craft"), - rs.getShort("inc_speed"), - rs.getShort("inc_jump"), - rs.getByte("ruc"), - rs.getByte("cuc"), - rs.getInt("iuc"), - rs.getByte("chuc"), - rs.getByte("grade"), - rs.getShort("option_1"), - rs.getShort("option_2"), - rs.getShort("option_3"), - rs.getShort("socket_1"), - rs.getShort("socket_2"), - rs.getByte("level_up_type"), - rs.getByte("level"), - rs.getInt("exp"), - rs.getInt("durability") - ); - } - - // Build PetData if applicable - PetData petData = null; - if (rs.getObject("pet_name") != null) { - petData = new PetData( - rs.getString("pet_name"), - rs.getByte("pet_level"), - rs.getByte("fullness"), - rs.getShort("tameness"), - rs.getShort("pet_skill"), - rs.getShort("pet_attribute"), - rs.getInt("remain_life") - ); - } - - // Build RingData if applicable - RingData ringData = null; - if (rs.getObject("pair_character_id") != null) { - ringData = new RingData( - rs.getInt("pair_character_id"), - rs.getString("pair_character_name"), - rs.getLong("pair_item_sn") - ); - } - - Item item = new Item( - itemId, - quantity, - itemSn, - false, // cash flag, adjust if you have it - attribute, - title, - dateExpireTs != null ? dateExpireTs.toInstant() : null, - equipData, - petData, - ringData - ); - - equipped.putItem(slot, item); - } - } - } - - return equipped; - } - - + /** + * Creates a new character in the database. + * + * Performs all dependent inserts (stats, inventory, skills, quests, config, popularity) + * using a single transaction. + * + * @param characterData the character data to insert + * @return true if creation was successful, false otherwise + */ @Override public synchronized boolean newCharacter(CharacterData characterData) { - if (!checkCharacterNameAvailable(characterData.getCharacterName())) return false; - - String sql = """ - INSERT INTO player.characters - (account_id, name, money, ext_slot_expire, friend_max, party_id, guild_id, creation_time, max_level_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id - """; - - Connection conn = null; - boolean success = false; - - try { - conn = dataSource.getConnection(); - conn.setAutoCommit(false); - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterData.getAccountId()); - stmt.setString(2, characterData.getCharacterName()); - stmt.setInt(3, characterData.getInventoryManager().getMoney()); - stmt.setTimestamp(4, characterData.getInventoryManager().getExtSlotExpire() != null ? - Timestamp.from(characterData.getInventoryManager().getExtSlotExpire()) : null); - stmt.setInt(5, characterData.getFriendMax()); - stmt.setInt(6, characterData.getPartyId()); - stmt.setInt(7, characterData.getGuildId()); - stmt.setTimestamp(8, characterData.getCreationTime() != null ? Timestamp.from(characterData.getCreationTime()) : null); - stmt.setTimestamp(9, characterData.getMaxLevelTime() != null ? Timestamp.from(characterData.getMaxLevelTime()) : null); - - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - int newCharacterId = rs.getInt(1); - characterData.getCharacterStat().setId(newCharacterId); - } else { - throw new SQLException("Failed to insert new character"); - } - } - } - - // Pass the same connection to all dependent methods - saveCharacterStats(conn, characterData); - saveCharacterInventory(conn, characterData); - saveCharacterSkills(conn, characterData); - saveCharacterQuests(conn, characterData); - saveCharacterConfig(conn, characterData); - saveCharacterPopularity(conn, characterData); - ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); - - conn.commit(); - success = true; - } catch (Exception e) { - e.printStackTrace(); - if (conn != null) { - try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } - } - } finally { - if (conn != null) { - try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ex) { ex.printStackTrace(); } - } - } - - return success; - } - - - private void saveCharacterConfig(Connection conn, CharacterData characterData) throws SQLException { - ConfigManager config = characterData.getConfigManager(); - - String sql = """ - INSERT INTO player.config - (character_id, pet_consume_item, pet_consume_mp_item, pet_exception_list, func_key_types, func_key_ids, quickslot_key_map) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (character_id) DO UPDATE - SET pet_consume_item = EXCLUDED.pet_consume_item, - pet_consume_mp_item = EXCLUDED.pet_consume_mp_item, - pet_exception_list = EXCLUDED.pet_exception_list, - func_key_types = EXCLUDED.func_key_types, - func_key_ids = EXCLUDED.func_key_ids, - quickslot_key_map = EXCLUDED.quickslot_key_map - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, config.getPetConsumeItem()); - stmt.setInt(3, config.getPetConsumeMpItem()); - - // pet_exception_list -> List -> Integer[] - List exceptionList = config.getPetExceptionList(); - Integer[] exceptionArray = exceptionList != null ? exceptionList.toArray(new Integer[0]) : new Integer[0]; - stmt.setArray(4, conn.createArrayOf("integer", exceptionArray)); - - // func_key_types & func_key_ids from FuncKeyMapped[] - FuncKeyMapped[] funcKeyMap = config.getFuncKeyMap(); - if (funcKeyMap == null) { - funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); - } - - Short[] funcTypes = Arrays.stream(funcKeyMap) - .map(f -> (short) f.getType().getValue()) - .toArray(Short[]::new); - Integer[] funcIds = Arrays.stream(funcKeyMap) - .map(FuncKeyMapped::getId) - .toArray(Integer[]::new); - - stmt.setArray(5, conn.createArrayOf("smallint", funcTypes)); // func_key_types - stmt.setArray(6, conn.createArrayOf("integer", funcIds)); // func_key_ids - - // quickslot_key_map -> int[] -> Integer[] - int[] quickslot = config.getQuickslotKeyMap(); - Integer[] quickslotKeys = quickslot != null - ? Arrays.stream(quickslot).boxed().toArray(Integer[]::new) - : new Integer[0]; - stmt.setArray(7, conn.createArrayOf("integer", quickslotKeys)); - - stmt.executeUpdate(); - } - // save skill macros - SkillMacrosDao.upsertMacros(conn, characterData.getCharacterId(), config.getMacroSysData()); - } - - private void saveCharacterPopularity(Connection conn, CharacterData characterData) throws SQLException { - String sql = """ - INSERT INTO player.popularity (character_id, other_character_id, timestamp) - VALUES (?, ?, ?) - ON CONFLICT (character_id, other_character_id) - DO UPDATE SET timestamp = EXCLUDED.timestamp - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - PopularityRecord pr = characterData.getPopularityRecord(); - int charId = characterData.getCharacterId(); - - for (var entry : pr.getRecords().entrySet()) { - stmt.setInt(1, charId); - stmt.setInt(2, entry.getKey()); - stmt.setTimestamp(3, Timestamp.from(entry.getValue())); - stmt.addBatch(); - } - - stmt.executeBatch(); - } - } - - - - private void saveCharacterSkills(Connection conn, CharacterData characterData) throws SQLException { - String skillRecordSql = """ - INSERT INTO player.skill_record (character_id, skill_id, level, master_level) - VALUES (?, ?, ?, ?) - ON CONFLICT (character_id, skill_id) - DO UPDATE SET level = EXCLUDED.level, master_level = EXCLUDED.master_level - """; - - try (PreparedStatement stmt = conn.prepareStatement(skillRecordSql)) { - for (SkillRecord sr : characterData.getSkillManager().getSkillRecords()) { - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, sr.getSkillId()); - stmt.setInt(3, sr.getSkillLevel()); - stmt.setInt(4, sr.getMasterLevel()); - stmt.addBatch(); - } - stmt.executeBatch(); - } - - // Save skill cooltimes - String skillCooltimeSql = """ - INSERT INTO player.skill_cooltime (character_id, skill_id, cooldown_end) - VALUES (?, ?, ?) - ON CONFLICT (character_id, skill_id) - DO UPDATE SET cooldown_end = EXCLUDED.cooldown_end - """; - - try (PreparedStatement stmt = conn.prepareStatement(skillCooltimeSql)) { - for (var entry : characterData.getSkillManager().getSkillCooltimes().entrySet()) { - int skillId = entry.getKey(); - Instant endTime = entry.getValue(); - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, skillId); - stmt.setTimestamp(3, Timestamp.from(endTime)); - stmt.addBatch(); - } - stmt.executeBatch(); - } - } - - private void saveCharacterQuests(Connection conn, CharacterData characterData) throws SQLException { - String sql = """ - INSERT INTO player.quest_record (character_id, quest_id, status, progress, completed_time) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT (character_id, quest_id) - DO UPDATE SET status = EXCLUDED.status, - progress = EXCLUDED.progress, - completed_time = EXCLUDED.completed_time - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - for (QuestRecord qr : characterData.getQuestManager().getQuestRecords()) { - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, qr.getQuestId()); - stmt.setInt(3, qr.getState().getValue()); - stmt.setString(4, qr.getValue()); - stmt.setTimestamp(5, qr.getCompletedTime() != null ? Timestamp.from(qr.getCompletedTime()) : null); - stmt.addBatch(); - } - stmt.executeBatch(); - } - } - + return withTransaction(conn -> { + return CharacterDataDao.newCharacter(conn, characterData); + }); + } + + /** + * Saves an existing character's data to the database. + * + * Updates the character row and all dependent tables within a transaction. + * + * @param characterData the character data to save + * @return true if the save was successful, false otherwise + */ @Override public boolean saveCharacter(CharacterData characterData) { - String sql = "UPDATE player.characters SET account_id=?, name=?, money=?, ext_slot_expire=?, " + - "friend_max=?, party_id=?, guild_id=?, creation_time=?, max_level_time=? " + - "WHERE id=?"; - Connection conn = null; - boolean previousAutoCommit = true; - - try { - conn = dataSource.getConnection(); - - // save previous auto-commit state - previousAutoCommit = conn.getAutoCommit(); - conn.setAutoCommit(false); - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterData.getAccountId()); - stmt.setString(2, characterData.getCharacterName()); - stmt.setInt(3, characterData.getInventoryManager().getMoney()); - stmt.setTimestamp(4, characterData.getInventoryManager().getExtSlotExpire() != null ? - Timestamp.from(characterData.getInventoryManager().getExtSlotExpire()) : null); - stmt.setInt(5, characterData.getFriendMax()); - stmt.setInt(6, characterData.getPartyId()); - stmt.setInt(7, characterData.getGuildId()); - stmt.setTimestamp(8, characterData.getCreationTime() != null ? Timestamp.from(characterData.getCreationTime()) : null); - stmt.setTimestamp(9, characterData.getMaxLevelTime() != null ? Timestamp.from(characterData.getMaxLevelTime()) : null); - stmt.setInt(10, characterData.getCharacterId()); - - int updated = stmt.executeUpdate(); - if (updated == 0) { - conn.rollback(); - return false; - } - } - - // Save dependent tables using the same connection - saveCharacterStats(conn, characterData); - saveCharacterInventory(conn, characterData); - saveCharacterSkills(conn, characterData); - saveCharacterQuests(conn, characterData); - saveCharacterConfig(conn, characterData); - saveCharacterPopularity(conn, characterData); - ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); - - conn.commit(); - return true; - } catch (Exception e) { - if (conn != null) { - try { - conn.rollback(); - } catch (SQLException rollbackEx) { - rollbackEx.printStackTrace(); - } - } - e.printStackTrace(); - return false; - } finally { - if (conn != null) { - try { - conn.setAutoCommit(previousAutoCommit); - conn.close(); - } catch (SQLException ex) { - ex.printStackTrace(); - } - } - } - } - - - private void saveCharacterStats(Connection conn, CharacterData characterData) throws SQLException { - String sql = """ - INSERT INTO player.stats ( - character_id, gender, skin, face, hair, level, job, sub_job, - base_str, base_dex, base_int, base_luk, hp, max_hp, mp, max_mp, - ap, exp, pop, pos_map, portal, pet_1, pet_2, pet_3 - ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? - ) - ON CONFLICT (character_id) DO UPDATE SET - gender = EXCLUDED.gender, - skin = EXCLUDED.skin, - face = EXCLUDED.face, - hair = EXCLUDED.hair, - level = EXCLUDED.level, - job = EXCLUDED.job, - sub_job = EXCLUDED.sub_job, - base_str = EXCLUDED.base_str, - base_dex = EXCLUDED.base_dex, - base_int = EXCLUDED.base_int, - base_luk = EXCLUDED.base_luk, - hp = EXCLUDED.hp, - max_hp = EXCLUDED.max_hp, - mp = EXCLUDED.mp, - max_mp = EXCLUDED.max_mp, - ap = EXCLUDED.ap, - exp = EXCLUDED.exp, - pop = EXCLUDED.pop, - pos_map = EXCLUDED.pos_map, - portal = EXCLUDED.portal, - pet_1 = EXCLUDED.pet_1, - pet_2 = EXCLUDED.pet_2, - pet_3 = EXCLUDED.pet_3 - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - CharacterStat cs = characterData.getCharacterStat(); - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, cs.getGender()); - stmt.setInt(3, cs.getSkin()); - stmt.setInt(4, cs.getFace()); - stmt.setInt(5, cs.getHair()); - stmt.setInt(6, cs.getLevel()); - stmt.setInt(7, cs.getJob()); - stmt.setInt(8, cs.getSubJob()); - stmt.setInt(9, cs.getBaseStr()); - stmt.setInt(10, cs.getBaseDex()); - stmt.setInt(11, cs.getBaseInt()); - stmt.setInt(12, cs.getBaseLuk()); - stmt.setInt(13, cs.getHp()); - stmt.setInt(14, cs.getMaxHp()); - stmt.setInt(15, cs.getMp()); - stmt.setInt(16, cs.getMaxMp()); - stmt.setInt(17, cs.getAp()); - stmt.setInt(18, cs.getExp()); - stmt.setInt(19, cs.getPop()); - stmt.setInt(20, cs.getPosMap()); - stmt.setInt(21, cs.getPortal()); - stmt.setLong(22, cs.getPetSn1()); - stmt.setLong(23, cs.getPetSn2()); - stmt.setLong(24, cs.getPetSn3()); - - stmt.executeUpdate(); - } - } - - - private void saveCharacterInventory(Connection conn, CharacterData characterData) throws SQLException { - InventoryDao.saveCharacter(conn, characterData); - } - + return withTransaction(conn -> { + return CharacterDataDao.saveCharacter(conn, characterData); + }); + } + + /** + * Deletes a character associated with the given account ID. + * + * @param accountId the account ID + * @param characterId the character ID to delete + * @return true if the character was deleted, false otherwise + */ @Override public boolean deleteCharacter(int accountId, int characterId) { - String sql = "DELETE FROM player.characters WHERE id=? AND account_id=?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - stmt.setInt(2, accountId); - return stmt.executeUpdate() > 0; - } catch (SQLException e) { - e.printStackTrace(); - return false; - } - } - + return withTransaction(conn -> { + return UserDao.deleteCharacter(conn, accountId, characterId); + }); + } + + /** + * Retrieves the world and job-specific ranks of all characters. + * + * Characters with admin or manager jobs are excluded. Ranking is + * based on cumulative EXP, and ties are broken by earliest max level time. + * + * @return a map from character ID to CharacterRank; empty map if an error occurs + */ @Override public Map getCharacterRanks() { - Map ranks = new HashMap<>(); - String sql = """ - SELECT c.id, c.max_level_time, s.job, s.exp - FROM player.characters c - JOIN player.stats s ON c.id = s.character_id - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - ResultSet rs = stmt.executeQuery(); - List rankDataList = new ArrayList<>(); - - while (rs.next()) { - int characterId = rs.getInt("id"); - int jobId = rs.getInt("job"); - long cumulativeExp = rs.getLong("exp"); - Timestamp ts = rs.getTimestamp("max_level_time"); - - // skip admin/manager characters - if (JobConstants.isAdminJob(jobId) || JobConstants.isManagerJob(jobId)) { - continue; - } - - rankDataList.add(new CharacterRankData( - characterId, - JobConstants.getJobCategory(jobId), - cumulativeExp, - ts != null ? ts.toInstant() : Instant.MAX - )); - } - - // Sort by EXP (descending) and then by earliest max level time - rankDataList.sort( - Comparator.comparingLong(CharacterRankData::getCumulativeExp).reversed() - .thenComparing(CharacterRankData::getMaxLevelTime) - ); - - // Compute world rank and job rank - Map jobRanks = new HashMap<>(); - for (CharacterRankData data : rankDataList) { - int worldRank = ranks.size() + 1; - int jobRank = jobRanks.getOrDefault(data.getJobCategory(), 0) + 1; - jobRanks.put(data.getJobCategory(), jobRank); - - ranks.put(data.getCharacterId(), new CharacterRank( - data.getCharacterId(), - worldRank, - jobRank - )); - } - - } catch (Exception e) { + try (Connection conn = getConnection()) { + return CharacterRankDao.getCharacterRanks(conn); + } catch (SQLException e) { e.printStackTrace(); + return new HashMap<>(); } - - return ranks; - } - - - private static class CharacterRankData { - private final int characterId; - private final int jobCategory; - private final long cumulativeExp; - private final Instant maxLevelTime; - - private CharacterRankData(int characterId, int jobCategory, long cumulativeExp, Instant maxLevelTime) { - this.characterId = characterId; - this.jobCategory = jobCategory; - this.cumulativeExp = cumulativeExp; - this.maxLevelTime = maxLevelTime; - } - - public int getCharacterId() { return characterId; } - public int getJobCategory() { return jobCategory; } - public long getCumulativeExp() { return cumulativeExp; } - public Instant getMaxLevelTime() { return maxLevelTime != null ? maxLevelTime : Instant.MAX; } } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java index 91d32ec9..7daa02cf 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresConnector.java +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -2,7 +2,6 @@ import kinoko.database.*; -import java.sql.Connection; import java.util.TimeZone; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; @@ -38,10 +37,11 @@ public void initialize() { config.setConnectionTimeout(5000); // 5s config.setIdleTimeout(60000); // 60s config.setMaxLifetime(1800000); // 30min - config.setLeakDetectionThreshold(5000L); + config.setLeakDetectionThreshold(5000L); // Connection Leak detection. dataSource = new HikariDataSource(config); + // manually run init file. // Path initPath = Path.of("src/main/java/kinoko/database/postgresql/setup/init.sql"); // if (Files.exists(initPath)) { // String sql = Files.readString(initPath); diff --git a/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java index 88d3cb7f..b3236e3a 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java @@ -2,100 +2,75 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.FriendAccessor; +import kinoko.database.postgresql.type.FriendDao; import kinoko.world.user.friend.Friend; -import kinoko.world.user.friend.FriendStatus; import java.sql.*; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; -public final class PostgresFriendAccessor implements FriendAccessor { - private final HikariDataSource dataSource; - +public final class PostgresFriendAccessor extends PostgresAccessor implements FriendAccessor { public PostgresFriendAccessor(HikariDataSource dataSource) { - this.dataSource = dataSource; - } - - private Friend loadFriend(ResultSet rs) throws SQLException { - int characterId = rs.getInt("character_id"); - int friendId = rs.getInt("friend_id"); - String friendName = rs.getString("friend_name"); - String friendGroup = rs.getString("friend_group"); - FriendStatus status = FriendStatus.getByValue(rs.getInt("friend_status")); - return new Friend(characterId, friendId, friendName, friendGroup, status); + super(dataSource); } + /** + * Retrieves all friends for a given character ID. + * + * @param characterId the ID of the character + * @return a list of Friend objects; empty list if none found or on error + */ @Override public List getFriendsByCharacterId(int characterId) { - List friends = new ArrayList<>(); - String sql = "SELECT * FROM friend.friends WHERE character_id = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - ResultSet rs = stmt.executeQuery(); - while (rs.next()) { - friends.add(loadFriend(rs)); - } + try (Connection conn = getConnection()) { + return FriendDao.getFriendsByCharacterId(conn, characterId); } catch (SQLException e) { e.printStackTrace(); + return Collections.emptyList(); } - return friends; } + /** + * Retrieves all friends where the given ID appears as the friend. + * + * @param friendId the ID of the friend + * @return a list of Friend objects; empty list if none found or on error + */ @Override public List getFriendsByFriendId(int friendId) { - List friends = new ArrayList<>(); - String sql = "SELECT * FROM friend.friends WHERE friend_id = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, friendId); - ResultSet rs = stmt.executeQuery(); - while (rs.next()) { - friends.add(loadFriend(rs)); - } + try (Connection conn = getConnection()) { + return FriendDao.getFriendsByFriendId(conn, friendId); } catch (SQLException e) { e.printStackTrace(); + return Collections.emptyList(); } - return friends; } - @Override + /** + * Saves a friend record to the database. + * If 'force' is true, existing records will be updated. + * + * @param friend the Friend object to save + * @param force whether to overwrite existing records + * @return true if the save operation succeeded, false otherwise + */ public boolean saveFriend(Friend friend, boolean force) { - String sql; - if (force) { - sql = "INSERT INTO friend.friends (character_id, friend_id, friend_name, friend_group, friend_status) " + - "VALUES (?, ?, ?, ?, ?) " + - "ON CONFLICT (character_id, friend_id) DO UPDATE SET friend_name = EXCLUDED.friend_name, " + - "friend_group = EXCLUDED.friend_group, friend_status = EXCLUDED.friend_status"; - } else { - sql = "INSERT INTO friend.friends (character_id, friend_id, friend_name, friend_group, friend_status) " + - "VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING"; - } - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, friend.getCharacterId()); - stmt.setInt(2, friend.getFriendId()); - stmt.setString(3, friend.getFriendName()); - stmt.setString(4, friend.getFriendGroup()); - stmt.setInt(5, friend.getStatus().getValue()); - return stmt.executeUpdate() > 0; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return FriendDao.saveFriend(conn, friend, force); + }); } + /** + * Deletes a friend record from the database. + * + * @param characterId the ID of the character + * @param friendId the ID of the friend to delete + * @return true if the deletion succeeded, false otherwise + */ @Override public boolean deleteFriend(int characterId, int friendId) { - String sql = "DELETE FROM friend.friends WHERE character_id = ? AND friend_id = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - stmt.setInt(2, friendId); - return stmt.executeUpdate() > 0; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return FriendDao.deleteFriend(conn, characterId, friendId); + }); } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java index 8073b429..d40d4c0f 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java @@ -2,125 +2,77 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.GiftAccessor; -import kinoko.database.postgresql.type.ItemDao; +import kinoko.database.postgresql.type.GiftDao; import kinoko.server.cashshop.Gift; -import kinoko.world.item.Item; import java.sql.*; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Optional; -public final class PostgresGiftAccessor implements GiftAccessor { - private final HikariDataSource dataSource; - +public final class PostgresGiftAccessor extends PostgresAccessor implements GiftAccessor { public PostgresGiftAccessor(HikariDataSource dataSource) { - this.dataSource = dataSource; + super(dataSource); } - private Gift loadGift(ResultSet rs) throws SQLException { - return new Gift( - rs.getLong("item_sn"), - rs.getInt("item_id"), - rs.getInt("commodity_id"), - rs.getInt("sender_id"), - rs.getString("sender_name"), - rs.getString("sender_message"), - rs.getLong("pair_gift_sn") - ); - } + /** + * Retrieves all gifts received by a specific character. + * + * @param characterId the ID of the character + * @return a list of gifts for the given character, or an empty list if none exist or an error occurs + */ @Override public List getGiftsByCharacterId(int characterId) { - List gifts = new ArrayList<>(); - String sql = """ - SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, - g.sender_name, g.sender_message, g.pair_gift_sn - FROM gift.gifts g - JOIN item.full_item fi ON fi.item_sn = g.item_sn - WHERE g.receiver_id = ? - """; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - ResultSet rs = stmt.executeQuery(); - while (rs.next()) { - gifts.add(loadGift(rs)); - } + try (Connection conn = getConnection()) { + return GiftDao.getGiftsByReceiverId(conn, characterId); } catch (SQLException e) { e.printStackTrace(); + return Collections.emptyList(); } - return gifts; } + /** + * Retrieves a gift by its item serial number. + * + * @param itemSn the item serial number of the gift + * @return an Optional containing the gift if found, or Optional.empty() if not found or an error occurs + */ @Override public Optional getGiftByItemSn(long itemSn) { - String sql = """ - SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, - g.sender_name, g.sender_message, g.pair_gift_sn FROM gift.gifts g - JOIN item.full_item fi ON fi.item_sn = g.item_sn - WHERE g.item_sn = ? - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setLong(1, itemSn); - ResultSet rs = stmt.executeQuery(); - if (rs.next()) { - return Optional.of(loadGift(rs)); - } + try (Connection conn = getConnection()) { + return GiftDao.getGiftByItemSn(conn, itemSn); } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } - + /** + * Creates a new gift for a specified receiver. + * If the gift requires a new item to be created, it will be handled within the same transaction. + * + * @param gift the gift to be created + * @param receiverId the ID of the receiver + * @return true if the gift was successfully created, false otherwise + */ @Override public boolean newGift(Gift gift, int receiverId) { - String sql = """ - INSERT INTO gift.gifts (item_sn, receiver_id, commodity_id, sender_id, sender_name, sender_message, pair_gift_sn) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (item_sn) DO NOTHING - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - - long itemSN = gift.getGiftSn(); - if (itemSN <= 0) { - // We need a new item created. - Item basicItem = new Item(gift.getItemId(), (short) 1); - ItemDao.createNewItem(conn, basicItem); - itemSN = basicItem.getItemSn(); - } - - stmt.setLong(1, itemSN); // item_sn is now the primary key - stmt.setInt(2, receiverId); - stmt.setInt(3, gift.getCommodityId()); - stmt.setInt(4, gift.getSenderId()); - stmt.setString(5, gift.getSenderName()); - stmt.setString(6, gift.getSenderMessage()); - stmt.setLong(7, gift.getPairItemSn()); - - return stmt.executeUpdate() > 0; - - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return GiftDao.insertGift(conn, gift, receiverId); + }); } + /** + * Deletes a specific gift from the database. + * + * @param gift the gift to delete + * @return true if the gift was successfully deleted, false otherwise + */ @Override public boolean deleteGift(Gift gift) { - String sql = "DELETE FROM gift.gifts WHERE item_sn = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setLong(1, gift.getGiftSn()); - return stmt.executeUpdate() > 0; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return GiftDao.deleteGift(conn, gift); + }); } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java index f9029fb2..a67a5a73 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -2,11 +2,8 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.GuildAccessor; -import kinoko.database.postgresql.type.BoardEntryDao; -import kinoko.database.postgresql.type.BoardNoticeDao; import kinoko.database.postgresql.type.GuildDao; import kinoko.server.guild.Guild; -import kinoko.server.guild.GuildBoardEntry; import kinoko.server.guild.GuildRanking; import java.sql.*; @@ -31,19 +28,12 @@ public PostgresGuildAccessor(HikariDataSource dataSource) { */ @Override public Optional getGuildById(int guildId) { - String sql = "SELECT * FROM guild.guilds WHERE id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, guildId); - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - return Optional.of(GuildDao.loadGuild(conn, rs)); - } - } + try (Connection conn = getConnection()) { + return GuildDao.getGuildById(conn, guildId); } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } /** @@ -113,6 +103,7 @@ public boolean deleteGuild(int guildId) { }); } + /** * Retrieves a list of guild rankings from the database. * @@ -126,24 +117,11 @@ public boolean deleteGuild(int guildId) { */ @Override public List getGuildRankings() { - List rankings = new ArrayList<>(); - String sql = "SELECT name, points, mark, mark_color, mark_bg, mark_bg_color FROM guild.guilds ORDER BY points DESC"; - try (Connection conn = getConnection(); - Statement stmt = conn.createStatement(); - ResultSet rs = stmt.executeQuery(sql)) { - while (rs.next()) { - rankings.add(new GuildRanking( - rs.getString("name"), - rs.getInt("points"), - rs.getShort("mark"), - rs.getByte("mark_color"), - rs.getShort("mark_bg"), - rs.getByte("mark_bg_color") - )); - } + try (Connection conn = getConnection()) { + return GuildDao.getGuildRankings(conn); } catch (SQLException e) { e.printStackTrace(); + return Collections.emptyList(); } - return rankings; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java index 00022cdc..c8d4e245 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java @@ -2,13 +2,10 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.IdAccessor; -import kinoko.database.postgresql.type.AccountDao; import kinoko.database.postgresql.type.ItemDao; import kinoko.world.item.Item; -import java.sql.Connection; import java.sql.SQLException; -import java.util.Optional; public final class PostgresIdAccessor extends PostgresAccessor implements IdAccessor { @@ -16,36 +13,16 @@ public PostgresIdAccessor(HikariDataSource dataSource) { super(dataSource); } - private Optional getNextId(String type) { - return Optional.of(-1); // Postgres auto-generates IDs, so we return -1 as a placeholder - } - @Override - public synchronized Optional nextAccountId() { - return getNextId("account_id"); - } - - @Override - public synchronized Optional nextCharacterId() { - return getNextId("character_id"); - } - - @Override - public synchronized Optional nextPartyId() { - return getNextId("party_id"); - } - - @Override - public synchronized Optional nextGuildId() { - return getNextId("guild_id"); - } - - @Override - public synchronized Optional nextMemoId() { - return getNextId("memo_id"); - } - + /** + * Generates a new item SN for the given item if it does not already have one. + * If the item already has a serial number, this method returns true immediately. + * Otherwise, it creates a new item entry in the database and assigns the generated ID. + * + * @param item the item for which to generate an ID + * @return true if the item already had an ID or was successfully assigned one, false if an error occurred + */ @Override - public boolean generateItemId(Item item) { + public boolean generateItemSn(Item item) { if (!item.hasNoSN()){ return true; } diff --git a/src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java index 37f1365f..0155aab7 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java @@ -1,16 +1,13 @@ package kinoko.database.postgresql; import com.zaxxer.hikari.HikariDataSource; -import kinoko.database.IdAccessor; import kinoko.database.ItemAccessor; -import kinoko.database.postgresql.type.GuildDao; import kinoko.database.postgresql.type.ItemDao; import kinoko.world.item.Item; import java.sql.Connection; import java.sql.SQLException; import java.util.Collections; -import java.util.Optional; public final class PostgresItemAccessor extends PostgresAccessor implements ItemAccessor { public PostgresItemAccessor(HikariDataSource dataSource) { diff --git a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java index 131092eb..29a2a25e 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java @@ -4,10 +4,8 @@ import kinoko.database.MemoAccessor; import kinoko.database.postgresql.type.MemoDao; import kinoko.server.memo.Memo; -import kinoko.server.memo.MemoType; import java.sql.*; -import java.util.ArrayList; import java.util.Collections; import java.util.List; diff --git a/src/main/java/kinoko/database/postgresql/type/AccountDao.java b/src/main/java/kinoko/database/postgresql/type/AccountDao.java index 714eda69..f358c7fe 100644 --- a/src/main/java/kinoko/database/postgresql/type/AccountDao.java +++ b/src/main/java/kinoko/database/postgresql/type/AccountDao.java @@ -6,6 +6,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.Optional; public class AccountDao { @@ -41,6 +42,16 @@ public static void save(Connection conn, Account account) throws SQLException { LockerDao.save(conn, accountId, account.getLocker()); } + /** + * Retrieves the hashed password (either primary or secondary) for the given account from the database. + * Determines which password column to query based on the {@code secondary} flag. + * + * @param conn the active database connection used to execute the query + * @param account the account whose password is being retrieved + * @param secondary true to fetch the secondary password, false to fetch the primary password + * @return the hashed password string if found, or null if no password exists for the account + * @throws SQLException if a database access error occurs + */ public static String getHashedPassword(Connection conn, Account account, boolean secondary) throws SQLException { String column = secondary ? "secondary_password" : "password"; String sql = "SELECT " + column + " FROM account.accounts WHERE id = ?"; @@ -55,6 +66,16 @@ public static String getHashedPassword(Connection conn, Account account, boolean return null; } + /** + * Loads an Account object and its related data from the database using the provided ResultSet and connection. + * Builds the Account instance from base fields such as ID, username, and secondary password, + * then loads associated data including the trunk, locker, and wishlist within the same connection. + * + * @param conn the active database connection used to load related account data + * @param rs the ResultSet containing the account information (positioned at a valid row) + * @return a fully initialized Account object + * @throws SQLException if a database access error occurs + */ public static Account load(Connection conn, ResultSet rs) throws SQLException { final int accountId = rs.getInt("id"); final String username = rs.getString("username"); @@ -73,4 +94,26 @@ public static Account load(Connection conn, ResultSet rs) throws SQLException { return account; } + + + /** + * Retrieves the account ID for the given character ID. + * + * @param conn the database connection to use + * @param characterId the ID of the character + * @return Optional containing the account ID if found, otherwise empty + * @throws SQLException if a database access error occurs + */ + public static Optional getAccountIdByCharacterId(Connection conn, int characterId) throws SQLException { + String sql = "SELECT account_id FROM player.characters WHERE id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(rs.getInt("account_id")); + } + } + } + return Optional.empty(); + } } diff --git a/src/main/java/kinoko/database/postgresql/type/AvatarDataDao.java b/src/main/java/kinoko/database/postgresql/type/AvatarDataDao.java new file mode 100644 index 00000000..b5de50b7 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/AvatarDataDao.java @@ -0,0 +1,98 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.item.Inventory; +import kinoko.world.user.AvatarData; +import kinoko.world.user.stat.CharacterStat; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +public final class AvatarDataDao { + + /** + * Retrieves all AvatarData for characters belonging to a given account. + * + * This includes character stats and equipped inventory. + * + * @param conn the database connection to use + * @param accountId the ID of the account + * @return a list of AvatarData for each character under the account + * @throws SQLException if a database access error occurs + */ + public static List getAvatarDataByAccountId(Connection conn, int accountId) throws SQLException { + List list = new ArrayList<>(); + String sql = """ + SELECT c.id AS character_id, + c.name AS character_name, + c.money, + s.gender, + s.skin, + s.face, + s.hair, + s.level, + s.job, + s.sub_job, + s.base_str, + s.base_dex, + s.base_int, + s.base_luk, + s.hp, + s.max_hp, + s.mp, + s.max_mp, + s.ap, + s.exp, + s.pop, + s.pos_map, + s.portal, + s.pet_1, + s.pet_2, + s.pet_3 + FROM player.characters c + JOIN player.stats s ON c.id = s.character_id + WHERE c.account_id = ? + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + CharacterStat cs = new CharacterStat( + rs.getInt("character_id"), + rs.getString("character_name"), + rs.getByte("gender"), + rs.getByte("skin"), + rs.getInt("face"), + rs.getInt("hair"), + rs.getShort("level"), + rs.getShort("job"), + rs.getShort("sub_job"), + rs.getShort("base_str"), + rs.getShort("base_dex"), + rs.getShort("base_int"), + rs.getShort("base_luk"), + rs.getInt("hp"), + rs.getInt("max_hp"), + rs.getInt("mp"), + rs.getInt("max_mp"), + rs.getShort("ap"), + rs.getInt("exp"), + rs.getShort("pop"), + rs.getInt("pos_map"), + rs.getByte("portal"), + rs.getLong("pet_1"), + rs.getLong("pet_2"), + rs.getLong("pet_3") + ); + + Inventory equipped = InventoryDao.loadEquippedInventory(conn, cs.getId()); + + list.add(AvatarData.from(cs, equipped)); + } + } + } + + return list; + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java b/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java new file mode 100644 index 00000000..fa88c4b3 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java @@ -0,0 +1,554 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.GameConstants; +import kinoko.world.item.InventoryManager; +import kinoko.world.quest.QuestRecord; +import kinoko.world.skill.SkillManager; +import kinoko.world.quest.QuestManager; +import kinoko.world.skill.SkillRecord; +import kinoko.world.user.CharacterData; +import kinoko.world.user.data.*; +import kinoko.world.user.stat.CharacterStat; + +import java.sql.*; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +public class CharacterDataDao { + /** + * Constructs and loads a CharacterData object from the given ResultSet and database connection. + * + * This method initializes a CharacterData instance for a specific character, populates its + * CharacterStat, InventoryManager, SkillManager, QuestManager, ConfigManager, PopularityRecord, + * MiniGameRecord, MapTransferInfo, WildHunterInfo, and CoupleRecord. It also sets the item SN + * counter, friend limit, party ID, guild ID, and timestamps for creation and max level. + * + * All required additional data is loaded via DAOs or helper methods using the provided Connection. + * + * @param conn the database connection to use for loading related character data + * @param rs the ResultSet containing the character row data + * @return a fully populated CharacterData object + * @throws SQLException if a database access error occurs + */ + public static CharacterData loadCharacterData(Connection conn, ResultSet rs) throws SQLException { + int accountId = rs.getInt("account_id"); + CharacterData cd = new CharacterData(accountId); + int characterID = rs.getInt("id"); + + CharacterStat cs = new CharacterStat( + characterID, + rs.getString("name"), + rs.getByte("gender"), + rs.getByte("skin"), + rs.getInt("face"), + rs.getInt("hair"), + rs.getShort("level"), + rs.getShort("job"), + rs.getShort("sub_job"), + rs.getShort("base_str"), + rs.getShort("base_dex"), + rs.getShort("base_int"), + rs.getShort("base_luk"), + rs.getInt("hp"), + rs.getInt("max_hp"), + rs.getInt("mp"), + rs.getInt("max_mp"), + rs.getShort("ap"), + rs.getInt("exp"), + rs.getShort("pop"), + rs.getInt("pos_map"), + rs.getByte("portal"), + rs.getLong("pet_1"), + rs.getLong("pet_2"), + rs.getLong("pet_3") + ); + cd.setCharacterStat(cs); + + cs.setSp(ExtendSpDao.loadExtendSp(conn, characterID)); + + InventoryManager im = InventoryDao.loadInventoryManager(conn, characterID); + im.setMoney(rs.getInt("money")); + + Timestamp extSlotExpireTs = rs.getTimestamp("ext_slot_expire"); + im.setExtSlotExpire(extSlotExpireTs != null ? extSlotExpireTs.toInstant() : null); + + cd.setInventoryManager(im); + cd.setCoupleRecord(CoupleRecord.from(im.getEquipped(), im.getEquipInventory())); + + SkillManager sm = SkillManagerDao.loadSkillCooltimesAndRecords(conn, characterID); + cd.setSkillManager(sm); + + QuestManager qm = QuestManagerDao.loadQuestRecords(conn, characterID); + cd.setQuestManager(qm); + + ConfigManager cm = ConfigManagerDao.loadConfig(conn, characterID); + cd.setConfigManager(cm); + + PopularityRecord pr = PopularityRecordDao.loadPopularityRecord(conn, characterID); + cd.setPopularityRecord(pr); + + MiniGameRecord mgr = MiniGameRecordDao.loadMiniGameRecord(conn, characterID); + cd.setMiniGameRecord(mgr); + + MapTransferInfo mto = MapTransferInfoDao.loadMapTransferInfo(conn, characterID); + cd.setMapTransferInfo(mto); + + WildHunterInfo whi = WildHunterInfoDao.loadWildHunterInfo(conn, characterID); + cd.setWildHunterInfo(whi); + + cd.setItemSnCounter(new AtomicInteger(-1)); + + cd.setFriendMax(rs.getInt("friend_max")); + cd.setPartyId(rs.getInt("party_id")); + cd.setGuildId(rs.getInt("guild_id")); + + Timestamp creationTs = rs.getTimestamp("creation_time"); + cd.setCreationTime(creationTs != null ? creationTs.toInstant() : null); + + Timestamp maxLevelTs = rs.getTimestamp("max_level_time"); + cd.setMaxLevelTime(maxLevelTs != null ? maxLevelTs.toInstant() : null); + + return cd; + } + + /** + * Retrieves a CharacterData object for the given character ID. + * + * This method fetches the character's basic data, stats, and guild information, + * then delegates loading of inventory, skills, quests, configs, popularity, + * mini-games, map transfer, and wild hunter info to the appropriate DAOs. + * + * @param conn the database connection to use + * @param characterId the ID of the character + * @return an Optional containing the CharacterData if found, otherwise empty + * @throws SQLException if a database access error occurs + */ + public static Optional getCharacterById(Connection conn, int characterId) throws SQLException { + String sql = """ + SELECT c.*, s.*, m.guild_id, m.grade + FROM player.characters c + LEFT JOIN player.stats s ON c.id = s.character_id + LEFT JOIN guild.member m ON m.character_id = c.id + WHERE c.id = ? + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(loadCharacterData(conn, rs)); + } + } + } + + return Optional.empty(); + } + + /** + * Retrieves a CharacterData object by character name (case-insensitive). + * + * This method queries the characters table using ILIKE for case-insensitive matching, + * then delegates to loadCharacterData to populate all associated managers and records. + * + * @param conn the database connection to use + * @param name the name of the character + * @return an Optional containing the CharacterData if found, otherwise empty + * @throws SQLException if a database access error occurs + */ + public static Optional getCharacterByName(Connection conn, String name) throws SQLException { + String sql = "SELECT * FROM player.characters WHERE name ILIKE ?"; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, name.toLowerCase()); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(loadCharacterData(conn, rs)); + } + } + } + + return Optional.empty(); + } + + /** + * Creates a new character in the database along with all dependent data. + * + * This includes stats, inventory, skills, quests, config, popularity, + * and extended SP. The operation is performed within a single transaction. + * + * @param conn the database connection + * @param characterData the CharacterData to insert + * @return true if the character was successfully created, false otherwise + * @throws SQLException if a database error occurs + */ + public static boolean newCharacter(Connection conn, CharacterData characterData) throws SQLException { + if (!checkCharacterNameAvailable(conn, characterData.getCharacterName())) return false; + + String sql = """ + INSERT INTO player.characters + (account_id, name, money, ext_slot_expire, friend_max, party_id, guild_id, creation_time, max_level_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterData.getAccountId()); + stmt.setString(2, characterData.getCharacterName()); + stmt.setInt(3, characterData.getInventoryManager().getMoney()); + stmt.setTimestamp(4, characterData.getInventoryManager().getExtSlotExpire() != null ? + Timestamp.from(characterData.getInventoryManager().getExtSlotExpire()) : null); + stmt.setInt(5, characterData.getFriendMax()); + stmt.setInt(6, characterData.getPartyId()); + stmt.setInt(7, characterData.getGuildId()); + stmt.setTimestamp(8, characterData.getCreationTime() != null ? Timestamp.from(characterData.getCreationTime()) : null); + stmt.setTimestamp(9, characterData.getMaxLevelTime() != null ? Timestamp.from(characterData.getMaxLevelTime()) : null); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + int newCharacterId = rs.getInt(1); + characterData.getCharacterStat().setId(newCharacterId); + } else { + throw new SQLException("Failed to insert new character"); + } + } + } + + // Save all dependent data using the same connection + saveCharacterStats(conn, characterData); + InventoryDao.saveCharacter(conn, characterData); + saveCharacterSkills(conn, characterData); + saveCharacterQuests(conn, characterData); + saveCharacterConfig(conn, characterData); + saveCharacterPopularity(conn, characterData); + ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); + + return true; + } + + /** + * Checks whether a character name is available (not already in use). + * + * Uses ILIKE to perform a case-insensitive check. + * + * @param conn the database connection + * @param name the character name to check + * @return true if the name is available, false if it already exists + * @throws SQLException if a database access error occurs + */ + public static boolean checkCharacterNameAvailable(Connection conn, String name) throws SQLException { + String sql = "SELECT COUNT(*) > 0 AS exists FROM player.characters WHERE name ILIKE ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, name); // original name + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + boolean exists = rs.getBoolean("exists"); + return !exists; // available if it does NOT exist + } + } + } + return false; + } + + /** + * Inserts or updates a character’s base statistics in the database. + * Uses UPSERT logic to ensure that stats are either created or updated as needed. + * + * @param conn the active database connection + * @param characterData the character whose stats should be saved + * @throws SQLException if a database access error occurs + */ + private static void saveCharacterStats(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.stats ( + character_id, gender, skin, face, hair, level, job, sub_job, + base_str, base_dex, base_int, base_luk, hp, max_hp, mp, max_mp, + ap, exp, pop, pos_map, portal, pet_1, pet_2, pet_3 + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + ON CONFLICT (character_id) DO UPDATE SET + gender = EXCLUDED.gender, + skin = EXCLUDED.skin, + face = EXCLUDED.face, + hair = EXCLUDED.hair, + level = EXCLUDED.level, + job = EXCLUDED.job, + sub_job = EXCLUDED.sub_job, + base_str = EXCLUDED.base_str, + base_dex = EXCLUDED.base_dex, + base_int = EXCLUDED.base_int, + base_luk = EXCLUDED.base_luk, + hp = EXCLUDED.hp, + max_hp = EXCLUDED.max_hp, + mp = EXCLUDED.mp, + max_mp = EXCLUDED.max_mp, + ap = EXCLUDED.ap, + exp = EXCLUDED.exp, + pop = EXCLUDED.pop, + pos_map = EXCLUDED.pos_map, + portal = EXCLUDED.portal, + pet_1 = EXCLUDED.pet_1, + pet_2 = EXCLUDED.pet_2, + pet_3 = EXCLUDED.pet_3 + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + CharacterStat cs = characterData.getCharacterStat(); + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, cs.getGender()); + stmt.setInt(3, cs.getSkin()); + stmt.setInt(4, cs.getFace()); + stmt.setInt(5, cs.getHair()); + stmt.setInt(6, cs.getLevel()); + stmt.setInt(7, cs.getJob()); + stmt.setInt(8, cs.getSubJob()); + stmt.setInt(9, cs.getBaseStr()); + stmt.setInt(10, cs.getBaseDex()); + stmt.setInt(11, cs.getBaseInt()); + stmt.setInt(12, cs.getBaseLuk()); + stmt.setInt(13, cs.getHp()); + stmt.setInt(14, cs.getMaxHp()); + stmt.setInt(15, cs.getMp()); + stmt.setInt(16, cs.getMaxMp()); + stmt.setInt(17, cs.getAp()); + stmt.setInt(18, cs.getExp()); + stmt.setInt(19, cs.getPop()); + stmt.setInt(20, cs.getPosMap()); + stmt.setInt(21, cs.getPortal()); + stmt.setLong(22, cs.getPetSn1()); + stmt.setLong(23, cs.getPetSn2()); + stmt.setLong(24, cs.getPetSn3()); + + stmt.executeUpdate(); + } + } + + /** + * Saves or updates a character’s configuration data, including pet settings, key mappings, + * quickslot layout, and skill macros. + * Uses UPSERT logic to maintain up-to-date configuration for the given character. + * + * @param conn the active database connection + * @param characterData the character whose configuration should be saved + * @throws SQLException if a database access error occurs + */ + private static void saveCharacterConfig(Connection conn, CharacterData characterData) throws SQLException { + ConfigManager config = characterData.getConfigManager(); + + String sql = """ + INSERT INTO player.config + (character_id, pet_consume_item, pet_consume_mp_item, pet_exception_list, func_key_types, func_key_ids, quickslot_key_map) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (character_id) DO UPDATE + SET pet_consume_item = EXCLUDED.pet_consume_item, + pet_consume_mp_item = EXCLUDED.pet_consume_mp_item, + pet_exception_list = EXCLUDED.pet_exception_list, + func_key_types = EXCLUDED.func_key_types, + func_key_ids = EXCLUDED.func_key_ids, + quickslot_key_map = EXCLUDED.quickslot_key_map + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, config.getPetConsumeItem()); + stmt.setInt(3, config.getPetConsumeMpItem()); + + // pet_exception_list -> List -> Integer[] + List exceptionList = config.getPetExceptionList(); + Integer[] exceptionArray = exceptionList != null ? exceptionList.toArray(new Integer[0]) : new Integer[0]; + stmt.setArray(4, conn.createArrayOf("integer", exceptionArray)); + + // func_key_types & func_key_ids from FuncKeyMapped[] + FuncKeyMapped[] funcKeyMap = config.getFuncKeyMap(); + if (funcKeyMap == null) { + funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); + } + + Short[] funcTypes = Arrays.stream(funcKeyMap) + .map(f -> (short) f.getType().getValue()) + .toArray(Short[]::new); + Integer[] funcIds = Arrays.stream(funcKeyMap) + .map(FuncKeyMapped::getId) + .toArray(Integer[]::new); + + stmt.setArray(5, conn.createArrayOf("smallint", funcTypes)); // func_key_types + stmt.setArray(6, conn.createArrayOf("integer", funcIds)); // func_key_ids + + // quickslot_key_map -> int[] -> Integer[] + int[] quickslot = config.getQuickslotKeyMap(); + Integer[] quickslotKeys = quickslot != null + ? Arrays.stream(quickslot).boxed().toArray(Integer[]::new) + : new Integer[0]; + stmt.setArray(7, conn.createArrayOf("integer", quickslotKeys)); + + stmt.executeUpdate(); + } + // save skill macros + SkillMacrosDao.upsertMacros(conn, characterData.getCharacterId(), config.getMacroSysData()); + } + + /** + * Saves the character’s popularity (fame) relationships to other characters. + * Each entry represents a character that has received or given popularity points. + * Uses UPSERT logic to ensure timestamps are updated for existing records. + * + * @param conn the active database connection + * @param characterData the character whose popularity data should be saved + * @throws SQLException if a database access error occurs + */ + private static void saveCharacterPopularity(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.popularity (character_id, other_character_id, timestamp) + VALUES (?, ?, ?) + ON CONFLICT (character_id, other_character_id) + DO UPDATE SET timestamp = EXCLUDED.timestamp + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + PopularityRecord pr = characterData.getPopularityRecord(); + int charId = characterData.getCharacterId(); + + for (var entry : pr.getRecords().entrySet()) { + stmt.setInt(1, charId); + stmt.setInt(2, entry.getKey()); + stmt.setTimestamp(3, Timestamp.from(entry.getValue())); + stmt.addBatch(); + } + + stmt.executeBatch(); + } + } + + + /** + * Saves or updates all skill-related data for the given character, including: + * - Skill levels and master levels + * - Active skill cooldowns + * Uses UPSERT logic to maintain consistency between client and server skill data. + * + * @param conn the active database connection + * @param characterData the character whose skills should be saved + * @throws SQLException if a database access error occurs + */ + private static void saveCharacterSkills(Connection conn, CharacterData characterData) throws SQLException { + String skillRecordSql = """ + INSERT INTO player.skill_record (character_id, skill_id, level, master_level) + VALUES (?, ?, ?, ?) + ON CONFLICT (character_id, skill_id) + DO UPDATE SET level = EXCLUDED.level, master_level = EXCLUDED.master_level + """; + + try (PreparedStatement stmt = conn.prepareStatement(skillRecordSql)) { + for (SkillRecord sr : characterData.getSkillManager().getSkillRecords()) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, sr.getSkillId()); + stmt.setInt(3, sr.getSkillLevel()); + stmt.setInt(4, sr.getMasterLevel()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + + // Save skill cooltimes + String skillCooltimeSql = """ + INSERT INTO player.skill_cooltime (character_id, skill_id, cooldown_end) + VALUES (?, ?, ?) + ON CONFLICT (character_id, skill_id) + DO UPDATE SET cooldown_end = EXCLUDED.cooldown_end + """; + try (PreparedStatement stmt = conn.prepareStatement(skillCooltimeSql)) { + for (var entry : characterData.getSkillManager().getSkillCooltimes().entrySet()) { + int skillId = entry.getKey(); + Instant endTime = entry.getValue(); + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, skillId); + stmt.setTimestamp(3, Timestamp.from(endTime)); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Saves or updates the character’s quest progress records. + * Each entry includes the quest ID, its current state, progress string, and completion timestamp. + * Uses UPSERT logic to handle both new and existing quest records efficiently. + * + * @param conn the active database connection + * @param characterData the character whose quest data should be saved + * @throws SQLException if a database access error occurs + */ + private static void saveCharacterQuests(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.quest_record (character_id, quest_id, status, progress, completed_time) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (character_id, quest_id) + DO UPDATE SET status = EXCLUDED.status, + progress = EXCLUDED.progress, + completed_time = EXCLUDED.completed_time + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (QuestRecord qr : characterData.getQuestManager().getQuestRecords()) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, qr.getQuestId()); + stmt.setInt(3, qr.getState().getValue()); + stmt.setString(4, qr.getValue()); + stmt.setTimestamp(5, qr.getCompletedTime() != null ? Timestamp.from(qr.getCompletedTime()) : null); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Saves/updates a CharacterData object to the database. + * + * Updates the main character row and all dependent tables (stats, inventory, skills, + * quests, config, popularity, extend SP) using the same connection and transaction. + * + * @param conn the database connection + * @param characterData the CharacterData to save + * @return true if the save succeeded, false if the update failed + * @throws SQLException if a database access error occurs + */ + public static boolean saveCharacter(Connection conn, CharacterData characterData) throws SQLException { + String sql = "UPDATE player.characters SET account_id=?, name=?, money=?, ext_slot_expire=?, " + + "friend_max=?, party_id=?, guild_id=?, creation_time=?, max_level_time=? " + + "WHERE id=?"; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterData.getAccountId()); + stmt.setString(2, characterData.getCharacterName()); + stmt.setInt(3, characterData.getInventoryManager().getMoney()); + stmt.setTimestamp(4, characterData.getInventoryManager().getExtSlotExpire() != null ? + Timestamp.from(characterData.getInventoryManager().getExtSlotExpire()) : null); + stmt.setInt(5, characterData.getFriendMax()); + stmt.setInt(6, characterData.getPartyId()); + stmt.setInt(7, characterData.getGuildId()); + stmt.setTimestamp(8, characterData.getCreationTime() != null ? + Timestamp.from(characterData.getCreationTime()) : null); + stmt.setTimestamp(9, characterData.getMaxLevelTime() != null ? + Timestamp.from(characterData.getMaxLevelTime()) : null); + stmt.setInt(10, characterData.getCharacterId()); + + int updated = stmt.executeUpdate(); + if (updated == 0) { + return false; // will rollback in a transaction. + } + } + + // Save dependent tables using the same connection + saveCharacterStats(conn, characterData); + InventoryDao.saveCharacter(conn, characterData); + saveCharacterSkills(conn, characterData); + saveCharacterQuests(conn, characterData); + saveCharacterConfig(conn, characterData); + saveCharacterPopularity(conn, characterData); + ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); + + return true; + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/CharacterInfoDao.java b/src/main/java/kinoko/database/postgresql/type/CharacterInfoDao.java new file mode 100644 index 00000000..2816855d --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/CharacterInfoDao.java @@ -0,0 +1,39 @@ +package kinoko.database.postgresql.type; + +import kinoko.database.CharacterInfo; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Optional; + +public final class CharacterInfoDao { + /** + * Retrieves CharacterInfo by character name. + * + * Performs a case-insensitive search using ILIKE. + * Returns an Optional containing CharacterInfo if found, otherwise empty. + * + * @param conn the database connection to use + * @param name the name of the character + * @return Optional containing CharacterInfo if the character exists + * @throws SQLException if a database access error occurs + */ + public static Optional getCharacterInfoByName(Connection conn, String name) throws SQLException { + String sql = "SELECT account_id, id, name FROM player.characters WHERE name ILIKE ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, name.toLowerCase()); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(new CharacterInfo( + rs.getInt("account_id"), + rs.getInt("id"), + rs.getString("name") + )); + } + } + } + return Optional.empty(); + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/CharacterRankDao.java b/src/main/java/kinoko/database/postgresql/type/CharacterRankDao.java new file mode 100644 index 00000000..3449c2ff --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/CharacterRankDao.java @@ -0,0 +1,78 @@ +package kinoko.database.postgresql.type; + + +import kinoko.database.types.CharacterRankData; +import kinoko.server.rank.CharacterRank; +import kinoko.world.job.JobConstants; + +import java.sql.*; +import java.time.Instant; +import java.util.*; + +public final class CharacterRankDao { + + /** + * Retrieves all character ranks (world and job-specific) using the provided connection. + * + * Characters with admin/manager jobs are skipped. Ranks are sorted by cumulative + * EXP descending, and for ties, by earliest max level time. + * + * @param conn an active SQL connection + * @return a map from character ID to CharacterRank + * @throws SQLException if a database access error occurs + */ + public static Map getCharacterRanks(Connection conn) throws SQLException { + Map ranks = new HashMap<>(); + String sql = """ + SELECT c.id, c.max_level_time, s.job, s.exp + FROM player.characters c + JOIN player.stats s ON c.id = s.character_id + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql); + ResultSet rs = stmt.executeQuery()) { + + List rankDataList = new ArrayList<>(); + + while (rs.next()) { + int characterId = rs.getInt("id"); + int jobId = rs.getInt("job"); + long cumulativeExp = rs.getLong("exp"); + Timestamp ts = rs.getTimestamp("max_level_time"); + + if (JobConstants.isAdminJob(jobId) || JobConstants.isManagerJob(jobId)) { + continue; + } + + rankDataList.add(new CharacterRankData( + characterId, + JobConstants.getJobCategory(jobId), + cumulativeExp, + ts != null ? ts.toInstant() : Instant.MAX + )); + } + + // Sort by EXP (descending) and then by earliest max level time + rankDataList.sort( + Comparator.comparingLong(CharacterRankData::cumulativeExp).reversed() + .thenComparing(CharacterRankData::maxLevelTime) + ); + + // compute world rank and job rank + Map jobRanks = new HashMap<>(); + for (CharacterRankData data : rankDataList) { + int worldRank = ranks.size() + 1; + int jobRank = jobRanks.getOrDefault(data.jobCategory(), 0) + 1; + jobRanks.put(data.jobCategory(), jobRank); + + ranks.put(data.characterId(), new CharacterRank( + data.characterId(), + worldRank, + jobRank + )); + } + } + + return ranks; + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java b/src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java new file mode 100644 index 00000000..1cb378e8 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java @@ -0,0 +1,92 @@ +package kinoko.database.postgresql.type; + + +import kinoko.world.GameConstants; +import kinoko.world.user.data.ConfigManager; +import kinoko.world.user.data.FuncKeyMapped; +import kinoko.world.user.data.FuncKeyType; + +import java.sql.*; +import java.util.Arrays; +import java.util.List; + +public final class ConfigManagerDao { + + /** + * Loads the ConfigManager for the specified character. + * + * Retrieves pet consume settings, pet exception list, function key mapping, + * quickslot key map, and associated macros. + * + * @param conn the database connection to use + * @param characterId the ID of the character + * @return a fully populated ConfigManager object + * @throws SQLException if a database access error occurs + */ + public static ConfigManager loadConfig(Connection conn, int characterId) throws SQLException { + String sql = """ + SELECT pet_consume_item, pet_consume_mp_item, pet_exception_list, + func_key_types, func_key_ids, quickslot_key_map + FROM player.config + WHERE character_id = ? + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + return ConfigManager.defaults(); + } + + int petConsumeItem = rs.getInt("pet_consume_item"); + int petConsumeMpItem = rs.getInt("pet_consume_mp_item"); + + // --- Pet exception list --- + List petExceptionList; + Array petExArr = rs.getArray("pet_exception_list"); + if (petExArr != null) { + Integer[] arr = (Integer[]) petExArr.getArray(); + petExceptionList = Arrays.asList(arr); + } else { + petExceptionList = List.of(); + } + + // --- Function key map --- + FuncKeyMapped[] funcKeyMap = new FuncKeyMapped[GameConstants.FUNC_KEY_MAP_SIZE]; + Array funcTypeArr = rs.getArray("func_key_types"); + Array funcIdArr = rs.getArray("func_key_ids"); + + if (funcTypeArr != null && funcIdArr != null) { + Short[] typeValues = (Short[]) funcTypeArr.getArray(); + Integer[] idValues = (Integer[]) funcIdArr.getArray(); + + for (int i = 0; i < funcKeyMap.length; i++) { + FuncKeyType type = FuncKeyType.getByValue(typeValues[i].byteValue()); + int id = idValues[i]; + funcKeyMap[i] = FuncKeyMapped.of(type, id); + } + } else { + funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); + } + + // --- Quickslot key map --- + int[] quickslotKeyMap; + Array quickArr = rs.getArray("quickslot_key_map"); + if (quickArr != null) { + Integer[] arr = (Integer[]) quickArr.getArray(); + quickslotKeyMap = Arrays.stream(arr).mapToInt(Integer::intValue).toArray(); + } else { + quickslotKeyMap = Arrays.copyOf(GameConstants.DEFAULT_QUICKSLOT_KEY_MAP, GameConstants.QUICKSLOT_KEY_MAP_SIZE); + } + + ConfigManager cm = new ConfigManager(petConsumeItem, petConsumeMpItem, petExceptionList, funcKeyMap, quickslotKeyMap); + + // Load macros from SkillMacrosDao + cm.updateMacroSysData(SkillMacrosDao.loadMacros(conn, characterId)); + + return cm; + } + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/FriendDao.java b/src/main/java/kinoko/database/postgresql/type/FriendDao.java new file mode 100644 index 00000000..1c592743 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/FriendDao.java @@ -0,0 +1,124 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.user.friend.Friend; +import kinoko.world.user.friend.FriendStatus; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +public final class FriendDao { + /** + * Creates a Friend object from the current row of a ResultSet. + * + * Extracts the character ID, friend ID, friend name, friend group, + * and friend status from the ResultSet and constructs a corresponding + * Friend instance. + * + * @param rs the ResultSet positioned at the row to load + * @return a Friend object representing the data in the current row + * @throws SQLException if a database access error occurs + */ + private static Friend loadFriend(ResultSet rs) throws SQLException { + int characterId = rs.getInt("character_id"); + int friendId = rs.getInt("friend_id"); + String friendName = rs.getString("friend_name"); + String friendGroup = rs.getString("friend_group"); + FriendStatus status = FriendStatus.getByValue(rs.getInt("friend_status")); + return new Friend(characterId, friendId, friendName, friendGroup, status); + } + + /** + * Retrieves all friends for a specific character ID. + * + * @param conn the active SQL connection + * @param characterId the character's ID + * @return a list of Friend objects + * @throws SQLException if a database error occurs + */ + public static List getFriendsByCharacterId(Connection conn, int characterId) throws SQLException { + List friends = new ArrayList<>(); + String sql = "SELECT * FROM friend.friends WHERE character_id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + friends.add(loadFriend(rs)); + } + } + } + return friends; + } + + /** + * Retrieves all friends where the given friend ID appears. + * + * @param conn the active SQL connection + * @param friendId the friend's ID + * @return a list of Friend objects + * @throws SQLException if a database error occurs + */ + public static List getFriendsByFriendId(Connection conn, int friendId) throws SQLException { + List friends = new ArrayList<>(); + String sql = "SELECT * FROM friend.friends WHERE friend_id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, friendId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + friends.add(loadFriend(rs)); + } + } + } + return friends; + } + + /** + * Inserts or updates a friend record in the database. + * + * @param conn the active SQL connection + * @param friend the Friend object to save + * @param force if true, update existing record; if false, do nothing on conflict + * @return true if the record was inserted or updated, false otherwise + * @throws SQLException if a database error occurs + */ + public static boolean saveFriend(Connection conn, Friend friend, boolean force) throws SQLException { + String sql; + if (force) { + sql = "INSERT INTO friend.friends (character_id, friend_id, friend_name, friend_group, friend_status) " + + "VALUES (?, ?, ?, ?, ?) " + + "ON CONFLICT (character_id, friend_id) DO UPDATE SET " + + "friend_name = EXCLUDED.friend_name, " + + "friend_group = EXCLUDED.friend_group, " + + "friend_status = EXCLUDED.friend_status"; + } else { + sql = "INSERT INTO friend.friends (character_id, friend_id, friend_name, friend_group, friend_status) " + + "VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING"; + } + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, friend.getCharacterId()); + stmt.setInt(2, friend.getFriendId()); + stmt.setString(3, friend.getFriendName()); + stmt.setString(4, friend.getFriendGroup()); + stmt.setInt(5, friend.getStatus().getValue()); + return stmt.executeUpdate() > 0; + } + } + + /** + * Deletes a friend record from the database. + * + * @param conn the active SQL connection + * @param characterId the character's ID + * @param friendId the friend's ID + * @return true if the record was deleted, false otherwise + * @throws SQLException if a database error occurs + */ + public static boolean deleteFriend(Connection conn, int characterId, int friendId) throws SQLException { + String sql = "DELETE FROM friend.friends WHERE character_id = ? AND friend_id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + stmt.setInt(2, friendId); + return stmt.executeUpdate() > 0; + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/GiftDao.java b/src/main/java/kinoko/database/postgresql/type/GiftDao.java new file mode 100644 index 00000000..e0696972 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/GiftDao.java @@ -0,0 +1,142 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.cashshop.Gift; +import kinoko.world.item.Item; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + + +public final class GiftDao { + /** + * Loads a Gift object from the current row of the ResultSet. + * + * @param rs the ResultSet positioned at the gift row + * @return the loaded Gift object + * @throws SQLException if a database access error occurs + */ + public static Gift loadGift(ResultSet rs) throws SQLException { + return new Gift( + rs.getLong("item_sn"), + rs.getInt("item_id"), + rs.getInt("commodity_id"), + rs.getInt("sender_id"), + rs.getString("sender_name"), + rs.getString("sender_message"), + rs.getLong("pair_gift_sn") + ); + } + + + /** + * Retrieves all gifts for a given receiver ID. + * + * @param conn the active SQL connection + * @param receiverId the ID of the character receiving gifts + * @return a list of Gift objects for the receiver + * @throws SQLException if a database access error occurs + */ + public static List getGiftsByReceiverId(Connection conn, int receiverId) throws SQLException { + List gifts = new ArrayList<>(); + String sql = """ + SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, + g.sender_name, g.sender_message, g.pair_gift_sn + FROM gift.gifts g + JOIN item.full_item fi ON fi.item_sn = g.item_sn + WHERE g.receiver_id = ? + """; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, receiverId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + gifts.add(loadGift(rs)); + } + } + } + return gifts; + } + + /** + * Retrieves a gift by its item serial number. + * + * @param conn the active SQL connection + * @param itemSn the item serial number of the gift + * @return an Optional containing the Gift if found, or empty if not + * @throws SQLException if a database access error occurs + */ + public static Optional getGiftByItemSn(Connection conn, long itemSn) throws SQLException { + String sql = """ + SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, + g.sender_name, g.sender_message, g.pair_gift_sn + FROM gift.gifts g + JOIN item.full_item fi ON fi.item_sn = g.item_sn + WHERE g.item_sn = ? + """; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, itemSn); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(loadGift(rs)); + } + } + } + return Optional.empty(); + } + + /** + * Inserts a new gift into the database. + * + * If the gift does not have a valid item serial number, a new item will be created first. + * + * @param conn the active SQL connection + * @param gift the gift to insert + * @param receiverId the ID of the receiver + * @return true if the gift was successfully inserted, false otherwise + * @throws SQLException if a database access error occurs + */ + public static boolean insertGift(Connection conn, Gift gift, int receiverId) throws SQLException { + String sql = """ + INSERT INTO gift.gifts (item_sn, receiver_id, commodity_id, sender_id, sender_name, sender_message, pair_gift_sn) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (item_sn) DO NOTHING + """; + + long itemSN = gift.getGiftSn(); + if (itemSN <= 0) { // create a new item. + Item basicItem = new Item(gift.getItemId(), (short) 1); + ItemDao.createNewItem(conn, basicItem); + itemSN = basicItem.getItemSn(); + } + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, itemSN); + stmt.setInt(2, receiverId); + stmt.setInt(3, gift.getCommodityId()); + stmt.setInt(4, gift.getSenderId()); + stmt.setString(5, gift.getSenderName()); + stmt.setString(6, gift.getSenderMessage()); + stmt.setLong(7, gift.getPairItemSn()); + + return stmt.executeUpdate() > 0; + } + } + + /** + * Deletes a gift from the database by its item serial number. + * + * @param conn the active SQL connection + * @param gift the gift to delete + * @return true if the gift was successfully deleted, false otherwise + * @throws SQLException if a database access error occurs + */ + public static boolean deleteGift(Connection conn, Gift gift) throws SQLException { + String sql = "DELETE FROM gift.gifts WHERE item_sn = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, gift.getGiftSn()); + return stmt.executeUpdate() > 0; + } + } +} + diff --git a/src/main/java/kinoko/database/postgresql/type/GuildDao.java b/src/main/java/kinoko/database/postgresql/type/GuildDao.java index f53fad84..6ee70c49 100644 --- a/src/main/java/kinoko/database/postgresql/type/GuildDao.java +++ b/src/main/java/kinoko/database/postgresql/type/GuildDao.java @@ -3,11 +3,13 @@ import kinoko.server.guild.Guild; import kinoko.server.guild.GuildBoardEntry; import kinoko.server.guild.GuildMember; +import kinoko.server.guild.GuildRanking; import java.sql.*; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Optional; public class GuildDao { @@ -85,7 +87,7 @@ public static synchronized boolean insertGuild(Connection conn, Guild guild) thr * Modifies all relevant guild fields such as name, grade names, emblems, notice, points, and level. * Also updates related members, board entries, and board notice after the main record update. * - * @param conn the active SQL connection to use for the update + * @param conn the active SQL connection to use for the update * @param guild the guild object containing updated data * @return true if the update affected at least one row; false otherwise * @throws SQLException if a database error occurs during the update @@ -205,11 +207,11 @@ public static boolean deleteGuild(Connection conn, int guildId) throws SQLExcept */ private static void upsertGrades(Connection conn, int guildId, List grades) throws SQLException { String sql = """ - INSERT INTO guild.grade (guild_id, grade_index, grade_name) - VALUES (?, ?, ?) - ON CONFLICT (guild_id, grade_index) DO UPDATE - SET grade_name = EXCLUDED.grade_name - """; + INSERT INTO guild.grade (guild_id, grade_index, grade_name) + VALUES (?, ?, ?) + ON CONFLICT (guild_id, grade_index) DO UPDATE + SET grade_name = EXCLUDED.grade_name + """; try (PreparedStatement stmt = conn.prepareStatement(sql)) { for (int i = 0; i < grades.size(); i++) { @@ -247,4 +249,57 @@ private static List loadGrades(Connection conn, int guildId) throws SQLE } return grades; } + + /** + * Retrieves a list of guild rankings from the database. + * + * Guilds are ordered by their points in descending order, so the guild with the highest points appears first. + * Each GuildRanking object contains the guild's name, points, and visual mark information (mark, mark color, background, background color). + * + * @param conn the active SQL connection to use for the query + * @return a list of GuildRanking objects representing all guilds ordered by points + * @throws SQLException if a database access error occurs + */ + public static List getGuildRankings(Connection conn) throws SQLException { + List rankings = new ArrayList<>(); + String sql = "SELECT name, points, mark, mark_color, mark_bg, mark_bg_color FROM guild.guilds ORDER BY points DESC"; + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + rankings.add(new GuildRanking( + rs.getString("name"), + rs.getInt("points"), + rs.getShort("mark"), + rs.getByte("mark_color"), + rs.getShort("mark_bg"), + rs.getByte("mark_bg_color") + )); + } + } + return rankings; + } + + /** + * Retrieves a guild from the database by its ID. + * + * Executes a query to fetch the guild record corresponding to the provided guild ID. + * If a matching guild is found, it is loaded into a Guild object using GuildDao.loadGuild. + * + * @param conn the active SQL connection to use for the query + * @param guildId the ID of the guild to retrieve + * @return an Optional containing the guild if found, or Optional.empty() if no guild exists with the given ID + * @throws SQLException if a database access error occurs + */ + public static Optional getGuildById(Connection conn, int guildId) throws SQLException { + String sql = "SELECT * FROM guild.guilds WHERE id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(loadGuild(conn, rs)); + } + } + } + return Optional.empty(); + } } \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java index f4760582..57d7310d 100644 --- a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java +++ b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java @@ -129,6 +129,22 @@ private static void deleteUnusedItems(Connection conn, int charId, Collection 0; + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java b/src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java new file mode 100644 index 00000000..d7cf370d --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java @@ -0,0 +1,48 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.user.data.WildHunterInfo; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +public final class WildHunterInfoDao { + /** + * Loads WildHunterInfo for the specified character. + * + * Retrieves riding type and up to 5 captured mobs for the character. + * + * @param characterId the ID of the character + * @return WildHunterInfo populated with riding type and captured mobs + * @throws SQLException if a database access error occurs + */ + public static WildHunterInfo loadWildHunterInfo(Connection conn, int characterId) throws SQLException { + WildHunterInfo wh = new WildHunterInfo(); + + String sqlRiding = "SELECT riding_type FROM player.wild_hunter WHERE character_id = ?"; + String sqlMobs = "SELECT mob_id FROM player.wild_hunter_mob WHERE character_id = ?"; + + try (PreparedStatement stmt = conn.prepareStatement(sqlRiding)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + wh.setRidingType(rs.getInt("riding_type")); + } + } + } + + // Load captured mobs + try (PreparedStatement stmt = conn.prepareStatement(sqlMobs)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + wh.getCapturedMobs().add(rs.getInt("mob_id")); + if (wh.getCapturedMobs().size() >= 5) break; // enforce max 5 + } + } + } + + return wh; + } +} diff --git a/src/main/java/kinoko/database/types/CharacterRankData.java b/src/main/java/kinoko/database/types/CharacterRankData.java new file mode 100644 index 00000000..cacd4a14 --- /dev/null +++ b/src/main/java/kinoko/database/types/CharacterRankData.java @@ -0,0 +1,11 @@ +package kinoko.database.types; + +import java.time.Instant; + +public record CharacterRankData(int characterId, int jobCategory, long cumulativeExp, Instant maxLevelTime) { + + @Override + public Instant maxLevelTime() { + return maxLevelTime != null ? maxLevelTime : Instant.MAX; + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/handler/stage/CashShopHandler.java b/src/main/java/kinoko/handler/stage/CashShopHandler.java index f8ca7bab..1a96c47f 100644 --- a/src/main/java/kinoko/handler/stage/CashShopHandler.java +++ b/src/main/java/kinoko/handler/stage/CashShopHandler.java @@ -93,7 +93,7 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { } // Generate an item SN for the locker item - (Relational DBs) - Safe for NoSQL DBs. - if (!DatabaseManager.idAccessor().generateItemId(cashItemInfo.getItem())){ + if (!DatabaseManager.idAccessor().generateItemSn(cashItemInfo.getItem())){ user.write(CashShopPacket.fail(CashItemResultType.Buy_Failed, CashItemFailReason.Unknown)); // Due to an unknown error, the request for Cash Shop has failed. log.error("Could not generate SN for Item ID: {}", cashItemInfo.getItem().getItemId()); return; @@ -594,7 +594,7 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { selfItemSn = cashItemInfo.getItem().getItemSn(); Item pairItem = new Item(cashItemInfo.getItem()); pairItem.resetSN(false); - if (!DatabaseManager.idAccessor().generateItemId(pairItem)){ + if (!DatabaseManager.idAccessor().generateItemSn(pairItem)){ user.write(CashShopPacket.fail(CashItemResultType.Couple_Failed, CashItemFailReason.Unknown)); // Due to an unknown error, the request for Cash Shop has failed. log.error("Could not generate SN for Item ID: {}", cashItemInfo.getItem().getItemId()); return; diff --git a/src/main/java/kinoko/server/cashshop/Commodity.java b/src/main/java/kinoko/server/cashshop/Commodity.java index 14d42e48..196fd5cd 100644 --- a/src/main/java/kinoko/server/cashshop/Commodity.java +++ b/src/main/java/kinoko/server/cashshop/Commodity.java @@ -84,7 +84,7 @@ public Optional createCashItemInfo(long itemSn, int accountId, int } // Generate an item SN for the cash item - (Relational DBs) - Safe for NoSQL DBs. - DatabaseManager.idAccessor().generateItemId(item); + DatabaseManager.idAccessor().generateItemSn(item); final CashItemInfo cashItemInfo = new CashItemInfo( item, From 1bbd845482b0f3212e97bca97277e5fabf015a77 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 14 Oct 2025 17:07:10 -0400 Subject: [PATCH 24/83] Cleanup --- .../postgresql/PostgresAccountAccessor.java | 157 ++++++++---------- .../database/postgresql/type/AccountDao.java | 128 ++++++++++++++ 2 files changed, 193 insertions(+), 92 deletions(-) diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java index a351229f..19bc22d8 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java @@ -2,12 +2,8 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.AccountAccessor; -import kinoko.database.DatabaseManager; import kinoko.database.postgresql.type.AccountDao; -import kinoko.database.postgresql.type.LockerDao; -import kinoko.server.ServerConfig; import kinoko.world.user.Account; -import org.mindrot.jbcrypt.BCrypt; import java.sql.*; @@ -19,124 +15,101 @@ public PostgresAccountAccessor(HikariDataSource dataSource) { super(dataSource); } - private String lowerUsername(String username) { - return username.toLowerCase(); - } - - private String hashPassword(String password) { - return BCrypt.hashpw(password, BCrypt.gensalt()); - } - - private boolean checkHashedPassword(String password, String hashedPassword) { - return BCrypt.checkpw(password, hashedPassword); - } - + /** + * Retrieves an account by its unique ID. + * Opens a database connection and delegates the loading logic to AccountDao. + * + * @param accountId the ID of the account to retrieve + * @return an Optional containing the Account if found, otherwise empty + */ @Override public Optional getAccountById(int accountId) { - String sql = "SELECT * FROM account.accounts WHERE id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - - stmt.setInt(1, accountId); - - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - return Optional.of(AccountDao.load(conn, rs)); - } - } - + try (Connection conn = getConnection()) { + return AccountDao.getAccountById(conn, accountId); } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } + /** + * Retrieves an account by its username. + * The username is normalized and passed to AccountDao for lookup. + * + * @param username the username to look up + * @return an Optional containing the Account if found, otherwise empty + */ @Override public Optional getAccountByUsername(String username) { - String sql = "SELECT * FROM account.accounts WHERE username = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - - stmt.setString(1, lowerUsername(username)); - - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - return Optional.of(AccountDao.load(conn, rs)); - } - } - + try (Connection conn = getConnection()) { + return AccountDao.getAccountByUsername(conn, username); } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } + /** + * Checks whether the provided password matches the stored hash for the account. + * Uses AccountDao to retrieve the hashed password and perform verification. + * + * @param account the account being authenticated + * @param password the plaintext password to verify + * @param secondary true if verifying the secondary password, false for the primary + * @return true if the password matches, false otherwise + */ @Override public boolean checkPassword(Account account, String password, boolean secondary) { - try (Connection conn = getConnection()) { + return withTransaction(conn -> { String hashed = AccountDao.getHashedPassword(conn, account, secondary); - return hashed != null && checkHashedPassword(password, hashed); - } catch (SQLException e) { - e.printStackTrace(); - return false; - } + return hashed != null && AccountDao.checkHashedPassword(password, hashed); + }); } + /** + * Updates the account's password if the provided old password matches the stored hash. + * The method handles both primary and secondary password updates through AccountDao. + * + * @param account the account whose password is being updated + * @param oldPassword the current plaintext password + * @param newPassword the new plaintext password to set + * @param secondary true if updating the secondary password, false for the primary + * @return true if the password was successfully updated, false otherwise + */ @Override public boolean savePassword(Account account, String oldPassword, String newPassword, boolean secondary) { - String sqlUpdate = "UPDATE account.accounts SET " + - (secondary ? "secondary_password" : "password") + - " = ? WHERE id = ?"; - try (Connection conn = getConnection()) { + return withTransaction(conn -> { String hashedOld = AccountDao.getHashedPassword(conn, account, secondary); - if (hashedOld == null || checkHashedPassword(oldPassword, hashedOld)) { - try (PreparedStatement updateStmt = conn.prepareStatement(sqlUpdate)) { - updateStmt.setString(1, hashPassword(newPassword)); - updateStmt.setInt(2, account.getId()); - return updateStmt.executeUpdate() > 0; - } + if (hashedOld == null || AccountDao.checkHashedPassword(oldPassword, hashedOld)) { + return AccountDao.updatePassword(conn, account, newPassword, secondary); } - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return false; + }); } + /** + * Creates a new account with the provided username and password. + * Executes all SQL operations inside a transaction for safety. + * + * @param username the desired username for the new account + * @param password the plaintext password for the new account + * @return true if the account was successfully created, false otherwise + */ @Override public synchronized boolean newAccount(String username, String password) { - Optional accountIdOpt = DatabaseManager.idAccessor().nextAccountId(); // should be -1 - if (accountIdOpt.isEmpty() || getAccountByUsername(username).isPresent()) { - return false; - } - int accountId; - - String sql = "INSERT INTO account.accounts (username, password, character_slots, nx_credit, nx_prepaid, maple_point, trunk_size, trunk_money) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + - "RETURNING ID"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setString(1, lowerUsername(username)); - stmt.setString(2, hashPassword(password)); - stmt.setInt(3, ServerConfig.CHARACTER_BASE_SLOTS); - stmt.setInt(4, 0); - stmt.setInt(5, 0); - stmt.setInt(6, 0); - stmt.setInt(7, ServerConfig.TRUNK_BASE_SLOTS); - stmt.setInt(8, 0); - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - accountId = rs.getInt("id"); - } else { - throw new SQLException("Failed to retrieve account ID after insert."); + return withTransaction(conn -> { + return AccountDao.createAccount(conn, username, password); } - } - return true; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + ); } + /** + * Saves all changes made to the given account, including its related data. + * Uses a transaction to ensure atomic updates across all tables. + * + * @param account the account containing updated data to persist + * @return true if the account was successfully saved, false otherwise + */ @Override public boolean saveAccount(Account account) { try { diff --git a/src/main/java/kinoko/database/postgresql/type/AccountDao.java b/src/main/java/kinoko/database/postgresql/type/AccountDao.java index f358c7fe..f99083ef 100644 --- a/src/main/java/kinoko/database/postgresql/type/AccountDao.java +++ b/src/main/java/kinoko/database/postgresql/type/AccountDao.java @@ -1,6 +1,8 @@ package kinoko.database.postgresql.type; +import kinoko.database.DatabaseManager; import kinoko.world.user.Account; +import org.mindrot.jbcrypt.BCrypt; import java.sql.Connection; import java.sql.PreparedStatement; @@ -116,4 +118,130 @@ public static Optional getAccountIdByCharacterId(Connection conn, int c } return Optional.empty(); } + + /** + * Retrieves an account by its unique ID from the database. + * Executes a query against the accounts table and constructs the Account object using load(). + * + * @param conn the active database connection + * @param accountId the ID of the account to retrieve + * @return an Optional containing the Account if found, otherwise empty + * @throws SQLException if a database access error occurs + */ + public static Optional getAccountById(Connection conn, int accountId) throws SQLException { + String sql = "SELECT * FROM account.accounts WHERE id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(load(conn, rs)); + } + } + } + return Optional.empty(); + } + + /** + * Retrieves an account by its username from the database. + * The username is converted to lowercase before lookup for consistency. + * + * @param conn the active database connection + * @param username the username of the account to retrieve + * @return an Optional containing the Account if found, otherwise empty + * @throws SQLException if a database access error occurs + */ + public static Optional getAccountByUsername(Connection conn, String username) throws SQLException { + String sql = "SELECT * FROM account.accounts WHERE username = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, username.toLowerCase()); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(load(conn, rs)); + } + } + } + return Optional.empty(); + } + + /** + * Updates the account's password or secondary password in the database. + * Automatically hashes the provided password before saving. + * + * @param conn the active database connection + * @param account the account whose password is being updated + * @param newPassword the new plaintext password + * @param secondary true to update the secondary password, false to update the primary + * @return true if the update succeeded, false otherwise + * @throws SQLException if a database access error occurs + */ + public static boolean updatePassword(Connection conn, Account account, String newPassword, boolean secondary) throws SQLException { + String column = secondary ? "secondary_password" : "password"; + String sql = "UPDATE account.accounts SET " + column + " = ? WHERE id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, hashPassword(newPassword)); + stmt.setInt(2, account.getId()); + return stmt.executeUpdate() > 0; + } + } + + /** + * Creates a new account in the database with default values for character slots and currencies. + * The password is automatically hashed before being stored. + * + * @param conn the active database connection + * @param username the desired username for the new account + * @param password the plaintext password for the new account + * @return true if the account was successfully created, false otherwise + * @throws SQLException if a database access error occurs + */ + public static boolean createAccount(Connection conn, String username, String password) throws SQLException { + String sql = """ + INSERT INTO account.accounts + (username, password, character_slots, nx_credit, nx_prepaid, maple_point, trunk_size, trunk_money) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id + """; + Optional accountIdOpt = DatabaseManager.idAccessor().nextAccountId(); // should be -1 + if (accountIdOpt.isEmpty() || getAccountByUsername(conn, username).isPresent()) { + return false; + } + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, username.toLowerCase()); + stmt.setString(2, hashPassword(password)); + stmt.setInt(3, kinoko.server.ServerConfig.CHARACTER_BASE_SLOTS); + stmt.setInt(4, 0); + stmt.setInt(5, 0); + stmt.setInt(6, 0); + stmt.setInt(7, kinoko.server.ServerConfig.TRUNK_BASE_SLOTS); + stmt.setInt(8, 0); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return true; + } + } + } + return false; + } + + /** + * Generates a BCrypt hash for the provided plaintext password. + * + * @param password the plaintext password to hash + * @return the hashed password string + */ + public static String hashPassword(String password) { + return BCrypt.hashpw(password, BCrypt.gensalt()); + } + + /** + * Verifies whether a plaintext password matches the provided hashed password. + * + * @param password the plaintext password to verify + * @param hashedPassword the stored hashed password to compare against + * @return true if the passwords match, false otherwise + */ + public static boolean checkHashedPassword(String password, String hashedPassword) { + return BCrypt.checkpw(password, hashedPassword); + } } From 50c94ac842cefa3d9cf23796f18f3121cb586a84 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 14 Oct 2025 17:54:00 -0400 Subject: [PATCH 25/83] Save Objects that weren't getting saved --- .../database/postgresql/PostgresAccessor.java | 26 +-- .../kinoko/database/postgresql/setup/init.sql | 6 +- .../postgresql/type/CharacterDataDao.java | 206 ++---------------- .../postgresql/type/ConfigManagerDao.java | 66 ++++++ .../database/postgresql/type/ExtendSpDao.java | 19 +- .../database/postgresql/type/ItemDao.java | 19 +- .../postgresql/type/MapTransferInfoDao.java | 61 +++++- .../postgresql/type/MiniGameRecordDao.java | 47 ++++ .../postgresql/type/PopularityRecordDao.java | 34 +++ .../postgresql/type/QuestManagerDao.java | 34 +++ .../postgresql/type/SkillManagerDao.java | 51 +++++ .../postgresql/type/WildHunterInfoDao.java | 49 +++++ 12 files changed, 389 insertions(+), 229 deletions(-) diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java index 2f7786c7..529ea662 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java @@ -24,13 +24,6 @@ protected final Connection getConnection() throws SQLException { return dataSource.getConnection(); } - /** - * Helper to lowercase strings (like usernames) - */ - protected final String lowerName(String name) { - return name.toLowerCase(); - } - /** * Executes the given action within a database transaction using a connection from the instance's data source. * Sets auto-commit to false, runs the action, commits the transaction if successful, and rolls back if a SQLException occurs. @@ -109,20 +102,21 @@ public static boolean withTransaction(Connection conn, try { conn.setAutoCommit(true); conn.close(); - } catch (SQLException ignored) { - ignored.printStackTrace(); + } catch (SQLException e) { + e.printStackTrace(); } } } } /** - * Executes the given action within a database transaction using a connection from the instance's data source. - * Sets auto-commit to false, runs the action, commits the transaction if successful, and rolls back if a SQLException occurs. - * Finally, restores auto-commit to true and closes the connection. + * Executes a database operation within a managed transaction using a connection from the data source. + * Auto-commit is disabled before execution and restored afterward. + * The provided action determines logical success; if it returns false or throws an exception, the transaction is rolled back. + * If successful, the transaction is committed before closing the connection. * - * @param action The action to execute inside the transaction. Can throw SQLException. - * @return true if the transaction committed successfully; false if an exception occurred and rollback was performed. + * @param action the action to execute inside the transaction; may throw SQLException + * @return true if the transaction was committed successfully, false if an exception occurred or rollback was performed */ public boolean withTransaction(SQLBooleanAction action) { Connection conn = null; @@ -157,8 +151,8 @@ public boolean withTransaction(SQLBooleanAction action) { conn.setAutoCommit(true); conn.close(); } catch - (SQLException ignored) { - ignored.printStackTrace(); + (SQLException e) { + e.printStackTrace(); } } } diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index 660a4991..8e20b7b7 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -319,9 +319,9 @@ CREATE TABLE IF NOT EXISTS player.minigame ( ); CREATE TABLE IF NOT EXISTS player.map_transfer ( - character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , - map_id INT NOT NULL, - old_map_id INT NOT NULL + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE, + map_ids INT[] NOT NULL, + old_map_ids INT[] NOT NULL ); CREATE TABLE IF NOT EXISTS player.wild_hunter ( diff --git a/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java b/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java index fa88c4b3..8aef82f6 100644 --- a/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java +++ b/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java @@ -1,19 +1,13 @@ package kinoko.database.postgresql.type; -import kinoko.world.GameConstants; import kinoko.world.item.InventoryManager; -import kinoko.world.quest.QuestRecord; import kinoko.world.skill.SkillManager; import kinoko.world.quest.QuestManager; -import kinoko.world.skill.SkillRecord; import kinoko.world.user.CharacterData; import kinoko.world.user.data.*; import kinoko.world.user.stat.CharacterStat; import java.sql.*; -import java.time.Instant; -import java.util.Arrays; -import java.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; @@ -218,10 +212,13 @@ public static boolean newCharacter(Connection conn, CharacterData characterData) // Save all dependent data using the same connection saveCharacterStats(conn, characterData); InventoryDao.saveCharacter(conn, characterData); - saveCharacterSkills(conn, characterData); - saveCharacterQuests(conn, characterData); - saveCharacterConfig(conn, characterData); - saveCharacterPopularity(conn, characterData); + SkillManagerDao.saveCharacterSkills(conn, characterData); + QuestManagerDao.saveCharacterQuests(conn, characterData); + ConfigManagerDao.saveCharacterConfig(conn, characterData); + PopularityRecordDao.saveCharacterPopularity(conn, characterData); + MapTransferInfoDao.saveMapTransferInfo(conn, characterData); + MiniGameRecordDao.saveMiniGameRecord(conn, characterData); + WildHunterInfoDao.saveWildHunterInfo(conn, characterData); ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); return true; @@ -325,184 +322,6 @@ ON CONFLICT (character_id) DO UPDATE SET } } - /** - * Saves or updates a character’s configuration data, including pet settings, key mappings, - * quickslot layout, and skill macros. - * Uses UPSERT logic to maintain up-to-date configuration for the given character. - * - * @param conn the active database connection - * @param characterData the character whose configuration should be saved - * @throws SQLException if a database access error occurs - */ - private static void saveCharacterConfig(Connection conn, CharacterData characterData) throws SQLException { - ConfigManager config = characterData.getConfigManager(); - - String sql = """ - INSERT INTO player.config - (character_id, pet_consume_item, pet_consume_mp_item, pet_exception_list, func_key_types, func_key_ids, quickslot_key_map) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (character_id) DO UPDATE - SET pet_consume_item = EXCLUDED.pet_consume_item, - pet_consume_mp_item = EXCLUDED.pet_consume_mp_item, - pet_exception_list = EXCLUDED.pet_exception_list, - func_key_types = EXCLUDED.func_key_types, - func_key_ids = EXCLUDED.func_key_ids, - quickslot_key_map = EXCLUDED.quickslot_key_map - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, config.getPetConsumeItem()); - stmt.setInt(3, config.getPetConsumeMpItem()); - - // pet_exception_list -> List -> Integer[] - List exceptionList = config.getPetExceptionList(); - Integer[] exceptionArray = exceptionList != null ? exceptionList.toArray(new Integer[0]) : new Integer[0]; - stmt.setArray(4, conn.createArrayOf("integer", exceptionArray)); - - // func_key_types & func_key_ids from FuncKeyMapped[] - FuncKeyMapped[] funcKeyMap = config.getFuncKeyMap(); - if (funcKeyMap == null) { - funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); - } - - Short[] funcTypes = Arrays.stream(funcKeyMap) - .map(f -> (short) f.getType().getValue()) - .toArray(Short[]::new); - Integer[] funcIds = Arrays.stream(funcKeyMap) - .map(FuncKeyMapped::getId) - .toArray(Integer[]::new); - - stmt.setArray(5, conn.createArrayOf("smallint", funcTypes)); // func_key_types - stmt.setArray(6, conn.createArrayOf("integer", funcIds)); // func_key_ids - - // quickslot_key_map -> int[] -> Integer[] - int[] quickslot = config.getQuickslotKeyMap(); - Integer[] quickslotKeys = quickslot != null - ? Arrays.stream(quickslot).boxed().toArray(Integer[]::new) - : new Integer[0]; - stmt.setArray(7, conn.createArrayOf("integer", quickslotKeys)); - - stmt.executeUpdate(); - } - // save skill macros - SkillMacrosDao.upsertMacros(conn, characterData.getCharacterId(), config.getMacroSysData()); - } - - /** - * Saves the character’s popularity (fame) relationships to other characters. - * Each entry represents a character that has received or given popularity points. - * Uses UPSERT logic to ensure timestamps are updated for existing records. - * - * @param conn the active database connection - * @param characterData the character whose popularity data should be saved - * @throws SQLException if a database access error occurs - */ - private static void saveCharacterPopularity(Connection conn, CharacterData characterData) throws SQLException { - String sql = """ - INSERT INTO player.popularity (character_id, other_character_id, timestamp) - VALUES (?, ?, ?) - ON CONFLICT (character_id, other_character_id) - DO UPDATE SET timestamp = EXCLUDED.timestamp - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - PopularityRecord pr = characterData.getPopularityRecord(); - int charId = characterData.getCharacterId(); - - for (var entry : pr.getRecords().entrySet()) { - stmt.setInt(1, charId); - stmt.setInt(2, entry.getKey()); - stmt.setTimestamp(3, Timestamp.from(entry.getValue())); - stmt.addBatch(); - } - - stmt.executeBatch(); - } - } - - - /** - * Saves or updates all skill-related data for the given character, including: - * - Skill levels and master levels - * - Active skill cooldowns - * Uses UPSERT logic to maintain consistency between client and server skill data. - * - * @param conn the active database connection - * @param characterData the character whose skills should be saved - * @throws SQLException if a database access error occurs - */ - private static void saveCharacterSkills(Connection conn, CharacterData characterData) throws SQLException { - String skillRecordSql = """ - INSERT INTO player.skill_record (character_id, skill_id, level, master_level) - VALUES (?, ?, ?, ?) - ON CONFLICT (character_id, skill_id) - DO UPDATE SET level = EXCLUDED.level, master_level = EXCLUDED.master_level - """; - - try (PreparedStatement stmt = conn.prepareStatement(skillRecordSql)) { - for (SkillRecord sr : characterData.getSkillManager().getSkillRecords()) { - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, sr.getSkillId()); - stmt.setInt(3, sr.getSkillLevel()); - stmt.setInt(4, sr.getMasterLevel()); - stmt.addBatch(); - } - stmt.executeBatch(); - } - - // Save skill cooltimes - String skillCooltimeSql = """ - INSERT INTO player.skill_cooltime (character_id, skill_id, cooldown_end) - VALUES (?, ?, ?) - ON CONFLICT (character_id, skill_id) - DO UPDATE SET cooldown_end = EXCLUDED.cooldown_end - """; - try (PreparedStatement stmt = conn.prepareStatement(skillCooltimeSql)) { - for (var entry : characterData.getSkillManager().getSkillCooltimes().entrySet()) { - int skillId = entry.getKey(); - Instant endTime = entry.getValue(); - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, skillId); - stmt.setTimestamp(3, Timestamp.from(endTime)); - stmt.addBatch(); - } - stmt.executeBatch(); - } - } - - /** - * Saves or updates the character’s quest progress records. - * Each entry includes the quest ID, its current state, progress string, and completion timestamp. - * Uses UPSERT logic to handle both new and existing quest records efficiently. - * - * @param conn the active database connection - * @param characterData the character whose quest data should be saved - * @throws SQLException if a database access error occurs - */ - private static void saveCharacterQuests(Connection conn, CharacterData characterData) throws SQLException { - String sql = """ - INSERT INTO player.quest_record (character_id, quest_id, status, progress, completed_time) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT (character_id, quest_id) - DO UPDATE SET status = EXCLUDED.status, - progress = EXCLUDED.progress, - completed_time = EXCLUDED.completed_time - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - for (QuestRecord qr : characterData.getQuestManager().getQuestRecords()) { - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, qr.getQuestId()); - stmt.setInt(3, qr.getState().getValue()); - stmt.setString(4, qr.getValue()); - stmt.setTimestamp(5, qr.getCompletedTime() != null ? Timestamp.from(qr.getCompletedTime()) : null); - stmt.addBatch(); - } - stmt.executeBatch(); - } - } - /** * Saves/updates a CharacterData object to the database. * @@ -543,10 +362,13 @@ public static boolean saveCharacter(Connection conn, CharacterData characterData // Save dependent tables using the same connection saveCharacterStats(conn, characterData); InventoryDao.saveCharacter(conn, characterData); - saveCharacterSkills(conn, characterData); - saveCharacterQuests(conn, characterData); - saveCharacterConfig(conn, characterData); - saveCharacterPopularity(conn, characterData); + SkillManagerDao.saveCharacterSkills(conn, characterData); + QuestManagerDao.saveCharacterQuests(conn, characterData); + ConfigManagerDao.saveCharacterConfig(conn, characterData); + PopularityRecordDao.saveCharacterPopularity(conn, characterData); + MapTransferInfoDao.saveMapTransferInfo(conn, characterData); + MiniGameRecordDao.saveMiniGameRecord(conn, characterData); + WildHunterInfoDao.saveWildHunterInfo(conn, characterData); ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); return true; diff --git a/src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java b/src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java index 1cb378e8..45b9e2e1 100644 --- a/src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java @@ -2,6 +2,7 @@ import kinoko.world.GameConstants; +import kinoko.world.user.CharacterData; import kinoko.world.user.data.ConfigManager; import kinoko.world.user.data.FuncKeyMapped; import kinoko.world.user.data.FuncKeyType; @@ -89,4 +90,69 @@ public static ConfigManager loadConfig(Connection conn, int characterId) throws } } } + + + /** + * Saves or updates a character’s configuration data, including pet settings, key mappings, + * quickslot layout, and skill macros. + * Uses UPSERT logic to maintain up-to-date configuration for the given character. + * + * @param conn the active database connection + * @param characterData the character whose configuration should be saved + * @throws SQLException if a database access error occurs + */ + public static void saveCharacterConfig(Connection conn, CharacterData characterData) throws SQLException { + ConfigManager config = characterData.getConfigManager(); + + String sql = """ + INSERT INTO player.config + (character_id, pet_consume_item, pet_consume_mp_item, pet_exception_list, func_key_types, func_key_ids, quickslot_key_map) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (character_id) DO UPDATE + SET pet_consume_item = EXCLUDED.pet_consume_item, + pet_consume_mp_item = EXCLUDED.pet_consume_mp_item, + pet_exception_list = EXCLUDED.pet_exception_list, + func_key_types = EXCLUDED.func_key_types, + func_key_ids = EXCLUDED.func_key_ids, + quickslot_key_map = EXCLUDED.quickslot_key_map + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, config.getPetConsumeItem()); + stmt.setInt(3, config.getPetConsumeMpItem()); + + // pet_exception_list -> List -> Integer[] + List exceptionList = config.getPetExceptionList(); + Integer[] exceptionArray = exceptionList != null ? exceptionList.toArray(new Integer[0]) : new Integer[0]; + stmt.setArray(4, conn.createArrayOf("integer", exceptionArray)); + + // func_key_types & func_key_ids from FuncKeyMapped[] + FuncKeyMapped[] funcKeyMap = config.getFuncKeyMap(); + if (funcKeyMap == null) { + funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); + } + + Short[] funcTypes = Arrays.stream(funcKeyMap) + .map(f -> (short) f.getType().getValue()) + .toArray(Short[]::new); + Integer[] funcIds = Arrays.stream(funcKeyMap) + .map(FuncKeyMapped::getId) + .toArray(Integer[]::new); + + stmt.setArray(5, conn.createArrayOf("smallint", funcTypes)); // func_key_types + stmt.setArray(6, conn.createArrayOf("integer", funcIds)); // func_key_ids + + // quickslot_key_map -> int[] -> Integer[] + int[] quickslot = config.getQuickslotKeyMap(); + Integer[] quickslotKeys = quickslot != null + ? Arrays.stream(quickslot).boxed().toArray(Integer[]::new) + : new Integer[0]; + stmt.setArray(7, conn.createArrayOf("integer", quickslotKeys)); + + stmt.executeUpdate(); + } + // save skill macros + SkillMacrosDao.upsertMacros(conn, characterData.getCharacterId(), config.getMacroSysData()); + } } diff --git a/src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java b/src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java index 2ec92ba7..9f1b6675 100644 --- a/src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java @@ -9,7 +9,15 @@ public final class ExtendSpDao { /** - * Upserts all entries from ExtendSp into player.extend_sp. + * Inserts or updates extended SP data for a character in the database. + * If an entry with the same character ID and job level already exists, the SP value is updated. + * Otherwise, a new record is inserted. + * Skips execution if the provided ExtendSp object is null or empty. + * + * @param conn the active database connection + * @param characterId the ID of the character whose SP data is being stored + * @param extendSp the ExtendSp object containing job-level-to-SP mappings + * @throws SQLException if a database access error occurs */ public static void upsertExtendSp(Connection conn, int characterId, ExtendSp extendSp) throws SQLException { if (extendSp == null || extendSp.getMap().isEmpty()) { @@ -35,7 +43,14 @@ ON CONFLICT (character_id, job_level) } /** - * Loads ExtendSp for a given character. + * Loads a character's extended SP data from the database. + * Retrieves all job level–SP pairs associated with the given character ID and constructs an ExtendSp object from them. + * If no records are found, an empty ExtendSp instance is returned. + * + * @param conn the active database connection + * @param characterId the ID of the character whose extended SP data is being loaded + * @return an ExtendSp object containing the character's job-level-to-SP mappings, or empty if none exist + * @throws SQLException if a database access error occurs */ public static ExtendSp loadExtendSp(Connection conn, int characterId) throws SQLException { String sql = """ diff --git a/src/main/java/kinoko/database/postgresql/type/ItemDao.java b/src/main/java/kinoko/database/postgresql/type/ItemDao.java index ff43c05c..f596c452 100644 --- a/src/main/java/kinoko/database/postgresql/type/ItemDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ItemDao.java @@ -15,7 +15,7 @@ public class ItemDao { /** * Inserts a new item into the `item.items` table and returns its generated item_sn. - *

+ * * If the insertion is successful, the auto-generated item_sn is also set in the provided Item object. * This method is useful when creating a new item that does not yet have an item_sn. * @@ -56,11 +56,11 @@ public static long createNewItem(Connection conn, Item item) throws SQLException /** * Saves a collection of items to the database in batch. - *

+ * * For each item, this method checks if it already has an item_sn: * - If the item_sn is missing (<=0), a new one is generated and the item is inserted. * - If the item_sn exists, the item is updated with the latest quantity, attributes, title, and expiration date. - *

+ * * This approach ensures efficient batch inserts for new items while keeping existing items up to date. * * @param conn the active database connection @@ -127,6 +127,15 @@ public static void saveItemsBatch(Connection conn, Collection items) throw } } + /** + * Constructs an Item instance from the provided ResultSet. + * Extracts all relevant item data, including equipment, pet, and ring information if applicable. + * Converts nullable SQL fields (e.g., timestamps or optional data groups) into corresponding Java objects. + * + * @param rs the ResultSet containing item data retrieved from the database + * @return a fully populated Item object built from the ResultSet data + * @throws SQLException if a database access error occurs or a column value cannot be retrieved + */ public static Item from(ResultSet rs) throws SQLException { long itemSn = rs.getLong("item_sn"); int itemId = rs.getInt("item_id"); @@ -208,12 +217,12 @@ public static Item from(ResultSet rs) throws SQLException { /** * Cleans up invalid items from the database that no longer have valid references. - *

+ * * In the PostgreSQL implementation, all items are stored in {@code item.Items}, regardless of * whether they are currently held by a player (inventory, trunk, locker, wishlist, gifted) * or not. This can lead to orphaned item records when items are dropped, since dropped * items are not tracked by the database. - *

+ * * This method queries and removes items that are not referenced anywhere else, ensuring * synchronization between the in-game state and the persistent database state. This function * typically called during server initialization, when no dropped items exist. diff --git a/src/main/java/kinoko/database/postgresql/type/MapTransferInfoDao.java b/src/main/java/kinoko/database/postgresql/type/MapTransferInfoDao.java index 3d30a660..f0329e14 100644 --- a/src/main/java/kinoko/database/postgresql/type/MapTransferInfoDao.java +++ b/src/main/java/kinoko/database/postgresql/type/MapTransferInfoDao.java @@ -1,41 +1,80 @@ package kinoko.database.postgresql.type; +import kinoko.world.user.CharacterData; import kinoko.world.user.data.MapTransferInfo; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; +import java.sql.*; +import java.util.Arrays; public final class MapTransferInfoDao { /** * Loads MapTransferInfo for the specified character. * - * Retrieves the main map ID and legacy/old map ID for the character. + * Retrieves the map_ids and old_map_ids arrays from the database and populates + * the corresponding lists in a MapTransferInfo object. * * @param conn the database connection to use * @param characterId the ID of the character - * @return MapTransferInfo populated with map transfer data + * @return MapTransferInfo populated with the character's map transfer lists * @throws SQLException if a database access error occurs */ public static MapTransferInfo loadMapTransferInfo(Connection conn, int characterId) throws SQLException { MapTransferInfo mti = new MapTransferInfo(); - String sql = "SELECT map_id, old_map_id FROM player.map_transfer WHERE character_id = ?"; + String sql = "SELECT map_ids, old_map_ids FROM player.map_transfer WHERE character_id = ?"; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, characterId); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - int mapId = rs.getInt("map_id"); - int oldMapId = rs.getInt("old_map_id"); + Array mapArray = rs.getArray("map_ids"); + Array oldMapArray = rs.getArray("old_map_ids"); - mti.getMapTransfer().add(mapId); // main list - mti.getMapTransferEx().add(oldMapId); // legacy/old map + if (mapArray != null) { + Integer[] mapIds = (Integer[]) mapArray.getArray(); + mti.getMapTransfer().addAll(Arrays.asList(mapIds)); + } + if (oldMapArray != null) { + Integer[] oldMapIds = (Integer[]) oldMapArray.getArray(); + mti.getMapTransferEx().addAll(Arrays.asList(oldMapIds)); + } } } } return mti; } + + /** + * Saves MapTransferInfo for the specified character. + * + * Replaces or inserts the character’s map transfer data in the database. + * Converts the map transfer lists into SQL integer arrays and performs an UPSERT. + * + * @param conn the database connection to use + * @param characterData CharacterData instance. + * @throws SQLException if a database access error occurs + */ + public static void saveMapTransferInfo(Connection conn, CharacterData characterData) throws SQLException { + MapTransferInfo mapTransferInfo = characterData.getMapTransferInfo(); + int characterId = characterData.getCharacterId(); + + String sql = """ + INSERT INTO player.map_transfer (character_id, map_ids, old_map_ids) + VALUES (?, ?, ?) + ON CONFLICT (character_id) + DO UPDATE SET map_ids = EXCLUDED.map_ids, old_map_ids = EXCLUDED.old_map_ids + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + // Convert Java lists to SQL array + Array mapArray = conn.createArrayOf("INTEGER", mapTransferInfo.getMapTransfer().toArray()); + Array oldMapArray = conn.createArrayOf("INTEGER", mapTransferInfo.getMapTransferEx().toArray()); + + stmt.setInt(1, characterId); + stmt.setArray(2, mapArray); + stmt.setArray(3, oldMapArray); + stmt.executeUpdate(); + } + } } diff --git a/src/main/java/kinoko/database/postgresql/type/MiniGameRecordDao.java b/src/main/java/kinoko/database/postgresql/type/MiniGameRecordDao.java index 46552689..723c3208 100644 --- a/src/main/java/kinoko/database/postgresql/type/MiniGameRecordDao.java +++ b/src/main/java/kinoko/database/postgresql/type/MiniGameRecordDao.java @@ -1,5 +1,6 @@ package kinoko.database.postgresql.type; +import kinoko.world.user.CharacterData; import kinoko.world.user.data.MiniGameRecord; import java.sql.Connection; @@ -48,4 +49,50 @@ public static MiniGameRecord loadMiniGameRecord(Connection conn, int characterId return record; } + + /** + * Saves MiniGameRecord for the specified character. + * + * Updates the Omok and Memory game statistics in the database. + * If a record for the character does not exist, it inserts a new one. + * + * @param conn the database connection to use + * @param characterData CharacterData object + * @throws SQLException if a database access error occurs + */ + public static void saveMiniGameRecord(Connection conn, CharacterData characterData) throws SQLException { + int characterId = characterData.getCharacterId(); + MiniGameRecord record = characterData.getMiniGameRecord(); + + String sql = """ + INSERT INTO player.minigame + (character_id, omok_wins, omok_ties, omok_losses, omok_score, + memory_wins, memory_ties, memory_losses, memory_score) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (character_id) + DO UPDATE SET + omok_wins = EXCLUDED.omok_wins, + omok_ties = EXCLUDED.omok_ties, + omok_losses = EXCLUDED.omok_losses, + omok_score = EXCLUDED.omok_score, + memory_wins = EXCLUDED.memory_wins, + memory_ties = EXCLUDED.memory_ties, + memory_losses = EXCLUDED.memory_losses, + memory_score = EXCLUDED.memory_score + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + stmt.setInt(2, record.getOmokGameWins()); + stmt.setInt(3, record.getOmokGameTies()); + stmt.setInt(4, record.getOmokGameLosses()); + stmt.setDouble(5, record.getOmokGameScore()); + stmt.setInt(6, record.getMemoryGameWins()); + stmt.setInt(7, record.getMemoryGameTies()); + stmt.setInt(8, record.getMemoryGameLosses()); + stmt.setDouble(9, record.getMemoryGameScore()); + + stmt.executeUpdate(); + } + } } diff --git a/src/main/java/kinoko/database/postgresql/type/PopularityRecordDao.java b/src/main/java/kinoko/database/postgresql/type/PopularityRecordDao.java index 7d8240a1..3de8d394 100644 --- a/src/main/java/kinoko/database/postgresql/type/PopularityRecordDao.java +++ b/src/main/java/kinoko/database/postgresql/type/PopularityRecordDao.java @@ -1,5 +1,6 @@ package kinoko.database.postgresql.type; +import kinoko.world.user.CharacterData; import kinoko.world.user.data.PopularityRecord; import java.sql.Connection; @@ -41,4 +42,37 @@ public static PopularityRecord loadPopularityRecord(Connection conn, int charact return pr; } + + /** + * Saves the character’s popularity (fame) relationships to other characters. + * Each entry represents a character that has received or given popularity points. + * Uses UPSERT logic to ensure timestamps are updated for existing records. + * + * @param conn the active database connection + * @param characterData the character whose popularity data should be saved + * @throws SQLException if a database access error occurs + */ + public static void saveCharacterPopularity(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.popularity (character_id, other_character_id, timestamp) + VALUES (?, ?, ?) + ON CONFLICT (character_id, other_character_id) + DO UPDATE SET timestamp = EXCLUDED.timestamp + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + PopularityRecord pr = characterData.getPopularityRecord(); + int charId = characterData.getCharacterId(); + + for (var entry : pr.getRecords().entrySet()) { + stmt.setInt(1, charId); + stmt.setInt(2, entry.getKey()); + stmt.setTimestamp(3, Timestamp.from(entry.getValue())); + stmt.addBatch(); + } + + stmt.executeBatch(); + } + } + } diff --git a/src/main/java/kinoko/database/postgresql/type/QuestManagerDao.java b/src/main/java/kinoko/database/postgresql/type/QuestManagerDao.java index 474a5a51..faf6ec63 100644 --- a/src/main/java/kinoko/database/postgresql/type/QuestManagerDao.java +++ b/src/main/java/kinoko/database/postgresql/type/QuestManagerDao.java @@ -3,6 +3,7 @@ import kinoko.world.quest.QuestManager; import kinoko.world.quest.QuestRecord; import kinoko.world.quest.QuestState; +import kinoko.world.user.CharacterData; import java.sql.*; import java.time.Instant; @@ -51,4 +52,37 @@ public static QuestManager loadQuestRecords(Connection conn, int characterId) th return qm; } + + + /** + * Saves or updates the character’s quest progress records. + * Each entry includes the quest ID, its current state, progress string, and completion timestamp. + * Uses UPSERT logic to handle both new and existing quest records efficiently. + * + * @param conn the active database connection + * @param characterData the character whose quest data should be saved + * @throws SQLException if a database access error occurs + */ + public static void saveCharacterQuests(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.quest_record (character_id, quest_id, status, progress, completed_time) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (character_id, quest_id) + DO UPDATE SET status = EXCLUDED.status, + progress = EXCLUDED.progress, + completed_time = EXCLUDED.completed_time + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (QuestRecord qr : characterData.getQuestManager().getQuestRecords()) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, qr.getQuestId()); + stmt.setInt(3, qr.getState().getValue()); + stmt.setString(4, qr.getValue()); + stmt.setTimestamp(5, qr.getCompletedTime() != null ? Timestamp.from(qr.getCompletedTime()) : null); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } } diff --git a/src/main/java/kinoko/database/postgresql/type/SkillManagerDao.java b/src/main/java/kinoko/database/postgresql/type/SkillManagerDao.java index 0bf51c2d..83b8c0c7 100644 --- a/src/main/java/kinoko/database/postgresql/type/SkillManagerDao.java +++ b/src/main/java/kinoko/database/postgresql/type/SkillManagerDao.java @@ -3,8 +3,10 @@ import kinoko.world.skill.SkillManager; import kinoko.world.skill.SkillRecord; +import kinoko.world.user.CharacterData; import java.sql.*; +import java.time.Instant; public final class SkillManagerDao { @@ -54,4 +56,53 @@ public static SkillManager loadSkillCooltimesAndRecords(Connection conn, int cha return sm; } + + /** + * Saves or updates all skill-related data for the given character, including: + * - Skill levels and master levels + * - Active skill cooldowns + * Uses UPSERT logic to maintain consistency between client and server skill data. + * + * @param conn the active database connection + * @param characterData the character whose skills should be saved + * @throws SQLException if a database access error occurs + */ + public static void saveCharacterSkills(Connection conn, CharacterData characterData) throws SQLException { + String skillRecordSql = """ + INSERT INTO player.skill_record (character_id, skill_id, level, master_level) + VALUES (?, ?, ?, ?) + ON CONFLICT (character_id, skill_id) + DO UPDATE SET level = EXCLUDED.level, master_level = EXCLUDED.master_level + """; + + try (PreparedStatement stmt = conn.prepareStatement(skillRecordSql)) { + for (SkillRecord sr : characterData.getSkillManager().getSkillRecords()) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, sr.getSkillId()); + stmt.setInt(3, sr.getSkillLevel()); + stmt.setInt(4, sr.getMasterLevel()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + + // Save skill cooltimes + String skillCooltimeSql = """ + INSERT INTO player.skill_cooltime (character_id, skill_id, cooldown_end) + VALUES (?, ?, ?) + ON CONFLICT (character_id, skill_id) + DO UPDATE SET cooldown_end = EXCLUDED.cooldown_end + """; + try (PreparedStatement stmt = conn.prepareStatement(skillCooltimeSql)) { + for (var entry : characterData.getSkillManager().getSkillCooltimes().entrySet()) { + int skillId = entry.getKey(); + Instant endTime = entry.getValue(); + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, skillId); + stmt.setTimestamp(3, Timestamp.from(endTime)); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } } diff --git a/src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java b/src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java index d7cf370d..bf98e3da 100644 --- a/src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java +++ b/src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java @@ -1,5 +1,6 @@ package kinoko.database.postgresql.type; +import kinoko.world.user.CharacterData; import kinoko.world.user.data.WildHunterInfo; import java.sql.Connection; @@ -45,4 +46,52 @@ public static WildHunterInfo loadWildHunterInfo(Connection conn, int characterId return wh; } + + /** + * Saves WildHunterInfo for the specified character. + * + * Updates the riding type and captured mobs for the character. + * Existing captured mobs are cleared and replaced with the provided list (up to 5 mobs). + * + * @param conn the database connection to use + * @param characterData The CharacterData object + * @throws SQLException if a database access error occurs + */ + public static void saveWildHunterInfo(Connection conn, CharacterData characterData) throws SQLException { + int characterId = characterData.getCharacterId(); + WildHunterInfo wh = characterData.getWildHunterInfo(); + + // Save riding type + String sqlRiding = """ + INSERT INTO player.wild_hunter (character_id, riding_type) + VALUES (?, ?) + ON CONFLICT (character_id) + DO UPDATE SET riding_type = EXCLUDED.riding_type + """; + try (PreparedStatement stmt = conn.prepareStatement(sqlRiding)) { + stmt.setInt(1, characterId); + stmt.setInt(2, wh.getRidingType()); + stmt.executeUpdate(); + } + + // Clear existing captured mobs + String sqlDeleteMobs = "DELETE FROM player.wild_hunter_mob WHERE character_id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sqlDeleteMobs)) { + stmt.setInt(1, characterId); + stmt.executeUpdate(); + } + + // Insert captured mobs (up to 5) + String sqlInsertMob = "INSERT INTO player.wild_hunter_mob (character_id, mob_id) VALUES (?, ?)"; + try (PreparedStatement stmt = conn.prepareStatement(sqlInsertMob)) { + int count = 0; + for (Integer mobId : wh.getCapturedMobs()) { + if (count++ >= 5) break; + stmt.setInt(1, characterId); + stmt.setInt(2, mobId); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } } From 0e43fa10462cb650563238b9622865074eab4972 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 23 Sep 2025 08:35:41 -0400 Subject: [PATCH 26/83] Created blank wz directory and added client setup links --- .gitignore | 2 +- README.md | 9 ++++++++- wz/.gitkeep | 0 3 files changed, 9 insertions(+), 2 deletions(-) create mode 100644 wz/.gitkeep diff --git a/.gitignore b/.gitignore index bd6e24a6..b1929c8d 100644 --- a/.gitignore +++ b/.gitignore @@ -41,5 +41,5 @@ build/ .idea/ ### Project ### -/wz/ +/wz/*.wz /json/ diff --git a/README.md b/README.md index be2993b6..2803ad4b 100644 --- a/README.md +++ b/README.md @@ -8,8 +8,14 @@ Basic configuration is available via environment variables - the names and defau are defined in [ServerConstants.java](src/main/java/kinoko/server/ServerConstants.java) and [ServerConfig.java](src/main/java/kinoko/server/ServerConfig.java). +### Client Downloads +[GMS V95 Client Setup](https://mega.nz/file/dWIgyR4I#6cDN_ycLLiFtad07Eby3UfjdY3TqGI65g6X-xEqlmds) +[GMS v95.1 Localhost](https://mega.nz/file/dWIgyR4I#6cDN_ycLLiFtad07Eby3UfjdY3TqGI65g6X-xEqlmds) + + + > [!NOTE] -> Client WZ files are expected to be present in the `wz/` directory in order for the provider classes to extract the +> Client .WZ files are expected to be present in the `wz/` directory in order for the provider classes to extract the > required data. The required files are as follows: > ``` > Character.wz @@ -25,6 +31,7 @@ and [ServerConfig.java](src/main/java/kinoko/server/ServerConfig.java). > Etc.wz > ``` + #### Java setup Building the project requires Java 21 and maven. diff --git a/wz/.gitkeep b/wz/.gitkeep new file mode 100644 index 00000000..e69de29b From e11941dd07cac1094288c2695ee96fc197a96018 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 23 Sep 2025 08:39:06 -0400 Subject: [PATCH 27/83] updated client setup link --- README.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/README.md b/README.md index 2803ad4b..6b7fc844 100644 --- a/README.md +++ b/README.md @@ -9,7 +9,7 @@ are defined in [ServerConstants.java](src/main/java/kinoko/server/ServerConstant and [ServerConfig.java](src/main/java/kinoko/server/ServerConfig.java). ### Client Downloads -[GMS V95 Client Setup](https://mega.nz/file/dWIgyR4I#6cDN_ycLLiFtad07Eby3UfjdY3TqGI65g6X-xEqlmds) +[GMS V95 Client Setup](https://ia600809.us.archive.org/19/items/GMSSetup93-133/GMS0095/GMSSetupv95.exe) [GMS v95.1 Localhost](https://mega.nz/file/dWIgyR4I#6cDN_ycLLiFtad07Eby3UfjdY3TqGI65g6X-xEqlmds) From 9d4fad707fb5b7f275fc9a380e9852ce26145552 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 15:11:54 -0400 Subject: [PATCH 28/83] Updated repo based configs --- .env.example | 27 ++ .gitignore | 1 + Dockerfile | 3 + README.md | 29 +- docker-compose.yml | 62 +++- pom.xml | 15 + .../database/postgresql/PostgresAccessor.java | 25 ++ .../postgresql/PostgresAccountAccessor.java | 198 ++++++++++ .../postgresql/PostgresCharacterAccessor.java | 347 ++++++++++++++++++ .../postgresql/PostgresConnector.java | 4 + .../postgresql/PostgresFriendAccessor.java | 84 +++++ .../postgresql/PostgresGiftAccessor.java | 86 +++++ .../postgresql/PostgresGuildAccessor.java | 161 ++++++++ .../postgresql/PostgresIdAccessor.java | 67 ++++ .../postgresql/PostgresMemoAccessor.java | 94 +++++ .../kinoko/database/postgresql/setup/init.sql | 0 16 files changed, 1195 insertions(+), 8 deletions(-) create mode 100644 .env.example create mode 100644 src/main/java/kinoko/database/postgresql/PostgresAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresConnector.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/setup/init.sql diff --git a/.env.example b/.env.example new file mode 100644 index 00000000..9fb73436 --- /dev/null +++ b/.env.example @@ -0,0 +1,27 @@ +# This example shows a setup for Cassandra on Prod + +# DEV ENV +TESPIA=FALSE + +# DOCKERIZED JAVA SERVER +DB_HOST=cassandra_kinoko +# DB_HOST=postgres_kinoko + +# LOCAL (Undockerized) JAVA SERVER (DB SERVER CAN BE DOCKERIZED) +# DB_HOST=localhost + +# Cassandra Settings +DB_DATACENTER="datacenter1" +DB_PROFILE_ONE="profile_one" + +DB_PORT=9042 +# DB_PORT=5432 + +# DB NAME (Cassandra Keyspace, or Postgres Database Name) +DB_NAME=kinoko + + +# Postgres Settings +DB_USER=postgres +DB_PASS=admin + diff --git a/.gitignore b/.gitignore index b1929c8d..9b66c505 100644 --- a/.gitignore +++ b/.gitignore @@ -2,6 +2,7 @@ target/ !.mvn/wrapper/maven-wrapper.jar !**/src/main/**/target/ !**/src/test/**/target/ +*.env ### IntelliJ IDEA ### .idea/modules.xml diff --git a/Dockerfile b/Dockerfile index 1490808e..2f1caa9b 100644 --- a/Dockerfile +++ b/Dockerfile @@ -6,6 +6,9 @@ WORKDIR /kinoko COPY pom.xml . RUN mvn dependency:go-offline +# not sure why this was left out originally, if it's needed to pass tests to build the JAR. +COPY wz ./wz + COPY src ./src RUN mvn clean package diff --git a/README.md b/README.md index 6b7fc844..b83a607e 100644 --- a/README.md +++ b/README.md @@ -41,16 +41,35 @@ Building the project requires Java 21 and maven. $ mvn clean package ``` +#### Environment setup +Before doing any Docker or Database Setup +You should: +1. Make a copy of `.env.example` and rename it to `.env`. +2. Adjust the ENV Variables to the database server you will be using. + + #### Database setup -It is possible to use either CassandraDB or ScyllaDB, no setup is required other than starting the database. +It is possible to use either CassandraDB, ScyllaDB, or Postgres. + + ```bash # Start CassandraDB +$ docker-compose up -d cassandra +# OR $ docker run -d -p 9042:9042 cassandra:5.0.0 # Alternatively, start ScyllaDB $ docker run -d -p 9042:9042 scylladb/scylla --smp 1 + +# Alternatively, start PostgreSQL +$ docker-compose up -d postgres +# OR (CHANGE THE PASSWORD) +$ docker run -d --name postgres_kinoko -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=admin -e POSTGRES_INITDB_ARGS="--auth-host=scram-sha-256 --auth-local=scram-sha-256" -e POSTGRES_DB=kinoko -p 5432:5432 -v "${PWD}\src\main\java\kinoko\database\postgresql\setup\init.sql:/docker-entrypoint-initdb.d/init.sql:ro" postgres:16 + +Important: If you are using PostgreSQL on a local machine (not using a dockerized server), make sure that you have any undockerized postgresql server offline. This can cause conflicts. + ``` You can use [Docker Desktop](https://www.docker.com/products/docker-desktop/) or WSL on Windows. @@ -65,5 +84,11 @@ the [docker-compose.yml](docker-compose.yml) file. The requirements are as follo ```bash # Build and start containers -$ docker compose up -d + +# Cassandra & Server (Recommended, default) +$ docker compose up -d cassandra server + +# Postgres & Server (Alternative) +$ docker compose up -d postgres server + ``` diff --git a/docker-compose.yml b/docker-compose.yml index 034332af..26500324 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,22 +1,28 @@ version: "3" services: server: + container_name: server_kinoko build: . - depends_on: - database: - condition: service_healthy +# we don't want to build both database servers. Not sure how to incorporate this dependency to only the chosen DB. +# depends_on: +# cassandra: +# condition: service_healthy +# postgres: +# condition: service_healthy ports: - 8484:8484 - 8585-8989:8585-8989 volumes: - ./data:/kinoko/data - ./wz:/kinoko/wz + env_file: + - .env environment: # ServerConstants CENTRAL_HOST: "127.0.0.1" CENTRAL_PORT: "8282" SERVER_HOST: "127.0.0.1" - DATABASE_HOST: "database" + # ServerConfig WORLD_ID: "0" WORLD_NAME: "Kinoko" @@ -25,10 +31,21 @@ services: REQUIRE_SECONDARY_PASSWORD: "true" WZ_DIRECTORY: "/kinoko/wz" DATA_DIRECTORY: "/kinoko/data" - COMMAND_PREFIX: "!" + PLAYER_COMMAND_PREFIX: "@" + STAFF_COMMAND_PREFIX: "!" DEBUG_MODE: "true" - database: + networks: + - kinoko_net + + cassandra: + profiles: ["cassandra"] + env_file: # not necessarily needed unless extra vars are added. + - .env + environment: + DB_TYPE: "cassandra" + DB_HOST: "cassandra" image: cassandra:5.0.0-jammy + container_name: cassandra_kinoko ports: - 9042:9042 healthcheck: @@ -36,3 +53,36 @@ services: interval: 10s timeout: 5s retries: 5 + volumes: + - ./cassanddata:/var/lib/cassandra + networks: + - kinoko_net + + postgres: + profiles: ["postgres", "postgresql"] + image: postgres:16 + container_name: postgres_kinoko + env_file: + - .env + environment: + DB_TYPE: "postgres" + DB_HOST: "postgres" + POSTGRES_USER: "${DB_USER:-postgres}" + POSTGRES_PASSWORD: "${DB_PASS:-admin}" + POSTGRES_DB: "${DB_NAME:-kinoko}" + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postgres} -d ${DB_NAME:-kinoko}"] + interval: 10s + timeout: 5s + retries: 5 + volumes: + - ./pgdata:/var/lib/postgresql/data + - ./src/main/java/kinoko/database/postgresql/setup/init.sql:/docker-entrypoint-initdb.d/init.sql:ro + networks: + - kinoko_net + +networks: + kinoko_net: + name: network_kinoko \ No newline at end of file diff --git a/pom.xml b/pom.xml index 0e397130..a773a9ed 100644 --- a/pom.xml +++ b/pom.xml @@ -23,6 +23,21 @@ + + io.github.cdimascio + java-dotenv + 5.2.2 + + + com.zaxxer + HikariCP + 7.0.2 + + + org.postgresql + postgresql + 42.7.8 + io.netty netty-all diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java new file mode 100644 index 00000000..54142e49 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java @@ -0,0 +1,25 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; + +public abstract class CassandraAccessor { + private final CqlSession session; + private final String keyspace; + + public CassandraAccessor(CqlSession session, String keyspace) { + this.session = session; + this.keyspace = keyspace; + } + + public final CqlSession getSession() { + return session; + } + + public final String getKeyspace() { + return keyspace; + } + + protected final String lowerName(String name) { + return name.toLowerCase(); + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java new file mode 100644 index 00000000..32efdb43 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java @@ -0,0 +1,198 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry; +import kinoko.database.AccountAccessor; +import kinoko.database.DatabaseManager; +import kinoko.database.cassandra.table.AccountTable; +import kinoko.server.ServerConfig; +import kinoko.server.cashshop.CashItemInfo; +import kinoko.world.item.Item; +import kinoko.world.item.Trunk; +import kinoko.world.user.Account; +import kinoko.world.user.Locker; +import org.mindrot.jbcrypt.BCrypt; + +import java.util.Collections; +import java.util.List; +import java.util.Optional; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; + +public final class CassandraAccountAccessor extends CassandraAccessor implements AccountAccessor { + public CassandraAccountAccessor(CqlSession session, String keyspace) { + super(session, keyspace); + } + + private Account loadAccount(Row row) { + final int accountId = row.getInt(AccountTable.ACCOUNT_ID); + final String username = row.getString(AccountTable.USERNAME); + final String secondaryPassword = row.getString(AccountTable.SECONDARY_PASSWORD); + + final Account account = new Account(accountId, username); + account.setHasSecondaryPassword(secondaryPassword != null && !secondaryPassword.isEmpty()); + account.setSlotCount(row.getInt(AccountTable.CHARACTER_SLOTS)); + account.setNxCredit(row.getInt(AccountTable.NX_CREDIT)); + account.setNxPrepaid(row.getInt(AccountTable.NX_PREPAID)); + account.setMaplePoint(row.getInt(AccountTable.MAPLE_POINT)); + + final Trunk trunk = new Trunk(row.getInt(AccountTable.TRUNK_SIZE)); + final List items = row.getList(AccountTable.TRUNK_ITEMS, Item.class); + if (items != null) { + for (Item item : items) { + trunk.getItems().add(item); + } + } + trunk.setMoney(row.getInt(AccountTable.TRUNK_MONEY)); + account.setTrunk(trunk); + + final Locker locker = new Locker(); + final List cashItems = row.getList(AccountTable.LOCKER_ITEMS, CashItemInfo.class); + if (cashItems != null) { + for (CashItemInfo cii : cashItems) { + locker.addCashItem(cii); + } + } + account.setLocker(locker); + + final List wishlist = row.getList(AccountTable.WISHLIST, Integer.class); + account.setWishlist(Collections.unmodifiableList(wishlist != null ? wishlist : Collections.nCopies(10, 0))); + + return account; + } + + private String lowerUsername(String username) { + return username.toLowerCase(); + } + + private String hashPassword(String password) { + return BCrypt.hashpw(password, BCrypt.gensalt()); + } + + private boolean checkHashedPassword(String password, String hashedPassword) { + return BCrypt.checkpw(password, hashedPassword); + } + + @Override + public Optional getAccountById(int accountId) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), AccountTable.getTableName()).all() + .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(accountId)) + .build() + ); + for (Row row : selectResult) { + return Optional.of(loadAccount(row)); + } + return Optional.empty(); + } + + @Override + public Optional getAccountByUsername(String username) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), AccountTable.getTableName()).all() + .whereColumn(AccountTable.USERNAME).isEqualTo(literal(lowerUsername(username))) + .build() + ); + for (Row row : selectResult) { + return Optional.of(loadAccount(row)); + } + return Optional.empty(); + } + + @Override + public boolean checkPassword(Account account, String password, boolean secondary) { + final String columnName = secondary ? AccountTable.SECONDARY_PASSWORD : AccountTable.PASSWORD; + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), AccountTable.getTableName()).all() + .column(columnName) + .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) + .build() + .setExecutionProfileName(CassandraConnector.PROFILE_ONE) + ); + for (Row row : selectResult) { + final String hashedPassword = row.getString(columnName); + if (hashedPassword == null) { + continue; + } + if (checkHashedPassword(password, hashedPassword)) { + return true; + } + } + return false; + } + + @Override + public boolean savePassword(Account account, String oldPassword, String newPassword, boolean secondary) { + final String columnName = secondary ? AccountTable.SECONDARY_PASSWORD : AccountTable.PASSWORD; + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), AccountTable.getTableName()).all() + .column(columnName) + .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) + .build() + ); + for (Row row : selectResult) { + final String hashedOldPassword = row.getString(columnName); + if (hashedOldPassword == null || checkHashedPassword(oldPassword, hashedOldPassword)) { + final ResultSet updateResult = getSession().execute( + update(getKeyspace(), AccountTable.getTableName()) + .setColumn(columnName, literal(hashPassword(newPassword))) + .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) + .build() + ); + return updateResult.wasApplied(); + } + } + return false; + } + + @Override + public synchronized boolean newAccount(String username, String password) { + final Optional accountId = DatabaseManager.idAccessor().nextAccountId(); + if (accountId.isEmpty()) { + return false; + } + if (getAccountByUsername(username).isPresent()) { + return false; + } + final ResultSet insertResult = getSession().execute( + insertInto(getKeyspace(), AccountTable.getTableName()) + .value(AccountTable.ACCOUNT_ID, literal(accountId.get())) + .value(AccountTable.USERNAME, literal(lowerUsername(username))) + .value(AccountTable.PASSWORD, literal(hashPassword(password))) + .value(AccountTable.CHARACTER_SLOTS, literal(ServerConfig.CHARACTER_BASE_SLOTS)) + .value(AccountTable.NX_CREDIT, literal(0)) + .value(AccountTable.NX_PREPAID, literal(0)) + .value(AccountTable.MAPLE_POINT, literal(0)) + .value(AccountTable.TRUNK_ITEMS, literal(List.of())) + .value(AccountTable.TRUNK_SIZE, literal(ServerConfig.TRUNK_BASE_SLOTS)) + .value(AccountTable.TRUNK_MONEY, literal(0)) + .value(AccountTable.LOCKER_ITEMS, literal(List.of())) + .value(AccountTable.WISHLIST, literal(List.of())) + .ifNotExists() + .build() + ); + return insertResult.wasApplied(); + } + + @Override + public boolean saveAccount(Account account) { + final CodecRegistry registry = getSession().getContext().getCodecRegistry(); + final ResultSet updateResult = getSession().execute( + update(getKeyspace(), AccountTable.getTableName()) + .setColumn(AccountTable.CHARACTER_SLOTS, literal(account.getSlotCount())) + .setColumn(AccountTable.NX_CREDIT, literal(account.getNxCredit())) + .setColumn(AccountTable.NX_PREPAID, literal(account.getNxPrepaid())) + .setColumn(AccountTable.MAPLE_POINT, literal(account.getMaplePoint())) + .setColumn(AccountTable.TRUNK_ITEMS, literal(account.getTrunk().getItems(), registry)) + .setColumn(AccountTable.TRUNK_SIZE, literal(account.getTrunk().getSize())) + .setColumn(AccountTable.TRUNK_MONEY, literal(account.getTrunk().getMoney())) + .setColumn(AccountTable.LOCKER_ITEMS, literal(account.getLocker().getCashItems(), registry)) + .setColumn(AccountTable.WISHLIST, literal(account.getWishlist())) + .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) + .build() + ); + return updateResult.wasApplied(); + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java new file mode 100644 index 00000000..cb27c8be --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -0,0 +1,347 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry; +import kinoko.database.CharacterAccessor; +import kinoko.database.CharacterInfo; +import kinoko.database.cassandra.table.CharacterTable; +import kinoko.server.rank.CharacterRank; +import kinoko.world.item.Inventory; +import kinoko.world.item.InventoryManager; +import kinoko.world.job.JobConstants; +import kinoko.world.quest.QuestManager; +import kinoko.world.quest.QuestRecord; +import kinoko.world.skill.SkillManager; +import kinoko.world.skill.SkillRecord; +import kinoko.world.user.AvatarData; +import kinoko.world.user.CharacterData; +import kinoko.world.user.data.*; +import kinoko.world.user.stat.CharacterStat; + +import java.time.Instant; +import java.util.*; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; + +public final class CassandraCharacterAccessor extends CassandraAccessor implements CharacterAccessor { + + public CassandraCharacterAccessor(CqlSession session, String keyspace) { + super(session, keyspace); + } + + private CharacterData loadCharacterData(Row row) { + final int accountId = row.getInt(CharacterTable.ACCOUNT_ID); + + final CharacterData cd = new CharacterData(accountId); + + final CharacterStat cs = row.get(CharacterTable.CHARACTER_STAT, CharacterStat.class); + cs.setId(row.getInt(CharacterTable.CHARACTER_ID)); + cs.setName(row.getString(CharacterTable.CHARACTER_NAME)); + cd.setCharacterStat(cs); + + final InventoryManager im = new InventoryManager(); + im.setEquipped(row.get(CharacterTable.CHARACTER_EQUIPPED, Inventory.class)); + im.setEquipInventory(row.get(CharacterTable.EQUIP_INVENTORY, Inventory.class)); + im.setConsumeInventory(row.get(CharacterTable.CONSUME_INVENTORY, Inventory.class)); + im.setInstallInventory(row.get(CharacterTable.INSTALL_INVENTORY, Inventory.class)); + im.setEtcInventory(row.get(CharacterTable.ETC_INVENTORY, Inventory.class)); + im.setCashInventory(row.get(CharacterTable.CASH_INVENTORY, Inventory.class)); + im.setMoney(row.getInt(CharacterTable.MONEY)); + im.setExtSlotExpire(row.getInstant(CharacterTable.EXT_SLOT_EXPIRE)); + cd.setInventoryManager(im); + + final SkillManager sm = new SkillManager(); + final Map skillCooltimes = row.getMap(CharacterTable.SKILL_COOLTIMES, Integer.class, Instant.class); + if (skillCooltimes != null) { + sm.getSkillCooltimes().putAll(skillCooltimes); + } + final List skillRecords = row.getList(CharacterTable.SKILL_RECORDS, SkillRecord.class); + if (skillRecords != null) { + for (SkillRecord sr : skillRecords) { + sm.addSkill(sr); + } + } + cd.setSkillManager(sm); + + final QuestManager qm = new QuestManager(); + final List questRecords = row.getList(CharacterTable.QUEST_RECORDS, QuestRecord.class); + if (questRecords != null) { + for (QuestRecord qr : questRecords) { + qm.addQuestRecord(qr); + } + } + cd.setQuestManager(qm); + + final ConfigManager cm = row.get(CharacterTable.CONFIG, ConfigManager.class); + cd.setConfigManager(cm); + + final PopularityRecord pr = new PopularityRecord(); + final Map popularityRecords = row.getMap(CharacterTable.POPULARITY_RECORD, Integer.class, Instant.class); + if (popularityRecords != null) { + pr.getRecords().putAll(popularityRecords); + } + cd.setPopularityRecord(pr); + + final MiniGameRecord mgr = row.get(CharacterTable.MINIGAME_RECORD, MiniGameRecord.class); + cd.setMiniGameRecord(mgr); + + final CoupleRecord cr = CoupleRecord.from(im.getEquipped(), im.getEquipInventory()); + cd.setCoupleRecord(cr); + + final MapTransferInfo mti = row.get(CharacterTable.MAP_TRANSFER_INFO, MapTransferInfo.class); + cd.setMapTransferInfo(mti); + + final WildHunterInfo whi = row.get(CharacterTable.WILD_HUNTER_INFO, WildHunterInfo.class); + cd.setWildHunterInfo(whi); + + cd.setItemSnCounter(new AtomicInteger(row.getInt(CharacterTable.ITEM_SN_COUNTER))); + cd.setFriendMax(row.getInt(CharacterTable.FRIEND_MAX)); + cd.setPartyId(row.getInt(CharacterTable.PARTY_ID)); + cd.setGuildId(row.getInt(CharacterTable.GUILD_ID)); + cd.setCreationTime(row.getInstant(CharacterTable.CREATION_TIME)); + cd.setMaxLevelTime(row.getInstant(CharacterTable.MAX_LEVEL_TIME)); + return cd; + } + + @Override + public boolean checkCharacterNameAvailable(String name) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), CharacterTable.getTableName()).all() + .whereColumn(CharacterTable.CHARACTER_NAME_INDEX).isEqualTo(literal(lowerName(name))) + .build() + ); + for (Row row : selectResult) { + final String existingName = row.getString(CharacterTable.CHARACTER_NAME); + if (existingName != null && existingName.equalsIgnoreCase(name)) { + return false; + } + } + return true; + } + + @Override + public Optional getCharacterById(int characterId) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), CharacterTable.getTableName()).all() + .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterId)) + .build() + ); + for (Row row : selectResult) { + return Optional.of(loadCharacterData(row)); + } + return Optional.empty(); + } + + @Override + public Optional getCharacterByName(String name) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), CharacterTable.getTableName()).all() + .whereColumn(CharacterTable.CHARACTER_NAME_INDEX).isEqualTo(literal(lowerName(name))) + .build() + ); + for (Row row : selectResult) { + return Optional.of(loadCharacterData(row)); + } + return Optional.empty(); + } + + @Override + public Optional getCharacterInfoByName(String name) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), CharacterTable.getTableName()) + .columns( + CharacterTable.ACCOUNT_ID, + CharacterTable.CHARACTER_ID, + CharacterTable.CHARACTER_NAME + ) + .whereColumn(CharacterTable.CHARACTER_NAME_INDEX).isEqualTo(literal(lowerName(name))) + .build() + .setExecutionProfileName(CassandraConnector.PROFILE_ONE) + ); + for (Row row : selectResult) { + return Optional.of(new CharacterInfo( + row.getInt(CharacterTable.ACCOUNT_ID), + row.getInt(CharacterTable.CHARACTER_ID), + row.getString(CharacterTable.CHARACTER_NAME) + )); + } + return Optional.empty(); + } + + @Override + public Optional getAccountIdByCharacterId(int characterId) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), CharacterTable.getTableName()) + .columns( + CharacterTable.ACCOUNT_ID + ) + .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterId)) + .build() + .setExecutionProfileName(CassandraConnector.PROFILE_ONE) + ); + for (Row row : selectResult) { + return Optional.of(row.getInt(CharacterTable.ACCOUNT_ID)); + } + return Optional.empty(); + } + + @Override + public List getAvatarDataByAccountId(int accountId) { + final List avatarDataList = new ArrayList<>(); + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), CharacterTable.getTableName()) + .columns( + CharacterTable.CHARACTER_ID, + CharacterTable.CHARACTER_NAME, + CharacterTable.CHARACTER_STAT, + CharacterTable.CHARACTER_EQUIPPED + ) + .whereColumn(CharacterTable.ACCOUNT_ID).isEqualTo(literal(accountId)) + .build() + ); + for (Row row : selectResult) { + final CharacterStat characterStat = row.get(CharacterTable.CHARACTER_STAT, CharacterStat.class); + characterStat.setId(row.getInt(CharacterTable.CHARACTER_ID)); + characterStat.setName(row.getString(CharacterTable.CHARACTER_NAME)); + final Inventory equipped = row.get(CharacterTable.CHARACTER_EQUIPPED, Inventory.class); + avatarDataList.add(AvatarData.from(characterStat, equipped)); + } + return avatarDataList; + } + + @Override + public synchronized boolean newCharacter(CharacterData characterData) { + if (!checkCharacterNameAvailable(characterData.getCharacterName())) { + return false; + } + return saveCharacter(characterData); + } + + @Override + public boolean saveCharacter(CharacterData characterData) { + final CodecRegistry registry = getSession().getContext().getCodecRegistry(); + final ResultSet updateResult = getSession().execute( + update(getKeyspace(), CharacterTable.getTableName()) + .setColumn(CharacterTable.ACCOUNT_ID, literal(characterData.getAccountId())) + .setColumn(CharacterTable.CHARACTER_NAME, literal(characterData.getCharacterName())) + .setColumn(CharacterTable.CHARACTER_NAME_INDEX, literal(lowerName(characterData.getCharacterName()))) + .setColumn(CharacterTable.CHARACTER_STAT, literal(characterData.getCharacterStat(), registry)) + .setColumn(CharacterTable.CHARACTER_EQUIPPED, literal(characterData.getInventoryManager().getEquipped(), registry)) + .setColumn(CharacterTable.EQUIP_INVENTORY, literal(characterData.getInventoryManager().getEquipInventory(), registry)) + .setColumn(CharacterTable.CONSUME_INVENTORY, literal(characterData.getInventoryManager().getConsumeInventory(), registry)) + .setColumn(CharacterTable.INSTALL_INVENTORY, literal(characterData.getInventoryManager().getInstallInventory(), registry)) + .setColumn(CharacterTable.ETC_INVENTORY, literal(characterData.getInventoryManager().getEtcInventory(), registry)) + .setColumn(CharacterTable.CASH_INVENTORY, literal(characterData.getInventoryManager().getCashInventory(), registry)) + .setColumn(CharacterTable.MONEY, literal(characterData.getInventoryManager().getMoney())) + .setColumn(CharacterTable.EXT_SLOT_EXPIRE, literal(characterData.getInventoryManager().getExtSlotExpire())) + .setColumn(CharacterTable.SKILL_COOLTIMES, literal(characterData.getSkillManager().getSkillCooltimes())) + .setColumn(CharacterTable.SKILL_RECORDS, literal(characterData.getSkillManager().getSkillRecords(), registry)) + .setColumn(CharacterTable.QUEST_RECORDS, literal(characterData.getQuestManager().getQuestRecords(), registry)) + .setColumn(CharacterTable.CONFIG, literal(characterData.getConfigManager(), registry)) + .setColumn(CharacterTable.POPULARITY_RECORD, literal(characterData.getPopularityRecord().getRecords(), registry)) + .setColumn(CharacterTable.MINIGAME_RECORD, literal(characterData.getMiniGameRecord(), registry)) + .setColumn(CharacterTable.MAP_TRANSFER_INFO, literal(characterData.getMapTransferInfo(), registry)) + .setColumn(CharacterTable.WILD_HUNTER_INFO, literal(characterData.getWildHunterInfo(), registry)) + .setColumn(CharacterTable.ITEM_SN_COUNTER, literal(characterData.getItemSnCounter().get())) + .setColumn(CharacterTable.FRIEND_MAX, literal(characterData.getFriendMax())) + .setColumn(CharacterTable.PARTY_ID, literal(characterData.getPartyId())) + .setColumn(CharacterTable.GUILD_ID, literal(characterData.getGuildId())) + .setColumn(CharacterTable.CREATION_TIME, literal(characterData.getCreationTime())) + .setColumn(CharacterTable.MAX_LEVEL_TIME, literal(characterData.getMaxLevelTime())) + .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterData.getCharacterId())) + .build() + ); + return updateResult.wasApplied(); + } + + @Override + public boolean deleteCharacter(int accountId, int characterId) { + final ResultSet updateResult = getSession().execute( + deleteFrom(getKeyspace(), CharacterTable.getTableName()) + .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterId)) + .ifColumn(CharacterTable.ACCOUNT_ID).isEqualTo(literal(accountId)) + .build() + ); + return updateResult.wasApplied(); + } + + @Override + public Map getCharacterRanks() { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), CharacterTable.getTableName()) + .columns( + CharacterTable.CHARACTER_ID, + CharacterTable.CHARACTER_STAT, + CharacterTable.MAX_LEVEL_TIME + ) + .build() + .setExecutionProfileName(CassandraConnector.PROFILE_ONE) + ); + final List rankDataList = new ArrayList<>(); + for (Row row : selectResult) { + final int characterId = row.getInt(CharacterTable.CHARACTER_ID); + final CharacterStat characterStat = row.get(CharacterTable.CHARACTER_STAT, CharacterStat.class); + final Instant maxLevelTime = row.getInstant(CharacterTable.MAX_LEVEL_TIME); + final int jobId = characterStat.getJob(); + if (JobConstants.isAdminJob(jobId) || JobConstants.isManagerJob(jobId)) { + continue; + } + rankDataList.add(new CharacterRankData( + characterId, + JobConstants.getJobCategory(jobId), + characterStat.getCumulativeExp(), + maxLevelTime + )); + } + // Sort and process rank data + rankDataList.sort(Comparator.comparing(CharacterRankData::getCumulativeExp).reversed().thenComparing(CharacterRankData::getMaxLevelTime)); + final Map jobRanks = new HashMap<>(); // job rank counter + final Map characterRanks = new HashMap<>(); // character id -> character rank + for (CharacterRankData rankData : rankDataList) { + final int characterId = rankData.getCharacterId(); + final int jobCategory = rankData.getJobCategory(); + final int worldRank = characterRanks.size() + 1; + final int jobRank = jobRanks.getOrDefault(jobCategory, 0) + 1; + jobRanks.put(jobCategory, jobRank); + characterRanks.put(characterId, new CharacterRank( + characterId, + worldRank, + jobRank + )); + } + return characterRanks; + } + + private static class CharacterRankData { + private final int characterId; + private final int jobCategory; + private final long cumulativeExp; + private final Instant maxLevelTime; + + private CharacterRankData(int characterId, int jobCategory, long cumulativeExp, Instant maxLevelTime) { + this.characterId = characterId; + this.jobCategory = jobCategory; + this.cumulativeExp = cumulativeExp; + this.maxLevelTime = maxLevelTime; + } + + public int getCharacterId() { + return characterId; + } + + public int getJobCategory() { + return jobCategory; + } + + public long getCumulativeExp() { + return cumulativeExp; + } + + public Instant getMaxLevelTime() { + return maxLevelTime != null ? maxLevelTime : Instant.MAX; + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java new file mode 100644 index 00000000..8df86137 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -0,0 +1,4 @@ +package kinoko.database.postgresql; + +public class PostgresConnector { +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java new file mode 100644 index 00000000..36b39a47 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java @@ -0,0 +1,84 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.querybuilder.insert.Insert; +import kinoko.database.FriendAccessor; +import kinoko.database.cassandra.table.FriendTable; +import kinoko.world.user.friend.Friend; +import kinoko.world.user.friend.FriendStatus; + +import java.util.ArrayList; +import java.util.List; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; + +public final class CassandraFriendAccessor extends CassandraAccessor implements FriendAccessor { + public CassandraFriendAccessor(CqlSession session, String keyspace) { + super(session, keyspace); + } + + private Friend loadFriend(Row row) { + final int characterId = row.getInt(FriendTable.CHARACTER_ID); + final int friendId = row.getInt(FriendTable.FRIEND_ID); + final String friendName = row.getString(FriendTable.FRIEND_NAME); + final String friendGroup = row.getString(FriendTable.FRIEND_GROUP); + final FriendStatus status = FriendStatus.getByValue(row.getInt(FriendTable.FRIEND_STATUS)); + return new Friend(characterId, friendId, friendName, friendGroup, status); + } + + @Override + public List getFriendsByCharacterId(int characterId) { + final List friends = new ArrayList<>(); + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), FriendTable.getTableName()).all() + .whereColumn(FriendTable.CHARACTER_ID).isEqualTo(literal(characterId)) + .build() + ); + for (Row row : selectResult) { + friends.add(loadFriend(row)); + } + return friends; + } + + @Override + public List getFriendsByFriendId(int friendId) { + final List friends = new ArrayList<>(); + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), FriendTable.getTableName()).all() + .whereColumn(FriendTable.FRIEND_ID).isEqualTo(literal(friendId)) + .build() + ); + for (Row row : selectResult) { + friends.add(loadFriend(row)); + } + return friends; + } + + @Override + public boolean saveFriend(Friend friend, boolean force) { + Insert insert = insertInto(getKeyspace(), FriendTable.getTableName()) + .value(FriendTable.CHARACTER_ID, literal(friend.getCharacterId())) + .value(FriendTable.FRIEND_ID, literal(friend.getFriendId())) + .value(FriendTable.FRIEND_NAME, literal(friend.getFriendName())) + .value(FriendTable.FRIEND_GROUP, literal(friend.getFriendGroup())) + .value(FriendTable.FRIEND_STATUS, literal(friend.getStatus().getValue())); + if (!force) { + insert = insert.ifNotExists(); + } + final ResultSet insertResult = getSession().execute(insert.build()); + return insertResult.wasApplied(); + } + + @Override + public boolean deleteFriend(int characterId, int friendId) { + final ResultSet deleteResult = getSession().execute( + deleteFrom(getKeyspace(), FriendTable.getTableName()) + .whereColumn(FriendTable.CHARACTER_ID).isEqualTo(literal(characterId)) + .whereColumn(FriendTable.FRIEND_ID).isEqualTo(literal(friendId)) + .build() + ); + return deleteResult.wasApplied(); + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java new file mode 100644 index 00000000..cea13ac1 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java @@ -0,0 +1,86 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import kinoko.database.GiftAccessor; +import kinoko.database.cassandra.table.GiftTable; +import kinoko.server.cashshop.Gift; + +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; + +public final class CassandraGiftAccessor extends CassandraAccessor implements GiftAccessor { + public CassandraGiftAccessor(CqlSession session, String keyspace) { + super(session, keyspace); + } + + private Gift loadGift(Row row) { + return new Gift( + row.getLong(GiftTable.GIFT_SN), + row.getInt(GiftTable.ITEM_ID), + row.getInt(GiftTable.COMMODITY_ID), + row.getInt(GiftTable.SENDER_ID), + row.getString(GiftTable.SENDER_NAME), + row.getString(GiftTable.SENDER_MESSAGE), + row.getLong(GiftTable.PAIR_ITEM_SN) + ); + } + + @Override + public List getGiftsByCharacterId(int characterId) { + final List gifts = new ArrayList<>(); + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), GiftTable.getTableName()).all() + .whereColumn(GiftTable.RECEIVER_ID).isEqualTo(literal(characterId)) + .build() + ); + for (Row row : selectResult) { + gifts.add(loadGift(row)); + } + return gifts; + } + + @Override + public Optional getGiftByItemSn(long itemSn) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), GiftTable.getTableName()).all() + .whereColumn(GiftTable.GIFT_SN).isEqualTo(literal(itemSn)) + .build() + ); + for (Row row : selectResult) { + return Optional.of(loadGift(row)); + } + return Optional.empty(); + } + + @Override + public boolean newGift(Gift gift, int receiverId) { + final ResultSet insertResult = getSession().execute( + insertInto(getKeyspace(), GiftTable.getTableName()) + .value(GiftTable.GIFT_SN, literal(gift.getGiftSn())) + .value(GiftTable.RECEIVER_ID, literal(receiverId)) + .value(GiftTable.ITEM_ID, literal(gift.getItemId())) + .value(GiftTable.COMMODITY_ID, literal(gift.getCommodityId())) + .value(GiftTable.SENDER_NAME, literal(gift.getSenderName())) + .value(GiftTable.SENDER_MESSAGE, literal(gift.getSenderMessage())) + .value(GiftTable.PAIR_ITEM_SN, literal(gift.getPairItemSn())) + .ifNotExists() + .build() + ); + return insertResult.wasApplied(); + } + + @Override + public boolean deleteGift(Gift gift) { + final ResultSet updateResult = getSession().execute( + deleteFrom(getKeyspace(), GiftTable.getTableName()) + .whereColumn(GiftTable.GIFT_SN).isEqualTo(literal(gift.getGiftSn())) + .build() + ); + return updateResult.wasApplied(); + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java new file mode 100644 index 00000000..f8fd07a0 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -0,0 +1,161 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry; +import kinoko.database.GuildAccessor; +import kinoko.database.cassandra.table.GuildTable; +import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildBoardEntry; +import kinoko.server.guild.GuildMember; +import kinoko.server.guild.GuildRanking; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; + +public final class CassandraGuildAccessor extends CassandraAccessor implements GuildAccessor { + public CassandraGuildAccessor(CqlSession session, String keyspace) { + super(session, keyspace); + } + + private Guild loadGuild(Row row) { + final int guildId = row.getInt(GuildTable.GUILD_ID); + final String guildName = row.getString(GuildTable.GUILD_NAME); + final Guild guild = new Guild(guildId, guildName); + final List gradeNames = row.getList(GuildTable.GRADE_NAMES, String.class); + if (gradeNames != null) { + guild.setGradeNames(gradeNames); + } + final List members = row.getList(GuildTable.MEMBERS, GuildMember.class); + if (members != null) { + for (GuildMember member : members) { + guild.addMember(member); + } + } + guild.setMemberMax(row.getInt(GuildTable.MEMBER_MAX)); + guild.setMarkBg(row.getShort(GuildTable.MARK_BG)); + guild.setMarkBgColor(row.getByte(GuildTable.MARK_BG_COLOR)); + guild.setMark(row.getShort(GuildTable.MARK)); + guild.setMarkColor(row.getByte(GuildTable.MARK_COLOR)); + guild.setNotice(row.getString(GuildTable.NOTICE)); + guild.setPoints(row.getInt(GuildTable.POINTS)); + guild.setLevel(row.getByte(GuildTable.LEVEL)); + final List boardEntries = row.getList(GuildTable.BOARD_ENTRY_LIST, GuildBoardEntry.class); + if (boardEntries != null) { + guild.getBoardEntries().addAll(boardEntries); + } + guild.setBoardNoticeEntry(row.get(GuildTable.BOARD_ENTRY_NOTICE, GuildBoardEntry.class)); + guild.setBoardEntryCounter(new AtomicInteger(row.getInt(GuildTable.BOARD_ENTRY_COUNTER))); + return guild; + } + + @Override + public Optional getGuildById(int guildId) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), GuildTable.getTableName()).all() + .whereColumn(GuildTable.GUILD_ID).isEqualTo(literal(guildId)) + .build() + ); + for (Row row : selectResult) { + return Optional.of(loadGuild(row)); + } + return Optional.empty(); + } + + @Override + public boolean checkGuildNameAvailable(String name) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), GuildTable.getTableName()).all() + .whereColumn(GuildTable.GUILD_NAME_INDEX).isEqualTo(literal(lowerName(name))) + .build() + ); + for (Row row : selectResult) { + final String existingName = row.getString(GuildTable.GUILD_NAME_INDEX); + if (existingName != null && existingName.equalsIgnoreCase(name)) { + return false; + } + } + return true; + } + + @Override + public synchronized boolean newGuild(Guild guild) { + if (!checkGuildNameAvailable(guild.getGuildName())) { + return false; + } + return saveGuild(guild); + } + + @Override + public boolean saveGuild(Guild guild) { + final CodecRegistry registry = getSession().getContext().getCodecRegistry(); + final ResultSet updateResult = getSession().execute( + update(getKeyspace(), GuildTable.getTableName()) + .setColumn(GuildTable.GUILD_NAME, literal(guild.getGuildName())) + .setColumn(GuildTable.GUILD_NAME_INDEX, literal(lowerName(guild.getGuildName()))) + .setColumn(GuildTable.GRADE_NAMES, literal(guild.getGradeNames())) + .setColumn(GuildTable.MEMBERS, literal(guild.getGuildMembers(), registry)) + .setColumn(GuildTable.MEMBER_MAX, literal(guild.getMemberMax())) + .setColumn(GuildTable.MARK_BG, literal(guild.getMarkBg())) + .setColumn(GuildTable.MARK_BG_COLOR, literal(guild.getMarkBgColor())) + .setColumn(GuildTable.MARK, literal(guild.getMark())) + .setColumn(GuildTable.MARK_COLOR, literal(guild.getMarkColor())) + .setColumn(GuildTable.NOTICE, literal(guild.getNotice())) + .setColumn(GuildTable.POINTS, literal(guild.getPoints())) + .setColumn(GuildTable.LEVEL, literal(guild.getLevel())) + .setColumn(GuildTable.BOARD_ENTRY_LIST, literal(guild.getBoardEntries(), registry)) + .setColumn(GuildTable.BOARD_ENTRY_NOTICE, literal(guild.getBoardNoticeEntry(), registry)) + .setColumn(GuildTable.BOARD_ENTRY_COUNTER, literal(guild.getBoardEntryCounter().get())) + .whereColumn(GuildTable.GUILD_ID).isEqualTo(literal(guild.getGuildId())) + .build() + ); + return updateResult.wasApplied(); + } + + @Override + public boolean deleteGuild(int guildId) { + final ResultSet updateResult = getSession().execute( + deleteFrom(getKeyspace(), GuildTable.getTableName()) + .whereColumn(GuildTable.GUILD_ID).isEqualTo(literal(guildId)) + .build() + ); + return updateResult.wasApplied(); + } + + @Override + public List getGuildRankings() { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), GuildTable.getTableName()) + .columns( + GuildTable.GUILD_NAME, + GuildTable.POINTS, + GuildTable.MARK, + GuildTable.MARK_COLOR, + GuildTable.MARK_BG, + GuildTable.MARK_BG_COLOR + ) + .build() + .setExecutionProfileName(CassandraConnector.PROFILE_ONE) + ); + final List guildRankings = new ArrayList<>(); + for (Row row : selectResult) { + guildRankings.add(new GuildRanking( + row.getString(GuildTable.GUILD_NAME), + row.getInt(GuildTable.POINTS), + row.getShort(GuildTable.MARK), + row.getByte(GuildTable.MARK_COLOR), + row.getShort(GuildTable.MARK_BG), + row.getByte(GuildTable.MARK_BG_COLOR) + )); + } + return guildRankings.stream() + .sorted(Comparator.comparing(GuildRanking::getPoints).reversed()) + .toList(); + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java new file mode 100644 index 00000000..50b9249d --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java @@ -0,0 +1,67 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import kinoko.database.IdAccessor; +import kinoko.database.cassandra.table.IdTable; + +import java.util.Optional; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; + +public final class CassandraIdAccessor extends CassandraAccessor implements IdAccessor { + public CassandraIdAccessor(CqlSession session, String keyspace) { + super(session, keyspace); + } + + private Optional getNextId(String type) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), IdTable.getTableName()).all() + .whereColumn(IdTable.ID_TYPE).isEqualTo(literal(type)) + .build() + ); + for (Row selectRow : selectResult) { + final int nextId = selectRow.getInt(IdTable.NEXT_ID); + final ResultSet updateResult = getSession().execute( + update(getKeyspace(), IdTable.getTableName()) + .setColumn(IdTable.NEXT_ID, literal(nextId + 1)) // increment ID + .whereColumn(IdTable.ID_TYPE).isEqualTo(literal(type)) + .ifColumn(IdTable.NEXT_ID).isEqualTo(literal(nextId)) // if not already updated + .build() + ); + if (updateResult.wasApplied()) { + return Optional.of(nextId); + } else { + // retry + return getNextId(type); + } + } + return Optional.empty(); + } + + @Override + public synchronized Optional nextAccountId() { + return getNextId(IdTable.ACCOUNT_ID); + } + + @Override + public synchronized Optional nextCharacterId() { + return getNextId(IdTable.CHARACTER_ID); + } + + @Override + public synchronized Optional nextPartyId() { + return getNextId(IdTable.PARTY_ID); + } + + @Override + public synchronized Optional nextGuildId() { + return getNextId(IdTable.GUILD_ID); + } + + @Override + public synchronized Optional nextMemoId() { + return getNextId(IdTable.MEMO_ID); + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java new file mode 100644 index 00000000..67699745 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java @@ -0,0 +1,94 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import kinoko.database.MemoAccessor; +import kinoko.database.cassandra.table.MemoTable; +import kinoko.server.memo.Memo; +import kinoko.server.memo.MemoType; + +import java.util.ArrayList; +import java.util.List; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; + +public final class CassandraMemoAccessor extends CassandraAccessor implements MemoAccessor { + public CassandraMemoAccessor(CqlSession session, String keyspace) { + super(session, keyspace); + } + + @Override + public List getMemosByCharacterId(int characterId) { + final List memos = new ArrayList<>(); + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), MemoTable.getTableName()) + .columns( + MemoTable.MEMO_ID, + MemoTable.MEMO_TYPE, + MemoTable.MEMO_CONTENT, + MemoTable.SENDER_NAME, + MemoTable.DATE_SENT + ) + .whereColumn(MemoTable.RECEIVER_ID).isEqualTo(literal(characterId)) + .build() + ); + for (Row row : selectResult) { + final MemoType type = MemoType.getByValue(row.getInt(MemoTable.MEMO_TYPE)); + final Memo memo = new Memo( + type != null ? type : MemoType.DEFAULT, + row.getInt(MemoTable.MEMO_ID), + row.getString(MemoTable.SENDER_NAME), + row.getString(MemoTable.MEMO_CONTENT), + row.getInstant(MemoTable.DATE_SENT) + ); + memos.add(memo); + } + return memos; + } + + @Override + public boolean hasMemo(int characterId) { + final ResultSet selectResult = getSession().execute( + selectFrom(getKeyspace(), MemoTable.getTableName()) + .columns( + MemoTable.RECEIVER_ID + ) + .whereColumn(MemoTable.RECEIVER_ID).isEqualTo(literal(characterId)) + .build() + ); + for (Row row : selectResult) { + final int receiverId = row.getInt(MemoTable.RECEIVER_ID); + if (receiverId == characterId) { + return true; + } + } + return false; + } + + @Override + public boolean newMemo(Memo memo, int receiverId) { + final ResultSet updateResult = getSession().execute( + insertInto(getKeyspace(), MemoTable.getTableName()) + .value(MemoTable.MEMO_ID, literal(memo.getMemoId())) + .value(MemoTable.RECEIVER_ID, literal(receiverId)) + .value(MemoTable.MEMO_TYPE, literal(memo.getType().getValue())) + .value(MemoTable.MEMO_CONTENT, literal(memo.getContent())) + .value(MemoTable.SENDER_NAME, literal(memo.getSender())) + .value(MemoTable.DATE_SENT, literal(memo.getDateSent())) + .ifNotExists() + .build() + ); + return updateResult.wasApplied(); + } + + @Override + public boolean deleteMemo(int memoId, int receiverId) { + final ResultSet updateResult = getSession().execute( + deleteFrom(getKeyspace(), MemoTable.getTableName()) + .whereColumn(MemoTable.MEMO_ID).isEqualTo(literal(memoId)) + .build() + ); + return updateResult.wasApplied(); + } +} diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql new file mode 100644 index 00000000..e69de29b From 9f78589907bfed90ecdd799d87bb485b06116c6a Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 15:12:24 -0400 Subject: [PATCH 29/83] Added support for PSQL --- .../java/kinoko/database/DatabaseManager.java | 29 +- .../cassandra/CassandraConnector.java | 8 +- .../database/postgresql/PostgresAccessor.java | 30 +- .../postgresql/PostgresAccountAccessor.java | 398 +++-- .../postgresql/PostgresCharacterAccessor.java | 1480 ++++++++++++++--- .../postgresql/PostgresConnector.java | 80 +- .../postgresql/PostgresFriendAccessor.java | 115 +- .../postgresql/PostgresGiftAccessor.java | 114 +- .../postgresql/PostgresGuildAccessor.java | 422 +++-- .../postgresql/PostgresIdAccessor.java | 52 +- .../postgresql/PostgresMemoAccessor.java | 120 +- .../kinoko/database/postgresql/setup/init.sql | 488 ++++++ .../kinoko/handler/stage/LoginHandler.java | 19 +- .../java/kinoko/handler/user/UserHandler.java | 4 +- src/main/java/kinoko/server/ServerConfig.java | 6 +- .../java/kinoko/server/ServerConstants.java | 19 +- .../kinoko/server/command/AdminCommands.java | 2 +- .../server/command/CommandProcessor.java | 4 +- src/main/java/kinoko/util/Util.java | 32 +- src/main/java/kinoko/world/GameConstants.java | 1 + .../java/kinoko/world/item/EquipData.java | 42 + .../java/kinoko/world/item/Inventory.java | 4 + .../kinoko/world/item/InventoryManager.java | 11 + src/main/java/kinoko/world/item/Item.java | 31 + src/main/java/kinoko/world/item/PetData.java | 19 + src/main/java/kinoko/world/item/RingData.java | 11 + .../java/kinoko/world/quest/QuestRecord.java | 8 + .../java/kinoko/world/skill/SkillRecord.java | 6 + .../java/kinoko/world/user/CharacterData.java | 11 +- .../kinoko/world/user/data/ConfigManager.java | 12 + .../kinoko/world/user/stat/CharacterStat.java | 40 + 31 files changed, 2913 insertions(+), 705 deletions(-) diff --git a/src/main/java/kinoko/database/DatabaseManager.java b/src/main/java/kinoko/database/DatabaseManager.java index 2808fd09..e2b3d19a 100644 --- a/src/main/java/kinoko/database/DatabaseManager.java +++ b/src/main/java/kinoko/database/DatabaseManager.java @@ -1,8 +1,15 @@ package kinoko.database; +import kinoko.database.postgresql.PostgresConnector; import kinoko.database.cassandra.CassandraConnector; +import kinoko.server.ServerConfig; +import kinoko.server.ServerConstants; +import kinoko.world.GameConstants; + +import java.util.Objects; public final class DatabaseManager { + private static DatabaseConnector connector; public static IdAccessor idAccessor() { @@ -33,8 +40,28 @@ public static MemoAccessor memoAccessor() { return connector.getMemoAccessor(); } + + public static boolean isRelational() { + // Get whether the database connection is a relational database. + if (connector != null){ + return connector instanceof PostgresConnector; + } + return false; + }; + + public static void initialize() { - connector = new CassandraConnector(); + // Prod Environment + if (Objects.equals(ServerConstants.DATABASE_HOST, "cassandra_kinoko")) { + connector = new CassandraConnector(); + } + else if (Objects.equals(ServerConstants.DATABASE_HOST, "postgres_kinoko")){ + connector = new PostgresConnector(); + } + else { // Your choice, likely in a dev environment. +// connector = new CassandraConnector(); + connector = new PostgresConnector(); + } connector.initialize(); } diff --git a/src/main/java/kinoko/database/cassandra/CassandraConnector.java b/src/main/java/kinoko/database/cassandra/CassandraConnector.java index 44ae4a0e..676c665e 100644 --- a/src/main/java/kinoko/database/cassandra/CassandraConnector.java +++ b/src/main/java/kinoko/database/cassandra/CassandraConnector.java @@ -17,6 +17,8 @@ import kinoko.database.cassandra.codec.*; import kinoko.database.cassandra.table.*; import kinoko.database.cassandra.type.*; +import kinoko.server.Server; +import kinoko.server.ServerConfig; import kinoko.server.ServerConstants; import kinoko.server.cashshop.CashItemInfo; import kinoko.server.guild.GuildBoardComment; @@ -38,9 +40,9 @@ public final class CassandraConnector implements DatabaseConnector { public static final InetSocketAddress DATABASE_ADDRESS = new InetSocketAddress(ServerConstants.DATABASE_HOST, ServerConstants.DATABASE_PORT); - public static final String DATABASE_DATACENTER = "datacenter1"; - public static final String DATABASE_KEYSPACE = "kinoko"; - public static final String PROFILE_ONE = "profile_one"; + public static final String DATABASE_DATACENTER = ServerConstants.DATABASE_DATACENTER; + public static final String DATABASE_KEYSPACE = ServerConstants.DATABASE_NAME; + public static final String PROFILE_ONE = ServerConstants.DATABASE_PROFILE; private CqlSession cqlSession; private IdAccessor idAccessor; private AccountAccessor accountAccessor; diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java index 54142e49..8afa32b6 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java @@ -1,24 +1,28 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; +import com.zaxxer.hikari.HikariDataSource; -public abstract class CassandraAccessor { - private final CqlSession session; - private final String keyspace; +import java.sql.Connection; +import java.sql.SQLException; - public CassandraAccessor(CqlSession session, String keyspace) { - this.session = session; - this.keyspace = keyspace; - } +public abstract class PostgresAccessor { + private final HikariDataSource dataSource; - public final CqlSession getSession() { - return session; + public PostgresAccessor(HikariDataSource dataSource) { + this.dataSource = dataSource; } - public final String getKeyspace() { - return keyspace; + /** + * Get a connection from the pool for a single operation. + * Use try-with-resources when calling this! + */ + protected final Connection getConnection() throws SQLException { + return dataSource.getConnection(); } + /** + * Helper to lowercase strings (like usernames) + */ protected final String lowerName(String name) { return name.toLowerCase(); } diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java index 32efdb43..820b0147 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java @@ -1,12 +1,8 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; -import com.datastax.oss.driver.api.core.cql.ResultSet; -import com.datastax.oss.driver.api.core.cql.Row; -import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry; +import com.zaxxer.hikari.HikariDataSource; import kinoko.database.AccountAccessor; import kinoko.database.DatabaseManager; -import kinoko.database.cassandra.table.AccountTable; import kinoko.server.ServerConfig; import kinoko.server.cashshop.CashItemInfo; import kinoko.world.item.Item; @@ -15,52 +11,123 @@ import kinoko.world.user.Locker; import org.mindrot.jbcrypt.BCrypt; +import java.sql.*; +import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Optional; -import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; +public final class PostgresAccountAccessor extends PostgresAccessor implements AccountAccessor { -public final class CassandraAccountAccessor extends CassandraAccessor implements AccountAccessor { - public CassandraAccountAccessor(CqlSession session, String keyspace) { - super(session, keyspace); + public PostgresAccountAccessor(HikariDataSource dataSource) { + super(dataSource); } - private Account loadAccount(Row row) { - final int accountId = row.getInt(AccountTable.ACCOUNT_ID); - final String username = row.getString(AccountTable.USERNAME); - final String secondaryPassword = row.getString(AccountTable.SECONDARY_PASSWORD); + private Account loadAccount(ResultSet rs) throws SQLException { + final int accountId = rs.getInt("id"); + final String username = rs.getString("username"); + final String secondaryPassword = rs.getString("secondary_password"); final Account account = new Account(accountId, username); account.setHasSecondaryPassword(secondaryPassword != null && !secondaryPassword.isEmpty()); - account.setSlotCount(row.getInt(AccountTable.CHARACTER_SLOTS)); - account.setNxCredit(row.getInt(AccountTable.NX_CREDIT)); - account.setNxPrepaid(row.getInt(AccountTable.NX_PREPAID)); - account.setMaplePoint(row.getInt(AccountTable.MAPLE_POINT)); + account.setSlotCount(rs.getInt("character_slots")); + account.setNxCredit(rs.getInt("nx_credit")); + account.setNxPrepaid(rs.getInt("nx_prepaid")); + account.setMaplePoint(rs.getInt("maple_point")); - final Trunk trunk = new Trunk(row.getInt(AccountTable.TRUNK_SIZE)); - final List items = row.getList(AccountTable.TRUNK_ITEMS, Item.class); - if (items != null) { - for (Item item : items) { - trunk.getItems().add(item); + account.setTrunk(loadTrunk(accountId)); + account.setLocker(loadLocker(accountId)); + account.setWishlist(loadWishlist(accountId)); + + return account; + } + + private Trunk loadTrunk(int accountId) throws SQLException { + int trunkSize = ServerConfig.TRUNK_BASE_SLOTS; + int trunkMoney = 0; + + String accountSql = "SELECT trunk_size, trunk_money FROM account.accounts WHERE id = ?"; + String itemsSql = """ + SELECT ti.slot, i.item_id, i.quantity + FROM account.trunk_item ti + JOIN item.items i ON ti.item_sn = i.item_sn + WHERE ti.account_id = ? + ORDER BY ti.slot + """; + + Trunk trunk; + + try (Connection conn = getConnection()) { + // Query trunk info + try (PreparedStatement stmt = conn.prepareStatement(accountSql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + trunkSize = rs.getInt("trunk_size"); + trunkMoney = rs.getInt("trunk_money"); + } + } } - } - trunk.setMoney(row.getInt(AccountTable.TRUNK_MONEY)); - account.setTrunk(trunk); - final Locker locker = new Locker(); - final List cashItems = row.getList(AccountTable.LOCKER_ITEMS, CashItemInfo.class); - if (cashItems != null) { - for (CashItemInfo cii : cashItems) { - locker.addCashItem(cii); + // Initialize trunk with proper size + trunk = new Trunk(trunkSize); + trunk.setMoney(trunkMoney); + + // Query items + try (PreparedStatement stmt = conn.prepareStatement(itemsSql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + Item item = new Item(rs.getInt("item_id"), (short) rs.getInt("quantity")); + trunk.getItems().add(item); + } + } } } - account.setLocker(locker); - final List wishlist = row.getList(AccountTable.WISHLIST, Integer.class); - account.setWishlist(Collections.unmodifiableList(wishlist != null ? wishlist : Collections.nCopies(10, 0))); + return trunk; + } - return account; + + private Locker loadLocker(int accountId) throws SQLException { + Locker locker = new Locker(); + String sql = "SELECT li.slot, li.item_sn, li.commodity_id, i.item_id, i.quantity " + + "FROM account.locker_item li " + + "JOIN item.items i ON li.item_sn = i.item_sn " + + "WHERE li.account_id = ? ORDER BY li.slot"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + Item item = new Item(rs.getInt("item_id"), (short) rs.getInt("quantity")); + CashItemInfo info = new CashItemInfo( + item, + rs.getInt("commodity_id"), + accountId, // account owner + -1, // character owner unknown at this point + null + ); + locker.addCashItem(info); + } + } + } + return locker; + } + + private List loadWishlist(int accountId) throws SQLException { + List wishlist = new ArrayList<>(); + String sql = "SELECT w.item_id FROM account.wishlist w " + + "WHERE w.account_id = ? ORDER BY w.slot"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + wishlist.add(rs.getInt("item_id")); + } + } + } + while (wishlist.size() < 10) wishlist.add(0); + return Collections.unmodifiableList(wishlist); } private String lowerUsername(String username) { @@ -77,122 +144,215 @@ private boolean checkHashedPassword(String password, String hashedPassword) { @Override public Optional getAccountById(int accountId) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), AccountTable.getTableName()).all() - .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(accountId)) - .build() - ); - for (Row row : selectResult) { - return Optional.of(loadAccount(row)); + String sql = "SELECT * FROM account.accounts WHERE id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(loadAccount(rs)); + } + } + } catch (SQLException e) { + e.printStackTrace(); } return Optional.empty(); } @Override public Optional getAccountByUsername(String username) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), AccountTable.getTableName()).all() - .whereColumn(AccountTable.USERNAME).isEqualTo(literal(lowerUsername(username))) - .build() - ); - for (Row row : selectResult) { - return Optional.of(loadAccount(row)); + String sql = "SELECT * FROM account.accounts WHERE username = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setString(1, lowerUsername(username)); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(loadAccount(rs)); + } + } + } catch (SQLException e) { + e.printStackTrace(); } return Optional.empty(); } @Override public boolean checkPassword(Account account, String password, boolean secondary) { - final String columnName = secondary ? AccountTable.SECONDARY_PASSWORD : AccountTable.PASSWORD; - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), AccountTable.getTableName()).all() - .column(columnName) - .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) - .build() - .setExecutionProfileName(CassandraConnector.PROFILE_ONE) - ); - for (Row row : selectResult) { - final String hashedPassword = row.getString(columnName); - if (hashedPassword == null) { - continue; - } - if (checkHashedPassword(password, hashedPassword)) { - return true; + String column = secondary ? "secondary_password" : "password"; + String sql = "SELECT " + column + " FROM account.accounts WHERE id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, account.getId()); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + String hashed = rs.getString(column); + return hashed != null && checkHashedPassword(password, hashed); + } } + } catch (SQLException e) { + e.printStackTrace(); } return false; } @Override public boolean savePassword(Account account, String oldPassword, String newPassword, boolean secondary) { - final String columnName = secondary ? AccountTable.SECONDARY_PASSWORD : AccountTable.PASSWORD; - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), AccountTable.getTableName()).all() - .column(columnName) - .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) - .build() - ); - for (Row row : selectResult) { - final String hashedOldPassword = row.getString(columnName); - if (hashedOldPassword == null || checkHashedPassword(oldPassword, hashedOldPassword)) { - final ResultSet updateResult = getSession().execute( - update(getKeyspace(), AccountTable.getTableName()) - .setColumn(columnName, literal(hashPassword(newPassword))) - .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) - .build() - ); - return updateResult.wasApplied(); + String column = secondary ? "secondary_password" : "password"; + String sqlSelect = "SELECT " + column + " FROM account.accounts WHERE id = ?"; + String sqlUpdate = "UPDATE account.accounts SET " + column + " = ? WHERE id = ?"; + try (PreparedStatement selectStmt = getConnection().prepareStatement(sqlSelect)) { + selectStmt.setInt(1, account.getId()); + try (ResultSet rs = selectStmt.executeQuery()) { + if (rs.next()) { + String hashedOld = rs.getString(column); + if (hashedOld == null || checkHashedPassword(oldPassword, hashedOld)) { + try (PreparedStatement updateStmt = getConnection().prepareStatement(sqlUpdate)) { + updateStmt.setString(1, hashPassword(newPassword)); + updateStmt.setInt(2, account.getId()); + return updateStmt.executeUpdate() > 0; + } + } + } } + } catch (SQLException e) { + e.printStackTrace(); } return false; } @Override public synchronized boolean newAccount(String username, String password) { - final Optional accountId = DatabaseManager.idAccessor().nextAccountId(); - if (accountId.isEmpty()) { + Optional accountIdOpt = DatabaseManager.idAccessor().nextAccountId(); // should be -1 + if (accountIdOpt.isEmpty() || getAccountByUsername(username).isPresent()) { return false; } - if (getAccountByUsername(username).isPresent()) { - return false; + int accountId; + + String sql = "INSERT INTO account.accounts (username, password, character_slots, nx_credit, nx_prepaid, maple_point, trunk_size, trunk_money) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + + "RETURNING ID"; + System.out.println("Creating account."); + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setString(1, lowerUsername(username)); + stmt.setString(2, hashPassword(password)); + stmt.setInt(3, ServerConfig.CHARACTER_BASE_SLOTS); + stmt.setInt(4, 0); + stmt.setInt(5, 0); + stmt.setInt(6, 0); + stmt.setInt(7, ServerConfig.TRUNK_BASE_SLOTS); + stmt.setInt(8, 0); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + accountId = rs.getInt("id"); + } else { + throw new SQLException("Failed to retrieve account ID after insert."); + } + } + + // Initialize a base trunk, locker, wishlist +// for (int i = 0; i < ServerConfig.TRUNK_BASE_SLOTS; i++) { +// try (PreparedStatement tStmt = getConnection().prepareStatement( +// "INSERT INTO account.trunk_item (account_id, slot, item_sn) VALUES (?, ?, ?)")) { +// tStmt.setInt(1, accountId); +// tStmt.setInt(2, i); +// tStmt.setLong(3, itemSn); +// tStmt.executeUpdate(); +// } +// } + +// for (int i = 0; i < 10; i++) { +// try (PreparedStatement wStmt = getConnection().prepareStatement( +// "INSERT INTO account.wishlist (account_id, slot, item_sn) VALUES (?, ?, ?)")) { +// wStmt.setInt(1, accountId); +// wStmt.setInt(2, i); +// wStmt.setLong(3, itemSn); +// wStmt.executeUpdate(); +// } +// } + + // Locker starts empty, no rows needed initially + return true; + } catch (SQLException e) { + e.printStackTrace(); } - final ResultSet insertResult = getSession().execute( - insertInto(getKeyspace(), AccountTable.getTableName()) - .value(AccountTable.ACCOUNT_ID, literal(accountId.get())) - .value(AccountTable.USERNAME, literal(lowerUsername(username))) - .value(AccountTable.PASSWORD, literal(hashPassword(password))) - .value(AccountTable.CHARACTER_SLOTS, literal(ServerConfig.CHARACTER_BASE_SLOTS)) - .value(AccountTable.NX_CREDIT, literal(0)) - .value(AccountTable.NX_PREPAID, literal(0)) - .value(AccountTable.MAPLE_POINT, literal(0)) - .value(AccountTable.TRUNK_ITEMS, literal(List.of())) - .value(AccountTable.TRUNK_SIZE, literal(ServerConfig.TRUNK_BASE_SLOTS)) - .value(AccountTable.TRUNK_MONEY, literal(0)) - .value(AccountTable.LOCKER_ITEMS, literal(List.of())) - .value(AccountTable.WISHLIST, literal(List.of())) - .ifNotExists() - .build() - ); - return insertResult.wasApplied(); + return false; } @Override public boolean saveAccount(Account account) { - final CodecRegistry registry = getSession().getContext().getCodecRegistry(); - final ResultSet updateResult = getSession().execute( - update(getKeyspace(), AccountTable.getTableName()) - .setColumn(AccountTable.CHARACTER_SLOTS, literal(account.getSlotCount())) - .setColumn(AccountTable.NX_CREDIT, literal(account.getNxCredit())) - .setColumn(AccountTable.NX_PREPAID, literal(account.getNxPrepaid())) - .setColumn(AccountTable.MAPLE_POINT, literal(account.getMaplePoint())) - .setColumn(AccountTable.TRUNK_ITEMS, literal(account.getTrunk().getItems(), registry)) - .setColumn(AccountTable.TRUNK_SIZE, literal(account.getTrunk().getSize())) - .setColumn(AccountTable.TRUNK_MONEY, literal(account.getTrunk().getMoney())) - .setColumn(AccountTable.LOCKER_ITEMS, literal(account.getLocker().getCashItems(), registry)) - .setColumn(AccountTable.WISHLIST, literal(account.getWishlist())) - .whereColumn(AccountTable.ACCOUNT_ID).isEqualTo(literal(account.getId())) - .build() - ); - return updateResult.wasApplied(); + try (Connection conn = getConnection()) { + conn.setAutoCommit(false); + + try (PreparedStatement stmt = conn.prepareStatement( + "UPDATE account.accounts SET character_slots = ?, nx_credit = ?, nx_prepaid = ?, maple_point = ?, trunk_size = ?, trunk_money = ? WHERE id = ?")) { + stmt.setInt(1, account.getSlotCount()); + stmt.setInt(2, account.getNxCredit()); + stmt.setInt(3, account.getNxPrepaid()); + stmt.setInt(4, account.getMaplePoint()); + stmt.setInt(5, account.getTrunk().getSize()); + stmt.setInt(6, account.getTrunk().getMoney()); + stmt.setInt(7, account.getId()); + stmt.executeUpdate(); + } + + // Save trunk + try (PreparedStatement delTrunk = conn.prepareStatement("DELETE FROM account.trunk_item WHERE account_id = ?")) { + delTrunk.setInt(1, account.getId()); + delTrunk.executeUpdate(); + } + int slot = 0; + for (Item item : account.getTrunk().getItems()) { + try (PreparedStatement insertTrunk = conn.prepareStatement( + "INSERT INTO account.trunk_item (account_id, slot, item_sn) VALUES (?, ?, ?)")) { + insertTrunk.setInt(1, account.getId()); + insertTrunk.setInt(2, slot++); + insertTrunk.setObject(3, item.getItemSn()); // null if empty + insertTrunk.executeUpdate(); + } + } + + // Save wishlist + try (PreparedStatement delWish = conn.prepareStatement("DELETE FROM account.wishlist WHERE account_id = ?")) { + delWish.setInt(1, account.getId()); + delWish.executeUpdate(); + } + slot = 0; + for (Integer itemId : account.getWishlist()) { + try (PreparedStatement insertWish = conn.prepareStatement( + """ + INSERT INTO account.wishlist (account_id, slot, item_id) + VALUES (?, ?, ?) + ON CONFLICT (account_id, slot) DO UPDATE + SET item_id = EXCLUDED.item_id + """ + )) { + insertWish.setInt(1, account.getId()); + insertWish.setInt(2, slot++); + insertWish.setInt(3, itemId); // now using item_id, not item_sn + insertWish.executeUpdate(); + } + } + + // Save locker + try (PreparedStatement delLocker = conn.prepareStatement("DELETE FROM account.locker_item WHERE account_id = ?")) { + delLocker.setInt(1, account.getId()); + delLocker.executeUpdate(); + } + slot = 0; + for (CashItemInfo cash : account.getLocker().getCashItems()) { + try (PreparedStatement insertLocker = conn.prepareStatement( + "INSERT INTO account.locker_item (account_id, slot, item_sn, commodity_id) VALUES (?, ?, ?, ?)")) { + insertLocker.setInt(1, account.getId()); + insertLocker.setInt(2, slot++); + insertLocker.setObject(3, cash.getItem().getItemSn()); + insertLocker.setInt(4, cash.getCommodityId()); + insertLocker.executeUpdate(); + } + } + + conn.commit(); + conn.setAutoCommit(true); + return true; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java index cb27c8be..ebc73e64 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -1,320 +1,1339 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; -import com.datastax.oss.driver.api.core.cql.ResultSet; -import com.datastax.oss.driver.api.core.cql.Row; -import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry; +import com.zaxxer.hikari.HikariDataSource; import kinoko.database.CharacterAccessor; import kinoko.database.CharacterInfo; -import kinoko.database.cassandra.table.CharacterTable; import kinoko.server.rank.CharacterRank; -import kinoko.world.item.Inventory; -import kinoko.world.item.InventoryManager; +import kinoko.world.GameConstants; +import kinoko.world.item.*; import kinoko.world.job.JobConstants; import kinoko.world.quest.QuestManager; import kinoko.world.quest.QuestRecord; +import kinoko.world.quest.QuestState; import kinoko.world.skill.SkillManager; import kinoko.world.skill.SkillRecord; import kinoko.world.user.AvatarData; import kinoko.world.user.CharacterData; import kinoko.world.user.data.*; import kinoko.world.user.stat.CharacterStat; +import org.postgresql.util.PGobject; +import java.io.*; +import java.sql.*; import java.time.Instant; import java.util.*; import java.util.concurrent.atomic.AtomicInteger; -import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; +public final class PostgresCharacterAccessor implements CharacterAccessor { + private final HikariDataSource dataSource; -public final class CassandraCharacterAccessor extends CassandraAccessor implements CharacterAccessor { - - public CassandraCharacterAccessor(CqlSession session, String keyspace) { - super(session, keyspace); + public PostgresCharacterAccessor(HikariDataSource dataSource) { + this.dataSource = dataSource; } - private CharacterData loadCharacterData(Row row) { - final int accountId = row.getInt(CharacterTable.ACCOUNT_ID); + private byte[] serialize(Object obj) throws IOException { + try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); + ObjectOutputStream oos = new ObjectOutputStream(baos)) { + oos.writeObject(obj); + return baos.toByteArray(); + } + } - final CharacterData cd = new CharacterData(accountId); + private T deserialize(byte[] bytes, Class clazz) throws IOException, ClassNotFoundException { + try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes); + ObjectInputStream ois = new ObjectInputStream(bais)) { + return clazz.cast(ois.readObject()); + } + } - final CharacterStat cs = row.get(CharacterTable.CHARACTER_STAT, CharacterStat.class); - cs.setId(row.getInt(CharacterTable.CHARACTER_ID)); - cs.setName(row.getString(CharacterTable.CHARACTER_NAME)); + private CharacterData loadCharacterData(ResultSet rs) throws SQLException, IOException, ClassNotFoundException { + int accountId = rs.getInt("account_id"); + CharacterData cd = new CharacterData(accountId); + int characterID = rs.getInt("id"); + CharacterStat cs = new CharacterStat( + characterID, + rs.getString("name"), + rs.getByte("gender"), + rs.getByte("skin"), + rs.getInt("face"), + rs.getInt("hair"), + rs.getShort("level"), + rs.getShort("job"), + rs.getShort("sub_job"), + rs.getShort("base_str"), + rs.getShort("base_dex"), + rs.getShort("base_int"), + rs.getShort("base_luk"), + rs.getInt("hp"), + rs.getInt("max_hp"), + rs.getInt("mp"), + rs.getInt("max_mp"), + rs.getShort("ap"), + rs.getInt("exp"), + rs.getShort("pop"), + rs.getInt("pos_map"), + rs.getByte("portal"), + rs.getLong("pet_1"), + rs.getLong("pet_2"), + rs.getLong("pet_3") + ); cd.setCharacterStat(cs); - final InventoryManager im = new InventoryManager(); - im.setEquipped(row.get(CharacterTable.CHARACTER_EQUIPPED, Inventory.class)); - im.setEquipInventory(row.get(CharacterTable.EQUIP_INVENTORY, Inventory.class)); - im.setConsumeInventory(row.get(CharacterTable.CONSUME_INVENTORY, Inventory.class)); - im.setInstallInventory(row.get(CharacterTable.INSTALL_INVENTORY, Inventory.class)); - im.setEtcInventory(row.get(CharacterTable.ETC_INVENTORY, Inventory.class)); - im.setCashInventory(row.get(CharacterTable.CASH_INVENTORY, Inventory.class)); - im.setMoney(row.getInt(CharacterTable.MONEY)); - im.setExtSlotExpire(row.getInstant(CharacterTable.EXT_SLOT_EXPIRE)); + InventoryManager im = loadInventory(characterID); cd.setInventoryManager(im); + im.setMoney(rs.getInt("money")); + + + Timestamp extSlotExpireTs = rs.getTimestamp("ext_slot_expire"); + im.setExtSlotExpire(extSlotExpireTs != null ? extSlotExpireTs.toInstant() : null); + cd.setInventoryManager(im); + + SkillManager sm = loadSkillCooltimesAndRecords(characterID); - final SkillManager sm = new SkillManager(); - final Map skillCooltimes = row.getMap(CharacterTable.SKILL_COOLTIMES, Integer.class, Instant.class); - if (skillCooltimes != null) { - sm.getSkillCooltimes().putAll(skillCooltimes); - } - final List skillRecords = row.getList(CharacterTable.SKILL_RECORDS, SkillRecord.class); - if (skillRecords != null) { - for (SkillRecord sr : skillRecords) { - sm.addSkill(sr); - } - } cd.setSkillManager(sm); - final QuestManager qm = new QuestManager(); - final List questRecords = row.getList(CharacterTable.QUEST_RECORDS, QuestRecord.class); - if (questRecords != null) { - for (QuestRecord qr : questRecords) { - qm.addQuestRecord(qr); - } - } + QuestManager qm = loadQuestRecords(characterID); + cd.setQuestManager(qm); - final ConfigManager cm = row.get(CharacterTable.CONFIG, ConfigManager.class); + ConfigManager cm = loadConfig(characterID); cd.setConfigManager(cm); - final PopularityRecord pr = new PopularityRecord(); - final Map popularityRecords = row.getMap(CharacterTable.POPULARITY_RECORD, Integer.class, Instant.class); - if (popularityRecords != null) { - pr.getRecords().putAll(popularityRecords); - } + PopularityRecord pr = loadPopularityRecord(characterID); cd.setPopularityRecord(pr); - final MiniGameRecord mgr = row.get(CharacterTable.MINIGAME_RECORD, MiniGameRecord.class); + MiniGameRecord mgr = loadMiniGameRecord(characterID); cd.setMiniGameRecord(mgr); - final CoupleRecord cr = CoupleRecord.from(im.getEquipped(), im.getEquipInventory()); - cd.setCoupleRecord(cr); - final MapTransferInfo mti = row.get(CharacterTable.MAP_TRANSFER_INFO, MapTransferInfo.class); - cd.setMapTransferInfo(mti); + cd.setCoupleRecord(CoupleRecord.from( + im.getEquipped(), im.getEquipInventory() + )); - final WildHunterInfo whi = row.get(CharacterTable.WILD_HUNTER_INFO, WildHunterInfo.class); + MapTransferInfo mto = loadMapTransferInfo(characterID); + cd.setMapTransferInfo(mto); + + WildHunterInfo whi = loadWildHunterInfo(characterID); cd.setWildHunterInfo(whi); - cd.setItemSnCounter(new AtomicInteger(row.getInt(CharacterTable.ITEM_SN_COUNTER))); - cd.setFriendMax(row.getInt(CharacterTable.FRIEND_MAX)); - cd.setPartyId(row.getInt(CharacterTable.PARTY_ID)); - cd.setGuildId(row.getInt(CharacterTable.GUILD_ID)); - cd.setCreationTime(row.getInstant(CharacterTable.CREATION_TIME)); - cd.setMaxLevelTime(row.getInstant(CharacterTable.MAX_LEVEL_TIME)); + cd.setItemSnCounter(new AtomicInteger(-1)); // Let Postgres handle item sn + + cd.setFriendMax(rs.getInt("friend_max")); + cd.setPartyId(rs.getInt("party_id")); + cd.setGuildId(rs.getInt("guild_id")); + + Timestamp creationTs = rs.getTimestamp("creation_time"); + cd.setCreationTime(creationTs != null ? creationTs.toInstant() : null); + Timestamp maxLevelTs = rs.getTimestamp("max_level_time"); + cd.setMaxLevelTime(maxLevelTs != null ? maxLevelTs.toInstant() : null); return cd; } + private WildHunterInfo loadWildHunterInfo(int characterId) throws SQLException { + WildHunterInfo wh = new WildHunterInfo(); + + String sqlRiding = "SELECT riding_type FROM player.wild_hunter WHERE character_id = ?"; + String sqlMobs = "SELECT mob_id FROM player.wild_hunter_mob WHERE character_id = ?"; + + try (Connection conn = dataSource.getConnection()) { + // Load riding_type + try (PreparedStatement stmt = conn.prepareStatement(sqlRiding)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + wh.setRidingType(rs.getInt("riding_type")); + } + } + } + + // Load captured mobs + try (PreparedStatement stmt = conn.prepareStatement(sqlMobs)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + wh.getCapturedMobs().add(rs.getInt("mob_id")); + if (wh.getCapturedMobs().size() >= 5) break; // enforce max 5 + } + } + } + } + + return wh; + } + + + private MapTransferInfo loadMapTransferInfo(int characterId) throws SQLException { + MapTransferInfo mti = new MapTransferInfo(); + + // Query the new table for this character + String sql = "SELECT map_id, old_map_id FROM player.map_transfer WHERE character_id = ?"; + try (PreparedStatement stmt = dataSource.getConnection().prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet mapRs = stmt.executeQuery()) { + if (mapRs.next()) { + int mapId = mapRs.getInt("map_id"); + int oldMapId = mapRs.getInt("old_map_id"); + + mti.getMapTransfer().add(mapId); // main list + mti.getMapTransferEx().add(oldMapId); // legacy/old map + } + } + } + + return mti; + } + + private MiniGameRecord loadMiniGameRecord(int characterId) throws SQLException { + MiniGameRecord record = new MiniGameRecord(); + + String sql = """ + SELECT omok_wins, omok_ties, omok_losses, omok_score, + memory_wins, memory_ties, memory_losses, memory_score + FROM player.minigame + WHERE character_id = ? + """; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)){ + stmt.setInt(1, characterId); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + record.setOmokGameWins(rs.getInt("omok_wins")); + record.setOmokGameTies(rs.getInt("omok_ties")); + record.setOmokGameLosses(rs.getInt("omok_losses")); + record.setOmokGameScore(rs.getDouble("omok_score")); + + record.setMemoryGameWins(rs.getInt("memory_wins")); + record.setMemoryGameTies(rs.getInt("memory_ties")); + record.setMemoryGameLosses(rs.getInt("memory_losses")); + record.setMemoryGameScore(rs.getDouble("memory_score")); + } + } + } + + return record; + } + + + private PopularityRecord loadPopularityRecord(int characterId) throws SQLException { + PopularityRecord pr = new PopularityRecord(); + + String sql = "SELECT other_character_id, timestamp FROM player.popularity WHERE character_id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int otherCharId = rs.getInt("other_character_id"); + Timestamp ts = rs.getTimestamp("timestamp"); + if (ts != null) { + pr.getRecords().put(otherCharId, ts.toInstant()); + } + } + } + } + + return pr; + } + + + private ConfigManager loadConfig(int characterId) throws SQLException { + String sql = """ + SELECT pet_consume_item, pet_consume_mp_item, pet_exception_list, + func_key_types, func_key_ids, quickslot_key_map + FROM player.config + WHERE character_id = ? + """; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + return ConfigManager.defaults(); + } + + int petConsumeItem = rs.getInt("pet_consume_item"); + int petConsumeMpItem = rs.getInt("pet_consume_mp_item"); + + // --- Pet exception list --- + List petExceptionList; + var petExArr = rs.getArray("pet_exception_list"); + if (petExArr != null) { + Integer[] arr = (Integer[]) petExArr.getArray(); + petExceptionList = Arrays.asList(arr); + } else { + petExceptionList = List.of(); + } + + // --- Function key map --- + FuncKeyMapped[] funcKeyMap = new FuncKeyMapped[GameConstants.FUNC_KEY_MAP_SIZE]; + var funcTypeArr = rs.getArray("func_key_types"); + var funcIdArr = rs.getArray("func_key_ids"); + + if (funcTypeArr != null && funcIdArr != null) { + Short[] typeValues = (Short[]) funcTypeArr.getArray(); + Integer[] idValues = (Integer[]) funcIdArr.getArray(); + + for (int i = 0; i < funcKeyMap.length; i++) { + FuncKeyType type = FuncKeyType.getByValue(typeValues[i].byteValue()); + int id = idValues[i]; + funcKeyMap[i] = FuncKeyMapped.of(type, id); + } + } else { + funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); + } + + // --- Quickslot key map --- + int[] quickslotKeyMap; + var quickArr = rs.getArray("quickslot_key_map"); + if (quickArr != null) { + Integer[] arr = (Integer[]) quickArr.getArray(); + quickslotKeyMap = Arrays.stream(arr).mapToInt(Integer::intValue).toArray(); + } else { + quickslotKeyMap = Arrays.copyOf(GameConstants.DEFAULT_QUICKSLOT_KEY_MAP, GameConstants.QUICKSLOT_KEY_MAP_SIZE); + } + + return new ConfigManager(petConsumeItem, petConsumeMpItem, petExceptionList, funcKeyMap, quickslotKeyMap); + } + } + } + + + private QuestManager loadQuestRecords(int characterId) throws SQLException { + QuestManager qm = new QuestManager(); + String sql = "SELECT quest_id, status, progress, completed_time FROM player.quest_record WHERE character_id = ?"; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int questId = rs.getInt("quest_id"); + int statusInt = rs.getInt("status"); + QuestState state = QuestState.getByValue(statusInt); // map int -> QuestState + String value = rs.getString("progress"); + Timestamp completedTs = rs.getTimestamp("completed_time"); + Instant completedTime = completedTs != null ? completedTs.toInstant() : null; + QuestRecord record = new QuestRecord(questId, state, value, completedTime); + qm.addQuestRecord(record); + } + } + } + + return qm; + } + + + private SkillManager loadSkillCooltimesAndRecords(int characterId) throws SQLException { + SkillManager sm = new SkillManager(); + + // Load skill cooldowns + String cooldownSql = "SELECT skill_id, cooldown_end FROM player.skill_cooltime WHERE character_id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(cooldownSql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int skillId = rs.getInt("skill_id"); + Timestamp cooldownEnd = rs.getTimestamp("cooldown_end"); + if (cooldownEnd != null) { + sm.getSkillCooltimes().put(skillId, cooldownEnd.toInstant()); + } + } + } + } + + // Load skill records + String recordSql = "SELECT skill_id, level, master_level FROM player.skill_record WHERE character_id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(recordSql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int skillId = rs.getInt("skill_id"); + int level = rs.getInt("level"); + int masterLevel = rs.getInt("master_level"); + SkillRecord record = new SkillRecord(skillId, level, masterLevel); + sm.addSkill(record); + } + } + } + + return sm; + } + + + + private String lowerName(String name) { + return name.toLowerCase(); + } + @Override public boolean checkCharacterNameAvailable(String name) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), CharacterTable.getTableName()).all() - .whereColumn(CharacterTable.CHARACTER_NAME_INDEX).isEqualTo(literal(lowerName(name))) - .build() - ); - for (Row row : selectResult) { - final String existingName = row.getString(CharacterTable.CHARACTER_NAME); - if (existingName != null && existingName.equalsIgnoreCase(name)) { - return false; + String sql = "SELECT COUNT(*) > 0 AS exists FROM player.characters WHERE name ILIKE ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, name); // original name + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + boolean exists = rs.getBoolean("exists"); + return !exists; // available if it does NOT exist + } } + } catch (SQLException e) { + e.printStackTrace(); } - return true; + return false; } + private InventoryManager loadInventory(int characterId) throws SQLException { + InventoryManager im = new InventoryManager(); + + String sql = """ + SELECT inv.inventory_type, inv.slot, fi.* + FROM player.inventory inv + JOIN item.full_item fi ON inv.item_sn = fi.item_sn + WHERE inv.character_id = ? + ORDER BY inv.inventory_type, inv.slot + """; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + ResultSet rs = stmt.executeQuery(); + + while (rs.next()) { + long itemSn = rs.getLong("item_sn"); + int slot = rs.getInt("slot"); + int itemId = rs.getInt("item_id"); + short quantity = rs.getShort("quantity"); + short attribute = rs.getShort("attribute"); + String title = rs.getString("title"); + Timestamp dateExpireTs = rs.getTimestamp("date_expire"); + + // EquipData + EquipData equipData; // declare once + if (rs.getObject("inc_str") != null) { + equipData = new EquipData( + rs.getShort("inc_str"), + rs.getShort("inc_dex"), + rs.getShort("inc_int"), + rs.getShort("inc_luk"), + rs.getShort("inc_max_hp"), + rs.getShort("inc_max_mp"), + rs.getShort("inc_pad"), + rs.getShort("inc_mad"), + rs.getShort("inc_pdd"), + rs.getShort("inc_mdd"), + rs.getShort("inc_acc"), + rs.getShort("inc_eva"), + rs.getShort("inc_craft"), + rs.getShort("inc_speed"), + rs.getShort("inc_jump"), + rs.getByte("ruc"), + rs.getByte("cuc"), + rs.getInt("iuc"), + rs.getByte("chuc"), + rs.getByte("grade"), + rs.getShort("option_1"), + rs.getShort("option_2"), + rs.getShort("option_3"), + rs.getShort("socket_1"), + rs.getShort("socket_2"), + rs.getByte("level_up_type"), + rs.getByte("level"), + rs.getInt("exp"), + rs.getInt("durability") + ); + } + else{ + equipData = new EquipData(); + } + + // PetData + PetData petData = null; + if (rs.getObject("pet_name") != null) { + petData = new PetData( + rs.getString("pet_name"), + rs.getByte("pet_level"), + rs.getByte("fullness"), + rs.getShort("tameness"), + rs.getShort("pet_skill"), + rs.getShort("pet_attribute"), + rs.getInt("remain_life") + ); + } + + // RingData + RingData ringData = null; + if (rs.getObject("pair_character_id") != null) { + ringData = new RingData( + rs.getInt("pair_character_id"), + rs.getString("pair_character_name"), + rs.getLong("pair_item_sn") + ); + } + + Item item = new Item( + itemId, + quantity, + itemSn, + false, // cash flag + attribute, + title, + dateExpireTs != null ? dateExpireTs.toInstant() : null, + equipData, + petData, + ringData + ); + + String invType = rs.getString("inventory_type"); + switch (invType.toUpperCase()) { + case "EQUIPPED" -> im.getEquipped().addItem(slot, item); + case "EQUIP" -> im.getEquipInventory().addItem(slot, item); + case "CONSUME" -> im.getConsumeInventory().addItem(slot, item); + case "INSTALL" -> im.getInstallInventory().addItem(slot, item); + case "ETC" -> im.getEtcInventory().addItem(slot, item); + case "CASH" -> im.getCashInventory().addItem(slot, item); + default -> throw new IllegalArgumentException("Unknown inventory type: " + invType); + } + } + } + + return im; + } + + @Override public Optional getCharacterById(int characterId) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), CharacterTable.getTableName()).all() - .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterId)) - .build() - ); - for (Row row : selectResult) { - return Optional.of(loadCharacterData(row)); + String sql = """ + SELECT c.*, s.* + FROM player.characters c + LEFT JOIN player.stats s ON c.id = s.character_id + WHERE c.id = ? + """; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + ResultSet rs = stmt.executeQuery(); + if (rs.next()) { + return Optional.of(loadCharacterData(rs)); + } + } catch (Exception e) { + e.printStackTrace(); } return Optional.empty(); } @Override public Optional getCharacterByName(String name) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), CharacterTable.getTableName()).all() - .whereColumn(CharacterTable.CHARACTER_NAME_INDEX).isEqualTo(literal(lowerName(name))) - .build() - ); - for (Row row : selectResult) { - return Optional.of(loadCharacterData(row)); + String sql = "SELECT * FROM player.characters WHERE name ILIKE ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, lowerName(name)); + ResultSet rs = stmt.executeQuery(); + if (rs.next()) { + return Optional.of(loadCharacterData(rs)); + } + } catch (Exception e) { + e.printStackTrace(); } return Optional.empty(); } @Override public Optional getCharacterInfoByName(String name) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), CharacterTable.getTableName()) - .columns( - CharacterTable.ACCOUNT_ID, - CharacterTable.CHARACTER_ID, - CharacterTable.CHARACTER_NAME - ) - .whereColumn(CharacterTable.CHARACTER_NAME_INDEX).isEqualTo(literal(lowerName(name))) - .build() - .setExecutionProfileName(CassandraConnector.PROFILE_ONE) - ); - for (Row row : selectResult) { - return Optional.of(new CharacterInfo( - row.getInt(CharacterTable.ACCOUNT_ID), - row.getInt(CharacterTable.CHARACTER_ID), - row.getString(CharacterTable.CHARACTER_NAME) - )); + String sql = "SELECT account_id, id, name FROM player.characters WHERE name ILIKE ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, lowerName(name)); + ResultSet rs = stmt.executeQuery(); + if (rs.next()) { + return Optional.of(new CharacterInfo( + rs.getInt("account_id"), + rs.getInt("id"), + rs.getString("name") + )); + } + } catch (SQLException e) { + e.printStackTrace(); } return Optional.empty(); } @Override public Optional getAccountIdByCharacterId(int characterId) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), CharacterTable.getTableName()) - .columns( - CharacterTable.ACCOUNT_ID - ) - .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterId)) - .build() - .setExecutionProfileName(CassandraConnector.PROFILE_ONE) - ); - for (Row row : selectResult) { - return Optional.of(row.getInt(CharacterTable.ACCOUNT_ID)); + String sql = "SELECT account_id FROM player.characters WHERE id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + ResultSet rs = stmt.executeQuery(); + if (rs.next()) return Optional.of(rs.getInt("account_id")); + } catch (SQLException e) { + e.printStackTrace(); } return Optional.empty(); } @Override public List getAvatarDataByAccountId(int accountId) { - final List avatarDataList = new ArrayList<>(); - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), CharacterTable.getTableName()) - .columns( - CharacterTable.CHARACTER_ID, - CharacterTable.CHARACTER_NAME, - CharacterTable.CHARACTER_STAT, - CharacterTable.CHARACTER_EQUIPPED - ) - .whereColumn(CharacterTable.ACCOUNT_ID).isEqualTo(literal(accountId)) - .build() - ); - for (Row row : selectResult) { - final CharacterStat characterStat = row.get(CharacterTable.CHARACTER_STAT, CharacterStat.class); - characterStat.setId(row.getInt(CharacterTable.CHARACTER_ID)); - characterStat.setName(row.getString(CharacterTable.CHARACTER_NAME)); - final Inventory equipped = row.get(CharacterTable.CHARACTER_EQUIPPED, Inventory.class); - avatarDataList.add(AvatarData.from(characterStat, equipped)); + List list = new ArrayList<>(); + String sql = """ + SELECT c.id AS character_id, + c.name AS character_name, + c.money, + s.gender, + s.skin, + s.face, + s.hair, + s.level, + s.job, + s.sub_job, + s.base_str, + s.base_dex, + s.base_int, + s.base_luk, + s.hp, + s.max_hp, + s.mp, + s.max_mp, + s.ap, + s.exp, + s.pop, + s.pos_map, + s.portal, + s.pet_1, + s.pet_2, + s.pet_3 + FROM player.characters c + JOIN player.stats s ON c.id = s.character_id + WHERE c.account_id = ? + """; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + CharacterStat cs = new CharacterStat( + rs.getInt("character_id"), + rs.getString("character_name"), + rs.getByte("gender"), + rs.getByte("skin"), + rs.getInt("face"), + rs.getInt("hair"), + rs.getShort("level"), + rs.getShort("job"), + rs.getShort("sub_job"), + rs.getShort("base_str"), + rs.getShort("base_dex"), + rs.getShort("base_int"), + rs.getShort("base_luk"), + rs.getInt("hp"), + rs.getInt("max_hp"), + rs.getInt("mp"), + rs.getInt("max_mp"), + rs.getShort("ap"), + rs.getInt("exp"), + rs.getShort("pop"), + rs.getInt("pos_map"), + rs.getByte("portal"), + rs.getLong("pet_1"), + rs.getLong("pet_2"), + rs.getLong("pet_3") + ); + + // For inventory, query normalized player.inventory table separately + Inventory equipped = loadEquippedInventory(cs.getId()); + + list.add(AvatarData.from(cs, equipped)); + } + } + } catch (SQLException e) { + e.printStackTrace(); } - return avatarDataList; + + return list; } + private Inventory loadEquippedInventory(int characterId) throws SQLException { + Inventory equipped = new Inventory(24); // default equipped size + + String sql = """ + SELECT f.*, i.slot + FROM player.inventory i + JOIN item.full_item f ON i.item_sn = f.item_sn + WHERE i.character_id = ? AND i.inventory_type = ? + """; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + + PGobject enumValue = new PGobject(); + enumValue.setType("inventory_type_enum"); + enumValue.setValue(InventoryType.EQUIPPED.name()); + stmt.setObject(2, enumValue); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + long itemSn = rs.getLong("item_sn"); + int slot = rs.getInt("slot"); + int itemId = rs.getInt("item_id"); + short quantity = rs.getShort("quantity"); + short attribute = rs.getShort("attribute"); + String title = rs.getString("title"); + Timestamp dateExpireTs = rs.getTimestamp("date_expire"); + + // Build EquipData if applicable + EquipData equipData = null; + if (rs.getObject("inc_str") != null) { + equipData = new EquipData( + rs.getShort("inc_str"), + rs.getShort("inc_dex"), + rs.getShort("inc_int"), + rs.getShort("inc_luk"), + rs.getShort("inc_max_hp"), + rs.getShort("inc_max_mp"), + rs.getShort("inc_pad"), + rs.getShort("inc_mad"), + rs.getShort("inc_pdd"), + rs.getShort("inc_mdd"), + rs.getShort("inc_acc"), + rs.getShort("inc_eva"), + rs.getShort("inc_craft"), + rs.getShort("inc_speed"), + rs.getShort("inc_jump"), + rs.getByte("ruc"), + rs.getByte("cuc"), + rs.getInt("iuc"), + rs.getByte("chuc"), + rs.getByte("grade"), + rs.getShort("option_1"), + rs.getShort("option_2"), + rs.getShort("option_3"), + rs.getShort("socket_1"), + rs.getShort("socket_2"), + rs.getByte("level_up_type"), + rs.getByte("level"), + rs.getInt("exp"), + rs.getInt("durability") + ); + } + + // Build PetData if applicable + PetData petData = null; + if (rs.getObject("pet_name") != null) { + petData = new PetData( + rs.getString("pet_name"), + rs.getByte("pet_level"), + rs.getByte("fullness"), + rs.getShort("tameness"), + rs.getShort("pet_skill"), + rs.getShort("pet_attribute"), + rs.getInt("remain_life") + ); + } + + // Build RingData if applicable + RingData ringData = null; + if (rs.getObject("pair_character_id") != null) { + ringData = new RingData( + rs.getInt("pair_character_id"), + rs.getString("pair_character_name"), + rs.getLong("pair_item_sn") + ); + } + + Item item = new Item( + itemId, + quantity, + itemSn, + false, // cash flag, adjust if you have it + attribute, + title, + dateExpireTs != null ? dateExpireTs.toInstant() : null, + equipData, + petData, + ringData + ); + + equipped.putItem(slot, item); + } + } + } + + return equipped; + } + + @Override public synchronized boolean newCharacter(CharacterData characterData) { - if (!checkCharacterNameAvailable(characterData.getCharacterName())) { - return false; + if (!checkCharacterNameAvailable(characterData.getCharacterName())) return false; + + String sql = """ + INSERT INTO player.characters + (account_id, name, money, ext_slot_expire, friend_max, party_id, guild_id, creation_time, max_level_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id + """; + + Connection conn = null; + boolean success = false; + + try { + conn = dataSource.getConnection(); + conn.setAutoCommit(false); + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterData.getAccountId()); + stmt.setString(2, characterData.getCharacterName()); + stmt.setInt(3, characterData.getInventoryManager().getMoney()); + stmt.setTimestamp(4, characterData.getInventoryManager().getExtSlotExpire() != null ? + Timestamp.from(characterData.getInventoryManager().getExtSlotExpire()) : null); + stmt.setInt(5, characterData.getFriendMax()); + stmt.setInt(6, characterData.getPartyId()); + stmt.setInt(7, characterData.getGuildId()); + stmt.setTimestamp(8, characterData.getCreationTime() != null ? Timestamp.from(characterData.getCreationTime()) : null); + stmt.setTimestamp(9, characterData.getMaxLevelTime() != null ? Timestamp.from(characterData.getMaxLevelTime()) : null); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + int newCharacterId = rs.getInt(1); + characterData.getCharacterStat().setId(newCharacterId); + } else { + throw new SQLException("Failed to insert new character"); + } + } + } + + // Pass the same connection to all dependent methods + saveCharacterStats(conn, characterData); + saveCharacterInventory(conn, characterData); + saveCharacterSkills(conn, characterData); + saveCharacterQuests(conn, characterData); + saveCharacterConfig(conn, characterData); + saveCharacterMacros(conn, characterData); + saveCharacterPopularity(conn, characterData); + + conn.commit(); + success = true; + } catch (Exception e) { + e.printStackTrace(); + if (conn != null) { + try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } + } + } finally { + if (conn != null) { + try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ex) { ex.printStackTrace(); } + } + } + + return success; + } + + + private void saveCharacterConfig(Connection conn, CharacterData characterData) throws SQLException { + ConfigManager config = characterData.getConfigManager(); + + String sql = """ + INSERT INTO player.config + (character_id, pet_consume_item, pet_consume_mp_item, pet_exception_list, func_key_types, func_key_ids, quickslot_key_map) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (character_id) DO UPDATE + SET pet_consume_item = EXCLUDED.pet_consume_item, + pet_consume_mp_item = EXCLUDED.pet_consume_mp_item, + pet_exception_list = EXCLUDED.pet_exception_list, + func_key_types = EXCLUDED.func_key_types, + func_key_ids = EXCLUDED.func_key_ids, + quickslot_key_map = EXCLUDED.quickslot_key_map + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, config.getPetConsumeItem()); + stmt.setInt(3, config.getPetConsumeMpItem()); + + // pet_exception_list -> List -> Integer[] + List exceptionList = config.getPetExceptionList(); + Integer[] exceptionArray = exceptionList != null ? exceptionList.toArray(new Integer[0]) : new Integer[0]; + stmt.setArray(4, conn.createArrayOf("integer", exceptionArray)); + + // func_key_types & func_key_ids from FuncKeyMapped[] + FuncKeyMapped[] funcKeyMap = config.getFuncKeyMap(); + if (funcKeyMap == null) { + funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); + } + + Short[] funcTypes = Arrays.stream(funcKeyMap) + .map(f -> (short) f.getType().getValue()) + .toArray(Short[]::new); + Integer[] funcIds = Arrays.stream(funcKeyMap) + .map(FuncKeyMapped::getId) + .toArray(Integer[]::new); + + stmt.setArray(5, conn.createArrayOf("smallint", funcTypes)); // func_key_types + stmt.setArray(6, conn.createArrayOf("integer", funcIds)); // func_key_ids + + // quickslot_key_map -> int[] -> Integer[] + int[] quickslot = config.getQuickslotKeyMap(); + Integer[] quickslotKeys = quickslot != null + ? Arrays.stream(quickslot).boxed().toArray(Integer[]::new) + : new Integer[0]; + stmt.setArray(7, conn.createArrayOf("integer", quickslotKeys)); + + stmt.executeUpdate(); + } + } + + + + private void saveCharacterMacros(Connection conn, CharacterData characterData) throws SQLException { + List macros = characterData.getConfigManager().getMacroSysData(); + + String sql = """ + INSERT INTO player.character_macro + (character_id, macro_index, name, mute, skills) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (character_id, macro_index) DO UPDATE + SET name = EXCLUDED.name, + mute = EXCLUDED.mute, + skills = EXCLUDED.skills + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (int i = 0; i < macros.size(); i++) { + SingleMacro macro = macros.get(i); + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, i); // macro_index + stmt.setString(3, macro.getName()); + stmt.setBoolean(4, macro.isMute()); + + // skills array -> Integer[] + int[] skills = macro.getSkills(); + Integer[] skillArray = Arrays.stream(skills).boxed().toArray(Integer[]::new); + stmt.setArray(5, conn.createArrayOf("INT", skillArray)); + + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + + private void saveCharacterPopularity(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.popularity (character_id, other_character_id, timestamp) + VALUES (?, ?, ?) + ON CONFLICT (character_id, other_character_id) + DO UPDATE SET timestamp = EXCLUDED.timestamp + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + PopularityRecord pr = characterData.getPopularityRecord(); + int charId = characterData.getCharacterId(); + + for (var entry : pr.getRecords().entrySet()) { + stmt.setInt(1, charId); + stmt.setInt(2, entry.getKey()); + stmt.setTimestamp(3, Timestamp.from(entry.getValue())); + stmt.addBatch(); + } + + stmt.executeBatch(); + } + } + + + + private void saveCharacterSkills(Connection conn, CharacterData characterData) throws SQLException { + String skillRecordSql = """ + INSERT INTO player.skill_record (character_id, skill_id, level, master_level) + VALUES (?, ?, ?, ?) + ON CONFLICT (character_id, skill_id) + DO UPDATE SET level = EXCLUDED.level, master_level = EXCLUDED.master_level + """; + + try (PreparedStatement stmt = conn.prepareStatement(skillRecordSql)) { + for (SkillRecord sr : characterData.getSkillManager().getSkillRecords()) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, sr.getSkillId()); + stmt.setInt(3, sr.getSkillLevel()); + if (sr.getMasterLevel() > 0) { + stmt.setInt(4, sr.getMasterLevel()); + } else { + stmt.setNull(4, java.sql.Types.INTEGER); + } + stmt.addBatch(); + } + stmt.executeBatch(); + } + + // Save skill cooltimes + String skillCooltimeSql = """ + INSERT INTO player.skill_cooltime (character_id, skill_id, cooldown_end) + VALUES (?, ?, ?) + ON CONFLICT (character_id, skill_id) + DO UPDATE SET cooldown_end = EXCLUDED.cooldown_end + """; + + try (PreparedStatement stmt = conn.prepareStatement(skillCooltimeSql)) { + for (var entry : characterData.getSkillManager().getSkillCooltimes().entrySet()) { + int skillId = entry.getKey(); + Instant endTime = entry.getValue(); + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, skillId); + stmt.setTimestamp(3, Timestamp.from(endTime)); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + private void saveCharacterQuests(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.quest_record (character_id, quest_id, status, progress, completed_time) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (character_id, quest_id) + DO UPDATE SET status = EXCLUDED.status, + progress = EXCLUDED.progress, + completed_time = EXCLUDED.completed_time + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (QuestRecord qr : characterData.getQuestManager().getQuestRecords()) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, qr.getQuestId()); + stmt.setInt(3, qr.getState().getValue()); + stmt.setString(4, qr.getValue()); + stmt.setTimestamp(5, qr.getCompletedTime() != null ? Timestamp.from(qr.getCompletedTime()) : null); + stmt.addBatch(); + } + stmt.executeBatch(); } - return saveCharacter(characterData); } @Override public boolean saveCharacter(CharacterData characterData) { - final CodecRegistry registry = getSession().getContext().getCodecRegistry(); - final ResultSet updateResult = getSession().execute( - update(getKeyspace(), CharacterTable.getTableName()) - .setColumn(CharacterTable.ACCOUNT_ID, literal(characterData.getAccountId())) - .setColumn(CharacterTable.CHARACTER_NAME, literal(characterData.getCharacterName())) - .setColumn(CharacterTable.CHARACTER_NAME_INDEX, literal(lowerName(characterData.getCharacterName()))) - .setColumn(CharacterTable.CHARACTER_STAT, literal(characterData.getCharacterStat(), registry)) - .setColumn(CharacterTable.CHARACTER_EQUIPPED, literal(characterData.getInventoryManager().getEquipped(), registry)) - .setColumn(CharacterTable.EQUIP_INVENTORY, literal(characterData.getInventoryManager().getEquipInventory(), registry)) - .setColumn(CharacterTable.CONSUME_INVENTORY, literal(characterData.getInventoryManager().getConsumeInventory(), registry)) - .setColumn(CharacterTable.INSTALL_INVENTORY, literal(characterData.getInventoryManager().getInstallInventory(), registry)) - .setColumn(CharacterTable.ETC_INVENTORY, literal(characterData.getInventoryManager().getEtcInventory(), registry)) - .setColumn(CharacterTable.CASH_INVENTORY, literal(characterData.getInventoryManager().getCashInventory(), registry)) - .setColumn(CharacterTable.MONEY, literal(characterData.getInventoryManager().getMoney())) - .setColumn(CharacterTable.EXT_SLOT_EXPIRE, literal(characterData.getInventoryManager().getExtSlotExpire())) - .setColumn(CharacterTable.SKILL_COOLTIMES, literal(characterData.getSkillManager().getSkillCooltimes())) - .setColumn(CharacterTable.SKILL_RECORDS, literal(characterData.getSkillManager().getSkillRecords(), registry)) - .setColumn(CharacterTable.QUEST_RECORDS, literal(characterData.getQuestManager().getQuestRecords(), registry)) - .setColumn(CharacterTable.CONFIG, literal(characterData.getConfigManager(), registry)) - .setColumn(CharacterTable.POPULARITY_RECORD, literal(characterData.getPopularityRecord().getRecords(), registry)) - .setColumn(CharacterTable.MINIGAME_RECORD, literal(characterData.getMiniGameRecord(), registry)) - .setColumn(CharacterTable.MAP_TRANSFER_INFO, literal(characterData.getMapTransferInfo(), registry)) - .setColumn(CharacterTable.WILD_HUNTER_INFO, literal(characterData.getWildHunterInfo(), registry)) - .setColumn(CharacterTable.ITEM_SN_COUNTER, literal(characterData.getItemSnCounter().get())) - .setColumn(CharacterTable.FRIEND_MAX, literal(characterData.getFriendMax())) - .setColumn(CharacterTable.PARTY_ID, literal(characterData.getPartyId())) - .setColumn(CharacterTable.GUILD_ID, literal(characterData.getGuildId())) - .setColumn(CharacterTable.CREATION_TIME, literal(characterData.getCreationTime())) - .setColumn(CharacterTable.MAX_LEVEL_TIME, literal(characterData.getMaxLevelTime())) - .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterData.getCharacterId())) - .build() - ); - return updateResult.wasApplied(); + String sql = "UPDATE player.characters SET account_id=?, name=?, money=?, ext_slot_expire=?, " + + "friend_max=?, party_id=?, guild_id=?, creation_time=?, max_level_time=? " + + "WHERE id=?"; + Connection conn = null; + boolean previousAutoCommit = true; + + try { + conn = dataSource.getConnection(); + + // save previous auto-commit state + previousAutoCommit = conn.getAutoCommit(); + conn.setAutoCommit(false); + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterData.getAccountId()); + stmt.setString(2, characterData.getCharacterName()); + stmt.setInt(3, characterData.getInventoryManager().getMoney()); + stmt.setTimestamp(4, characterData.getInventoryManager().getExtSlotExpire() != null ? + Timestamp.from(characterData.getInventoryManager().getExtSlotExpire()) : null); + stmt.setInt(5, characterData.getFriendMax()); + stmt.setInt(6, characterData.getPartyId()); + stmt.setInt(7, characterData.getGuildId()); + stmt.setTimestamp(8, characterData.getCreationTime() != null ? Timestamp.from(characterData.getCreationTime()) : null); + stmt.setTimestamp(9, characterData.getMaxLevelTime() != null ? Timestamp.from(characterData.getMaxLevelTime()) : null); + stmt.setInt(10, characterData.getCharacterId()); + + int updated = stmt.executeUpdate(); + if (updated == 0) { + conn.rollback(); + return false; + } + } + + // Save dependent tables using the same connection + saveCharacterStats(conn, characterData); + saveCharacterInventory(conn, characterData); + saveCharacterSkills(conn, characterData); + saveCharacterQuests(conn, characterData); + saveCharacterConfig(conn, characterData); + saveCharacterMacros(conn, characterData); + saveCharacterPopularity(conn, characterData); + + conn.commit(); + return true; + } catch (Exception e) { + if (conn != null) { + try { + conn.rollback(); + } catch (SQLException rollbackEx) { + rollbackEx.printStackTrace(); + } + } + e.printStackTrace(); + return false; + } finally { + if (conn != null) { + try { + conn.setAutoCommit(previousAutoCommit); + conn.close(); + } catch (SQLException ex) { + ex.printStackTrace(); + } + } + } + } + + + private void saveCharacterStats(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.stats ( + character_id, gender, skin, face, hair, level, job, sub_job, + base_str, base_dex, base_int, base_luk, hp, max_hp, mp, max_mp, + ap, exp, pop, pos_map, portal, pet_1, pet_2, pet_3 + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + ON CONFLICT (character_id) DO UPDATE SET + gender = EXCLUDED.gender, + skin = EXCLUDED.skin, + face = EXCLUDED.face, + hair = EXCLUDED.hair, + level = EXCLUDED.level, + job = EXCLUDED.job, + sub_job = EXCLUDED.sub_job, + base_str = EXCLUDED.base_str, + base_dex = EXCLUDED.base_dex, + base_int = EXCLUDED.base_int, + base_luk = EXCLUDED.base_luk, + hp = EXCLUDED.hp, + max_hp = EXCLUDED.max_hp, + mp = EXCLUDED.mp, + max_mp = EXCLUDED.max_mp, + ap = EXCLUDED.ap, + exp = EXCLUDED.exp, + pop = EXCLUDED.pop, + pos_map = EXCLUDED.pos_map, + portal = EXCLUDED.portal, + pet_1 = EXCLUDED.pet_1, + pet_2 = EXCLUDED.pet_2, + pet_3 = EXCLUDED.pet_3 + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + CharacterStat cs = characterData.getCharacterStat(); + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, cs.getGender()); + stmt.setInt(3, cs.getSkin()); + stmt.setInt(4, cs.getFace()); + stmt.setInt(5, cs.getHair()); + stmt.setInt(6, cs.getLevel()); + stmt.setInt(7, cs.getJob()); + stmt.setInt(8, cs.getSubJob()); + stmt.setInt(9, cs.getBaseStr()); + stmt.setInt(10, cs.getBaseDex()); + stmt.setInt(11, cs.getBaseInt()); + stmt.setInt(12, cs.getBaseLuk()); + stmt.setInt(13, cs.getHp()); + stmt.setInt(14, cs.getMaxHp()); + stmt.setInt(15, cs.getMp()); + stmt.setInt(16, cs.getMaxMp()); + stmt.setInt(17, cs.getAp()); + stmt.setInt(18, cs.getExp()); + stmt.setInt(19, cs.getPop()); + stmt.setInt(20, cs.getPosMap()); + stmt.setInt(21, cs.getPortal()); + stmt.setLong(22, cs.getPetSn1()); + stmt.setLong(23, cs.getPetSn2()); + stmt.setLong(24, cs.getPetSn3()); + + stmt.executeUpdate(); + } + } + + + private void saveCharacterInventory(Connection conn, CharacterData characterData) throws SQLException { + String sqlInventory = """ + INSERT INTO player.inventory (character_id, inventory_type, slot, item_sn) + VALUES (?, ?, ?, ?) + ON CONFLICT (character_id, item_sn) + DO UPDATE SET slot = EXCLUDED.slot, inventory_type = EXCLUDED.inventory_type + """; + + String sqlItems = """ + INSERT INTO item.items (item_sn, item_id, quantity, attribute, title, date_expire) + VALUES (?, ?, ?, ?, ?, ?) + """; + + try (PreparedStatement stmtInventory = conn.prepareStatement(sqlInventory); + PreparedStatement stmtItems = conn.prepareStatement(sqlItems)) { + + InventoryManager inv = characterData.getInventoryManager(); + + saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.EQUIPPED, inv.getEquipped().getItems()); + saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.EQUIP, inv.getEquipInventory().getItems()); + saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.CONSUME, inv.getConsumeInventory().getItems()); + saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.INSTALL, inv.getInstallInventory().getItems()); + saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.ETC, inv.getEtcInventory().getItems()); + saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.CASH, inv.getCashInventory().getItems()); + + stmtItems.executeBatch(); + stmtInventory.executeBatch(); + } + } + + + + private void saveInventoryBatch( + Connection conn, + PreparedStatement stmtItems, + PreparedStatement stmtInventory, + int charId, + InventoryType type, + Map items + ) throws SQLException { + + PGobject enumValue = new PGobject(); + enumValue.setType("inventory_type_enum"); + enumValue.setValue(type.name()); + + // --- Delete inventory items that no longer exist --- + if (!items.isEmpty()) { + Long[] itemSnArray = items.values().stream() + .map(Item::getItemSn) + .toArray(Long[]::new); + + try (PreparedStatement deleteStmt = conn.prepareStatement("DELETE FROM player.inventory WHERE character_id = ? AND item_sn <> ALL (?)")) { + deleteStmt.setInt(1, charId); + Array sqlArray = conn.createArrayOf("bigint", itemSnArray); + deleteStmt.setArray(2, sqlArray); + deleteStmt.executeUpdate(); + } + } else { // delete all items. + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM player.inventory WHERE character_id = ?")) { + deleteStmt.setInt(1, charId); + deleteStmt.executeUpdate(); + } + } + + // --- Insert/update items --- + for (var entry : items.entrySet()) { + Item item = entry.getValue(); + long itemSn = item.getItemSn(); + + if (itemSn <= 0) { // DNE + try (PreparedStatement seqStmt = conn.prepareStatement( + "SELECT nextval(pg_get_serial_sequence('item.items', 'item_sn'))"); + ResultSet rs = seqStmt.executeQuery()) { + rs.next(); + itemSn = rs.getLong(1); + item.setItemSn(itemSn); + } + + stmtItems.setLong(1, itemSn); + stmtItems.setInt(2, item.getItemId()); + stmtItems.setInt(3, item.getQuantity()); + stmtItems.setShort(4, item.getAttribute()); + stmtItems.setString(5, item.getTitle()); + stmtItems.setTimestamp(6, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); + stmtItems.addBatch(); + } else { + try (PreparedStatement updateStmt = conn.prepareStatement( + "UPDATE item.items SET quantity = ?, attribute = ?, title = ?, date_expire = ? WHERE item_sn = ?")) { + updateStmt.setInt(1, item.getQuantity()); + updateStmt.setShort(2, item.getAttribute()); + updateStmt.setString(3, item.getTitle()); + updateStmt.setTimestamp(4, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); + updateStmt.setLong(5, itemSn); + updateStmt.executeUpdate(); + } + } + + stmtInventory.setInt(1, charId); + stmtInventory.setObject(2, enumValue); + stmtInventory.setInt(3, entry.getKey()); + stmtInventory.setLong(4, itemSn); + stmtInventory.addBatch(); + } } @Override public boolean deleteCharacter(int accountId, int characterId) { - final ResultSet updateResult = getSession().execute( - deleteFrom(getKeyspace(), CharacterTable.getTableName()) - .whereColumn(CharacterTable.CHARACTER_ID).isEqualTo(literal(characterId)) - .ifColumn(CharacterTable.ACCOUNT_ID).isEqualTo(literal(accountId)) - .build() - ); - return updateResult.wasApplied(); + String sql = "DELETE FROM player.characters WHERE id=? AND account_id=?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + stmt.setInt(2, accountId); + return stmt.executeUpdate() > 0; + } catch (SQLException e) { + e.printStackTrace(); + return false; + } } @Override public Map getCharacterRanks() { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), CharacterTable.getTableName()) - .columns( - CharacterTable.CHARACTER_ID, - CharacterTable.CHARACTER_STAT, - CharacterTable.MAX_LEVEL_TIME - ) - .build() - .setExecutionProfileName(CassandraConnector.PROFILE_ONE) - ); - final List rankDataList = new ArrayList<>(); - for (Row row : selectResult) { - final int characterId = row.getInt(CharacterTable.CHARACTER_ID); - final CharacterStat characterStat = row.get(CharacterTable.CHARACTER_STAT, CharacterStat.class); - final Instant maxLevelTime = row.getInstant(CharacterTable.MAX_LEVEL_TIME); - final int jobId = characterStat.getJob(); - if (JobConstants.isAdminJob(jobId) || JobConstants.isManagerJob(jobId)) { - continue; - } - rankDataList.add(new CharacterRankData( - characterId, - JobConstants.getJobCategory(jobId), - characterStat.getCumulativeExp(), - maxLevelTime - )); - } - // Sort and process rank data - rankDataList.sort(Comparator.comparing(CharacterRankData::getCumulativeExp).reversed().thenComparing(CharacterRankData::getMaxLevelTime)); - final Map jobRanks = new HashMap<>(); // job rank counter - final Map characterRanks = new HashMap<>(); // character id -> character rank - for (CharacterRankData rankData : rankDataList) { - final int characterId = rankData.getCharacterId(); - final int jobCategory = rankData.getJobCategory(); - final int worldRank = characterRanks.size() + 1; - final int jobRank = jobRanks.getOrDefault(jobCategory, 0) + 1; - jobRanks.put(jobCategory, jobRank); - characterRanks.put(characterId, new CharacterRank( - characterId, - worldRank, - jobRank - )); - } - return characterRanks; + Map ranks = new HashMap<>(); + String sql = """ + SELECT c.id, c.max_level_time, s.job, s.exp + FROM player.characters c + JOIN player.stats s ON c.id = s.character_id + """; + + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + ResultSet rs = stmt.executeQuery(); + List rankDataList = new ArrayList<>(); + + while (rs.next()) { + int characterId = rs.getInt("id"); + int jobId = rs.getInt("job"); + long cumulativeExp = rs.getLong("exp"); + Timestamp ts = rs.getTimestamp("max_level_time"); + + // skip admin/manager characters + if (JobConstants.isAdminJob(jobId) || JobConstants.isManagerJob(jobId)) { + continue; + } + + rankDataList.add(new CharacterRankData( + characterId, + JobConstants.getJobCategory(jobId), + cumulativeExp, + ts != null ? ts.toInstant() : Instant.MAX + )); + } + + // Sort by EXP (descending) and then by earliest max level time + rankDataList.sort( + Comparator.comparingLong(CharacterRankData::getCumulativeExp).reversed() + .thenComparing(CharacterRankData::getMaxLevelTime) + ); + + // Compute world rank and job rank + Map jobRanks = new HashMap<>(); + for (CharacterRankData data : rankDataList) { + int worldRank = ranks.size() + 1; + int jobRank = jobRanks.getOrDefault(data.getJobCategory(), 0) + 1; + jobRanks.put(data.getJobCategory(), jobRank); + + ranks.put(data.getCharacterId(), new CharacterRank( + data.getCharacterId(), + worldRank, + jobRank + )); + } + + } catch (Exception e) { + e.printStackTrace(); + } + + return ranks; } + private static class CharacterRankData { private final int characterId; private final int jobCategory; @@ -328,20 +1347,9 @@ private CharacterRankData(int characterId, int jobCategory, long cumulativeExp, this.maxLevelTime = maxLevelTime; } - public int getCharacterId() { - return characterId; - } - - public int getJobCategory() { - return jobCategory; - } - - public long getCumulativeExp() { - return cumulativeExp; - } - - public Instant getMaxLevelTime() { - return maxLevelTime != null ? maxLevelTime : Instant.MAX; - } + public int getCharacterId() { return characterId; } + public int getJobCategory() { return jobCategory; } + public long getCumulativeExp() { return cumulativeExp; } + public Instant getMaxLevelTime() { return maxLevelTime != null ? maxLevelTime : Instant.MAX; } } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java index 8df86137..52a9cd4c 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresConnector.java +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -1,4 +1,82 @@ package kinoko.database.postgresql; -public class PostgresConnector { +import kinoko.database.*; +import java.util.TimeZone; +import com.zaxxer.hikari.HikariConfig; +import com.zaxxer.hikari.HikariDataSource; +import kinoko.server.ServerConfig; +import kinoko.server.ServerConstants; + +public final class PostgresConnector implements DatabaseConnector { + private HikariDataSource dataSource; + private IdAccessor idAccessor; + private AccountAccessor accountAccessor; + private CharacterAccessor characterAccessor; + private FriendAccessor friendAccessor; + private GuildAccessor guildAccessor; + private GiftAccessor giftAccessor; + private MemoAccessor memoAccessor; + + @Override + public void initialize() { + try { + // Connect + String DATABASE_URL = String.format( + "jdbc:postgresql://%s:%s/%s", + ServerConstants.DATABASE_HOST, + ServerConstants.DATABASE_PORT, + ServerConstants.DATABASE_NAME + ); + TimeZone.setDefault(TimeZone.getTimeZone("UTC")); // Set a custom timezone. + HikariConfig config = new HikariConfig(); + config.setJdbcUrl(DATABASE_URL); + config.setUsername(ServerConstants.DATABASE_USER); + config.setPassword(ServerConstants.DATABASE_PASSWORD); + config.setMaximumPoolSize(50); // Adjust as needed + config.setConnectionTimeout(5000); // 5s + config.setIdleTimeout(60000); // 60s + config.setMaxLifetime(1800000); // 30min + config.setLeakDetectionThreshold(5000); + + dataSource = new HikariDataSource(config); + + // Run init.sql if needed +// Path initPath = Path.of("src/main/java/kinoko/database/postgresql/setup/init.sql"); +// if (Files.exists(initPath)) { +// String sql = Files.readString(initPath); +// try (Statement stmt = connection.createStatement()) { +// stmt.execute(sql); +// } +// } + + // Create Accessors + idAccessor = new PostgresIdAccessor(dataSource); + accountAccessor = new PostgresAccountAccessor(dataSource); + characterAccessor = new PostgresCharacterAccessor(dataSource); + friendAccessor = new PostgresFriendAccessor(dataSource); + guildAccessor = new PostgresGuildAccessor(dataSource); + giftAccessor = new PostgresGiftAccessor(dataSource); + memoAccessor = new PostgresMemoAccessor(dataSource); + + } catch (Exception e) { + e.printStackTrace(); + throw new RuntimeException("Failed to initialize PostgresConnector", e); + } + } + + @Override + public void shutdown() { + if (dataSource != null) { + dataSource.close(); // safely closes all pooled connections + dataSource = null; + } + } + + @Override public IdAccessor getIdAccessor() { return idAccessor; } + @Override public AccountAccessor getAccountAccessor() { return accountAccessor; } + @Override public CharacterAccessor getCharacterAccessor() { return characterAccessor; } + @Override public FriendAccessor getFriendAccessor() { return friendAccessor; } + @Override public GuildAccessor getGuildAccessor() { return guildAccessor; } + @Override public GiftAccessor getGiftAccessor() { return giftAccessor; } + @Override public MemoAccessor getMemoAccessor() { return memoAccessor; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java index 36b39a47..88d3cb7f 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java @@ -1,84 +1,101 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; -import com.datastax.oss.driver.api.core.cql.ResultSet; -import com.datastax.oss.driver.api.core.cql.Row; -import com.datastax.oss.driver.api.querybuilder.insert.Insert; +import com.zaxxer.hikari.HikariDataSource; import kinoko.database.FriendAccessor; -import kinoko.database.cassandra.table.FriendTable; import kinoko.world.user.friend.Friend; import kinoko.world.user.friend.FriendStatus; +import java.sql.*; import java.util.ArrayList; import java.util.List; -import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; +public final class PostgresFriendAccessor implements FriendAccessor { + private final HikariDataSource dataSource; -public final class CassandraFriendAccessor extends CassandraAccessor implements FriendAccessor { - public CassandraFriendAccessor(CqlSession session, String keyspace) { - super(session, keyspace); + public PostgresFriendAccessor(HikariDataSource dataSource) { + this.dataSource = dataSource; } - private Friend loadFriend(Row row) { - final int characterId = row.getInt(FriendTable.CHARACTER_ID); - final int friendId = row.getInt(FriendTable.FRIEND_ID); - final String friendName = row.getString(FriendTable.FRIEND_NAME); - final String friendGroup = row.getString(FriendTable.FRIEND_GROUP); - final FriendStatus status = FriendStatus.getByValue(row.getInt(FriendTable.FRIEND_STATUS)); + private Friend loadFriend(ResultSet rs) throws SQLException { + int characterId = rs.getInt("character_id"); + int friendId = rs.getInt("friend_id"); + String friendName = rs.getString("friend_name"); + String friendGroup = rs.getString("friend_group"); + FriendStatus status = FriendStatus.getByValue(rs.getInt("friend_status")); return new Friend(characterId, friendId, friendName, friendGroup, status); } @Override public List getFriendsByCharacterId(int characterId) { - final List friends = new ArrayList<>(); - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), FriendTable.getTableName()).all() - .whereColumn(FriendTable.CHARACTER_ID).isEqualTo(literal(characterId)) - .build() - ); - for (Row row : selectResult) { - friends.add(loadFriend(row)); + List friends = new ArrayList<>(); + String sql = "SELECT * FROM friend.friends WHERE character_id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + ResultSet rs = stmt.executeQuery(); + while (rs.next()) { + friends.add(loadFriend(rs)); + } + } catch (SQLException e) { + e.printStackTrace(); } return friends; } @Override public List getFriendsByFriendId(int friendId) { - final List friends = new ArrayList<>(); - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), FriendTable.getTableName()).all() - .whereColumn(FriendTable.FRIEND_ID).isEqualTo(literal(friendId)) - .build() - ); - for (Row row : selectResult) { - friends.add(loadFriend(row)); + List friends = new ArrayList<>(); + String sql = "SELECT * FROM friend.friends WHERE friend_id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, friendId); + ResultSet rs = stmt.executeQuery(); + while (rs.next()) { + friends.add(loadFriend(rs)); + } + } catch (SQLException e) { + e.printStackTrace(); } return friends; } @Override public boolean saveFriend(Friend friend, boolean force) { - Insert insert = insertInto(getKeyspace(), FriendTable.getTableName()) - .value(FriendTable.CHARACTER_ID, literal(friend.getCharacterId())) - .value(FriendTable.FRIEND_ID, literal(friend.getFriendId())) - .value(FriendTable.FRIEND_NAME, literal(friend.getFriendName())) - .value(FriendTable.FRIEND_GROUP, literal(friend.getFriendGroup())) - .value(FriendTable.FRIEND_STATUS, literal(friend.getStatus().getValue())); - if (!force) { - insert = insert.ifNotExists(); + String sql; + if (force) { + sql = "INSERT INTO friend.friends (character_id, friend_id, friend_name, friend_group, friend_status) " + + "VALUES (?, ?, ?, ?, ?) " + + "ON CONFLICT (character_id, friend_id) DO UPDATE SET friend_name = EXCLUDED.friend_name, " + + "friend_group = EXCLUDED.friend_group, friend_status = EXCLUDED.friend_status"; + } else { + sql = "INSERT INTO friend.friends (character_id, friend_id, friend_name, friend_group, friend_status) " + + "VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING"; } - final ResultSet insertResult = getSession().execute(insert.build()); - return insertResult.wasApplied(); + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, friend.getCharacterId()); + stmt.setInt(2, friend.getFriendId()); + stmt.setString(3, friend.getFriendName()); + stmt.setString(4, friend.getFriendGroup()); + stmt.setInt(5, friend.getStatus().getValue()); + return stmt.executeUpdate() > 0; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } @Override public boolean deleteFriend(int characterId, int friendId) { - final ResultSet deleteResult = getSession().execute( - deleteFrom(getKeyspace(), FriendTable.getTableName()) - .whereColumn(FriendTable.CHARACTER_ID).isEqualTo(literal(characterId)) - .whereColumn(FriendTable.FRIEND_ID).isEqualTo(literal(friendId)) - .build() - ); - return deleteResult.wasApplied(); + String sql = "DELETE FROM friend.friends WHERE character_id = ? AND friend_id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + stmt.setInt(2, friendId); + return stmt.executeUpdate() > 0; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java index cea13ac1..34b283e1 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java @@ -1,86 +1,98 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; -import com.datastax.oss.driver.api.core.cql.ResultSet; -import com.datastax.oss.driver.api.core.cql.Row; +import com.zaxxer.hikari.HikariDataSource; import kinoko.database.GiftAccessor; -import kinoko.database.cassandra.table.GiftTable; import kinoko.server.cashshop.Gift; +import java.sql.*; import java.util.ArrayList; import java.util.List; import java.util.Optional; -import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; +public final class PostgresGiftAccessor implements GiftAccessor { + private final HikariDataSource dataSource; -public final class CassandraGiftAccessor extends CassandraAccessor implements GiftAccessor { - public CassandraGiftAccessor(CqlSession session, String keyspace) { - super(session, keyspace); + public PostgresGiftAccessor(HikariDataSource dataSource) { + this.dataSource = dataSource; } - private Gift loadGift(Row row) { + private Gift loadGift(ResultSet rs) throws SQLException { return new Gift( - row.getLong(GiftTable.GIFT_SN), - row.getInt(GiftTable.ITEM_ID), - row.getInt(GiftTable.COMMODITY_ID), - row.getInt(GiftTable.SENDER_ID), - row.getString(GiftTable.SENDER_NAME), - row.getString(GiftTable.SENDER_MESSAGE), - row.getLong(GiftTable.PAIR_ITEM_SN) + rs.getLong("gift_sn"), + rs.getInt("item_id"), + rs.getInt("commodity_id"), + rs.getInt("sender_id"), + rs.getString("sender_name"), + rs.getString("sender_message"), + rs.getLong("pair_item_sn") ); } @Override public List getGiftsByCharacterId(int characterId) { - final List gifts = new ArrayList<>(); - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), GiftTable.getTableName()).all() - .whereColumn(GiftTable.RECEIVER_ID).isEqualTo(literal(characterId)) - .build() - ); - for (Row row : selectResult) { - gifts.add(loadGift(row)); + List gifts = new ArrayList<>(); + String sql = "SELECT * FROM gifts WHERE receiver_id = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + ResultSet rs = stmt.executeQuery(); + while (rs.next()) { + gifts.add(loadGift(rs)); + } + } catch (SQLException e) { + e.printStackTrace(); } return gifts; } @Override public Optional getGiftByItemSn(long itemSn) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), GiftTable.getTableName()).all() - .whereColumn(GiftTable.GIFT_SN).isEqualTo(literal(itemSn)) - .build() - ); - for (Row row : selectResult) { - return Optional.of(loadGift(row)); + String sql = "SELECT * FROM gifts WHERE gift_sn = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, itemSn); + ResultSet rs = stmt.executeQuery(); + if (rs.next()) { + return Optional.of(loadGift(rs)); + } + } catch (SQLException e) { + e.printStackTrace(); } return Optional.empty(); } @Override public boolean newGift(Gift gift, int receiverId) { - final ResultSet insertResult = getSession().execute( - insertInto(getKeyspace(), GiftTable.getTableName()) - .value(GiftTable.GIFT_SN, literal(gift.getGiftSn())) - .value(GiftTable.RECEIVER_ID, literal(receiverId)) - .value(GiftTable.ITEM_ID, literal(gift.getItemId())) - .value(GiftTable.COMMODITY_ID, literal(gift.getCommodityId())) - .value(GiftTable.SENDER_NAME, literal(gift.getSenderName())) - .value(GiftTable.SENDER_MESSAGE, literal(gift.getSenderMessage())) - .value(GiftTable.PAIR_ITEM_SN, literal(gift.getPairItemSn())) - .ifNotExists() - .build() - ); - return insertResult.wasApplied(); + String sql = "INSERT INTO gifts (gift_sn, receiver_id, item_id, commodity_id, sender_id, sender_name, sender_message, pair_item_sn) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (gift_sn) DO NOTHING"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, gift.getGiftSn()); + stmt.setInt(2, receiverId); + stmt.setInt(3, gift.getItemId()); + stmt.setInt(4, gift.getCommodityId()); + stmt.setInt(5, gift.getSenderId()); + stmt.setString(6, gift.getSenderName()); + stmt.setString(7, gift.getSenderMessage()); + stmt.setLong(8, gift.getPairItemSn()); + return stmt.executeUpdate() > 0; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } @Override public boolean deleteGift(Gift gift) { - final ResultSet updateResult = getSession().execute( - deleteFrom(getKeyspace(), GiftTable.getTableName()) - .whereColumn(GiftTable.GIFT_SN).isEqualTo(literal(gift.getGiftSn())) - .build() - ); - return updateResult.wasApplied(); + String sql = "DELETE FROM gifts WHERE gift_sn = ?"; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, gift.getGiftSn()); + return stmt.executeUpdate() > 0; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java index f8fd07a0..04360a08 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -1,161 +1,353 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; -import com.datastax.oss.driver.api.core.cql.ResultSet; -import com.datastax.oss.driver.api.core.cql.Row; -import com.datastax.oss.driver.api.core.type.codec.registry.CodecRegistry; +import com.zaxxer.hikari.HikariDataSource; import kinoko.database.GuildAccessor; -import kinoko.database.cassandra.table.GuildTable; import kinoko.server.guild.Guild; import kinoko.server.guild.GuildBoardEntry; import kinoko.server.guild.GuildMember; import kinoko.server.guild.GuildRanking; +import kinoko.server.guild.GuildRank; -import java.util.ArrayList; -import java.util.Comparator; -import java.util.List; -import java.util.Optional; +import java.sql.*; +import java.util.*; import java.util.concurrent.atomic.AtomicInteger; -import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; +public final class PostgresGuildAccessor extends PostgresAccessor implements GuildAccessor { -public final class CassandraGuildAccessor extends CassandraAccessor implements GuildAccessor { - public CassandraGuildAccessor(CqlSession session, String keyspace) { - super(session, keyspace); + public PostgresGuildAccessor(HikariDataSource dataSource) { + super(dataSource); } - private Guild loadGuild(Row row) { - final int guildId = row.getInt(GuildTable.GUILD_ID); - final String guildName = row.getString(GuildTable.GUILD_NAME); - final Guild guild = new Guild(guildId, guildName); - final List gradeNames = row.getList(GuildTable.GRADE_NAMES, String.class); - if (gradeNames != null) { - guild.setGradeNames(gradeNames); - } - final List members = row.getList(GuildTable.MEMBERS, GuildMember.class); - if (members != null) { - for (GuildMember member : members) { - guild.addMember(member); - } - } - guild.setMemberMax(row.getInt(GuildTable.MEMBER_MAX)); - guild.setMarkBg(row.getShort(GuildTable.MARK_BG)); - guild.setMarkBgColor(row.getByte(GuildTable.MARK_BG_COLOR)); - guild.setMark(row.getShort(GuildTable.MARK)); - guild.setMarkColor(row.getByte(GuildTable.MARK_COLOR)); - guild.setNotice(row.getString(GuildTable.NOTICE)); - guild.setPoints(row.getInt(GuildTable.POINTS)); - guild.setLevel(row.getByte(GuildTable.LEVEL)); - final List boardEntries = row.getList(GuildTable.BOARD_ENTRY_LIST, GuildBoardEntry.class); - if (boardEntries != null) { - guild.getBoardEntries().addAll(boardEntries); + // --------------------------------------------- + // LOAD A GUILD + // --------------------------------------------- + private Guild loadGuild(ResultSet rs) throws SQLException { + final int guildId = rs.getInt("guild_id"); + final String guildName = rs.getString("guild_name"); + Guild guild = new Guild(guildId, guildName); + + guild.setMemberMax(rs.getInt("member_max")); + guild.setMarkBg(rs.getShort("mark_bg")); + guild.setMarkBgColor(rs.getByte("mark_bg_color")); + guild.setMark(rs.getShort("mark")); + guild.setMarkColor(rs.getByte("mark_color")); + guild.setNotice(rs.getString("notice")); + guild.setPoints(rs.getInt("points")); + guild.setLevel(rs.getByte("level")); + + final List members = loadMembers(guildId); + for (GuildMember member : members) { + guild.addMember(member); } - guild.setBoardNoticeEntry(row.get(GuildTable.BOARD_ENTRY_NOTICE, GuildBoardEntry.class)); - guild.setBoardEntryCounter(new AtomicInteger(row.getInt(GuildTable.BOARD_ENTRY_COUNTER))); + + guild.setGradeNames(loadGrades(guild.getGuildId())); + + final List boardEntries = loadBoardEntries(guildId); + guild.getBoardEntries().addAll(boardEntries); + + guild.setBoardNoticeEntry(loadBoardNotice(guildId)); + return guild; } @Override public Optional getGuildById(int guildId) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), GuildTable.getTableName()).all() - .whereColumn(GuildTable.GUILD_ID).isEqualTo(literal(guildId)) - .build() - ); - for (Row row : selectResult) { - return Optional.of(loadGuild(row)); + String sql = "SELECT * FROM guild.guilds WHERE guild_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(loadGuild(rs)); + } + } + } catch (SQLException e) { + e.printStackTrace(); } return Optional.empty(); } + + private List loadGrades(int guildId) throws SQLException { + List grades = new ArrayList<>(); + String sql = "SELECT grade_name FROM guild.grade WHERE guild_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + grades.add(rs.getString("grade_name")); + } + } + } + return grades; + } + + // --------------------------------------------- + // MEMBERS + // --------------------------------------------- + private List loadMembers(int guildId) { + List members = new ArrayList<>(); + String sql = """ + SELECT c.character_id, c.character_name, s.job, s.level, + m.grade AS guildRank, NULL AS allianceRank, c.online + FROM guild.member m + JOIN player.characters c ON c.character_id = m.character_id + JOIN character.stats s ON s.character_id = c.character_id + WHERE m.guild_id = ? + """; + + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int charId = rs.getInt("character_id"); + String charName = rs.getString("character_name"); + int job = rs.getInt("job"); + int level = rs.getInt("level"); + boolean online = rs.getBoolean("online"); // now works with the new column + int guildRankInt = rs.getInt("guildRank"); + Integer allianceRankInt = null; // no alliance rank yet + + members.add(new GuildMember( + charId, + charName, + job, + level, + online, + GuildRank.getByValue(guildRankInt), + allianceRankInt != null ? GuildRank.getByValue(allianceRankInt) : null // setting to null for now. + )); + } + } + } catch (SQLException e) { + e.printStackTrace(); + } + + return members; + } + + + + + // --------------------------------------------- + // BOARD ENTRIES + // --------------------------------------------- + private List loadBoardEntries(int guildId) { + List entries = new ArrayList<>(); + String sql = "SELECT entry_id, character_id, title, message, timestamp, 0 AS emoticon " + + "FROM guild.board_entry WHERE guild_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + entries.add(new GuildBoardEntry( + rs.getInt("entry_id"), + rs.getInt("character_id"), + rs.getString("title"), + rs.getString("message"), + rs.getTimestamp("timestamp").toInstant(), + rs.getInt("emoticon") + )); + } + } + } catch (SQLException e) { + e.printStackTrace(); + } + return entries; + } + + private GuildBoardEntry loadBoardNotice(int guildId) { + String sql = "SELECT entry_id FROM guild.notice WHERE guild_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + int entryId = rs.getInt("entry_id"); + // Load full entry + String entrySql = "SELECT entry_id, character_id, title, message, timestamp, 0 AS emoticon " + + "FROM guild.board_entry WHERE entry_id = ?"; + try (PreparedStatement entryStmt = getConnection().prepareStatement(entrySql)) { + entryStmt.setInt(1, entryId); + try (ResultSet ers = entryStmt.executeQuery()) { + if (ers.next()) { + return new GuildBoardEntry( + ers.getInt("entry_id"), + ers.getInt("character_id"), + ers.getString("title"), + ers.getString("message"), + ers.getTimestamp("timestamp").toInstant(), + ers.getInt("emoticon") + ); + } + } + } + } + } + } catch (SQLException e) { + e.printStackTrace(); + } + return null; + } + + // --------------------------------------------- + // CHECK NAME + // --------------------------------------------- @Override public boolean checkGuildNameAvailable(String name) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), GuildTable.getTableName()).all() - .whereColumn(GuildTable.GUILD_NAME_INDEX).isEqualTo(literal(lowerName(name))) - .build() - ); - for (Row row : selectResult) { - final String existingName = row.getString(GuildTable.GUILD_NAME_INDEX); - if (existingName != null && existingName.equalsIgnoreCase(name)) { - return false; + String sql = "SELECT 1 FROM guild.guilds WHERE LOWER(guild_name) = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setString(1, name.toLowerCase()); + try (ResultSet rs = stmt.executeQuery()) { + return !rs.next(); } + } catch (SQLException e) { + e.printStackTrace(); } - return true; + return false; } + // --------------------------------------------- + // SAVE / CREATE + // --------------------------------------------- @Override public synchronized boolean newGuild(Guild guild) { - if (!checkGuildNameAvailable(guild.getGuildName())) { - return false; - } + if (!checkGuildNameAvailable(guild.getGuildName())) return false; return saveGuild(guild); } @Override public boolean saveGuild(Guild guild) { - final CodecRegistry registry = getSession().getContext().getCodecRegistry(); - final ResultSet updateResult = getSession().execute( - update(getKeyspace(), GuildTable.getTableName()) - .setColumn(GuildTable.GUILD_NAME, literal(guild.getGuildName())) - .setColumn(GuildTable.GUILD_NAME_INDEX, literal(lowerName(guild.getGuildName()))) - .setColumn(GuildTable.GRADE_NAMES, literal(guild.getGradeNames())) - .setColumn(GuildTable.MEMBERS, literal(guild.getGuildMembers(), registry)) - .setColumn(GuildTable.MEMBER_MAX, literal(guild.getMemberMax())) - .setColumn(GuildTable.MARK_BG, literal(guild.getMarkBg())) - .setColumn(GuildTable.MARK_BG_COLOR, literal(guild.getMarkBgColor())) - .setColumn(GuildTable.MARK, literal(guild.getMark())) - .setColumn(GuildTable.MARK_COLOR, literal(guild.getMarkColor())) - .setColumn(GuildTable.NOTICE, literal(guild.getNotice())) - .setColumn(GuildTable.POINTS, literal(guild.getPoints())) - .setColumn(GuildTable.LEVEL, literal(guild.getLevel())) - .setColumn(GuildTable.BOARD_ENTRY_LIST, literal(guild.getBoardEntries(), registry)) - .setColumn(GuildTable.BOARD_ENTRY_NOTICE, literal(guild.getBoardNoticeEntry(), registry)) - .setColumn(GuildTable.BOARD_ENTRY_COUNTER, literal(guild.getBoardEntryCounter().get())) - .whereColumn(GuildTable.GUILD_ID).isEqualTo(literal(guild.getGuildId())) - .build() - ); - return updateResult.wasApplied(); + String sql = "INSERT INTO guild.guilds (guild_id, guild_name, grade_names, member_max, mark_bg, mark_bg_color, mark, mark_color, notice, points, level, board_entry_counter) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + + "ON CONFLICT (guild_id) DO UPDATE SET " + + "guild_name = EXCLUDED.guild_name, " + + "grade_names = EXCLUDED.grade_names, " + + "member_max = EXCLUDED.member_max, " + + "mark_bg = EXCLUDED.mark_bg, " + + "mark_bg_color = EXCLUDED.mark_bg_color, " + + "mark = EXCLUDED.mark, " + + "mark_color = EXCLUDED.mark_color, " + + "notice = EXCLUDED.notice, " + + "points = EXCLUDED.points, " + + "level = EXCLUDED.level, " + + "board_entry_counter = EXCLUDED.board_entry_counter"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, guild.getGuildId()); + stmt.setString(2, guild.getGuildName()); + stmt.setArray(3, getConnection().createArrayOf("text", guild.getGradeNames().toArray())); + stmt.setInt(4, guild.getMemberMax()); + stmt.setShort(5, guild.getMarkBg()); + stmt.setByte(6, guild.getMarkBgColor()); + stmt.setShort(7, guild.getMark()); + stmt.setByte(8, guild.getMarkColor()); + stmt.setString(9, guild.getNotice()); + stmt.setInt(10, guild.getPoints()); + stmt.setByte(11, guild.getLevel()); + stmt.setInt(12, guild.getBoardEntryCounter().get()); + stmt.executeUpdate(); + + saveMembers(guild); + saveBoardEntries(guild); + saveBoardNotice(guild); + + return true; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } + private void saveMembers(Guild guild) throws SQLException { + String deleteSql = "DELETE FROM guild.member WHERE guild_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(deleteSql)) { + stmt.setInt(1, guild.getGuildId()); + stmt.executeUpdate(); + } + + String insertSql = "INSERT INTO guild.member (guild_id, character_id, grade) VALUES (?, ?, ?)"; + try (PreparedStatement stmt = getConnection().prepareStatement(insertSql)) { + for (GuildMember member : guild.getGuildMembers()) { + stmt.setInt(1, guild.getGuildId()); + stmt.setInt(2, member.getCharacterId()); + stmt.setShort(3, (short) member.getGuildRank().getValue()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + private void saveBoardEntries(Guild guild) throws SQLException { + String deleteSql = "DELETE FROM guild.board_entry WHERE guild_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(deleteSql)) { + stmt.setInt(1, guild.getGuildId()); + stmt.executeUpdate(); + } + + String insertSql = "INSERT INTO guild.board_entry (entry_id, guild_id, character_id, title, message, timestamp, emoticon) VALUES (?, ?, ?, ?, ?, ?, ?)"; + try (PreparedStatement stmt = getConnection().prepareStatement(insertSql)) { + for (GuildBoardEntry entry : guild.getBoardEntries()) { + stmt.setInt(1, entry.getEntryId()); + stmt.setInt(2, guild.getGuildId()); + stmt.setInt(3, entry.getCharacterId()); + stmt.setString(4, entry.getTitle()); + stmt.setString(5, entry.getText()); + stmt.setTimestamp(6, Timestamp.from(entry.getDate())); + stmt.setInt(7, entry.getEmoticon()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + private void saveBoardNotice(Guild guild) throws SQLException { + String sql = "INSERT INTO guild.notice (guild_id, entry_id) VALUES (?, ?) " + + "ON CONFLICT (guild_id) DO UPDATE SET entry_id = EXCLUDED.entry_id"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + GuildBoardEntry notice = guild.getBoardNoticeEntry(); + if (notice != null) { + stmt.setInt(1, guild.getGuildId()); + stmt.setInt(2, notice.getEntryId()); + stmt.executeUpdate(); + } + } + } + + // --------------------------------------------- + // DELETE + // --------------------------------------------- @Override public boolean deleteGuild(int guildId) { - final ResultSet updateResult = getSession().execute( - deleteFrom(getKeyspace(), GuildTable.getTableName()) - .whereColumn(GuildTable.GUILD_ID).isEqualTo(literal(guildId)) - .build() - ); - return updateResult.wasApplied(); + String sql = "DELETE FROM guild.guilds WHERE guild_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, guildId); + return stmt.executeUpdate() > 0; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } + // --------------------------------------------- + // RANKINGS + // --------------------------------------------- @Override public List getGuildRankings() { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), GuildTable.getTableName()) - .columns( - GuildTable.GUILD_NAME, - GuildTable.POINTS, - GuildTable.MARK, - GuildTable.MARK_COLOR, - GuildTable.MARK_BG, - GuildTable.MARK_BG_COLOR - ) - .build() - .setExecutionProfileName(CassandraConnector.PROFILE_ONE) - ); - final List guildRankings = new ArrayList<>(); - for (Row row : selectResult) { - guildRankings.add(new GuildRanking( - row.getString(GuildTable.GUILD_NAME), - row.getInt(GuildTable.POINTS), - row.getShort(GuildTable.MARK), - row.getByte(GuildTable.MARK_COLOR), - row.getShort(GuildTable.MARK_BG), - row.getByte(GuildTable.MARK_BG_COLOR) - )); + List rankings = new ArrayList<>(); + String sql = "SELECT name, points, mark, mark_color, mark_bg, mark_bg_color FROM guild.guilds ORDER BY points DESC"; + try (Statement stmt = getConnection().createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + rankings.add(new GuildRanking( + rs.getString("name"), + rs.getInt("points"), + rs.getShort("mark"), + rs.getByte("mark_color"), + rs.getShort("mark_bg"), + rs.getByte("mark_bg_color") + )); + } + } catch (SQLException e) { + e.printStackTrace(); } - return guildRankings.stream() - .sorted(Comparator.comparing(GuildRanking::getPoints).reversed()) - .toList(); + return rankings; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java index 50b9249d..038dcf39 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java @@ -1,67 +1,45 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; -import com.datastax.oss.driver.api.core.cql.ResultSet; -import com.datastax.oss.driver.api.core.cql.Row; +import com.zaxxer.hikari.HikariDataSource; import kinoko.database.IdAccessor; -import kinoko.database.cassandra.table.IdTable; +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; import java.util.Optional; -import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; +public final class PostgresIdAccessor extends PostgresAccessor implements IdAccessor { -public final class CassandraIdAccessor extends CassandraAccessor implements IdAccessor { - public CassandraIdAccessor(CqlSession session, String keyspace) { - super(session, keyspace); + public PostgresIdAccessor(HikariDataSource dataSource) { + super(dataSource); } private Optional getNextId(String type) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), IdTable.getTableName()).all() - .whereColumn(IdTable.ID_TYPE).isEqualTo(literal(type)) - .build() - ); - for (Row selectRow : selectResult) { - final int nextId = selectRow.getInt(IdTable.NEXT_ID); - final ResultSet updateResult = getSession().execute( - update(getKeyspace(), IdTable.getTableName()) - .setColumn(IdTable.NEXT_ID, literal(nextId + 1)) // increment ID - .whereColumn(IdTable.ID_TYPE).isEqualTo(literal(type)) - .ifColumn(IdTable.NEXT_ID).isEqualTo(literal(nextId)) // if not already updated - .build() - ); - if (updateResult.wasApplied()) { - return Optional.of(nextId); - } else { - // retry - return getNextId(type); - } - } - return Optional.empty(); + return Optional.of(-1); // Postgres auto-generates IDs, so we return -1 as a placeholder } - @Override public synchronized Optional nextAccountId() { - return getNextId(IdTable.ACCOUNT_ID); + return getNextId("account_id"); } @Override public synchronized Optional nextCharacterId() { - return getNextId(IdTable.CHARACTER_ID); + return getNextId("character_id"); } @Override public synchronized Optional nextPartyId() { - return getNextId(IdTable.PARTY_ID); + return getNextId("party_id"); } @Override public synchronized Optional nextGuildId() { - return getNextId(IdTable.GUILD_ID); + return getNextId("guild_id"); } @Override public synchronized Optional nextMemoId() { - return getNextId(IdTable.MEMO_ID); + return getNextId("memo_id"); } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java index 67699745..d58f4fe1 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java @@ -1,94 +1,90 @@ -package kinoko.database.cassandra; +package kinoko.database.postgresql; -import com.datastax.oss.driver.api.core.CqlSession; -import com.datastax.oss.driver.api.core.cql.ResultSet; -import com.datastax.oss.driver.api.core.cql.Row; +import com.zaxxer.hikari.HikariDataSource; import kinoko.database.MemoAccessor; -import kinoko.database.cassandra.table.MemoTable; import kinoko.server.memo.Memo; import kinoko.server.memo.MemoType; +import java.sql.*; import java.util.ArrayList; import java.util.List; -import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; +public final class PostgresMemoAccessor extends PostgresAccessor implements MemoAccessor { -public final class CassandraMemoAccessor extends CassandraAccessor implements MemoAccessor { - public CassandraMemoAccessor(CqlSession session, String keyspace) { - super(session, keyspace); + public PostgresMemoAccessor(HikariDataSource dataSource) { + super(dataSource); } @Override public List getMemosByCharacterId(int characterId) { - final List memos = new ArrayList<>(); - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), MemoTable.getTableName()) - .columns( - MemoTable.MEMO_ID, - MemoTable.MEMO_TYPE, - MemoTable.MEMO_CONTENT, - MemoTable.SENDER_NAME, - MemoTable.DATE_SENT - ) - .whereColumn(MemoTable.RECEIVER_ID).isEqualTo(literal(characterId)) - .build() - ); - for (Row row : selectResult) { - final MemoType type = MemoType.getByValue(row.getInt(MemoTable.MEMO_TYPE)); - final Memo memo = new Memo( - type != null ? type : MemoType.DEFAULT, - row.getInt(MemoTable.MEMO_ID), - row.getString(MemoTable.SENDER_NAME), - row.getString(MemoTable.MEMO_CONTENT), - row.getInstant(MemoTable.DATE_SENT) - ); - memos.add(memo); + List memos = new ArrayList<>(); + String sql = "SELECT id, memo_type, memo_content, sender_name, date_sent " + + "FROM memo.memo WHERE receiver_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + MemoType type = MemoType.getByValue(rs.getInt("memo_type")); + Memo memo = new Memo( + type != null ? type : MemoType.DEFAULT, + rs.getInt("id"), + rs.getString("sender_name"), + rs.getString("memo_content"), + rs.getTimestamp("date_sent").toInstant() + ); + memos.add(memo); + } + } + } catch (SQLException e) { + e.printStackTrace(); } return memos; } @Override public boolean hasMemo(int characterId) { - final ResultSet selectResult = getSession().execute( - selectFrom(getKeyspace(), MemoTable.getTableName()) - .columns( - MemoTable.RECEIVER_ID - ) - .whereColumn(MemoTable.RECEIVER_ID).isEqualTo(literal(characterId)) - .build() - ); - for (Row row : selectResult) { - final int receiverId = row.getInt(MemoTable.RECEIVER_ID); - if (receiverId == characterId) { - return true; + String sql = "SELECT 1 FROM memo.memo WHERE receiver_id = ? LIMIT 1"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + return rs.next(); } + } catch (SQLException e) { + e.printStackTrace(); } return false; } @Override public boolean newMemo(Memo memo, int receiverId) { - final ResultSet updateResult = getSession().execute( - insertInto(getKeyspace(), MemoTable.getTableName()) - .value(MemoTable.MEMO_ID, literal(memo.getMemoId())) - .value(MemoTable.RECEIVER_ID, literal(receiverId)) - .value(MemoTable.MEMO_TYPE, literal(memo.getType().getValue())) - .value(MemoTable.MEMO_CONTENT, literal(memo.getContent())) - .value(MemoTable.SENDER_NAME, literal(memo.getSender())) - .value(MemoTable.DATE_SENT, literal(memo.getDateSent())) - .ifNotExists() - .build() - ); - return updateResult.wasApplied(); + // `id` is SERIAL, no need to provide it manually + String sql = "INSERT INTO memo.memo (receiver_id, memo_type, memo_content, sender_name, date_sent) " + + "VALUES (?, ?, ?, ?, ?)"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, receiverId); + stmt.setInt(2, memo.getType().getValue()); + stmt.setString(3, memo.getContent()); + stmt.setString(4, memo.getSender()); + stmt.setTimestamp(5, memo.getDateSent() != null ? Timestamp.from(memo.getDateSent()) : Timestamp.from(java.time.Instant.now())); + stmt.executeUpdate(); + return true; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } @Override public boolean deleteMemo(int memoId, int receiverId) { - final ResultSet updateResult = getSession().execute( - deleteFrom(getKeyspace(), MemoTable.getTableName()) - .whereColumn(MemoTable.MEMO_ID).isEqualTo(literal(memoId)) - .build() - ); - return updateResult.wasApplied(); + String sql = "DELETE FROM memo.memo WHERE id = ? AND receiver_id = ?"; + try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + stmt.setInt(1, memoId); + stmt.setInt(2, receiverId); + int affected = stmt.executeUpdate(); + return affected > 0; + } catch (SQLException e) { + e.printStackTrace(); + } + return false; } } diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index e69de29b..39937392 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -0,0 +1,488 @@ +BEGIN TRANSACTION; + +------------------------------------------ +--------------SCHEMAS--------------------- +------------------------------------------ +CREATE SCHEMA IF NOT EXISTS account; +CREATE SCHEMA IF NOT EXISTS player; +CREATE SCHEMA IF NOT EXISTS guild; +CREATE SCHEMA IF NOT EXISTS friend; +CREATE SCHEMA IF NOT EXISTS gift; +CREATE SCHEMA IF NOT EXISTS memo; +CREATE SCHEMA IF NOT EXISTS item; + + +------------------------------------------ +--------------FUNCTIONS------------------- +------------------------------------------ +CREATE OR REPLACE FUNCTION public.utc_now() +RETURNS timestamp without time zone +LANGUAGE sql +AS $function$ + SELECT now() AT TIME ZONE 'UTC'; +$function$; + +------------------------------------------ +--------------ITEM TABLES----------------- +------------------------------------------ +CREATE TABLE item.items ( + item_sn BIGSERIAL PRIMARY KEY, -- auto-increment unique ID for every item instance + item_id INT NOT NULL, + quantity INT NOT NULL DEFAULT 1, + attribute SMALLINT DEFAULT 0, + title TEXT DEFAULT '', + date_expire TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_items_item_id + ON item.items(item_id); + +CREATE INDEX IF NOT EXISTS idx_items_date_expire + ON item.items(date_expire); + +CREATE TABLE IF NOT EXISTS item.equip_data ( + item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE, -- unique for every equip item instance + inc_str SMALLINT DEFAULT 0, + inc_dex SMALLINT DEFAULT 0, + inc_int SMALLINT DEFAULT 0, + inc_luk SMALLINT DEFAULT 0, + inc_max_hp SMALLINT DEFAULT 0, + inc_max_mp SMALLINT DEFAULT 0, + inc_pad SMALLINT DEFAULT 0, + inc_mad SMALLINT DEFAULT 0, + inc_pdd SMALLINT DEFAULT 0, + inc_mdd SMALLINT DEFAULT 0, + inc_acc SMALLINT DEFAULT 0, + inc_eva SMALLINT DEFAULT 0, + inc_craft SMALLINT DEFAULT 0, + inc_speed SMALLINT DEFAULT 0, + inc_jump SMALLINT DEFAULT 0, + ruc SMALLINT DEFAULT 0, + cuc SMALLINT DEFAULT 0, + iuc INT DEFAULT 0, + chuc SMALLINT DEFAULT 0, + grade SMALLINT DEFAULT 0, + option_1 SMALLINT DEFAULT 0, + option_2 SMALLINT DEFAULT 0, + option_3 SMALLINT DEFAULT 0, + socket_1 SMALLINT DEFAULT 0, + socket_2 SMALLINT DEFAULT 0, + level_up_type SMALLINT DEFAULT 0, + level SMALLINT DEFAULT 0, + exp INT DEFAULT 0, + durability INT DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_equip_data_item_sn + ON item.equip_data(item_sn); + + +CREATE TABLE IF NOT EXISTS item.pet_data ( + item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE, + pet_name TEXT, + level SMALLINT DEFAULT 0, + fullness SMALLINT DEFAULT 0, + tameness SMALLINT DEFAULT 0, + pet_skill SMALLINT DEFAULT 0, + pet_attribute SMALLINT DEFAULT 0, + remain_life INT DEFAULT 0 +); + +CREATE INDEX IF NOT EXISTS idx_pet_data_item_sn + ON item.pet_data(item_sn); + + +CREATE TABLE IF NOT EXISTS item.ring_data ( + item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE, + pair_character_id INT, + pair_character_name TEXT, + pair_item_sn BIGINT +); + +CREATE INDEX IF NOT EXISTS idx_ring_data_item_sn + ON item.ring_data(item_sn); + + +------------------------------------------ +--------------ACCOUNT TABLES-------------- +------------------------------------------ + +CREATE TABLE IF NOT EXISTS account.accounts ( + id SERIAL PRIMARY KEY, + username TEXT NOT NULL UNIQUE, + password TEXT NOT NULL, + secondary_password TEXT, + character_slots INT NOT NULL DEFAULT 3, + nx_credit INT NOT NULL DEFAULT 0, + nx_prepaid INT NOT NULL DEFAULT 0, + maple_point INT NOT NULL DEFAULT 0, + trunk_size INT NOT NULL DEFAULT 24, + trunk_money INT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS account.trunk_item ( + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + slot INT NOT NULL, + PRIMARY KEY (account_id, slot) +); + +CREATE INDEX IF NOT EXISTS idx_trunk_item_sn + ON account.trunk_item(item_sn); + +CREATE INDEX IF NOT EXISTS idx_trunk_item_account_item + ON account.trunk_item(account_id, item_sn); + + +CREATE TABLE IF NOT EXISTS account.locker_item ( + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + slot INT NOT NULL, + commodity_id INT, + PRIMARY KEY (account_id, slot) +); + +CREATE INDEX IF NOT EXISTS idx_locker_item_sn + ON account.locker_item(item_sn); + +CREATE INDEX IF NOT EXISTS idx_locker_item_account_item + ON account.locker_item(account_id, item_sn); + + +CREATE TABLE IF NOT EXISTS account.wishlist ( + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, + item_id BIGINT NOT NULL, + slot INT NOT NULL, + PRIMARY KEY (account_id, slot) +); + +CREATE INDEX IF NOT EXISTS idx_wishlist_item_id + ON account.wishlist(item_id); + +CREATE INDEX IF NOT EXISTS idx_wishlist_account_item + ON account.wishlist(account_id, item_id); + + +------------------------------------------ +-------------PLAYER TABLES---------------- +------------------------------------------ + +CREATE TABLE IF NOT EXISTS player.characters ( + id SERIAL PRIMARY KEY, + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, + name TEXT NOT NULL, + money INT NOT NULL DEFAULT 0, + ext_slot_expire TIMESTAMP, + friend_max INT NOT NULL DEFAULT 100, + party_id INT, + guild_id INT, + creation_time TIMESTAMP NOT NULL DEFAULT UTC_NOW(), + max_level_time TIMESTAMP, + online BOOLEAN NOT NULL DEFAULT FALSE +); + +CREATE TABLE IF NOT EXISTS player.stats ( + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + gender SMALLINT NOT NULL, + skin SMALLINT NOT NULL, + face INT NOT NULL, + hair INT NOT NULL, + level SMALLINT NOT NULL DEFAULT 1, + job SMALLINT NOT NULL DEFAULT 0, + sub_job SMALLINT NOT NULL DEFAULT 0, + base_str SMALLINT NOT NULL DEFAULT 4, + base_dex SMALLINT NOT NULL DEFAULT 4, + base_int SMALLINT NOT NULL DEFAULT 4, + base_luk SMALLINT NOT NULL DEFAULT 4, + hp INT NOT NULL DEFAULT 50, + max_hp INT NOT NULL DEFAULT 50, + mp INT NOT NULL DEFAULT 50, + max_mp INT NOT NULL DEFAULT 50, + ap SMALLINT NOT NULL DEFAULT 0, + exp INT NOT NULL DEFAULT 0, + pop SMALLINT NOT NULL DEFAULT 0, + pos_map INT NOT NULL DEFAULT 0, + portal SMALLINT NOT NULL DEFAULT 0, + pet_1 BIGINT, + pet_2 BIGINT, + pet_3 BIGINT +); + +CREATE TABLE IF NOT EXISTS player.skill_points ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + skill_id INT NOT NULL, + points INT NOT NULL DEFAULT 0, + PRIMARY KEY (character_id, skill_id) +); + +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM pg_type WHERE typname = 'inventory_type_enum') THEN + CREATE TYPE inventory_type_enum AS ENUM ( + 'EQUIPPED', + 'EQUIP', + 'CONSUME', + 'INSTALL', + 'ETC', + 'CASH' + ); + END IF; +END$$; + +CREATE TABLE IF NOT EXISTS player.inventory ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + inventory_type inventory_type_enum NOT NULL, + slot INT NOT NULL, + PRIMARY KEY (character_id, item_sn) +); + +CREATE INDEX IF NOT EXISTS idx_inventory_item_sn + ON player.inventory(item_sn); + +CREATE INDEX IF NOT EXISTS idx_inventory_char_item + ON player.inventory(character_id, item_sn); + + +CREATE TABLE IF NOT EXISTS player.skill_cooltime ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + skill_id INT NOT NULL, + cooldown_end TIMESTAMP NOT NULL, + PRIMARY KEY (character_id, skill_id) +); + + +CREATE TABLE IF NOT EXISTS player.skill_record ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + skill_id INT NOT NULL, + level INT NOT NULL, + master_level INT, + PRIMARY KEY (character_id, skill_id) +); + +CREATE TABLE IF NOT EXISTS player.quest_record ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + quest_id INT NOT NULL, + status INT NOT NULL, + progress TEXT, + completed_time TIMESTAMP, + PRIMARY KEY (character_id, quest_id) +); + +CREATE TABLE IF NOT EXISTS player.config ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + pet_consume_item INT NOT NULL DEFAULT 0, + pet_consume_mp_item INT NOT NULL DEFAULT 0, + pet_exception_list INT[] NOT NULL DEFAULT '{}', + func_key_types SMALLINT[] NOT NULL DEFAULT '{}', + func_key_ids INT[] NOT NULL DEFAULT '{}', + quickslot_key_map INT[] NOT NULL DEFAULT '{}', + PRIMARY KEY (character_id) +); + +CREATE TABLE IF NOT EXISTS player.character_macro ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + macro_index INT NOT NULL, -- index/order of the macro + name TEXT NOT NULL, + mute BOOLEAN NOT NULL DEFAULT FALSE, + skills INT[] NOT NULL, -- size: GameConstants.MACRO_SKILL_COUNT + PRIMARY KEY (character_id, macro_index) +); + +CREATE TABLE IF NOT EXISTS player.popularity ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + other_character_id INT NOT NULL, + timestamp TIMESTAMP NOT NULL, + PRIMARY KEY (character_id, other_character_id) +); + +CREATE TABLE IF NOT EXISTS player.minigame ( + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + omok_wins INT NOT NULL DEFAULT 0, + omok_ties INT NOT NULL DEFAULT 0, + omok_losses INT NOT NULL DEFAULT 0, + omok_score DOUBLE PRECISION NOT NULL DEFAULT 0, + memory_wins INT NOT NULL DEFAULT 0, + memory_ties INT NOT NULL DEFAULT 0, + memory_losses INT NOT NULL DEFAULT 0, + memory_score DOUBLE PRECISION NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS player.map_transfer ( + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + map_id INT NOT NULL, + old_map_id INT NOT NULL +); + +CREATE TABLE IF NOT EXISTS player.wild_hunter ( + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + riding_type INT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS player.wild_hunter_mob ( + character_id INT REFERENCES player.characters(id) ON DELETE CASCADE, + mob_id INT NOT NULL, + PRIMARY KEY (character_id, mob_id) +); + +CREATE TABLE IF NOT EXISTS player.config ( + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + config_key TEXT NOT NULL, + config_value TEXT +); + + +------------------------------------------ +--------------FRIEND TABLES--------------- +------------------------------------------ + +CREATE TABLE IF NOT EXISTS friend.friends ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + friend_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + friend_name TEXT NOT NULL, + friend_group TEXT, + friend_status INT NOT NULL DEFAULT 0, + PRIMARY KEY (character_id, friend_id) +); + +CREATE INDEX IF NOT EXISTS idx_friend_friend_id + ON friend.friends(friend_id); + + +------------------------------------------ +---------------GIFT TABLES---------------- +------------------------------------------ + +CREATE TABLE IF NOT EXISTS gift.gift ( + id BIGSERIAL PRIMARY KEY, -- auto-increment unique ID for the gift + receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + commodity_id INT, + sender_id INT, + sender_name TEXT, + sender_message TEXT +); + +CREATE INDEX IF NOT EXISTS idx_gift_item_sn + ON gift.gift(item_sn); + + +CREATE INDEX IF NOT EXISTS idx_gift_receiver + ON gift.gift(receiver_id); + +CREATE INDEX IF NOT EXISTS idx_gift_receiver_item + ON gift.gift(receiver_id, item_sn); + + +------------------------------------------ +---------------GUILD TABLES--------------- +------------------------------------------ + +CREATE TABLE IF NOT EXISTS guild.guilds ( + id SERIAL PRIMARY KEY, + name TEXT NOT NULL UNIQUE, + member_max INT NOT NULL DEFAULT 50, + mark_bg SMALLINT, + mark_bg_color SMALLINT, + mark SMALLINT, + mark_color SMALLINT, + notice TEXT, + points INT NOT NULL DEFAULT 0, + level SMALLINT NOT NULL DEFAULT 1 +); + +CREATE TABLE IF NOT EXISTS guild.grade ( + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, + grade_index INT NOT NULL, + grade_name TEXT NOT NULL, + PRIMARY KEY (guild_id, grade_index) +); + +CREATE TABLE IF NOT EXISTS guild.member ( + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + grade SMALLINT NOT NULL, + join_date TIMESTAMP NOT NULL, + last_login TIMESTAMP, + PRIMARY KEY (guild_id, character_id) +); + +CREATE TABLE IF NOT EXISTS guild.board_entry ( + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, + entry_id SERIAL PRIMARY KEY, + poster_id INT NOT NULL, + poster_name TEXT, + message TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL +); + +CREATE TABLE IF NOT EXISTS guild.notice ( + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, + poster_id INT NOT NULL, + poster_name TEXT, + message TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL, + PRIMARY KEY (guild_id) +); + + +------------------------------------------ +---------------MEMO TABLES---------------- +------------------------------------------ + +CREATE TABLE IF NOT EXISTS memo.memo ( + id SERIAL PRIMARY KEY, + receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + memo_type INT NOT NULL, + memo_content TEXT NOT NULL, + sender_name TEXT, + date_sent TIMESTAMP NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_memo_receiver + ON memo.memo(receiver_id); + + +------------------------------------------ +---------------CREATE VIEWS--------------- +------------------------------------------ +CREATE OR REPLACE VIEW item.full_item AS +SELECT + i.item_sn, + i.item_id, + i.quantity, + i.attribute, + i.title, + i.date_expire, + e.inc_str, e.inc_dex, e.inc_int, e.inc_luk, + e.inc_max_hp, e.inc_max_mp, e.inc_pad, e.inc_mad, + e.inc_pdd, e.inc_mdd, e.inc_acc, e.inc_eva, + e.inc_craft, e.inc_speed, e.inc_jump, e.ruc, + e.cuc, e.iuc, e.chuc, e.grade, e.option_1, e.option_2, e.option_3, + e.socket_1, e.socket_2, e.level_up_type, e.level, e.exp, e.durability, + p.pet_name, p.level AS pet_level, p.fullness, p.tameness, p.pet_skill, p.pet_attribute, p.remain_life, + r.pair_character_id, r.pair_character_name, r.pair_item_sn +FROM item.items i +LEFT JOIN item.equip_data e ON i.item_sn = e.item_sn +LEFT JOIN item.pet_data p ON i.item_sn = p.item_sn +LEFT JOIN item.ring_data r ON i.item_sn = r.item_sn; + + +------------------------------------------ +---------------ON CREATION---------------- +------------------------------------------ + +INSERT INTO account.accounts (username, password, secondary_password, character_slots, nx_credit, nx_prepaid, maple_point, trunk_size, trunk_money) +VALUES ( + 'admin', + '$2a$10$LGtpvyti5yVVdWxN8L5sH.UiioiRweGw84mFaWJlfasSFDJ8.QPaW', -- bcrypt hash of "admin" + NULL, + 3, + 0, + 0, + 0, + 24, + 0 +); + +COMMIT TRANSACTION; \ No newline at end of file diff --git a/src/main/java/kinoko/handler/stage/LoginHandler.java b/src/main/java/kinoko/handler/stage/LoginHandler.java index ca425ef3..9f102ff2 100644 --- a/src/main/java/kinoko/handler/stage/LoginHandler.java +++ b/src/main/java/kinoko/handler/stage/LoginHandler.java @@ -56,14 +56,23 @@ public static void handleCheckPassword(Client c, InPacket inPacket) { final byte[] partnerCode = inPacket.decodeArray(4); // Resolve account - final Optional accountResult = DatabaseManager.accountAccessor().getAccountByUsername(username); + Optional accountResult = DatabaseManager.accountAccessor().getAccountByUsername(username); if (accountResult.isEmpty()) { if (ServerConfig.AUTO_CREATE_ACCOUNT) { DatabaseManager.accountAccessor().newAccount(username, password); + // allow an instant login + accountResult = DatabaseManager.accountAccessor().getAccountByUsername(username); } + else { + c.write(LoginPacket.checkPasswordResultFail(LoginResultType.NotRegistered)); + return; + } + } + if (accountResult.isEmpty()){ // final check for ID being registered. c.write(LoginPacket.checkPasswordResultFail(LoginResultType.NotRegistered)); return; } + final Account account = accountResult.get(); // Check if logged in @@ -212,6 +221,9 @@ public static void handleCreateNewCharacter(Client c, InPacket inPacket) { c.write(LoginPacket.createNewCharacterResultFail(LoginResultType.Timeout)); return; } + + + final CharacterData characterData = new CharacterData(c.getAccount().getId()); characterData.setItemSnCounter(new AtomicInteger(1)); characterData.setCreationTime(Instant.now()); @@ -221,7 +233,10 @@ public static void handleCreateNewCharacter(Client c, InPacket inPacket) { final int hp = StatConstants.getMinHp(level, job.getJobId()); final int mp = StatConstants.getMinMp(level, job.getJobId()); final CharacterStat cs = new CharacterStat(); - cs.setId(characterIdResult.get()); + if (characterIdResult.get() != -1) { + // let non-relational databases handle IDs here. + cs.setId(characterIdResult.get()); + } cs.setName(name); cs.setGender(gender); cs.setSkin((byte) selectedAL[3]); diff --git a/src/main/java/kinoko/handler/user/UserHandler.java b/src/main/java/kinoko/handler/user/UserHandler.java index 0fc011c2..0416d4d0 100644 --- a/src/main/java/kinoko/handler/user/UserHandler.java +++ b/src/main/java/kinoko/handler/user/UserHandler.java @@ -124,7 +124,7 @@ public static void handleUserChat(User user, InPacket inPacket) { inPacket.decodeInt(); // update_time final String text = inPacket.decodeString(); // sText final boolean onlyBalloon = inPacket.decodeBoolean(); // bOnlyBalloon - if (text.startsWith(ServerConfig.COMMAND_PREFIX) && text.length() > 1) { + if (text.startsWith(ServerConfig.PLAYER_COMMAND_PREFIX) && text.length() > 1) { CommandProcessor.tryProcessCommand(user, text); return; } @@ -1217,7 +1217,7 @@ public static void handleGroupMessage(User user, InPacket inPacket) { targetIds.add(inPacket.decodeInt()); } final String text = inPacket.decodeString(); // sText - if (text.startsWith(ServerConfig.COMMAND_PREFIX) && text.length() > 1) { + if (text.startsWith(ServerConfig.PLAYER_COMMAND_PREFIX) && text.length() > 1) { CommandProcessor.tryProcessCommand(user, text); return; } diff --git a/src/main/java/kinoko/server/ServerConfig.java b/src/main/java/kinoko/server/ServerConfig.java index e67eaf70..99c9b974 100644 --- a/src/main/java/kinoko/server/ServerConfig.java +++ b/src/main/java/kinoko/server/ServerConfig.java @@ -3,7 +3,10 @@ import kinoko.util.Util; import kinoko.world.GameConstants; + public final class ServerConfig { + public static final boolean TESPIA = Util.getEnv("TESPIA", true); // DEV ENV + public static final int WORLD_ID = Util.getEnv("WORLD_ID", 0); public static final String WORLD_NAME = Util.getEnv("WORLD_NAME", "Kinoko"); public static final int CHANNELS_PER_WORLD = Util.getEnv("CHANNEL_COUNT", 5); @@ -25,7 +28,8 @@ public final class ServerConfig { public static final int ITEM_EXPIRE_INTERVAL = 60; // 180 seconds in BMS public static final int WORLD_SPEAKER_COOLTIME = 60; - public static final String COMMAND_PREFIX = Util.getEnv("COMMAND_PREFIX", "!"); + public static final String PLAYER_COMMAND_PREFIX = Util.getEnv("PLAYER_COMMAND_PREFIX", "@"); + public static final String STAFF_COMMAND_PREFIX = Util.getEnv("STAFF_COMMAND_PREFIX", "!"); public static final boolean DEBUG_MODE = Util.getEnv("DEBUG_MODE", true); public static final boolean PLAIN_TRAFFIC = Util.getEnv("PLAIN_TRAFFIC", false); } diff --git a/src/main/java/kinoko/server/ServerConstants.java b/src/main/java/kinoko/server/ServerConstants.java index e3abd88f..0fc9a361 100644 --- a/src/main/java/kinoko/server/ServerConstants.java +++ b/src/main/java/kinoko/server/ServerConstants.java @@ -14,7 +14,22 @@ public final class ServerConstants { public static final int LOGIN_PORT = 8484; public static final int CHANNEL_PORT = 8585; - public static final String DATABASE_HOST = Util.getEnv("DATABASE_HOST", "127.0.0.1"); - public static final int DATABASE_PORT = 9042; + + // ----------------- Database ----------------- + // Supports localized and containerized env variables. + // It is advised to set these variables in the .env file. + + // General + public static final String DATABASE_HOST = Util.getEnv("DB_HOST", "127.0.0.1"); + public static final int DATABASE_PORT = Util.getEnv("DB_PORT", 9042); // Defaulting to Cassandra port + public static final String DATABASE_NAME = Util.getEnv("DB_NAME", "kinoko"); // Cassandra KeySpace, Postgres DB Name + + // Postgres Specific + public static final String DATABASE_USER = Util.getEnv("DB_USER", "postgres"); + public static final String DATABASE_PASSWORD = Util.getEnv("DB_PASS","admin"); + + // Cassandra Specific + public static final String DATABASE_DATACENTER = Util.getEnv("DB_DATACENTER","datacenter1"); + public static final String DATABASE_PROFILE = Util.getEnv("DB_PROFILE_ONE","profile_one"); } diff --git a/src/main/java/kinoko/server/command/AdminCommands.java b/src/main/java/kinoko/server/command/AdminCommands.java index 46a87387..8b92879d 100644 --- a/src/main/java/kinoko/server/command/AdminCommands.java +++ b/src/main/java/kinoko/server/command/AdminCommands.java @@ -620,7 +620,7 @@ public static void stat(User user, String[] args) { } } default -> { - user.write(MessagePacket.system("Syntax : %sstat hp/mp/str/dex/int/luk/ap/sp ", ServerConfig.COMMAND_PREFIX)); + user.write(MessagePacket.system("Syntax : %sstat hp/mp/str/dex/int/luk/ap/sp ", ServerConfig.PLAYER_COMMAND_PREFIX)); return; } } diff --git a/src/main/java/kinoko/server/command/CommandProcessor.java b/src/main/java/kinoko/server/command/CommandProcessor.java index d8ad56cf..95c8d19b 100644 --- a/src/main/java/kinoko/server/command/CommandProcessor.java +++ b/src/main/java/kinoko/server/command/CommandProcessor.java @@ -44,11 +44,11 @@ public static String getHelpString(Method method) { final Arguments arguments = method.getAnnotation(Arguments.class); final String commandString = String.join("|", command.value()); final List argumentString = Arrays.stream(arguments != null ? arguments.value() : new String[]{}).map((value) -> String.format("<%s>", value)).toList(); - return String.format("%s%s %s", ServerConfig.COMMAND_PREFIX, commandString, String.join(" ", argumentString)); + return String.format("%s%s %s", ServerConfig.PLAYER_COMMAND_PREFIX, commandString, String.join(" ", argumentString)); } public static void tryProcessCommand(User user, String text) { - final String[] arguments = text.replaceFirst(ServerConfig.COMMAND_PREFIX, "").split(" "); + final String[] arguments = text.replaceFirst(ServerConfig.PLAYER_COMMAND_PREFIX, "").split(" "); final String commandName = arguments[0].toLowerCase(); final Optional commandResult = getCommand(commandName); if (commandResult.isEmpty()) { diff --git a/src/main/java/kinoko/util/Util.java b/src/main/java/kinoko/util/Util.java index 83a61f72..1c70ce20 100644 --- a/src/main/java/kinoko/util/Util.java +++ b/src/main/java/kinoko/util/Util.java @@ -1,5 +1,7 @@ package kinoko.util; +import io.github.cdimascio.dotenv.Dotenv; + import java.net.InetAddress; import java.net.UnknownHostException; import java.security.SecureRandom; @@ -13,19 +15,37 @@ public final class Util { private static final HexFormat hexFormat = HexFormat.ofDelimiter(" ").withUpperCase(); private static final Random random = new SecureRandom(); + + private static final Dotenv dotenv = Dotenv.configure() + .ignoreIfMissing() // doesn't fail if .env is missing + .load(); + public static String getEnv(String name, String defaultValue) { - final String value = System.getenv(name); - return value != null ? value : defaultValue; + String value = System.getenv(name); // check system env + if (value != null) return value; + + value = dotenv.get(name); // check .env file + if (value != null) return value; + + return defaultValue; // fallback to the default } public static int getEnv(String name, int defaultValue) { - final String value = System.getenv(name); - return value != null ? Integer.parseInt(value) : defaultValue; + String value = getEnv(name, null); + if (value != null) { + try { + return Integer.parseInt(value); + } catch (NumberFormatException ignored) {} + } + return defaultValue; } public static boolean getEnv(String name, boolean defaultValue) { - final String value = System.getenv(name); - return value != null ? Boolean.parseBoolean(value) : defaultValue; + String value = getEnv(name, null); + if (value != null) { + return Boolean.parseBoolean(value); + } + return defaultValue; } public static byte[] getHost(String name) { diff --git a/src/main/java/kinoko/world/GameConstants.java b/src/main/java/kinoko/world/GameConstants.java index 18617e51..385688a1 100644 --- a/src/main/java/kinoko/world/GameConstants.java +++ b/src/main/java/kinoko/world/GameConstants.java @@ -1,5 +1,6 @@ package kinoko.world; +import kinoko.database.DatabaseManager; import kinoko.server.ServerConfig; import kinoko.util.Tuple; import kinoko.world.job.Job; diff --git a/src/main/java/kinoko/world/item/EquipData.java b/src/main/java/kinoko/world/item/EquipData.java index fa0010fc..dc7d167c 100644 --- a/src/main/java/kinoko/world/item/EquipData.java +++ b/src/main/java/kinoko/world/item/EquipData.java @@ -81,6 +81,48 @@ public EquipData(EquipData equipData) { this.durability = equipData.durability; } + public EquipData( + short incStr, short incDex, short incInt, short incLuk, + short incMaxHp, short incMaxMp, short incPad, short incMad, + short incPdd, short incMdd, short incAcc, short incEva, + short incCraft, short incSpeed, short incJump, + byte ruc, byte cuc, int iuc, byte chuc, byte grade, + short option1, short option2, short option3, + short socket1, short socket2, + byte levelUpType, byte level, + int exp, int durability + ) { + this.incStr = incStr; + this.incDex = incDex; + this.incInt = incInt; + this.incLuk = incLuk; + this.incMaxHp = incMaxHp; + this.incMaxMp = incMaxMp; + this.incPad = incPad; + this.incMad = incMad; + this.incPdd = incPdd; + this.incMdd = incMdd; + this.incAcc = incAcc; + this.incEva = incEva; + this.incCraft = incCraft; + this.incSpeed = incSpeed; + this.incJump = incJump; + this.ruc = ruc; + this.cuc = cuc; + this.iuc = iuc; + this.chuc = chuc; + this.grade = grade; + this.option1 = option1; + this.option2 = option2; + this.option3 = option3; + this.socket1 = socket1; + this.socket2 = socket2; + this.levelUpType = levelUpType; + this.level = level; + this.exp = exp; + this.durability = durability; + } + public void encode(OutPacket outPacket, Item item) { outPacket.encodeByte(getRuc()); // nRUC outPacket.encodeByte(getCuc()); // nCUC diff --git a/src/main/java/kinoko/world/item/Inventory.java b/src/main/java/kinoko/world/item/Inventory.java index 83e87ca2..a3aedb78 100644 --- a/src/main/java/kinoko/world/item/Inventory.java +++ b/src/main/java/kinoko/world/item/Inventory.java @@ -39,6 +39,10 @@ public void putItem(int position, Item item) { } } + public void addItem(int position, Item item) { + putItem(position, item); + } + public Item removeItem(int position) { return items.remove(Math.abs(position)); } diff --git a/src/main/java/kinoko/world/item/InventoryManager.java b/src/main/java/kinoko/world/item/InventoryManager.java index 6716e00b..8d875686 100644 --- a/src/main/java/kinoko/world/item/InventoryManager.java +++ b/src/main/java/kinoko/world/item/InventoryManager.java @@ -20,6 +20,17 @@ public final class InventoryManager { private int money; private Instant extSlotExpire; + public InventoryManager() { + this.equipped = new Inventory(24); + this.equipInventory = new Inventory(96); + this.consumeInventory = new Inventory(96); + this.installInventory = new Inventory(96); + this.etcInventory = new Inventory(96); + this.cashInventory = new Inventory(96); + this.money = 0; + this.extSlotExpire = null; + } + public Inventory getEquipped() { return equipped; } diff --git a/src/main/java/kinoko/world/item/Item.java b/src/main/java/kinoko/world/item/Item.java index 231cc8c1..a9ab16bb 100644 --- a/src/main/java/kinoko/world/item/Item.java +++ b/src/main/java/kinoko/world/item/Item.java @@ -36,6 +36,37 @@ public Item(Item item) { this.ringData = item.ringData != null ? new RingData(item.ringData) : null; } + public Item(int itemId, short quantity) { + this(ItemType.getByItemId(itemId)); + this.itemId = itemId; + this.quantity = quantity; + } + + public Item( + int itemId, + short quantity, + long itemSn, + boolean cash, + short attribute, + String title, + Instant dateExpire, + EquipData equipData, + PetData petData, + RingData ringData + ) { + this(ItemType.getByItemId(itemId)); + this.itemId = itemId; + this.quantity = quantity; + this.itemSn = itemSn; + this.cash = cash; + this.attribute = attribute; + this.title = title; + this.dateExpire = dateExpire; + this.equipData = equipData; + this.petData = petData; + this.ringData = ringData; + } + @Override public void encode(OutPacket outPacket) { outPacket.encodeByte(getItemType().getValue()); // nType diff --git a/src/main/java/kinoko/world/item/PetData.java b/src/main/java/kinoko/world/item/PetData.java index 5d8e93b2..7392a819 100644 --- a/src/main/java/kinoko/world/item/PetData.java +++ b/src/main/java/kinoko/world/item/PetData.java @@ -26,6 +26,25 @@ public PetData(PetData petData) { this.remainLife = petData.remainLife; } + public PetData( + String petName, + byte level, + byte fullness, + short tameness, + short petSkill, + short petAttribute, + int remainLife + ) { + this.petName = petName; + this.level = level; + this.fullness = fullness; + this.tameness = tameness; + this.petSkill = petSkill; + this.petAttribute = petAttribute; + this.remainLife = remainLife; + } + + public void encode(OutPacket outPacket, Item item) { outPacket.encodeString(getPetName(), 13); // sPetName outPacket.encodeByte(getLevel()); // nLevel diff --git a/src/main/java/kinoko/world/item/RingData.java b/src/main/java/kinoko/world/item/RingData.java index 5aa9879c..2d5e6b60 100644 --- a/src/main/java/kinoko/world/item/RingData.java +++ b/src/main/java/kinoko/world/item/RingData.java @@ -14,6 +14,17 @@ public RingData(RingData ringData) { this.pairItemSn = ringData.pairItemSn; } + public RingData( + int pairCharacterId, + String pairCharacterName, + long pairItemSn + ) { + this.pairCharacterId = pairCharacterId; + this.pairCharacterName = pairCharacterName; + this.pairItemSn = pairItemSn; + } + + public int getPairCharacterId() { return pairCharacterId; } diff --git a/src/main/java/kinoko/world/quest/QuestRecord.java b/src/main/java/kinoko/world/quest/QuestRecord.java index 24ec6385..cd340e24 100644 --- a/src/main/java/kinoko/world/quest/QuestRecord.java +++ b/src/main/java/kinoko/world/quest/QuestRecord.java @@ -12,6 +12,14 @@ public QuestRecord(int questId) { this.questId = questId; } + public QuestRecord(int questId, QuestState state, String value, Instant completedTime) { + this.questId = questId; + this.state = state; + this.value = value; + this.completedTime = completedTime; + } + + public int getQuestId() { return questId; } diff --git a/src/main/java/kinoko/world/skill/SkillRecord.java b/src/main/java/kinoko/world/skill/SkillRecord.java index 9baa3b8d..5b45b56a 100644 --- a/src/main/java/kinoko/world/skill/SkillRecord.java +++ b/src/main/java/kinoko/world/skill/SkillRecord.java @@ -9,6 +9,12 @@ public SkillRecord(int skillId) { this.skillId = skillId; } + public SkillRecord(int skillId, int skillLevel, int masterLevel) { + this.skillId = skillId; + this.skillLevel = skillLevel; + this.masterLevel = masterLevel; + } + public int getSkillId() { return skillId; } diff --git a/src/main/java/kinoko/world/user/CharacterData.java b/src/main/java/kinoko/world/user/CharacterData.java index ed9e8e25..d8440b24 100644 --- a/src/main/java/kinoko/world/user/CharacterData.java +++ b/src/main/java/kinoko/world/user/CharacterData.java @@ -1,5 +1,6 @@ package kinoko.world.user; +import kinoko.database.DatabaseManager; import kinoko.server.dialog.miniroom.MiniRoomType; import kinoko.server.packet.OutPacket; import kinoko.util.Encodable; @@ -16,6 +17,7 @@ import kinoko.world.user.data.*; import kinoko.world.user.stat.CharacterStat; +import javax.xml.crypto.Data; import java.time.Duration; import java.time.Instant; import java.util.HashMap; @@ -131,11 +133,11 @@ public void setWildHunterInfo(WildHunterInfo wildHunterInfo) { } public AtomicInteger getItemSnCounter() { - return itemSnCounter; + return DatabaseManager.isRelational() ? new AtomicInteger(-1) : itemSnCounter; } public void setItemSnCounter(AtomicInteger itemSnCounter) { - this.itemSnCounter = itemSnCounter; + this.itemSnCounter = DatabaseManager.isRelational() ? new AtomicInteger(-1) : itemSnCounter; } public int getFriendMax() { @@ -179,6 +181,11 @@ public void setMaxLevelTime(Instant maxLevelTime) { } public long getNextItemSn() { + if (DatabaseManager.isRelational()) { + // Let the relational database handle SN generation; return placeholder + return -1; + } + return ((long) itemSnCounter.getAndIncrement()) | (((long) getCharacterId()) << 32); } diff --git a/src/main/java/kinoko/world/user/data/ConfigManager.java b/src/main/java/kinoko/world/user/data/ConfigManager.java index 9e153581..2bf1b0a9 100644 --- a/src/main/java/kinoko/world/user/data/ConfigManager.java +++ b/src/main/java/kinoko/world/user/data/ConfigManager.java @@ -25,6 +25,18 @@ public ConfigManager(FuncKeyMapped[] funcKeyMap, int[] quickslotKeyMap) { this.petExceptionList = List.of(); } + public ConfigManager(int petConsumeItem, int petConsumeMpItem, List petExceptionList, + FuncKeyMapped[] funcKeyMap, int[] quickslotKeyMap) { + assert funcKeyMap.length == GameConstants.FUNC_KEY_MAP_SIZE; + assert quickslotKeyMap.length == GameConstants.QUICKSLOT_KEY_MAP_SIZE; + + this.petConsumeItem = petConsumeItem; + this.petConsumeMpItem = petConsumeMpItem; + this.petExceptionList = petExceptionList != null ? petExceptionList : List.of(); + this.funcKeyMap = funcKeyMap; + this.quickslotKeyMap = quickslotKeyMap; + } + public List getMacroSysData() { return macroSysData; } diff --git a/src/main/java/kinoko/world/user/stat/CharacterStat.java b/src/main/java/kinoko/world/user/stat/CharacterStat.java index 0c171f6c..0dd20bd7 100644 --- a/src/main/java/kinoko/world/user/stat/CharacterStat.java +++ b/src/main/java/kinoko/world/user/stat/CharacterStat.java @@ -7,6 +7,7 @@ import kinoko.world.job.JobConstants; import java.util.EnumMap; +import java.util.HashMap; import java.util.Map; public final class CharacterStat implements Encodable { @@ -37,6 +38,45 @@ public final class CharacterStat implements Encodable { private long petSn2; private long petSn3; + public CharacterStat(){ + + } + + public CharacterStat(int id, String name, byte gender, byte skin, int face, int hair, + short level, short job, short subJob, + short baseStr, short baseDex, short baseInt, short baseLuk, + int hp, int maxHp, int mp, int maxMp, short ap, + int exp, short pop, int posMap, byte portal, + long petSn1, long petSn2, long petSn3) { + this.id = id; + this.name = name; + this.gender = gender; + this.skin = skin; + this.face = face; + this.hair = hair; + this.level = level; + this.job = job; + this.subJob = subJob; + this.baseStr = baseStr; + this.baseDex = baseDex; + this.baseInt = baseInt; + this.baseLuk = baseLuk; + this.hp = hp; + this.maxHp = maxHp; + this.mp = mp; + this.maxMp = maxMp; + this.ap = ap; + this.exp = exp; + this.pop = pop; + this.posMap = posMap; + this.portal = portal; + this.petSn1 = petSn1; + this.petSn2 = petSn2; + this.petSn3 = petSn3; + // TODO: give this a legit value. + this.sp = new ExtendSp(new HashMap<>()); + } + public int getId() { return id; } From d5a8b0f3dce4ac0ff47510aa45652c4feaf43e36 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 15:16:37 -0400 Subject: [PATCH 30/83] updated readme --- README.md | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/README.md b/README.md index b83a607e..79d00861 100644 --- a/README.md +++ b/README.md @@ -44,7 +44,7 @@ $ mvn clean package #### Environment setup Before doing any Docker or Database Setup You should: -1. Make a copy of `.env.example` and rename it to `.env`. +1. Make a copy of `.env.example` and rename it to `.env` 2. Adjust the ENV Variables to the database server you will be using. @@ -53,7 +53,6 @@ You should: It is possible to use either CassandraDB, ScyllaDB, or Postgres. - ```bash # Start CassandraDB $ docker-compose up -d cassandra @@ -68,7 +67,8 @@ $ docker-compose up -d postgres # OR (CHANGE THE PASSWORD) $ docker run -d --name postgres_kinoko -e POSTGRES_USER=postgres -e POSTGRES_PASSWORD=admin -e POSTGRES_INITDB_ARGS="--auth-host=scram-sha-256 --auth-local=scram-sha-256" -e POSTGRES_DB=kinoko -p 5432:5432 -v "${PWD}\src\main\java\kinoko\database\postgresql\setup\init.sql:/docker-entrypoint-initdb.d/init.sql:ro" postgres:16 -Important: If you are using PostgreSQL on a local machine (not using a dockerized server), make sure that you have any undockerized postgresql server offline. This can cause conflicts. +Important: If you are using PostgreSQL on a local machine (not using a dockerized server), +make sure that you have any undockerized postgresql server offline. This can cause conflicts. ``` From f552cfa843ebe1906cb9248e1d0c19f70b29cdfa Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 15:39:31 -0400 Subject: [PATCH 31/83] Added a DB TYPE to the .env.example --- .env.example | 3 +++ src/main/java/kinoko/database/DatabaseManager.java | 14 ++++++++------ src/main/java/kinoko/server/ServerConstants.java | 2 ++ 3 files changed, 13 insertions(+), 6 deletions(-) diff --git a/.env.example b/.env.example index 9fb73436..a9edac1c 100644 --- a/.env.example +++ b/.env.example @@ -1,5 +1,8 @@ # This example shows a setup for Cassandra on Prod +# cassandra, postgres +DB_TYPE=cassandra + # DEV ENV TESPIA=FALSE diff --git a/src/main/java/kinoko/database/DatabaseManager.java b/src/main/java/kinoko/database/DatabaseManager.java index e2b3d19a..6feddc9d 100644 --- a/src/main/java/kinoko/database/DatabaseManager.java +++ b/src/main/java/kinoko/database/DatabaseManager.java @@ -6,6 +6,7 @@ import kinoko.server.ServerConstants; import kinoko.world.GameConstants; +import java.util.List; import java.util.Objects; public final class DatabaseManager { @@ -51,16 +52,17 @@ public static boolean isRelational() { public static void initialize() { - // Prod Environment - if (Objects.equals(ServerConstants.DATABASE_HOST, "cassandra_kinoko")) { + if (Objects.equals(ServerConstants.DATABASE_HOST, "cassandra_kinoko") + || Objects.equals(ServerConstants.DATABASE_TYPE, "cassandra")) { connector = new CassandraConnector(); } - else if (Objects.equals(ServerConstants.DATABASE_HOST, "postgres_kinoko")){ + else if (Objects.equals(ServerConstants.DATABASE_HOST, "postgres_kinoko") + || (List.of("psql", "postgres", "postgresql").contains(ServerConstants.DATABASE_TYPE))){ connector = new PostgresConnector(); } - else { // Your choice, likely in a dev environment. -// connector = new CassandraConnector(); - connector = new PostgresConnector(); + else { // Your choice, defaulting to cassandra. + connector = new CassandraConnector(); +// connector = new PostgresConnector(); } connector.initialize(); } diff --git a/src/main/java/kinoko/server/ServerConstants.java b/src/main/java/kinoko/server/ServerConstants.java index 0fc9a361..c95d53ed 100644 --- a/src/main/java/kinoko/server/ServerConstants.java +++ b/src/main/java/kinoko/server/ServerConstants.java @@ -20,6 +20,7 @@ public final class ServerConstants { // It is advised to set these variables in the .env file. // General + public static final String DATABASE_TYPE = Util.getEnv("DB_TYPE","cassandra").toLowerCase(); public static final String DATABASE_HOST = Util.getEnv("DB_HOST", "127.0.0.1"); public static final int DATABASE_PORT = Util.getEnv("DB_PORT", 9042); // Defaulting to Cassandra port public static final String DATABASE_NAME = Util.getEnv("DB_NAME", "kinoko"); // Cassandra KeySpace, Postgres DB Name @@ -31,5 +32,6 @@ public final class ServerConstants { // Cassandra Specific public static final String DATABASE_DATACENTER = Util.getEnv("DB_DATACENTER","datacenter1"); public static final String DATABASE_PROFILE = Util.getEnv("DB_PROFILE_ONE","profile_one"); + } From d1e0c4735558aaeb0213bcc933c5ab4cb12fadda Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 15:40:15 -0400 Subject: [PATCH 32/83] updated gitignore --- .gitignore | 1 + 1 file changed, 1 insertion(+) diff --git a/.gitignore b/.gitignore index 9b66c505..e4b9dcdb 100644 --- a/.gitignore +++ b/.gitignore @@ -42,5 +42,6 @@ build/ .idea/ ### Project ### +/wz/ /wz/*.wz /json/ From e97bf4303a9e87e3ba3485947f54c23d6e27e357 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 15:47:22 -0400 Subject: [PATCH 33/83] removed unused imports --- src/main/java/kinoko/database/DatabaseManager.java | 2 -- .../java/kinoko/database/cassandra/CassandraConnector.java | 2 -- .../java/kinoko/database/postgresql/PostgresConnector.java | 1 - .../kinoko/database/postgresql/PostgresGuildAccessor.java | 1 - .../java/kinoko/database/postgresql/PostgresIdAccessor.java | 4 ---- src/main/java/kinoko/world/GameConstants.java | 1 - src/main/java/kinoko/world/user/CharacterData.java | 1 - 7 files changed, 12 deletions(-) diff --git a/src/main/java/kinoko/database/DatabaseManager.java b/src/main/java/kinoko/database/DatabaseManager.java index 6feddc9d..baba218e 100644 --- a/src/main/java/kinoko/database/DatabaseManager.java +++ b/src/main/java/kinoko/database/DatabaseManager.java @@ -2,9 +2,7 @@ import kinoko.database.postgresql.PostgresConnector; import kinoko.database.cassandra.CassandraConnector; -import kinoko.server.ServerConfig; import kinoko.server.ServerConstants; -import kinoko.world.GameConstants; import java.util.List; import java.util.Objects; diff --git a/src/main/java/kinoko/database/cassandra/CassandraConnector.java b/src/main/java/kinoko/database/cassandra/CassandraConnector.java index 676c665e..9960261e 100644 --- a/src/main/java/kinoko/database/cassandra/CassandraConnector.java +++ b/src/main/java/kinoko/database/cassandra/CassandraConnector.java @@ -17,8 +17,6 @@ import kinoko.database.cassandra.codec.*; import kinoko.database.cassandra.table.*; import kinoko.database.cassandra.type.*; -import kinoko.server.Server; -import kinoko.server.ServerConfig; import kinoko.server.ServerConstants; import kinoko.server.cashshop.CashItemInfo; import kinoko.server.guild.GuildBoardComment; diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java index 52a9cd4c..fe656e54 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresConnector.java +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -4,7 +4,6 @@ import java.util.TimeZone; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; -import kinoko.server.ServerConfig; import kinoko.server.ServerConstants; public final class PostgresConnector implements DatabaseConnector { diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java index 04360a08..9d69cdae 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -10,7 +10,6 @@ import java.sql.*; import java.util.*; -import java.util.concurrent.atomic.AtomicInteger; public final class PostgresGuildAccessor extends PostgresAccessor implements GuildAccessor { diff --git a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java index 038dcf39..d8fc3b99 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java @@ -3,10 +3,6 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.IdAccessor; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; import java.util.Optional; public final class PostgresIdAccessor extends PostgresAccessor implements IdAccessor { diff --git a/src/main/java/kinoko/world/GameConstants.java b/src/main/java/kinoko/world/GameConstants.java index 385688a1..18617e51 100644 --- a/src/main/java/kinoko/world/GameConstants.java +++ b/src/main/java/kinoko/world/GameConstants.java @@ -1,6 +1,5 @@ package kinoko.world; -import kinoko.database.DatabaseManager; import kinoko.server.ServerConfig; import kinoko.util.Tuple; import kinoko.world.job.Job; diff --git a/src/main/java/kinoko/world/user/CharacterData.java b/src/main/java/kinoko/world/user/CharacterData.java index d8440b24..2b20e5c3 100644 --- a/src/main/java/kinoko/world/user/CharacterData.java +++ b/src/main/java/kinoko/world/user/CharacterData.java @@ -17,7 +17,6 @@ import kinoko.world.user.data.*; import kinoko.world.user.stat.CharacterStat; -import javax.xml.crypto.Data; import java.time.Duration; import java.time.Instant; import java.util.HashMap; From 6f786f1e15e20bd3c864e2b7702d873bc9a92ef9 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 19:33:31 -0400 Subject: [PATCH 34/83] Fixed Trunk --- .../postgresql/PostgresAccountAccessor.java | 134 ++--------- .../postgresql/PostgresCharacterAccessor.java | 105 +-------- .../postgresql/PostgresGuildAccessor.java | 39 ++-- .../postgresql/PostgresMemoAccessor.java | 12 +- .../postgresql/type/InventoryDao.java | 134 +++++++++++ .../database/postgresql/type/ItemDao.java | 217 ++++++++++++++++++ .../database/postgresql/type/LockerDao.java | 92 ++++++++ .../database/postgresql/type/TrunkDao.java | 131 +++++++++++ .../database/postgresql/type/WishlistDao.java | 75 ++++++ .../java/kinoko/world/item/Inventory.java | 21 +- .../kinoko/world/item/InventoryEntry.java | 5 + .../kinoko/world/item/InventoryManager.java | 12 +- src/main/java/kinoko/world/item/Item.java | 13 ++ 13 files changed, 752 insertions(+), 238 deletions(-) create mode 100644 src/main/java/kinoko/database/postgresql/type/InventoryDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/ItemDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/LockerDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/TrunkDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/WishlistDao.java create mode 100644 src/main/java/kinoko/world/item/InventoryEntry.java diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java index 820b0147..495819ee 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java @@ -3,6 +3,9 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.AccountAccessor; import kinoko.database.DatabaseManager; +import kinoko.database.postgresql.type.LockerDao; +import kinoko.database.postgresql.type.TrunkDao; +import kinoko.database.postgresql.type.WishlistDao; import kinoko.server.ServerConfig; import kinoko.server.cashshop.CashItemInfo; import kinoko.world.item.Item; @@ -23,7 +26,7 @@ public PostgresAccountAccessor(HikariDataSource dataSource) { super(dataSource); } - private Account loadAccount(ResultSet rs) throws SQLException { + private Account loadAccount(Connection conn, ResultSet rs) throws SQLException { final int accountId = rs.getInt("id"); final String username = rs.getString("username"); final String secondaryPassword = rs.getString("secondary_password"); @@ -35,59 +38,14 @@ private Account loadAccount(ResultSet rs) throws SQLException { account.setNxPrepaid(rs.getInt("nx_prepaid")); account.setMaplePoint(rs.getInt("maple_point")); - account.setTrunk(loadTrunk(accountId)); + account.setTrunk(TrunkDao.load(conn, accountId)); + account.setLocker(loadLocker(accountId)); account.setWishlist(loadWishlist(accountId)); return account; } - private Trunk loadTrunk(int accountId) throws SQLException { - int trunkSize = ServerConfig.TRUNK_BASE_SLOTS; - int trunkMoney = 0; - - String accountSql = "SELECT trunk_size, trunk_money FROM account.accounts WHERE id = ?"; - String itemsSql = """ - SELECT ti.slot, i.item_id, i.quantity - FROM account.trunk_item ti - JOIN item.items i ON ti.item_sn = i.item_sn - WHERE ti.account_id = ? - ORDER BY ti.slot - """; - - Trunk trunk; - - try (Connection conn = getConnection()) { - // Query trunk info - try (PreparedStatement stmt = conn.prepareStatement(accountSql)) { - stmt.setInt(1, accountId); - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - trunkSize = rs.getInt("trunk_size"); - trunkMoney = rs.getInt("trunk_money"); - } - } - } - - // Initialize trunk with proper size - trunk = new Trunk(trunkSize); - trunk.setMoney(trunkMoney); - - // Query items - try (PreparedStatement stmt = conn.prepareStatement(itemsSql)) { - stmt.setInt(1, accountId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - Item item = new Item(rs.getInt("item_id"), (short) rs.getInt("quantity")); - trunk.getItems().add(item); - } - } - } - } - - return trunk; - } - private Locker loadLocker(int accountId) throws SQLException { Locker locker = new Locker(); @@ -95,7 +53,8 @@ private Locker loadLocker(int accountId) throws SQLException { "FROM account.locker_item li " + "JOIN item.items i ON li.item_sn = i.item_sn " + "WHERE li.account_id = ? ORDER BY li.slot"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, accountId); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { @@ -118,7 +77,8 @@ private List loadWishlist(int accountId) throws SQLException { List wishlist = new ArrayList<>(); String sql = "SELECT w.item_id FROM account.wishlist w " + "WHERE w.account_id = ? ORDER BY w.slot"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, accountId); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { @@ -145,11 +105,12 @@ private boolean checkHashedPassword(String password, String hashedPassword) { @Override public Optional getAccountById(int accountId) { String sql = "SELECT * FROM account.accounts WHERE id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, accountId); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - return Optional.of(loadAccount(rs)); + return Optional.of(loadAccount(conn, rs)); } } } catch (SQLException e) { @@ -161,11 +122,12 @@ public Optional getAccountById(int accountId) { @Override public Optional getAccountByUsername(String username) { String sql = "SELECT * FROM account.accounts WHERE username = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, lowerUsername(username)); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - return Optional.of(loadAccount(rs)); + return Optional.of(loadAccount(conn, rs)); } } } catch (SQLException e) { @@ -178,7 +140,8 @@ public Optional getAccountByUsername(String username) { public boolean checkPassword(Account account, String password, boolean secondary) { String column = secondary ? "secondary_password" : "password"; String sql = "SELECT " + column + " FROM account.accounts WHERE id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, account.getId()); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { @@ -229,7 +192,8 @@ public synchronized boolean newAccount(String username, String password) { "VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + "RETURNING ID"; System.out.println("Creating account."); - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, lowerUsername(username)); stmt.setString(2, hashPassword(password)); stmt.setInt(3, ServerConfig.CHARACTER_BASE_SLOTS); @@ -292,60 +256,10 @@ public boolean saveAccount(Account account) { stmt.executeUpdate(); } - // Save trunk - try (PreparedStatement delTrunk = conn.prepareStatement("DELETE FROM account.trunk_item WHERE account_id = ?")) { - delTrunk.setInt(1, account.getId()); - delTrunk.executeUpdate(); - } - int slot = 0; - for (Item item : account.getTrunk().getItems()) { - try (PreparedStatement insertTrunk = conn.prepareStatement( - "INSERT INTO account.trunk_item (account_id, slot, item_sn) VALUES (?, ?, ?)")) { - insertTrunk.setInt(1, account.getId()); - insertTrunk.setInt(2, slot++); - insertTrunk.setObject(3, item.getItemSn()); // null if empty - insertTrunk.executeUpdate(); - } - } - - // Save wishlist - try (PreparedStatement delWish = conn.prepareStatement("DELETE FROM account.wishlist WHERE account_id = ?")) { - delWish.setInt(1, account.getId()); - delWish.executeUpdate(); - } - slot = 0; - for (Integer itemId : account.getWishlist()) { - try (PreparedStatement insertWish = conn.prepareStatement( - """ - INSERT INTO account.wishlist (account_id, slot, item_id) - VALUES (?, ?, ?) - ON CONFLICT (account_id, slot) DO UPDATE - SET item_id = EXCLUDED.item_id - """ - )) { - insertWish.setInt(1, account.getId()); - insertWish.setInt(2, slot++); - insertWish.setInt(3, itemId); // now using item_id, not item_sn - insertWish.executeUpdate(); - } - } - - // Save locker - try (PreparedStatement delLocker = conn.prepareStatement("DELETE FROM account.locker_item WHERE account_id = ?")) { - delLocker.setInt(1, account.getId()); - delLocker.executeUpdate(); - } - slot = 0; - for (CashItemInfo cash : account.getLocker().getCashItems()) { - try (PreparedStatement insertLocker = conn.prepareStatement( - "INSERT INTO account.locker_item (account_id, slot, item_sn, commodity_id) VALUES (?, ?, ?, ?)")) { - insertLocker.setInt(1, account.getId()); - insertLocker.setInt(2, slot++); - insertLocker.setObject(3, cash.getItem().getItemSn()); - insertLocker.setInt(4, cash.getCommodityId()); - insertLocker.executeUpdate(); - } - } + int accountId = account.getId(); + TrunkDao.save(conn, accountId, account.getTrunk()); + WishlistDao.save(conn, accountId, account.getWishlist()); + LockerDao.save(conn, accountId, account.getLocker()); conn.commit(); conn.setAutoCommit(true); diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java index ebc73e64..49486c4d 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -3,6 +3,7 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.CharacterAccessor; import kinoko.database.CharacterInfo; +import kinoko.database.postgresql.type.InventoryDao; import kinoko.server.rank.CharacterRank; import kinoko.world.GameConstants; import kinoko.world.item.*; @@ -1154,109 +1155,7 @@ ON CONFLICT (character_id) DO UPDATE SET private void saveCharacterInventory(Connection conn, CharacterData characterData) throws SQLException { - String sqlInventory = """ - INSERT INTO player.inventory (character_id, inventory_type, slot, item_sn) - VALUES (?, ?, ?, ?) - ON CONFLICT (character_id, item_sn) - DO UPDATE SET slot = EXCLUDED.slot, inventory_type = EXCLUDED.inventory_type - """; - - String sqlItems = """ - INSERT INTO item.items (item_sn, item_id, quantity, attribute, title, date_expire) - VALUES (?, ?, ?, ?, ?, ?) - """; - - try (PreparedStatement stmtInventory = conn.prepareStatement(sqlInventory); - PreparedStatement stmtItems = conn.prepareStatement(sqlItems)) { - - InventoryManager inv = characterData.getInventoryManager(); - - saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.EQUIPPED, inv.getEquipped().getItems()); - saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.EQUIP, inv.getEquipInventory().getItems()); - saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.CONSUME, inv.getConsumeInventory().getItems()); - saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.INSTALL, inv.getInstallInventory().getItems()); - saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.ETC, inv.getEtcInventory().getItems()); - saveInventoryBatch(conn, stmtItems, stmtInventory, characterData.getCharacterId(), InventoryType.CASH, inv.getCashInventory().getItems()); - - stmtItems.executeBatch(); - stmtInventory.executeBatch(); - } - } - - - - private void saveInventoryBatch( - Connection conn, - PreparedStatement stmtItems, - PreparedStatement stmtInventory, - int charId, - InventoryType type, - Map items - ) throws SQLException { - - PGobject enumValue = new PGobject(); - enumValue.setType("inventory_type_enum"); - enumValue.setValue(type.name()); - - // --- Delete inventory items that no longer exist --- - if (!items.isEmpty()) { - Long[] itemSnArray = items.values().stream() - .map(Item::getItemSn) - .toArray(Long[]::new); - - try (PreparedStatement deleteStmt = conn.prepareStatement("DELETE FROM player.inventory WHERE character_id = ? AND item_sn <> ALL (?)")) { - deleteStmt.setInt(1, charId); - Array sqlArray = conn.createArrayOf("bigint", itemSnArray); - deleteStmt.setArray(2, sqlArray); - deleteStmt.executeUpdate(); - } - } else { // delete all items. - try (PreparedStatement deleteStmt = conn.prepareStatement( - "DELETE FROM player.inventory WHERE character_id = ?")) { - deleteStmt.setInt(1, charId); - deleteStmt.executeUpdate(); - } - } - - // --- Insert/update items --- - for (var entry : items.entrySet()) { - Item item = entry.getValue(); - long itemSn = item.getItemSn(); - - if (itemSn <= 0) { // DNE - try (PreparedStatement seqStmt = conn.prepareStatement( - "SELECT nextval(pg_get_serial_sequence('item.items', 'item_sn'))"); - ResultSet rs = seqStmt.executeQuery()) { - rs.next(); - itemSn = rs.getLong(1); - item.setItemSn(itemSn); - } - - stmtItems.setLong(1, itemSn); - stmtItems.setInt(2, item.getItemId()); - stmtItems.setInt(3, item.getQuantity()); - stmtItems.setShort(4, item.getAttribute()); - stmtItems.setString(5, item.getTitle()); - stmtItems.setTimestamp(6, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); - stmtItems.addBatch(); - } else { - try (PreparedStatement updateStmt = conn.prepareStatement( - "UPDATE item.items SET quantity = ?, attribute = ?, title = ?, date_expire = ? WHERE item_sn = ?")) { - updateStmt.setInt(1, item.getQuantity()); - updateStmt.setShort(2, item.getAttribute()); - updateStmt.setString(3, item.getTitle()); - updateStmt.setTimestamp(4, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); - updateStmt.setLong(5, itemSn); - updateStmt.executeUpdate(); - } - } - - stmtInventory.setInt(1, charId); - stmtInventory.setObject(2, enumValue); - stmtInventory.setInt(3, entry.getKey()); - stmtInventory.setLong(4, itemSn); - stmtInventory.addBatch(); - } + InventoryDao.saveCharacter(conn, characterData); } @Override diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java index 9d69cdae..f9abedf7 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -52,7 +52,8 @@ private Guild loadGuild(ResultSet rs) throws SQLException { @Override public Optional getGuildById(int guildId) { String sql = "SELECT * FROM guild.guilds WHERE guild_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { @@ -69,7 +70,8 @@ public Optional getGuildById(int guildId) { private List loadGrades(int guildId) throws SQLException { List grades = new ArrayList<>(); String sql = "SELECT grade_name FROM guild.grade WHERE guild_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { @@ -94,7 +96,8 @@ private List loadMembers(int guildId) { WHERE m.guild_id = ? """; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { @@ -134,7 +137,8 @@ private List loadBoardEntries(int guildId) { List entries = new ArrayList<>(); String sql = "SELECT entry_id, character_id, title, message, timestamp, 0 AS emoticon " + "FROM guild.board_entry WHERE guild_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { @@ -156,7 +160,8 @@ private List loadBoardEntries(int guildId) { private GuildBoardEntry loadBoardNotice(int guildId) { String sql = "SELECT entry_id FROM guild.notice WHERE guild_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { @@ -193,7 +198,8 @@ private GuildBoardEntry loadBoardNotice(int guildId) { @Override public boolean checkGuildNameAvailable(String name) { String sql = "SELECT 1 FROM guild.guilds WHERE LOWER(guild_name) = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, name.toLowerCase()); try (ResultSet rs = stmt.executeQuery()) { return !rs.next(); @@ -229,7 +235,8 @@ public boolean saveGuild(Guild guild) { "points = EXCLUDED.points, " + "level = EXCLUDED.level, " + "board_entry_counter = EXCLUDED.board_entry_counter"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guild.getGuildId()); stmt.setString(2, guild.getGuildName()); stmt.setArray(3, getConnection().createArrayOf("text", guild.getGradeNames().toArray())); @@ -257,13 +264,15 @@ public boolean saveGuild(Guild guild) { private void saveMembers(Guild guild) throws SQLException { String deleteSql = "DELETE FROM guild.member WHERE guild_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(deleteSql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(deleteSql)) { stmt.setInt(1, guild.getGuildId()); stmt.executeUpdate(); } String insertSql = "INSERT INTO guild.member (guild_id, character_id, grade) VALUES (?, ?, ?)"; - try (PreparedStatement stmt = getConnection().prepareStatement(insertSql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(insertSql)) { for (GuildMember member : guild.getGuildMembers()) { stmt.setInt(1, guild.getGuildId()); stmt.setInt(2, member.getCharacterId()); @@ -276,13 +285,15 @@ private void saveMembers(Guild guild) throws SQLException { private void saveBoardEntries(Guild guild) throws SQLException { String deleteSql = "DELETE FROM guild.board_entry WHERE guild_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(deleteSql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(deleteSql)) { stmt.setInt(1, guild.getGuildId()); stmt.executeUpdate(); } String insertSql = "INSERT INTO guild.board_entry (entry_id, guild_id, character_id, title, message, timestamp, emoticon) VALUES (?, ?, ?, ?, ?, ?, ?)"; - try (PreparedStatement stmt = getConnection().prepareStatement(insertSql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(insertSql)) { for (GuildBoardEntry entry : guild.getBoardEntries()) { stmt.setInt(1, entry.getEntryId()); stmt.setInt(2, guild.getGuildId()); @@ -300,7 +311,8 @@ private void saveBoardEntries(Guild guild) throws SQLException { private void saveBoardNotice(Guild guild) throws SQLException { String sql = "INSERT INTO guild.notice (guild_id, entry_id) VALUES (?, ?) " + "ON CONFLICT (guild_id) DO UPDATE SET entry_id = EXCLUDED.entry_id"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { GuildBoardEntry notice = guild.getBoardNoticeEntry(); if (notice != null) { stmt.setInt(1, guild.getGuildId()); @@ -316,7 +328,8 @@ private void saveBoardNotice(Guild guild) throws SQLException { @Override public boolean deleteGuild(int guildId) { String sql = "DELETE FROM guild.guilds WHERE guild_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); return stmt.executeUpdate() > 0; } catch (SQLException e) { diff --git a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java index d58f4fe1..3b0e51c8 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java @@ -20,7 +20,8 @@ public List getMemosByCharacterId(int characterId) { List memos = new ArrayList<>(); String sql = "SELECT id, memo_type, memo_content, sender_name, date_sent " + "FROM memo.memo WHERE receiver_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, characterId); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { @@ -44,7 +45,8 @@ public List getMemosByCharacterId(int characterId) { @Override public boolean hasMemo(int characterId) { String sql = "SELECT 1 FROM memo.memo WHERE receiver_id = ? LIMIT 1"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, characterId); try (ResultSet rs = stmt.executeQuery()) { return rs.next(); @@ -60,7 +62,8 @@ public boolean newMemo(Memo memo, int receiverId) { // `id` is SERIAL, no need to provide it manually String sql = "INSERT INTO memo.memo (receiver_id, memo_type, memo_content, sender_name, date_sent) " + "VALUES (?, ?, ?, ?, ?)"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, receiverId); stmt.setInt(2, memo.getType().getValue()); stmt.setString(3, memo.getContent()); @@ -77,7 +80,8 @@ public boolean newMemo(Memo memo, int receiverId) { @Override public boolean deleteMemo(int memoId, int receiverId) { String sql = "DELETE FROM memo.memo WHERE id = ? AND receiver_id = ?"; - try (PreparedStatement stmt = getConnection().prepareStatement(sql)) { + try (Connection conn = getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, memoId); stmt.setInt(2, receiverId); int affected = stmt.executeUpdate(); diff --git a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java new file mode 100644 index 00000000..bd381f26 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java @@ -0,0 +1,134 @@ +package kinoko.database.postgresql.type; + + +import kinoko.world.item.InventoryEntry; +import kinoko.world.item.InventoryManager; +import kinoko.world.item.InventoryType; +import kinoko.world.item.Item; +import kinoko.world.user.CharacterData; +import org.postgresql.util.PGobject; + +import java.util.Map; +import java.util.EnumMap; +import java.sql.*; +import java.util.Collection; +import java.util.stream.Stream; + + +public class InventoryDao { + + /** + * Saves a character's inventory to the database. + * + * This method collects all inventory entries from the character's InventoryManager, + * saves all the items to the items table (creating new item_sn values if needed), + * removes any items that the character no longer has, and updates the player inventory + * table with the current inventory entries using a batch insert/update. + * + * @param conn the active database connection + * @param characterData the character whose inventory is being saved + * @throws SQLException if any SQL error occurs during the operation + */ + public static void saveCharacter(Connection conn, CharacterData characterData) throws SQLException { + String sqlInventory = """ + INSERT INTO player.inventory (character_id, inventory_type, slot, item_sn) + VALUES (?, ?, ?, ?) + ON CONFLICT (character_id, item_sn) + DO UPDATE SET slot = EXCLUDED.slot, inventory_type = EXCLUDED.inventory_type + """; + + int characterId = characterData.getCharacterId(); + + InventoryManager inv = characterData.getInventoryManager(); + Collection allEntries = Stream.of( + inv.getEquipped(), + inv.getEquipInventory(), + inv.getConsumeInventory(), + inv.getInstallInventory(), + inv.getEtcInventory(), + inv.getCashInventory() + ) + .flatMap(inventory -> inventory.getType() + .map(type -> inventory.asInventoryEntries(type).stream()) + .orElseGet(Stream::empty)) // skip inventories with no type + .toList(); + + // Extract just the Item objects + Collection allItems = allEntries.stream() + .map(InventoryEntry::item) + .toList(); + + ItemDao.saveItemsBatch(conn, allItems); // Insert or Save the Items themselves. + deleteUnusedItems(conn, characterId, allItems); // Remove any inventory items that the player no longer has. + + Map typeObjects = new EnumMap<>(InventoryType.class); + for (InventoryType type : InventoryType.values()) { + PGobject obj = new PGobject(); + obj.setType("inventory_type_enum"); + obj.setValue(type.name()); + typeObjects.put(type, obj); + } + + // Add in items to the player inventory. + try (PreparedStatement stmtInventory = conn.prepareStatement(sqlInventory); + ) { + // --- Insert/update items --- + for (InventoryEntry entry : allEntries) { + Item item = entry.item(); + InventoryType type = entry.type(); // from InventoryEntry + PGobject enumValue = typeObjects.get(type); + + stmtInventory.setInt(1, characterId); + stmtInventory.setObject(2, enumValue); + stmtInventory.setInt(3, entry.slot()); + // Item SN should be updated since we handled all items earlier. + stmtInventory.setLong(4, item.getItemSn()); + stmtInventory.addBatch(); + } + + stmtInventory.executeBatch(); + } + } + + + /** + * Remove inventory items that are no longer in use for a specific character. + * + * If the provided collection of items is non-empty, this method deletes all rows in + * `player.inventory` for the given character whose `item_sn` is not present in the collection. + * CAUTION: If the collection is empty, it deletes all inventory items for the character. + * This is because it is expected for the player's entire inventory to be passed in. + * + * @param conn the database connection to use + * @param charId the ID of the character whose inventory should be cleaned + * @param items the collection of items to retain; all others will be deleted + * @throws SQLException if a database access error occurs + */ + private static void deleteUnusedItems(Connection conn, int charId, Collection items) throws SQLException { + if (!items.isEmpty()) { + Long[] itemSnArray = items.stream() + .filter(item -> !item.hasNoSN()) // skip items with no SN, aka has not been created in item.Items yet. + .map(Item::getItemSn) + .toArray(Long[]::new); + + if (itemSnArray.length == 0) { + return; // nothing to delete + } + + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM player.inventory WHERE character_id = ? AND item_sn <> ALL (?)")) { + deleteStmt.setInt(1, charId); + Array sqlArray = conn.createArrayOf("bigint", itemSnArray); + deleteStmt.setArray(2, sqlArray); + deleteStmt.executeUpdate(); + } + } else { + // delete all items if the player's collection is empty + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM player.inventory WHERE character_id = ?")) { + deleteStmt.setInt(1, charId); + deleteStmt.executeUpdate(); + } + } + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/type/ItemDao.java b/src/main/java/kinoko/database/postgresql/type/ItemDao.java new file mode 100644 index 00000000..89c8ab4b --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/ItemDao.java @@ -0,0 +1,217 @@ +package kinoko.database.postgresql.type; + + +import kinoko.world.item.EquipData; +import kinoko.world.item.Item; +import kinoko.world.item.PetData; +import kinoko.world.item.RingData; + +import java.sql.*; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + +public class ItemDao { + + // Save an item: insert if not exists, update quantity if exists + public void saveItemToAccount(Connection conn, int accountId, Item item) throws SQLException { + String sql = """ + INSERT INTO account.items (account_id, item_sn, item_id, quantity) + VALUES (?, ?, ?, ?) + ON CONFLICT (account_id, item_sn) + DO UPDATE SET quantity = EXCLUDED.quantity + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + if (item.getItemSn() <= 0){ + item.setItemSn(createNewItem(conn, item)); + } + stmt.setInt(1, accountId); + stmt.setLong(2, item.getItemSn()); + stmt.setInt(3, item.getItemId()); + stmt.setInt(4, item.getQuantity()); + stmt.executeUpdate(); + } + } + + /** + * Inserts a new item into the `item.items` table and returns its generated item_sn. + * + * If the insertion is successful, the auto-generated item_sn is also set in the provided Item object. + * This method is useful when creating a new item that does not yet have an item_sn. + * + * @param conn the active database connection + * @param item the Item object to insert + * @return the auto-generated item_sn for the newly inserted item + * @throws SQLException if the insertion fails or the item_sn cannot be generated + */ + public static long createNewItem(Connection conn, Item item) throws SQLException { + + String sql = """ + INSERT INTO item.items (item_id, quantity, attribute, title, date_expire) + VALUES (?, ?, ?, ?, ?) + RETURNING item_sn + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, item.getItemId()); + stmt.setInt(2, item.getQuantity()); + stmt.setShort(3, item.getAttribute()); + stmt.setString(4, item.getTitle()); + stmt.setTimestamp(5, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + long generatedSn = rs.getLong("item_sn"); + item.setItemSn(generatedSn); // store it in the item object + return generatedSn; + } else { + throw new SQLException("Failed to generate item_sn for new item"); + } + } + } + } + + /** + * Saves a collection of items to the database in batch. + * + * For each item, this method checks if it already has an item_sn: + * - If the item_sn is missing (<=0), a new one is generated and the item is inserted. + * - If the item_sn exists, the item is updated with the latest quantity, attributes, title, and expiration date. + * + * This approach ensures efficient batch inserts for new items while keeping existing items up to date. + * + * @param conn the active database connection + * @param items the collection of items to insert or update + * @throws SQLException if any SQL error occurs during insert or update + */ + public static void saveItemsBatch(Connection conn, Collection items) throws SQLException { + if (items.isEmpty()) return; + + String sqlInsert = """ + INSERT INTO item.items (item_sn, item_id, quantity, attribute, title, date_expire) + VALUES (?, ?, ?, ?, ?, ?) + """; + + String sqlUpdate = """ + UPDATE item.items + SET quantity = ?, attribute = ?, title = ?, date_expire = ? + WHERE item_sn = ? + """; + + try (PreparedStatement stmtInsert = conn.prepareStatement(sqlInsert)) { + for (Item item : items) { + long itemSn = item.getItemSn(); + + // Generate new item_sn if needed + if (item.hasNoSN()) { + try (PreparedStatement seqStmt = conn.prepareStatement( + "SELECT nextval(pg_get_serial_sequence('item.items', 'item_sn'))"); + ResultSet rs = seqStmt.executeQuery()) { + rs.next(); + itemSn = rs.getLong(1); + item.setItemSn(itemSn); + } + + stmtInsert.setLong(1, itemSn); + stmtInsert.setInt(2, item.getItemId()); + stmtInsert.setInt(3, item.getQuantity()); + stmtInsert.setShort(4, item.getAttribute()); + stmtInsert.setString(5, item.getTitle()); + stmtInsert.setTimestamp(6, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); + stmtInsert.addBatch(); + } else { + try (PreparedStatement stmtUpdate = conn.prepareStatement(sqlUpdate)) { + stmtUpdate.setInt(1, item.getQuantity()); + stmtUpdate.setShort(2, item.getAttribute()); + stmtUpdate.setString(3, item.getTitle()); + stmtUpdate.setTimestamp(4, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); + stmtUpdate.setLong(5, itemSn); + stmtUpdate.executeUpdate(); + } + } + } + + // Execute all insert batches + stmtInsert.executeBatch(); + } + } + + public static Item from(ResultSet rs) throws SQLException { + long itemSn = rs.getLong("item_sn"); + int itemId = rs.getInt("item_id"); + short quantity = rs.getShort("quantity"); + short attribute = rs.getShort("attribute"); + String title = rs.getString("title"); + Timestamp dateExpireTs = rs.getTimestamp("date_expire"); + + EquipData equipData = new EquipData( + rs.getShort("inc_str"), + rs.getShort("inc_dex"), + rs.getShort("inc_int"), + rs.getShort("inc_luk"), + rs.getShort("inc_max_hp"), + rs.getShort("inc_max_mp"), + rs.getShort("inc_pad"), + rs.getShort("inc_mad"), + rs.getShort("inc_pdd"), + rs.getShort("inc_mdd"), + rs.getShort("inc_acc"), + rs.getShort("inc_eva"), + rs.getShort("inc_craft"), + rs.getShort("inc_speed"), + rs.getShort("inc_jump"), + rs.getByte("ruc"), + rs.getByte("cuc"), + rs.getInt("iuc"), + rs.getByte("chuc"), + rs.getByte("grade"), + rs.getShort("option_1"), + rs.getShort("option_2"), + rs.getShort("option_3"), + rs.getShort("socket_1"), + rs.getShort("socket_2"), + rs.getByte("level_up_type"), + rs.getByte("level"), + rs.getInt("exp"), + rs.getInt("durability") + ); + + PetData petData = null; + if (rs.getObject("pet_name") != null) { + petData = new PetData( + rs.getString("pet_name"), + rs.getByte("pet_level"), + rs.getByte("fullness"), + rs.getShort("tameness"), + rs.getShort("pet_skill"), + rs.getShort("pet_attribute"), + rs.getInt("remain_life") + ); + } + + // RingData + RingData ringData = null; + if (rs.getObject("pair_character_id") != null) { + ringData = new RingData( + rs.getInt("pair_character_id"), + rs.getString("pair_character_name"), + rs.getLong("pair_item_sn") + ); + } + + Item item = new Item( + itemId, + quantity, + itemSn, + false, // cash flag + attribute, + title, + dateExpireTs != null ? dateExpireTs.toInstant() : null, + equipData, + petData, + ringData + ); + return item; + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/type/LockerDao.java b/src/main/java/kinoko/database/postgresql/type/LockerDao.java new file mode 100644 index 00000000..c6e9bd22 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/LockerDao.java @@ -0,0 +1,92 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.cashshop.CashItemInfo; +import kinoko.world.item.Item; +import kinoko.world.user.Locker; + +import java.sql.*; +import java.util.Collection; + +public class LockerDao { + + /** + * Saves all items in the given Locker for a specific account. + * + * For each item in the locker, a new item SN will be generated if it doesn't exist. + * Items are then inserted into the `account.locker_item` table with their slot and optional commodity_id. + * + * @param conn the database connection to use + * @param accountId the ID of the account whose locker is being saved + * @param locker the Locker object containing items to save + * @throws SQLException if a database error occurs + */ + public static void save(Connection conn, int accountId, Locker locker) throws SQLException { + Collection cashItems = locker.getCashItems(); + Collection allItems = cashItems.stream() + .map(CashItemInfo::getItem) + .toList(); + + deleteUnusedItems(conn, accountId, allItems); + ItemDao.saveItemsBatch(conn, allItems); // insert/update + + String sql = """ + INSERT INTO account.locker_item (account_id, slot, item_sn, commodity_id) + VALUES (?, ?, ?, ?) + ON CONFLICT (account_id, slot) DO UPDATE + SET item_sn = EXCLUDED.item_sn, + commodity_id = EXCLUDED.commodity_id + """; // There can be conflicts if items swapped slots + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int slot = 0; + for (CashItemInfo cash : cashItems) { + stmt.setInt(1, accountId); + stmt.setInt(2, slot++); + stmt.setLong(3, cash.getItem().getItemSn()); + stmt.setInt(4, cash.getCommodityId()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Remove locker items that are no longer in use for a specific account. + * + * If the provided collection of CashItemInfo is non-empty, this method deletes all rows in + * `account.locker_item` for the given account whose `item_sn` is not present in the collection. + * If the collection is empty, all locker items for the account are deleted. + * + * @param conn the database connection to use + * @param accountId the ID of the account whose locker should be cleaned + * @param items the collection of CashItemInfo to retain; all others will be deleted + * @throws SQLException if a database access error occurs + */ + private static void deleteUnusedItems(Connection conn, int accountId, Collection items) throws SQLException { + if (!items.isEmpty()) { + Long[] itemSnArray = items.stream() + .filter(item -> !item.hasNoSN()) // skip items with no SN, aka has not been created in item.Items yet. + .map(Item::getItemSn) + .toArray(Long[]::new); + + if (itemSnArray.length == 0) { + return; // nothing to delete + } + + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM account.locker_item WHERE account_id = ? AND item_sn <> ALL (?)")) { + deleteStmt.setInt(1, accountId); + Array sqlArray = conn.createArrayOf("bigint", itemSnArray); + deleteStmt.setArray(2, sqlArray); + deleteStmt.executeUpdate(); + } + } else { + // delete all items if the locker collection is empty + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM account.locker_item WHERE account_id = ?")) { + deleteStmt.setInt(1, accountId); + deleteStmt.executeUpdate(); + } + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/TrunkDao.java b/src/main/java/kinoko/database/postgresql/type/TrunkDao.java new file mode 100644 index 00000000..3fd5cb86 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/TrunkDao.java @@ -0,0 +1,131 @@ +package kinoko.database.postgresql.type; + + +import kinoko.provider.item.ItemInfo; +import kinoko.server.ServerConfig; +import kinoko.world.item.*; + +import java.sql.*; +import java.util.Collection; + +public class TrunkDao { + + /** + * Saves all items in the given Trunk (Account Storage) for a specific account. + + * For each item in the trunk, a new item SN will be generated if it doesn't exist. + * Items are then inserted into the `account.trunk_item` table with their slot. + * + * @param conn the database connection to use + * @param accountId the ID of the account whose trunk is being saved + * @param trunk the Trunk object containing items to save + * @throws SQLException if a database error occurs + */ + public static void save(Connection conn, int accountId, Trunk trunk) throws SQLException { + String sql = """ + INSERT INTO account.trunk_item (account_id, slot, item_sn) + VALUES (?, ?, ?) + ON CONFLICT (account_id, slot) + DO UPDATE SET item_sn = EXCLUDED.item_sn + """; // There can be conflicts if items swapped slots + Collection items = trunk.getItems(); + deleteUnusedItems(conn, accountId, items); + ItemDao.saveItemsBatch(conn, items); // insert/update + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int slot = 0; + for (Item item : items) { + stmt.setInt(1, accountId); + stmt.setInt(2, slot++); + stmt.setLong(3, item.getItemSn()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Remove trunk items that are no longer in use for a specific account. + * + * If the provided collection of items is non-empty, this method deletes all rows in + * `account.trunk_item` for the given account whose `item_sn` is not present in the collection. + * CAUTION: If the collection is empty, it deletes all trunk items for the account. + * This is because it is expected for the account's entire trunk to be passed in. + * + * @param conn the database connection to use + * @param accountId the ID of the account whose trunk should be cleaned + * @param items the collection of items to retain; all others will be deleted + * @throws SQLException if a database access error occurs + */ + private static void deleteUnusedItems(Connection conn, int accountId, Collection items) throws SQLException { + if (!items.isEmpty()) { + Long[] itemSnArray = items.stream() + .filter(item -> !item.hasNoSN()) // skip items with no SN, aka has not been created in item.Items yet. + .map(Item::getItemSn) + .toArray(Long[]::new); + + if (itemSnArray.length == 0) { + return; // nothing to delete + } + + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM account.trunk_item WHERE account_id = ? AND item_sn <> ALL (?)")) { + deleteStmt.setInt(1, accountId); + Array sqlArray = conn.createArrayOf("bigint", itemSnArray); + deleteStmt.setArray(2, sqlArray); + deleteStmt.executeUpdate(); + } + } else { + // delete all items if the trunk collection is empty + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM account.trunk_item WHERE account_id = ?")) { + deleteStmt.setInt(1, accountId); + deleteStmt.executeUpdate(); + } + } + } + + public static Trunk load(Connection conn, int accountId) throws SQLException { + String accountSql = "SELECT trunk_size, trunk_money FROM account.accounts WHERE id = ?"; + String itemsSql = """ + SELECT ti.slot, fi.* + FROM account.trunk_item ti + JOIN item.full_item fi ON ti.item_sn = fi.item_sn + WHERE ti.account_id = ? + """; + + + int trunkSize = ServerConfig.TRUNK_BASE_SLOTS; + int trunkMoney = 0; + Trunk trunk; + + + try (PreparedStatement stmt = conn.prepareStatement(accountSql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + trunkSize = rs.getInt("trunk_size"); + trunkMoney = rs.getInt("trunk_money"); + } + } + } + + // Initialize trunk with proper size + trunk = new Trunk(trunkSize); + trunk.setMoney(trunkMoney); + + try (PreparedStatement stmt = conn.prepareStatement(itemsSql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int slot = rs.getInt("slot"); + Item item = ItemDao.from(rs); +// trunk.getItems().add(item); + trunk.addItem(item); + } + } + } + + return trunk; + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/type/WishlistDao.java b/src/main/java/kinoko/database/postgresql/type/WishlistDao.java new file mode 100644 index 00000000..2751f7ef --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/WishlistDao.java @@ -0,0 +1,75 @@ +package kinoko.database.postgresql.type; + +import java.sql.*; +import java.util.List; + + +public class WishlistDao { + + /** + * Saves the account's wishlist to the database. + * + * Existing wishlist entries for the account are deleted first. + * Each item_id is then inserted into the `account.wishlist` table + * with its slot. If a slot already exists, the item_id is updated. + * + * @param conn the active database connection + * @param wishlist The wishlist to save. + * @throws SQLException if a database error occurs + */ + public static void save(Connection conn, int accountId, List wishlist) throws SQLException { + deleteUnusedItems(conn, accountId, wishlist); + + String sqlInsert = """ + INSERT INTO account.wishlist (account_id, slot, item_id) + VALUES (?, ?, ?) + ON CONFLICT (account_id, slot) DO UPDATE + SET item_id = EXCLUDED.item_id + """; + try (PreparedStatement insertStmt = conn.prepareStatement(sqlInsert)) { + int slot = 0; + for (Integer itemId : wishlist) { + insertStmt.setInt(1, accountId); + insertStmt.setInt(2, slot++); + insertStmt.setInt(3, itemId); + insertStmt.addBatch(); + } + insertStmt.executeBatch(); + } + } + + /** + * Deletes wishlist entries for a specific account that are no longer in use. + * + * If the provided wishlist is non-empty, only entries whose item_id + * is not in the list are deleted. If the list is empty, all wishlist + * entries for the account are deleted. + * + * @param conn the active database connection + * @param accountId the ID of the account whose wishlist should be cleaned + * @param wishlist the list of item IDs to retain in the wishlist + * @throws SQLException if a database error occurs + */ + public static void deleteUnusedItems(Connection conn, int accountId, List wishlist) throws SQLException { + if (wishlist != null && !wishlist.isEmpty()) { + Integer[] itemArray = wishlist.toArray(new Integer[0]); + + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM account.wishlist WHERE account_id = ? AND item_id <> ALL (?)" + )) { + deleteStmt.setInt(1, accountId); + Array sqlArray = conn.createArrayOf("integer", itemArray); + deleteStmt.setArray(2, sqlArray); + deleteStmt.executeUpdate(); + } + } else { + // Delete all wishlist entries if the list is empty + try (PreparedStatement deleteStmt = conn.prepareStatement( + "DELETE FROM account.wishlist WHERE account_id = ?" + )) { + deleteStmt.setInt(1, accountId); + deleteStmt.executeUpdate(); + } + } + } +} diff --git a/src/main/java/kinoko/world/item/Inventory.java b/src/main/java/kinoko/world/item/Inventory.java index a3aedb78..78ac1677 100644 --- a/src/main/java/kinoko/world/item/Inventory.java +++ b/src/main/java/kinoko/world/item/Inventory.java @@ -1,16 +1,27 @@ package kinoko.world.item; -import java.util.SortedMap; -import java.util.TreeMap; +import java.util.*; +import java.util.stream.Collectors; + public final class Inventory { private final SortedMap items = new TreeMap<>(); private int size; + private InventoryType type; public Inventory(int size) { this.size = size; } + public Inventory(int size, InventoryType type) { + this.size = size; + this.type = type; + } + + public Optional getType(){ + return Optional.ofNullable(type); + } + public SortedMap getItems() { return items; } @@ -50,4 +61,10 @@ public Item removeItem(int position) { public boolean removeItem(int position, Item item) { return items.remove(Math.abs(position), item); } + + public Collection asInventoryEntries(InventoryType type) { + return items.entrySet().stream() + .map(entry -> new InventoryEntry(entry.getKey(), entry.getValue(), type)) + .collect(Collectors.toCollection(ArrayList::new)); + } } diff --git a/src/main/java/kinoko/world/item/InventoryEntry.java b/src/main/java/kinoko/world/item/InventoryEntry.java new file mode 100644 index 00000000..bd4fd326 --- /dev/null +++ b/src/main/java/kinoko/world/item/InventoryEntry.java @@ -0,0 +1,5 @@ +package kinoko.world.item; + +public record InventoryEntry(int slot, Item item, InventoryType type) { + +} diff --git a/src/main/java/kinoko/world/item/InventoryManager.java b/src/main/java/kinoko/world/item/InventoryManager.java index 8d875686..0dbf8a9f 100644 --- a/src/main/java/kinoko/world/item/InventoryManager.java +++ b/src/main/java/kinoko/world/item/InventoryManager.java @@ -21,12 +21,12 @@ public final class InventoryManager { private Instant extSlotExpire; public InventoryManager() { - this.equipped = new Inventory(24); - this.equipInventory = new Inventory(96); - this.consumeInventory = new Inventory(96); - this.installInventory = new Inventory(96); - this.etcInventory = new Inventory(96); - this.cashInventory = new Inventory(96); + this.equipped = new Inventory(24, InventoryType.EQUIPPED); + this.equipInventory = new Inventory(96, InventoryType.EQUIP); + this.consumeInventory = new Inventory(96, InventoryType.CONSUME); + this.installInventory = new Inventory(96, InventoryType.INSTALL); + this.etcInventory = new Inventory(96, InventoryType.ETC); + this.cashInventory = new Inventory(96, InventoryType.CASH); this.money = 0; this.extSlotExpire = null; } diff --git a/src/main/java/kinoko/world/item/Item.java b/src/main/java/kinoko/world/item/Item.java index a9ab16bb..40dc045c 100644 --- a/src/main/java/kinoko/world/item/Item.java +++ b/src/main/java/kinoko/world/item/Item.java @@ -213,4 +213,17 @@ public void setPossibleTrading(boolean set) { removeAttribute(ItemAttribute.getPossibleTradingAttribute(getItemType())); } } + + /** + * Checks whether this Item has a valid item serial number (SN). + + * Useful in relational databases where item SNs are automatically generated. + * Returns true if the item has no SN and therefore needs to be inserted + * into the database to obtain one. + * + * @return true if the item SN is zero or negative, false otherwise + */ + public boolean hasNoSN() { + return getItemSn() <= 0; + } } From 8398c28d46b296585ca080a5f2bba76b8df8d395 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 20:53:05 -0400 Subject: [PATCH 35/83] Fixed Gifting + Characters not spawning with items --- .../postgresql/PostgresCharacterAccessor.java | 144 ++---------------- .../postgresql/PostgresGiftAccessor.java | 51 +++++-- .../kinoko/database/postgresql/setup/init.sql | 14 +- .../postgresql/type/InventoryDao.java | 40 ++++- .../database/postgresql/type/ItemDao.java | 23 --- .../kinoko/handler/stage/LoginHandler.java | 12 +- .../java/kinoko/world/user/AvatarData.java | 3 +- 7 files changed, 103 insertions(+), 184 deletions(-) diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java index 49486c4d..a23dc0e2 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -80,14 +80,22 @@ private CharacterData loadCharacterData(ResultSet rs) throws SQLException, IOExc ); cd.setCharacterStat(cs); - InventoryManager im = loadInventory(characterID); - cd.setInventoryManager(im); - im.setMoney(rs.getInt("money")); + try (Connection conn = dataSource.getConnection()) { + InventoryManager im = InventoryDao.loadInventoryManager(conn, characterID); + + cd.setInventoryManager(im); + im.setMoney(rs.getInt("money")); + + Timestamp extSlotExpireTs = rs.getTimestamp("ext_slot_expire"); + im.setExtSlotExpire(extSlotExpireTs != null ? extSlotExpireTs.toInstant() : null); + cd.setInventoryManager(im); + + cd.setCoupleRecord(CoupleRecord.from( + im.getEquipped(), im.getEquipInventory() + )); + } - Timestamp extSlotExpireTs = rs.getTimestamp("ext_slot_expire"); - im.setExtSlotExpire(extSlotExpireTs != null ? extSlotExpireTs.toInstant() : null); - cd.setInventoryManager(im); SkillManager sm = loadSkillCooltimesAndRecords(characterID); @@ -106,11 +114,6 @@ private CharacterData loadCharacterData(ResultSet rs) throws SQLException, IOExc MiniGameRecord mgr = loadMiniGameRecord(characterID); cd.setMiniGameRecord(mgr); - - cd.setCoupleRecord(CoupleRecord.from( - im.getEquipped(), im.getEquipInventory() - )); - MapTransferInfo mto = loadMapTransferInfo(characterID); cd.setMapTransferInfo(mto); @@ -392,123 +395,6 @@ public boolean checkCharacterNameAvailable(String name) { return false; } - private InventoryManager loadInventory(int characterId) throws SQLException { - InventoryManager im = new InventoryManager(); - - String sql = """ - SELECT inv.inventory_type, inv.slot, fi.* - FROM player.inventory inv - JOIN item.full_item fi ON inv.item_sn = fi.item_sn - WHERE inv.character_id = ? - ORDER BY inv.inventory_type, inv.slot - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - ResultSet rs = stmt.executeQuery(); - - while (rs.next()) { - long itemSn = rs.getLong("item_sn"); - int slot = rs.getInt("slot"); - int itemId = rs.getInt("item_id"); - short quantity = rs.getShort("quantity"); - short attribute = rs.getShort("attribute"); - String title = rs.getString("title"); - Timestamp dateExpireTs = rs.getTimestamp("date_expire"); - - // EquipData - EquipData equipData; // declare once - if (rs.getObject("inc_str") != null) { - equipData = new EquipData( - rs.getShort("inc_str"), - rs.getShort("inc_dex"), - rs.getShort("inc_int"), - rs.getShort("inc_luk"), - rs.getShort("inc_max_hp"), - rs.getShort("inc_max_mp"), - rs.getShort("inc_pad"), - rs.getShort("inc_mad"), - rs.getShort("inc_pdd"), - rs.getShort("inc_mdd"), - rs.getShort("inc_acc"), - rs.getShort("inc_eva"), - rs.getShort("inc_craft"), - rs.getShort("inc_speed"), - rs.getShort("inc_jump"), - rs.getByte("ruc"), - rs.getByte("cuc"), - rs.getInt("iuc"), - rs.getByte("chuc"), - rs.getByte("grade"), - rs.getShort("option_1"), - rs.getShort("option_2"), - rs.getShort("option_3"), - rs.getShort("socket_1"), - rs.getShort("socket_2"), - rs.getByte("level_up_type"), - rs.getByte("level"), - rs.getInt("exp"), - rs.getInt("durability") - ); - } - else{ - equipData = new EquipData(); - } - - // PetData - PetData petData = null; - if (rs.getObject("pet_name") != null) { - petData = new PetData( - rs.getString("pet_name"), - rs.getByte("pet_level"), - rs.getByte("fullness"), - rs.getShort("tameness"), - rs.getShort("pet_skill"), - rs.getShort("pet_attribute"), - rs.getInt("remain_life") - ); - } - - // RingData - RingData ringData = null; - if (rs.getObject("pair_character_id") != null) { - ringData = new RingData( - rs.getInt("pair_character_id"), - rs.getString("pair_character_name"), - rs.getLong("pair_item_sn") - ); - } - - Item item = new Item( - itemId, - quantity, - itemSn, - false, // cash flag - attribute, - title, - dateExpireTs != null ? dateExpireTs.toInstant() : null, - equipData, - petData, - ringData - ); - - String invType = rs.getString("inventory_type"); - switch (invType.toUpperCase()) { - case "EQUIPPED" -> im.getEquipped().addItem(slot, item); - case "EQUIP" -> im.getEquipInventory().addItem(slot, item); - case "CONSUME" -> im.getConsumeInventory().addItem(slot, item); - case "INSTALL" -> im.getInstallInventory().addItem(slot, item); - case "ETC" -> im.getEtcInventory().addItem(slot, item); - case "CASH" -> im.getCashInventory().addItem(slot, item); - default -> throw new IllegalArgumentException("Unknown inventory type: " + invType); - } - } - } - - return im; - } - @Override public Optional getCharacterById(int characterId) { @@ -663,7 +549,7 @@ public List getAvatarDataByAccountId(int accountId) { } private Inventory loadEquippedInventory(int characterId) throws SQLException { - Inventory equipped = new Inventory(24); // default equipped size + Inventory equipped = new Inventory(24, InventoryType.EQUIPPED); // default equipped size String sql = """ SELECT f.*, i.slot diff --git a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java index 34b283e1..50c938be 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java @@ -2,7 +2,9 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.GiftAccessor; +import kinoko.database.postgresql.type.ItemDao; import kinoko.server.cashshop.Gift; +import kinoko.world.item.Item; import java.sql.*; import java.util.ArrayList; @@ -18,7 +20,7 @@ public PostgresGiftAccessor(HikariDataSource dataSource) { private Gift loadGift(ResultSet rs) throws SQLException { return new Gift( - rs.getLong("gift_sn"), + rs.getLong("item_sn"), rs.getInt("item_id"), rs.getInt("commodity_id"), rs.getInt("sender_id"), @@ -31,7 +33,13 @@ private Gift loadGift(ResultSet rs) throws SQLException { @Override public List getGiftsByCharacterId(int characterId) { List gifts = new ArrayList<>(); - String sql = "SELECT * FROM gifts WHERE receiver_id = ?"; + String sql = """ + SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, + g.sender_name, g.sender_message, fi.pair_item_sn + FROM gift.gifts g + JOIN item.full_item fi ON fi.item_sn = g.item_sn + WHERE g.receiver_id = ? + """; try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, characterId); @@ -47,7 +55,13 @@ public List getGiftsByCharacterId(int characterId) { @Override public Optional getGiftByItemSn(long itemSn) { - String sql = "SELECT * FROM gifts WHERE gift_sn = ?"; + String sql = """ + SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, + g.sender_name, g.sender_message, fi.pair_item_sn FROM gift.gifts g + JOIN item.full_item fi ON fi.item_sn = g.item_sn + WHERE g.item_sn = ? + """; + try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setLong(1, itemSn); @@ -61,22 +75,31 @@ public Optional getGiftByItemSn(long itemSn) { return Optional.empty(); } + @Override public boolean newGift(Gift gift, int receiverId) { - String sql = "INSERT INTO gifts (gift_sn, receiver_id, item_id, commodity_id, sender_id, sender_name, sender_message, pair_item_sn) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?) " + - "ON CONFLICT (gift_sn) DO NOTHING"; + String sql = """ + INSERT INTO gift.gifts (item_sn, receiver_id, commodity_id, sender_id, sender_name, sender_message) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (item_sn) DO NOTHING + """; + try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setLong(1, gift.getGiftSn()); + + // We need a new item created. + Item basicItem = new Item(gift.getItemId(), (short) 1); + ItemDao.createNewItem(conn, basicItem); + + stmt.setLong(1, basicItem.getItemSn()); // item_sn is now the primary key stmt.setInt(2, receiverId); - stmt.setInt(3, gift.getItemId()); - stmt.setInt(4, gift.getCommodityId()); - stmt.setInt(5, gift.getSenderId()); - stmt.setString(6, gift.getSenderName()); - stmt.setString(7, gift.getSenderMessage()); - stmt.setLong(8, gift.getPairItemSn()); + stmt.setInt(3, gift.getCommodityId()); + stmt.setInt(4, gift.getSenderId()); + stmt.setString(5, gift.getSenderName()); + stmt.setString(6, gift.getSenderMessage()); + return stmt.executeUpdate() > 0; + } catch (SQLException e) { e.printStackTrace(); } @@ -85,7 +108,7 @@ public boolean newGift(Gift gift, int receiverId) { @Override public boolean deleteGift(Gift gift) { - String sql = "DELETE FROM gifts WHERE gift_sn = ?"; + String sql = "DELETE FROM gift.gifts WHERE item_sn = ?"; try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setLong(1, gift.getGiftSn()); diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index 39937392..77b5ec57 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -353,25 +353,25 @@ CREATE INDEX IF NOT EXISTS idx_friend_friend_id ---------------GIFT TABLES---------------- ------------------------------------------ -CREATE TABLE IF NOT EXISTS gift.gift ( - id BIGSERIAL PRIMARY KEY, -- auto-increment unique ID for the gift - receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, +CREATE TABLE IF NOT EXISTS gift.gifts ( item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, commodity_id INT, sender_id INT, sender_name TEXT, - sender_message TEXT + sender_message TEXT, + PRIMARY KEY (item_sn) ); CREATE INDEX IF NOT EXISTS idx_gift_item_sn - ON gift.gift(item_sn); + ON gift.gifts(item_sn); CREATE INDEX IF NOT EXISTS idx_gift_receiver - ON gift.gift(receiver_id); + ON gift.gifts(receiver_id); CREATE INDEX IF NOT EXISTS idx_gift_receiver_item - ON gift.gift(receiver_id, item_sn); + ON gift.gifts(receiver_id, item_sn); ------------------------------------------ diff --git a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java index bd381f26..68658389 100644 --- a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java +++ b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java @@ -1,10 +1,7 @@ package kinoko.database.postgresql.type; -import kinoko.world.item.InventoryEntry; -import kinoko.world.item.InventoryManager; -import kinoko.world.item.InventoryType; -import kinoko.world.item.Item; +import kinoko.world.item.*; import kinoko.world.user.CharacterData; import org.postgresql.util.PGobject; @@ -117,6 +114,7 @@ private static void deleteUnusedItems(Connection conn, int charId, Collection ALL (?)")) { + System.out.println("DELETING INVENTORY"); deleteStmt.setInt(1, charId); Array sqlArray = conn.createArrayOf("bigint", itemSnArray); deleteStmt.setArray(2, sqlArray); @@ -131,4 +129,38 @@ private static void deleteUnusedItems(Connection conn, int charId, Collection im.getEquipped().addItem(slot, item); + case "EQUIP" -> im.getEquipInventory().addItem(slot, item); + case "CONSUME" -> im.getConsumeInventory().addItem(slot, item); + case "INSTALL" -> im.getInstallInventory().addItem(slot, item); + case "ETC" -> im.getEtcInventory().addItem(slot, item); + case "CASH" -> im.getCashInventory().addItem(slot, item); + default -> throw new IllegalArgumentException("Unknown inventory type: " + invType); + } + } + } + + return im; + } } \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/type/ItemDao.java b/src/main/java/kinoko/database/postgresql/type/ItemDao.java index 89c8ab4b..572e8cc6 100644 --- a/src/main/java/kinoko/database/postgresql/type/ItemDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ItemDao.java @@ -7,33 +7,10 @@ import kinoko.world.item.RingData; import java.sql.*; -import java.util.ArrayList; import java.util.Collection; -import java.util.List; public class ItemDao { - // Save an item: insert if not exists, update quantity if exists - public void saveItemToAccount(Connection conn, int accountId, Item item) throws SQLException { - String sql = """ - INSERT INTO account.items (account_id, item_sn, item_id, quantity) - VALUES (?, ?, ?, ?) - ON CONFLICT (account_id, item_sn) - DO UPDATE SET quantity = EXCLUDED.quantity - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - if (item.getItemSn() <= 0){ - item.setItemSn(createNewItem(conn, item)); - } - stmt.setInt(1, accountId); - stmt.setLong(2, item.getItemSn()); - stmt.setInt(3, item.getItemId()); - stmt.setInt(4, item.getQuantity()); - stmt.executeUpdate(); - } - } - /** * Inserts a new item into the `item.items` table and returns its generated item_sn. * diff --git a/src/main/java/kinoko/handler/stage/LoginHandler.java b/src/main/java/kinoko/handler/stage/LoginHandler.java index 9f102ff2..327c4187 100644 --- a/src/main/java/kinoko/handler/stage/LoginHandler.java +++ b/src/main/java/kinoko/handler/stage/LoginHandler.java @@ -263,12 +263,12 @@ public static void handleCreateNewCharacter(Client c, InPacket inPacket) { // Initialize inventory and add starting equips final InventoryManager im = new InventoryManager(); - im.setEquipped(new Inventory(Short.MAX_VALUE)); - im.setEquipInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS)); - im.setConsumeInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS)); - im.setInstallInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS)); - im.setEtcInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS)); - im.setCashInventory(new Inventory(ServerConfig.INVENTORY_CASH_SLOTS)); + im.setEquipped(new Inventory(Short.MAX_VALUE, InventoryType.EQUIPPED)); + im.setEquipInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS, InventoryType.EQUIP)); + im.setConsumeInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS, InventoryType.CONSUME)); + im.setInstallInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS, InventoryType.INSTALL)); + im.setEtcInventory(new Inventory(ServerConfig.INVENTORY_BASE_SLOTS, InventoryType.ETC)); + im.setCashInventory(new Inventory(ServerConfig.INVENTORY_CASH_SLOTS, InventoryType.CASH)); im.setMoney(0); im.setExtSlotExpire(Instant.now()); characterData.setInventoryManager(im); diff --git a/src/main/java/kinoko/world/user/AvatarData.java b/src/main/java/kinoko/world/user/AvatarData.java index 9d8b8c70..583fad9e 100644 --- a/src/main/java/kinoko/world/user/AvatarData.java +++ b/src/main/java/kinoko/world/user/AvatarData.java @@ -3,10 +3,11 @@ import kinoko.server.packet.OutPacket; import kinoko.util.Encodable; import kinoko.world.item.Inventory; +import kinoko.world.item.InventoryType; import kinoko.world.user.stat.CharacterStat; public final class AvatarData implements Encodable { - private static final Inventory EMPTY_INVENTORY = new Inventory(0); + private static final Inventory EMPTY_INVENTORY = new Inventory(0, InventoryType.EQUIPPED); private final CharacterStat characterStat; private final AvatarLook avatarLook; From 9318bede67a4f5d4fceaf34aa0b3eba383a9e5a8 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 21:22:11 -0400 Subject: [PATCH 36/83] Added invalid item reference cleanup on bootup --- .../postgresql/PostgresConnector.java | 2 + .../postgresql/PostgresIdAccessor.java | 9 +++ .../database/postgresql/type/ItemDao.java | 65 ++++++++++++++----- 3 files changed, 61 insertions(+), 15 deletions(-) diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java index fe656e54..6b3f16da 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresConnector.java +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -57,6 +57,8 @@ public void initialize() { giftAccessor = new PostgresGiftAccessor(dataSource); memoAccessor = new PostgresMemoAccessor(dataSource); + + } catch (Exception e) { e.printStackTrace(); throw new RuntimeException("Failed to initialize PostgresConnector", e); diff --git a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java index d8fc3b99..8200cdec 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java @@ -2,13 +2,22 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.IdAccessor; +import kinoko.database.postgresql.type.ItemDao; +import java.sql.Connection; +import java.sql.SQLException; import java.util.Optional; public final class PostgresIdAccessor extends PostgresAccessor implements IdAccessor { public PostgresIdAccessor(HikariDataSource dataSource) { super(dataSource); + + try (Connection conn = getConnection()){ + ItemDao.cleanupInvalidItems(conn); + } catch (SQLException e) { + e.printStackTrace(); + } } private Optional getNextId(String type) { diff --git a/src/main/java/kinoko/database/postgresql/type/ItemDao.java b/src/main/java/kinoko/database/postgresql/type/ItemDao.java index 572e8cc6..ef669ddb 100644 --- a/src/main/java/kinoko/database/postgresql/type/ItemDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ItemDao.java @@ -1,19 +1,21 @@ package kinoko.database.postgresql.type; - import kinoko.world.item.EquipData; import kinoko.world.item.Item; import kinoko.world.item.PetData; import kinoko.world.item.RingData; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; import java.sql.*; import java.util.Collection; public class ItemDao { + private static final Logger log = LogManager.getLogger(ItemDao.class); /** * Inserts a new item into the `item.items` table and returns its generated item_sn. - * + *

* If the insertion is successful, the auto-generated item_sn is also set in the provided Item object. * This method is useful when creating a new item that does not yet have an item_sn. * @@ -25,10 +27,10 @@ public class ItemDao { public static long createNewItem(Connection conn, Item item) throws SQLException { String sql = """ - INSERT INTO item.items (item_id, quantity, attribute, title, date_expire) - VALUES (?, ?, ?, ?, ?) - RETURNING item_sn - """; + INSERT INTO item.items (item_id, quantity, attribute, title, date_expire) + VALUES (?, ?, ?, ?, ?) + RETURNING item_sn + """; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, item.getItemId()); @@ -51,11 +53,11 @@ public static long createNewItem(Connection conn, Item item) throws SQLException /** * Saves a collection of items to the database in batch. - * + *

* For each item, this method checks if it already has an item_sn: * - If the item_sn is missing (<=0), a new one is generated and the item is inserted. * - If the item_sn exists, the item is updated with the latest quantity, attributes, title, and expiration date. - * + *

* This approach ensures efficient batch inserts for new items while keeping existing items up to date. * * @param conn the active database connection @@ -66,15 +68,15 @@ public static void saveItemsBatch(Connection conn, Collection items) throw if (items.isEmpty()) return; String sqlInsert = """ - INSERT INTO item.items (item_sn, item_id, quantity, attribute, title, date_expire) - VALUES (?, ?, ?, ?, ?, ?) - """; + INSERT INTO item.items (item_sn, item_id, quantity, attribute, title, date_expire) + VALUES (?, ?, ?, ?, ?, ?) + """; String sqlUpdate = """ - UPDATE item.items - SET quantity = ?, attribute = ?, title = ?, date_expire = ? - WHERE item_sn = ? - """; + UPDATE item.items + SET quantity = ?, attribute = ?, title = ?, date_expire = ? + WHERE item_sn = ? + """; try (PreparedStatement stmtInsert = conn.prepareStatement(sqlInsert)) { for (Item item : items) { @@ -191,4 +193,37 @@ public static Item from(ResultSet rs) throws SQLException { ); return item; } + + /** + * Cleans up invalid items from the database that no longer have valid references. + *

+ * In the PostgreSQL implementation, all items are stored in {@code item.Items}, regardless of + * whether they are currently held by a player (inventory, trunk, locker, wishlist, gifted) + * or not. This can lead to orphaned item records when items are dropped, since dropped + * items are not tracked by the database. + *

+ * This method queries and removes items that are not referenced anywhere else, ensuring + * synchronization between the in-game state and the persistent database state. This function + * typically called during server initialization, when no dropped items exist. + * BE CAREFUL to run this in any other situation. + * + * @param conn the active SQL connection used to perform cleanup operations + */ + public static void cleanupInvalidItems(Connection conn) throws SQLException { + String sql = """ + DELETE FROM item.items i + WHERE NOT EXISTS (SELECT 1 FROM item.equip_data e WHERE e.item_sn = i.item_sn) + AND NOT EXISTS (SELECT 1 FROM item.pet_data p WHERE p.item_sn = i.item_sn) + AND NOT EXISTS (SELECT 1 FROM item.ring_data r WHERE r.item_sn = i.item_sn) + AND NOT EXISTS (SELECT 1 FROM player.inventory inv WHERE inv.item_sn = i.item_sn) + AND NOT EXISTS (SELECT 1 FROM account.trunk_item t WHERE t.item_sn = i.item_sn) + AND NOT EXISTS (SELECT 1 FROM account.locker_item l WHERE l.item_sn = i.item_sn) + AND NOT EXISTS (SELECT 1 FROM gift.gifts g WHERE g.item_sn = i.item_sn); + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int rowsDeleted = stmt.executeUpdate(); + log.info("Cleaned up {} items with no references.", rowsDeleted); + } + } } \ No newline at end of file From 490e8ccb5dead0a0566b80e45b3e990783486baf Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 9 Oct 2025 23:37:38 -0400 Subject: [PATCH 37/83] Added hikari logging + better log4j logging + fixed connection leaks --- .../postgresql/PostgresAccountAccessor.java | 8 +- .../postgresql/PostgresCharacterAccessor.java | 4 +- .../postgresql/PostgresConnector.java | 7 +- .../postgresql/PostgresGuildAccessor.java | 3 +- .../postgresql/type/EquipDataDao.java | 202 ++++++++++++++++++ .../postgresql/type/InventoryDao.java | 1 - .../database/postgresql/type/ItemDao.java | 3 + src/main/resources/log4j2.xml | 9 +- .../world/item/InventoryManagerTest.java | 10 +- 9 files changed, 231 insertions(+), 16 deletions(-) create mode 100644 src/main/java/kinoko/database/postgresql/type/EquipDataDao.java diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java index 495819ee..4a77efe6 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java @@ -160,13 +160,14 @@ public boolean savePassword(Account account, String oldPassword, String newPassw String column = secondary ? "secondary_password" : "password"; String sqlSelect = "SELECT " + column + " FROM account.accounts WHERE id = ?"; String sqlUpdate = "UPDATE account.accounts SET " + column + " = ? WHERE id = ?"; - try (PreparedStatement selectStmt = getConnection().prepareStatement(sqlSelect)) { + try (Connection conn = getConnection(); + PreparedStatement selectStmt = conn.prepareStatement(sqlSelect)) { selectStmt.setInt(1, account.getId()); try (ResultSet rs = selectStmt.executeQuery()) { if (rs.next()) { String hashedOld = rs.getString(column); if (hashedOld == null || checkHashedPassword(oldPassword, hashedOld)) { - try (PreparedStatement updateStmt = getConnection().prepareStatement(sqlUpdate)) { + try (PreparedStatement updateStmt = conn.prepareStatement(sqlUpdate)) { updateStmt.setString(1, hashPassword(newPassword)); updateStmt.setInt(2, account.getId()); return updateStmt.executeUpdate() > 0; @@ -210,6 +211,7 @@ public synchronized boolean newAccount(String username, String password) { } } + // WARNING CODED AS A LEAK RN // Initialize a base trunk, locker, wishlist // for (int i = 0; i < ServerConfig.TRUNK_BASE_SLOTS; i++) { // try (PreparedStatement tStmt = getConnection().prepareStatement( @@ -220,7 +222,7 @@ public synchronized boolean newAccount(String username, String password) { // tStmt.executeUpdate(); // } // } - +// WARNING CODED AS A LEAK RN // for (int i = 0; i < 10; i++) { // try (PreparedStatement wStmt = getConnection().prepareStatement( // "INSERT INTO account.wishlist (account_id, slot, item_sn) VALUES (?, ?, ?)")) { diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java index a23dc0e2..3ed7d4f9 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -169,9 +169,9 @@ private WildHunterInfo loadWildHunterInfo(int characterId) throws SQLException { private MapTransferInfo loadMapTransferInfo(int characterId) throws SQLException { MapTransferInfo mti = new MapTransferInfo(); - // Query the new table for this character String sql = "SELECT map_id, old_map_id FROM player.map_transfer WHERE character_id = ?"; - try (PreparedStatement stmt = dataSource.getConnection().prepareStatement(sql)) { + try (Connection con = dataSource.getConnection(); + PreparedStatement stmt = con.prepareStatement(sql)) { stmt.setInt(1, characterId); try (ResultSet mapRs = stmt.executeQuery()) { if (mapRs.next()) { diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java index 6b3f16da..6b9c736d 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresConnector.java +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -1,6 +1,8 @@ package kinoko.database.postgresql; import kinoko.database.*; + +import java.sql.Connection; import java.util.TimeZone; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; @@ -31,15 +33,14 @@ public void initialize() { config.setJdbcUrl(DATABASE_URL); config.setUsername(ServerConstants.DATABASE_USER); config.setPassword(ServerConstants.DATABASE_PASSWORD); - config.setMaximumPoolSize(50); // Adjust as needed + config.setMaximumPoolSize(10); // Adjust as needed config.setConnectionTimeout(5000); // 5s config.setIdleTimeout(60000); // 60s config.setMaxLifetime(1800000); // 30min - config.setLeakDetectionThreshold(5000); + config.setLeakDetectionThreshold(5000L); dataSource = new HikariDataSource(config); - // Run init.sql if needed // Path initPath = Path.of("src/main/java/kinoko/database/postgresql/setup/init.sql"); // if (Files.exists(initPath)) { // String sql = Files.readString(initPath); diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java index f9abedf7..94454b16 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -345,7 +345,8 @@ public boolean deleteGuild(int guildId) { public List getGuildRankings() { List rankings = new ArrayList<>(); String sql = "SELECT name, points, mark, mark_color, mark_bg, mark_bg_color FROM guild.guilds ORDER BY points DESC"; - try (Statement stmt = getConnection().createStatement(); + try (Connection conn = getConnection(); + Statement stmt = conn.createStatement(); ResultSet rs = stmt.executeQuery(sql)) { while (rs.next()) { rankings.add(new GuildRanking( diff --git a/src/main/java/kinoko/database/postgresql/type/EquipDataDao.java b/src/main/java/kinoko/database/postgresql/type/EquipDataDao.java new file mode 100644 index 00000000..a83be181 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/EquipDataDao.java @@ -0,0 +1,202 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.item.EquipData; +import kinoko.world.item.Item; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; +import java.util.Collection; + +public class EquipDataDao { + /** + * Inserts or updates (upserts) an EquipData record into the item.equip_data table. + *

+ * If the given item_sn already exists, the existing record will be updated + * with the latest equip data values. Otherwise, a new record will be inserted. + * + * @param conn the active SQL connection + * @param itemSn the unique item_sn associated with the equip + * @param equipData the EquipData instance containing the stats to insert or update + * @throws SQLException if any SQL error occurs + */ + public static void upsertEquipData(Connection conn, long itemSn, EquipData equipData) throws SQLException { + if (equipData == null) return; + + String sql = """ + INSERT INTO item.equip_data ( + item_sn, inc_str, inc_dex, inc_int, inc_luk, inc_max_hp, inc_max_mp, + inc_pad, inc_mad, inc_pdd, inc_mdd, inc_acc, inc_eva, inc_craft, + inc_speed, inc_jump, ruc, cuc, iuc, chuc, grade, + option_1, option_2, option_3, socket_1, socket_2, + level_up_type, level, exp, durability + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (item_sn) DO UPDATE SET + inc_str = EXCLUDED.inc_str, + inc_dex = EXCLUDED.inc_dex, + inc_int = EXCLUDED.inc_int, + inc_luk = EXCLUDED.inc_luk, + inc_max_hp = EXCLUDED.inc_max_hp, + inc_max_mp = EXCLUDED.inc_max_mp, + inc_pad = EXCLUDED.inc_pad, + inc_mad = EXCLUDED.inc_mad, + inc_pdd = EXCLUDED.inc_pdd, + inc_mdd = EXCLUDED.inc_mdd, + inc_acc = EXCLUDED.inc_acc, + inc_eva = EXCLUDED.inc_eva, + inc_craft = EXCLUDED.inc_craft, + inc_speed = EXCLUDED.inc_speed, + inc_jump = EXCLUDED.inc_jump, + ruc = EXCLUDED.ruc, + cuc = EXCLUDED.cuc, + iuc = EXCLUDED.iuc, + chuc = EXCLUDED.chuc, + grade = EXCLUDED.grade, + option_1 = EXCLUDED.option_1, + option_2 = EXCLUDED.option_2, + option_3 = EXCLUDED.option_3, + socket_1 = EXCLUDED.socket_1, + socket_2 = EXCLUDED.socket_2, + level_up_type = EXCLUDED.level_up_type, + level = EXCLUDED.level, + exp = EXCLUDED.exp, + durability = EXCLUDED.durability + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int idx = 1; + stmt.setLong(idx++, itemSn); + stmt.setShort(idx++, equipData.getIncStr()); + stmt.setShort(idx++, equipData.getIncDex()); + stmt.setShort(idx++, equipData.getIncInt()); + stmt.setShort(idx++, equipData.getIncLuk()); + stmt.setShort(idx++, equipData.getIncMaxHp()); + stmt.setShort(idx++, equipData.getIncMaxMp()); + stmt.setShort(idx++, equipData.getIncPad()); + stmt.setShort(idx++, equipData.getIncMad()); + stmt.setShort(idx++, equipData.getIncPdd()); + stmt.setShort(idx++, equipData.getIncMdd()); + stmt.setShort(idx++, equipData.getIncAcc()); + stmt.setShort(idx++, equipData.getIncEva()); + stmt.setShort(idx++, equipData.getIncCraft()); + stmt.setShort(idx++, equipData.getIncSpeed()); + stmt.setShort(idx++, equipData.getIncJump()); + stmt.setByte(idx++, equipData.getRuc()); + stmt.setByte(idx++, equipData.getCuc()); + stmt.setInt(idx++, equipData.getIuc()); + stmt.setByte(idx++, equipData.getChuc()); + stmt.setByte(idx++, equipData.getGrade()); + stmt.setShort(idx++, equipData.getOption1()); + stmt.setShort(idx++, equipData.getOption2()); + stmt.setShort(idx++, equipData.getOption3()); + stmt.setShort(idx++, equipData.getSocket1()); + stmt.setShort(idx++, equipData.getSocket2()); + stmt.setByte(idx++, equipData.getLevelUpType()); + stmt.setByte(idx++, equipData.getLevel()); + stmt.setInt(idx++, equipData.getExp()); + stmt.setInt(idx, equipData.getDurability()); + + stmt.executeUpdate(); + } + } + + /** + * Batch upserts equip data for multiple items. + *

+ * For each item, if the item has EquipData, it will be inserted or updated. + * Existing equip_data rows are updated; missing ones are inserted. + * Uses a single PreparedStatement batch for efficiency. + * + * @param conn active SQL connection + * @param items collection of items that may contain equip data + * @throws SQLException if any SQL error occurs + */ + public static void saveEquipDataBatch(Connection conn, Collection items) throws SQLException { + if (items == null || items.isEmpty()) return; + + String sql = """ + INSERT INTO item.equip_data ( + item_sn, inc_str, inc_dex, inc_int, inc_luk, inc_max_hp, inc_max_mp, + inc_pad, inc_mad, inc_pdd, inc_mdd, inc_acc, inc_eva, inc_craft, + inc_speed, inc_jump, ruc, cuc, iuc, chuc, grade, + option_1, option_2, option_3, socket_1, socket_2, + level_up_type, level, exp, durability + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (item_sn) DO UPDATE SET + inc_str = EXCLUDED.inc_str, + inc_dex = EXCLUDED.inc_dex, + inc_int = EXCLUDED.inc_int, + inc_luk = EXCLUDED.inc_luk, + inc_max_hp = EXCLUDED.inc_max_hp, + inc_max_mp = EXCLUDED.inc_max_mp, + inc_pad = EXCLUDED.inc_pad, + inc_mad = EXCLUDED.inc_mad, + inc_pdd = EXCLUDED.inc_pdd, + inc_mdd = EXCLUDED.inc_mdd, + inc_acc = EXCLUDED.inc_acc, + inc_eva = EXCLUDED.inc_eva, + inc_craft = EXCLUDED.inc_craft, + inc_speed = EXCLUDED.inc_speed, + inc_jump = EXCLUDED.inc_jump, + ruc = EXCLUDED.ruc, + cuc = EXCLUDED.cuc, + iuc = EXCLUDED.iuc, + chuc = EXCLUDED.chuc, + grade = EXCLUDED.grade, + option_1 = EXCLUDED.option_1, + option_2 = EXCLUDED.option_2, + option_3 = EXCLUDED.option_3, + socket_1 = EXCLUDED.socket_1, + socket_2 = EXCLUDED.socket_2, + level_up_type = EXCLUDED.level_up_type, + level = EXCLUDED.level, + exp = EXCLUDED.exp, + durability = EXCLUDED.durability + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (Item item : items) { + EquipData e = item.getEquipData(); + if (e == null) continue; + + int idx = 1; + stmt.setLong(idx++, item.getItemSn()); + stmt.setShort(idx++, e.getIncStr()); + stmt.setShort(idx++, e.getIncDex()); + stmt.setShort(idx++, e.getIncInt()); + stmt.setShort(idx++, e.getIncLuk()); + stmt.setShort(idx++, e.getIncMaxHp()); + stmt.setShort(idx++, e.getIncMaxMp()); + stmt.setShort(idx++, e.getIncPad()); + stmt.setShort(idx++, e.getIncMad()); + stmt.setShort(idx++, e.getIncPdd()); + stmt.setShort(idx++, e.getIncMdd()); + stmt.setShort(idx++, e.getIncAcc()); + stmt.setShort(idx++, e.getIncEva()); + stmt.setShort(idx++, e.getIncCraft()); + stmt.setShort(idx++, e.getIncSpeed()); + stmt.setShort(idx++, e.getIncJump()); + stmt.setByte(idx++, e.getRuc()); + stmt.setByte(idx++, e.getCuc()); + stmt.setInt(idx++, e.getIuc()); + stmt.setByte(idx++, e.getChuc()); + stmt.setByte(idx++, e.getGrade()); + stmt.setShort(idx++, e.getOption1()); + stmt.setShort(idx++, e.getOption2()); + stmt.setShort(idx++, e.getOption3()); + stmt.setShort(idx++, e.getSocket1()); + stmt.setShort(idx++, e.getSocket2()); + stmt.setByte(idx++, e.getLevelUpType()); + stmt.setByte(idx++, e.getLevel()); + stmt.setInt(idx++, e.getExp()); + stmt.setInt(idx, e.getDurability()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java index 68658389..54fa6059 100644 --- a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java +++ b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java @@ -114,7 +114,6 @@ private static void deleteUnusedItems(Connection conn, int charId, Collection ALL (?)")) { - System.out.println("DELETING INVENTORY"); deleteStmt.setInt(1, charId); Array sqlArray = conn.createArrayOf("bigint", itemSnArray); deleteStmt.setArray(2, sqlArray); diff --git a/src/main/java/kinoko/database/postgresql/type/ItemDao.java b/src/main/java/kinoko/database/postgresql/type/ItemDao.java index ef669ddb..8f30cd67 100644 --- a/src/main/java/kinoko/database/postgresql/type/ItemDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ItemDao.java @@ -43,6 +43,7 @@ public static long createNewItem(Connection conn, Item item) throws SQLException if (rs.next()) { long generatedSn = rs.getLong("item_sn"); item.setItemSn(generatedSn); // store it in the item object + EquipDataDao.upsertEquipData(conn, generatedSn, item.getEquipData()); return generatedSn; } else { throw new SQLException("Failed to generate item_sn for new item"); @@ -113,6 +114,8 @@ public static void saveItemsBatch(Connection conn, Collection items) throw // Execute all insert batches stmtInsert.executeBatch(); + // Update all EquipData + EquipDataDao.saveEquipDataBatch(conn, items); } } diff --git a/src/main/resources/log4j2.xml b/src/main/resources/log4j2.xml index f69bb25e..796846f8 100644 --- a/src/main/resources/log4j2.xml +++ b/src/main/resources/log4j2.xml @@ -1,8 +1,11 @@ + + + - + @@ -15,6 +18,10 @@ + + + + diff --git a/src/test/java/kinoko/world/item/InventoryManagerTest.java b/src/test/java/kinoko/world/item/InventoryManagerTest.java index 30176cac..7cd89a40 100644 --- a/src/test/java/kinoko/world/item/InventoryManagerTest.java +++ b/src/test/java/kinoko/world/item/InventoryManagerTest.java @@ -42,7 +42,7 @@ public void testMoney() { @Test public void testItemCount() { final InventoryManager im = new InventoryManager(); - im.setConsumeInventory(new Inventory(5)); + im.setConsumeInventory(new Inventory(5, InventoryType.CONSUME)); Assertions.assertEquals(0, im.getItemCount(RED_POTION)); @@ -62,7 +62,7 @@ public void testItemCount() { @Test public void testRemoveItem() { final InventoryManager im = new InventoryManager(); - im.setConsumeInventory(new Inventory(5)); + im.setConsumeInventory(new Inventory(5, InventoryType.CONSUME)); im.getConsumeInventory().putItem(1, createItem(RED_POTION, 5)); Assertions.assertEquals(5, im.getItemCount(RED_POTION)); @@ -83,7 +83,7 @@ public void testRemoveItem() { @Test public void testAddItem() { final InventoryManager im = new InventoryManager(); - im.setConsumeInventory(new Inventory(3)); + im.setConsumeInventory(new Inventory(3, InventoryType.CONSUME)); Assertions.assertTrue(im.addItem(createItem(RED_POTION, 5)).isPresent()); Assertions.assertNotNull(im.getConsumeInventory().getItem(1)); @@ -106,7 +106,7 @@ public void testAddItem() { @Test public void testCanAddItems() { final InventoryManager im = new InventoryManager(); - im.setConsumeInventory(new Inventory(5)); + im.setConsumeInventory(new Inventory(5, InventoryType.CONSUME)); Assertions.assertTrue(im.canAddItem(createItem(RED_POTION, 5))); Assertions.assertTrue(im.canAddItems(Set.of(createItem(RED_POTION, 5), createItem(ORANGE_POTION, 5)))); @@ -132,7 +132,7 @@ public void testCanAddItems() { @Test public void testRechargeableItems() { final InventoryManager im = new InventoryManager(); - im.setConsumeInventory(new Inventory(2)); + im.setConsumeInventory(new Inventory(2, InventoryType.CONSUME)); Assertions.assertTrue(im.canAddItem(createItem(SUBI_THROWING_STARS, 400))); Assertions.assertTrue(im.addItem(createItem(SUBI_THROWING_STARS, 400)).isPresent()); From f9997973c8e99a194b8d8e95e820e2069950cef8 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Fri, 10 Oct 2025 00:24:32 -0400 Subject: [PATCH 38/83] Added whereami command --- src/main/java/kinoko/server/command/AdminCommands.java | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/main/java/kinoko/server/command/AdminCommands.java b/src/main/java/kinoko/server/command/AdminCommands.java index 8b92879d..2d11a793 100644 --- a/src/main/java/kinoko/server/command/AdminCommands.java +++ b/src/main/java/kinoko/server/command/AdminCommands.java @@ -415,6 +415,11 @@ public static void map(User user, String[] args) { user.warp(targetField, portalResult.get(), false, false); } + @Command("whereami") + public static void whereAmI(User user, String[] args) { + user.write(MessagePacket.system("You are in map: %d", user.getField().getFieldId())); + } + @Command("reactor") @Arguments("reactor template ID") public static void reactor(User user, String[] args) { From f27a182e605dae50b0cc1098feb11e355596767e Mon Sep 17 00:00:00 2001 From: MujyKun Date: Fri, 10 Oct 2025 16:53:54 -0400 Subject: [PATCH 39/83] Refactored DAOs, fixed guilds, notices, and entries. --- .../database/postgresql/PostgresAccessor.java | 139 +++++++ .../postgresql/PostgresAccountAccessor.java | 184 ++------- .../postgresql/PostgresGuildAccessor.java | 362 ++++-------------- .../kinoko/database/postgresql/setup/init.sql | 29 +- .../database/postgresql/type/AccountDao.java | 76 ++++ .../postgresql/type/BoardEntryCommentDao.java | 121 ++++++ .../postgresql/type/BoardEntryDao.java | 209 ++++++++++ .../postgresql/type/BoardNoticeDao.java | 104 +++++ .../postgresql/type/EquipDataDao.java | 2 - .../database/postgresql/type/GuildDao.java | 212 ++++++++++ .../postgresql/type/GuildMemberDao.java | 169 ++++++++ .../database/postgresql/type/LockerDao.java | 40 ++ .../database/postgresql/type/TrunkDao.java | 1 - .../database/postgresql/type/WishlistDao.java | 34 ++ .../database/postgresql/util/SQLAction.java | 9 + .../postgresql/util/SQLBooleanAction.java | 9 + src/main/java/kinoko/server/guild/Guild.java | 6 + .../server/guild/GuildBoardComment.java | 19 +- .../kinoko/server/guild/GuildBoardEntry.java | 25 +- 19 files changed, 1288 insertions(+), 462 deletions(-) create mode 100644 src/main/java/kinoko/database/postgresql/type/AccountDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/GuildDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java create mode 100644 src/main/java/kinoko/database/postgresql/util/SQLAction.java create mode 100644 src/main/java/kinoko/database/postgresql/util/SQLBooleanAction.java diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java index 8afa32b6..8dbea1e2 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java @@ -1,9 +1,14 @@ package kinoko.database.postgresql; import com.zaxxer.hikari.HikariDataSource; +import kinoko.database.postgresql.util.SQLAction; +import kinoko.database.postgresql.util.SQLBooleanAction; import java.sql.Connection; import java.sql.SQLException; +import java.util.function.Supplier; + + public abstract class PostgresAccessor { private final HikariDataSource dataSource; @@ -26,4 +31,138 @@ protected final Connection getConnection() throws SQLException { protected final String lowerName(String name) { return name.toLowerCase(); } + + /** + * Executes the given action within a database transaction using a connection from the instance's data source. + * Sets auto-commit to false, runs the action, commits the transaction if successful, and rolls back if a SQLException occurs. + * Finally, restores auto-commit to true and closes the connection. + * + * @param action The action to execute inside the transaction. Can throw SQLException. + * @return true if the transaction committed successfully; false if an exception occurred and rollback was performed. + */ + public boolean withTransaction(SQLAction action) { + Connection conn = null; + try { + conn = dataSource.getConnection(); + conn.setAutoCommit(false); + + action.apply(conn); + + conn.commit(); + return true; + + } catch (SQLException e) { + if (conn != null) { + try { + conn.rollback(); + } + catch (SQLException rollbackEx) + { + rollbackEx.printStackTrace(); + } + } + e.printStackTrace(); + return false; + } finally { + if (conn != null) { + try { + conn.setAutoCommit(true); + conn.close(); + } catch + (SQLException ignored) { + ignored.printStackTrace(); + } + } + } + } + + /** + * Executes the given action within a database transaction using the provided connection. + * Sets auto-commit to false on the connection, executes the action, commits the transaction + * if successful, and rolls back if a SQLException occurs. Restores auto-commit to true and closes + * the connection in the finally block. + * + * @param conn The database connection to use for the transaction. This connection will be closed by this method. + * @param action The action to execute inside the transaction. Can throw SQLException. + * @return true if the transaction committed successfully; false if an exception occurred and rollback was performed. + */ + public static boolean withTransaction(Connection conn, + SQLAction action) { + try { + conn.setAutoCommit(false); + action.apply(conn); + conn.commit(); + return true; + } catch (SQLException e) { + if (conn != null) { + try { + conn.rollback(); + } + catch (SQLException rollbackEx) + { + rollbackEx.printStackTrace(); + } + } + e.printStackTrace(); + return false; + } finally { + if (conn != null) { + try { + conn.setAutoCommit(true); + conn.close(); + } catch (SQLException ignored) { + ignored.printStackTrace(); + } + } + } + } + + /** + * Executes the given action within a database transaction using a connection from the instance's data source. + * Sets auto-commit to false, runs the action, commits the transaction if successful, and rolls back if a SQLException occurs. + * Finally, restores auto-commit to true and closes the connection. + * + * @param action The action to execute inside the transaction. Can throw SQLException. + * @return true if the transaction committed successfully; false if an exception occurred and rollback was performed. + */ + public boolean withTransaction(SQLBooleanAction action) { + Connection conn = null; + try { + conn = dataSource.getConnection(); + conn.setAutoCommit(false); + + boolean logicalSuccess = action.apply(conn); + if (!logicalSuccess){ + conn.rollback(); + return false; + } + + conn.commit(); + return true; + + } catch (SQLException e) { + if (conn != null) { + try { + conn.rollback(); + } + catch (SQLException rollbackEx) + { + rollbackEx.printStackTrace(); + } + } + e.printStackTrace(); + return false; + } finally { + if (conn != null) { + try { + conn.setAutoCommit(true); + conn.close(); + } catch + (SQLException ignored) { + ignored.printStackTrace(); + } + } + } + } } + diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java index 4a77efe6..8efa91b4 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java @@ -3,21 +3,13 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.AccountAccessor; import kinoko.database.DatabaseManager; -import kinoko.database.postgresql.type.LockerDao; -import kinoko.database.postgresql.type.TrunkDao; -import kinoko.database.postgresql.type.WishlistDao; +import kinoko.database.postgresql.type.AccountDao; import kinoko.server.ServerConfig; -import kinoko.server.cashshop.CashItemInfo; -import kinoko.world.item.Item; -import kinoko.world.item.Trunk; import kinoko.world.user.Account; -import kinoko.world.user.Locker; import org.mindrot.jbcrypt.BCrypt; import java.sql.*; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; + import java.util.Optional; public final class PostgresAccountAccessor extends PostgresAccessor implements AccountAccessor { @@ -26,70 +18,6 @@ public PostgresAccountAccessor(HikariDataSource dataSource) { super(dataSource); } - private Account loadAccount(Connection conn, ResultSet rs) throws SQLException { - final int accountId = rs.getInt("id"); - final String username = rs.getString("username"); - final String secondaryPassword = rs.getString("secondary_password"); - - final Account account = new Account(accountId, username); - account.setHasSecondaryPassword(secondaryPassword != null && !secondaryPassword.isEmpty()); - account.setSlotCount(rs.getInt("character_slots")); - account.setNxCredit(rs.getInt("nx_credit")); - account.setNxPrepaid(rs.getInt("nx_prepaid")); - account.setMaplePoint(rs.getInt("maple_point")); - - account.setTrunk(TrunkDao.load(conn, accountId)); - - account.setLocker(loadLocker(accountId)); - account.setWishlist(loadWishlist(accountId)); - - return account; - } - - - private Locker loadLocker(int accountId) throws SQLException { - Locker locker = new Locker(); - String sql = "SELECT li.slot, li.item_sn, li.commodity_id, i.item_id, i.quantity " + - "FROM account.locker_item li " + - "JOIN item.items i ON li.item_sn = i.item_sn " + - "WHERE li.account_id = ? ORDER BY li.slot"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, accountId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - Item item = new Item(rs.getInt("item_id"), (short) rs.getInt("quantity")); - CashItemInfo info = new CashItemInfo( - item, - rs.getInt("commodity_id"), - accountId, // account owner - -1, // character owner unknown at this point - null - ); - locker.addCashItem(info); - } - } - } - return locker; - } - - private List loadWishlist(int accountId) throws SQLException { - List wishlist = new ArrayList<>(); - String sql = "SELECT w.item_id FROM account.wishlist w " + - "WHERE w.account_id = ? ORDER BY w.slot"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, accountId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - wishlist.add(rs.getInt("item_id")); - } - } - } - while (wishlist.size() < 10) wishlist.add(0); - return Collections.unmodifiableList(wishlist); - } - private String lowerUsername(String username) { return username.toLowerCase(); } @@ -106,13 +34,16 @@ private boolean checkHashedPassword(String password, String hashedPassword) { public Optional getAccountById(int accountId) { String sql = "SELECT * FROM account.accounts WHERE id = ?"; try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - return Optional.of(loadAccount(conn, rs)); + return Optional.of(AccountDao.load(conn, rs)); } } + } catch (SQLException e) { e.printStackTrace(); } @@ -123,13 +54,16 @@ public Optional getAccountById(int accountId) { public Optional getAccountByUsername(String username) { String sql = "SELECT * FROM account.accounts WHERE username = ?"; try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, lowerUsername(username)); + try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - return Optional.of(loadAccount(conn, rs)); + return Optional.of(AccountDao.load(conn, rs)); } } + } catch (SQLException e) { e.printStackTrace(); } @@ -138,41 +72,27 @@ public Optional getAccountByUsername(String username) { @Override public boolean checkPassword(Account account, String password, boolean secondary) { - String column = secondary ? "secondary_password" : "password"; - String sql = "SELECT " + column + " FROM account.accounts WHERE id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, account.getId()); - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - String hashed = rs.getString(column); - return hashed != null && checkHashedPassword(password, hashed); - } - } + try (Connection conn = getConnection()) { + String hashed = AccountDao.getHashedPassword(conn, account, secondary); + return hashed != null && checkHashedPassword(password, hashed); } catch (SQLException e) { e.printStackTrace(); + return false; } - return false; } @Override public boolean savePassword(Account account, String oldPassword, String newPassword, boolean secondary) { - String column = secondary ? "secondary_password" : "password"; - String sqlSelect = "SELECT " + column + " FROM account.accounts WHERE id = ?"; - String sqlUpdate = "UPDATE account.accounts SET " + column + " = ? WHERE id = ?"; - try (Connection conn = getConnection(); - PreparedStatement selectStmt = conn.prepareStatement(sqlSelect)) { - selectStmt.setInt(1, account.getId()); - try (ResultSet rs = selectStmt.executeQuery()) { - if (rs.next()) { - String hashedOld = rs.getString(column); - if (hashedOld == null || checkHashedPassword(oldPassword, hashedOld)) { - try (PreparedStatement updateStmt = conn.prepareStatement(sqlUpdate)) { - updateStmt.setString(1, hashPassword(newPassword)); - updateStmt.setInt(2, account.getId()); - return updateStmt.executeUpdate() > 0; - } - } + String sqlUpdate = "UPDATE account.accounts SET " + + (secondary ? "secondary_password" : "password") + + " = ? WHERE id = ?"; + try (Connection conn = getConnection()) { + String hashedOld = AccountDao.getHashedPassword(conn, account, secondary); + if (hashedOld == null || checkHashedPassword(oldPassword, hashedOld)) { + try (PreparedStatement updateStmt = conn.prepareStatement(sqlUpdate)) { + updateStmt.setString(1, hashPassword(newPassword)); + updateStmt.setInt(2, account.getId()); + return updateStmt.executeUpdate() > 0; } } } catch (SQLException e) { @@ -192,7 +112,6 @@ public synchronized boolean newAccount(String username, String password) { String sql = "INSERT INTO account.accounts (username, password, character_slots, nx_credit, nx_prepaid, maple_point, trunk_size, trunk_money) " + "VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + "RETURNING ID"; - System.out.println("Creating account."); try (Connection conn = getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, lowerUsername(username)); @@ -210,30 +129,6 @@ public synchronized boolean newAccount(String username, String password) { throw new SQLException("Failed to retrieve account ID after insert."); } } - - // WARNING CODED AS A LEAK RN - // Initialize a base trunk, locker, wishlist -// for (int i = 0; i < ServerConfig.TRUNK_BASE_SLOTS; i++) { -// try (PreparedStatement tStmt = getConnection().prepareStatement( -// "INSERT INTO account.trunk_item (account_id, slot, item_sn) VALUES (?, ?, ?)")) { -// tStmt.setInt(1, accountId); -// tStmt.setInt(2, i); -// tStmt.setLong(3, itemSn); -// tStmt.executeUpdate(); -// } -// } -// WARNING CODED AS A LEAK RN -// for (int i = 0; i < 10; i++) { -// try (PreparedStatement wStmt = getConnection().prepareStatement( -// "INSERT INTO account.wishlist (account_id, slot, item_sn) VALUES (?, ?, ?)")) { -// wStmt.setInt(1, accountId); -// wStmt.setInt(2, i); -// wStmt.setLong(3, itemSn); -// wStmt.executeUpdate(); -// } -// } - - // Locker starts empty, no rows needed initially return true; } catch (SQLException e) { e.printStackTrace(); @@ -243,32 +138,11 @@ public synchronized boolean newAccount(String username, String password) { @Override public boolean saveAccount(Account account) { - try (Connection conn = getConnection()) { - conn.setAutoCommit(false); - - try (PreparedStatement stmt = conn.prepareStatement( - "UPDATE account.accounts SET character_slots = ?, nx_credit = ?, nx_prepaid = ?, maple_point = ?, trunk_size = ?, trunk_money = ? WHERE id = ?")) { - stmt.setInt(1, account.getSlotCount()); - stmt.setInt(2, account.getNxCredit()); - stmt.setInt(3, account.getNxPrepaid()); - stmt.setInt(4, account.getMaplePoint()); - stmt.setInt(5, account.getTrunk().getSize()); - stmt.setInt(6, account.getTrunk().getMoney()); - stmt.setInt(7, account.getId()); - stmt.executeUpdate(); - } - - int accountId = account.getId(); - TrunkDao.save(conn, accountId, account.getTrunk()); - WishlistDao.save(conn, accountId, account.getWishlist()); - LockerDao.save(conn, accountId, account.getLocker()); - - conn.commit(); - conn.setAutoCommit(true); - return true; + try { + return withTransaction(getConnection(), c -> AccountDao.save(c, account)); } catch (SQLException e) { e.printStackTrace(); + return false; } - return false; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java index 94454b16..66eabb5c 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -2,11 +2,9 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.GuildAccessor; +import kinoko.database.postgresql.type.GuildDao; import kinoko.server.guild.Guild; -import kinoko.server.guild.GuildBoardEntry; -import kinoko.server.guild.GuildMember; import kinoko.server.guild.GuildRanking; -import kinoko.server.guild.GuildRank; import java.sql.*; import java.util.*; @@ -17,38 +15,17 @@ public PostgresGuildAccessor(HikariDataSource dataSource) { super(dataSource); } - // --------------------------------------------- - // LOAD A GUILD - // --------------------------------------------- - private Guild loadGuild(ResultSet rs) throws SQLException { - final int guildId = rs.getInt("guild_id"); - final String guildName = rs.getString("guild_name"); - Guild guild = new Guild(guildId, guildName); - - guild.setMemberMax(rs.getInt("member_max")); - guild.setMarkBg(rs.getShort("mark_bg")); - guild.setMarkBgColor(rs.getByte("mark_bg_color")); - guild.setMark(rs.getShort("mark")); - guild.setMarkColor(rs.getByte("mark_color")); - guild.setNotice(rs.getString("notice")); - guild.setPoints(rs.getInt("points")); - guild.setLevel(rs.getByte("level")); - - final List members = loadMembers(guildId); - for (GuildMember member : members) { - guild.addMember(member); - } - - guild.setGradeNames(loadGrades(guild.getGuildId())); - - final List boardEntries = loadBoardEntries(guildId); - guild.getBoardEntries().addAll(boardEntries); - - guild.setBoardNoticeEntry(loadBoardNotice(guildId)); - - return guild; - } - + /** + * Retrieves a guild from the database by its ID. + * + * Executes a query to fetch the guild record corresponding to the + * provided guild ID. If a matching guild is found, it is loaded + * into a {@link Guild} object using {@link GuildDao#loadGuild}. + * + * @param guildId the ID of the guild to retrieve + * @return an {@link Optional} containing the guild if found, or + * {@link Optional#empty()} if no guild exists with the given ID + */ @Override public Optional getGuildById(int guildId) { String sql = "SELECT * FROM guild.guilds WHERE guild_id = ?"; @@ -57,7 +34,7 @@ public Optional getGuildById(int guildId) { stmt.setInt(1, guildId); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - return Optional.of(loadGuild(rs)); + return Optional.of(GuildDao.loadGuild(conn, rs)); } } } catch (SQLException e) { @@ -66,281 +43,84 @@ public Optional getGuildById(int guildId) { return Optional.empty(); } - - private List loadGrades(int guildId) throws SQLException { - List grades = new ArrayList<>(); - String sql = "SELECT grade_name FROM guild.grade WHERE guild_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, guildId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - grades.add(rs.getString("grade_name")); - } - } - } - return grades; - } - - // --------------------------------------------- - // MEMBERS - // --------------------------------------------- - private List loadMembers(int guildId) { - List members = new ArrayList<>(); - String sql = """ - SELECT c.character_id, c.character_name, s.job, s.level, - m.grade AS guildRank, NULL AS allianceRank, c.online - FROM guild.member m - JOIN player.characters c ON c.character_id = m.character_id - JOIN character.stats s ON s.character_id = c.character_id - WHERE m.guild_id = ? - """; - - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, guildId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - int charId = rs.getInt("character_id"); - String charName = rs.getString("character_name"); - int job = rs.getInt("job"); - int level = rs.getInt("level"); - boolean online = rs.getBoolean("online"); // now works with the new column - int guildRankInt = rs.getInt("guildRank"); - Integer allianceRankInt = null; // no alliance rank yet - - members.add(new GuildMember( - charId, - charName, - job, - level, - online, - GuildRank.getByValue(guildRankInt), - allianceRankInt != null ? GuildRank.getByValue(allianceRankInt) : null // setting to null for now. - )); - } - } - } catch (SQLException e) { - e.printStackTrace(); - } - - return members; - } - - - - - // --------------------------------------------- - // BOARD ENTRIES - // --------------------------------------------- - private List loadBoardEntries(int guildId) { - List entries = new ArrayList<>(); - String sql = "SELECT entry_id, character_id, title, message, timestamp, 0 AS emoticon " + - "FROM guild.board_entry WHERE guild_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, guildId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - entries.add(new GuildBoardEntry( - rs.getInt("entry_id"), - rs.getInt("character_id"), - rs.getString("title"), - rs.getString("message"), - rs.getTimestamp("timestamp").toInstant(), - rs.getInt("emoticon") - )); - } - } - } catch (SQLException e) { - e.printStackTrace(); - } - return entries; - } - - private GuildBoardEntry loadBoardNotice(int guildId) { - String sql = "SELECT entry_id FROM guild.notice WHERE guild_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, guildId); - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - int entryId = rs.getInt("entry_id"); - // Load full entry - String entrySql = "SELECT entry_id, character_id, title, message, timestamp, 0 AS emoticon " + - "FROM guild.board_entry WHERE entry_id = ?"; - try (PreparedStatement entryStmt = getConnection().prepareStatement(entrySql)) { - entryStmt.setInt(1, entryId); - try (ResultSet ers = entryStmt.executeQuery()) { - if (ers.next()) { - return new GuildBoardEntry( - ers.getInt("entry_id"), - ers.getInt("character_id"), - ers.getString("title"), - ers.getString("message"), - ers.getTimestamp("timestamp").toInstant(), - ers.getInt("emoticon") - ); - } - } - } - } - } - } catch (SQLException e) { - e.printStackTrace(); - } - return null; - } - - // --------------------------------------------- - // CHECK NAME - // --------------------------------------------- + /** + * Checks if a guild name is available for use. + * + * Queries the database to determine whether the given guild name + * already exists. Returns true if the name is not taken, false otherwise. + * + * @param name the guild name to check + * @return true if the name is available, false if it is already in use + */ @Override public boolean checkGuildNameAvailable(String name) { - String sql = "SELECT 1 FROM guild.guilds WHERE LOWER(guild_name) = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setString(1, name.toLowerCase()); - try (ResultSet rs = stmt.executeQuery()) { - return !rs.next(); - } + try (Connection conn = getConnection();) { + return GuildDao.checkGuildNameAvailable(conn, name); } catch (SQLException e) { e.printStackTrace(); } return false; } - // --------------------------------------------- - // SAVE / CREATE - // --------------------------------------------- + /** + * Creates a new guild in the database within a transaction. + * + * This method wraps the insertion in a transaction to ensure atomicity. + * It delegates the actual insertion to GuildDao.insertGuild. + * + * @param guild the Guild object to be inserted + * @return true if the guild was successfully created, false otherwise + */ @Override public synchronized boolean newGuild(Guild guild) { - if (!checkGuildNameAvailable(guild.getGuildName())) return false; - return saveGuild(guild); + return withTransaction(conn -> { + return GuildDao.insertGuild(conn, guild); + }); } + /** + * Saves (updates) an existing guild in the database within a transaction. + * + * This method wraps the update in a transaction to ensure atomicity. + * It delegates the actual update to GuildDao.updateGuild. + * + * @param guild the Guild object with updated data + * @return true if the guild was successfully updated, false otherwise + */ @Override public boolean saveGuild(Guild guild) { - String sql = "INSERT INTO guild.guilds (guild_id, guild_name, grade_names, member_max, mark_bg, mark_bg_color, mark, mark_color, notice, points, level, board_entry_counter) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) " + - "ON CONFLICT (guild_id) DO UPDATE SET " + - "guild_name = EXCLUDED.guild_name, " + - "grade_names = EXCLUDED.grade_names, " + - "member_max = EXCLUDED.member_max, " + - "mark_bg = EXCLUDED.mark_bg, " + - "mark_bg_color = EXCLUDED.mark_bg_color, " + - "mark = EXCLUDED.mark, " + - "mark_color = EXCLUDED.mark_color, " + - "notice = EXCLUDED.notice, " + - "points = EXCLUDED.points, " + - "level = EXCLUDED.level, " + - "board_entry_counter = EXCLUDED.board_entry_counter"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, guild.getGuildId()); - stmt.setString(2, guild.getGuildName()); - stmt.setArray(3, getConnection().createArrayOf("text", guild.getGradeNames().toArray())); - stmt.setInt(4, guild.getMemberMax()); - stmt.setShort(5, guild.getMarkBg()); - stmt.setByte(6, guild.getMarkBgColor()); - stmt.setShort(7, guild.getMark()); - stmt.setByte(8, guild.getMarkColor()); - stmt.setString(9, guild.getNotice()); - stmt.setInt(10, guild.getPoints()); - stmt.setByte(11, guild.getLevel()); - stmt.setInt(12, guild.getBoardEntryCounter().get()); - stmt.executeUpdate(); - - saveMembers(guild); - saveBoardEntries(guild); - saveBoardNotice(guild); - - return true; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; - } - - private void saveMembers(Guild guild) throws SQLException { - String deleteSql = "DELETE FROM guild.member WHERE guild_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(deleteSql)) { - stmt.setInt(1, guild.getGuildId()); - stmt.executeUpdate(); - } - - String insertSql = "INSERT INTO guild.member (guild_id, character_id, grade) VALUES (?, ?, ?)"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(insertSql)) { - for (GuildMember member : guild.getGuildMembers()) { - stmt.setInt(1, guild.getGuildId()); - stmt.setInt(2, member.getCharacterId()); - stmt.setShort(3, (short) member.getGuildRank().getValue()); - stmt.addBatch(); - } - stmt.executeBatch(); - } + return withTransaction(conn -> { + return GuildDao.updateGuild(conn, guild); + }); } - private void saveBoardEntries(Guild guild) throws SQLException { - String deleteSql = "DELETE FROM guild.board_entry WHERE guild_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(deleteSql)) { - stmt.setInt(1, guild.getGuildId()); - stmt.executeUpdate(); - } - - String insertSql = "INSERT INTO guild.board_entry (entry_id, guild_id, character_id, title, message, timestamp, emoticon) VALUES (?, ?, ?, ?, ?, ?, ?)"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(insertSql)) { - for (GuildBoardEntry entry : guild.getBoardEntries()) { - stmt.setInt(1, entry.getEntryId()); - stmt.setInt(2, guild.getGuildId()); - stmt.setInt(3, entry.getCharacterId()); - stmt.setString(4, entry.getTitle()); - stmt.setString(5, entry.getText()); - stmt.setTimestamp(6, Timestamp.from(entry.getDate())); - stmt.setInt(7, entry.getEmoticon()); - stmt.addBatch(); - } - stmt.executeBatch(); - } - } - - private void saveBoardNotice(Guild guild) throws SQLException { - String sql = "INSERT INTO guild.notice (guild_id, entry_id) VALUES (?, ?) " + - "ON CONFLICT (guild_id) DO UPDATE SET entry_id = EXCLUDED.entry_id"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - GuildBoardEntry notice = guild.getBoardNoticeEntry(); - if (notice != null) { - stmt.setInt(1, guild.getGuildId()); - stmt.setInt(2, notice.getEntryId()); - stmt.executeUpdate(); - } - } - } - - // --------------------------------------------- - // DELETE - // --------------------------------------------- + /** + * Deletes a guild from the database within a transaction. + * + * This method wraps the deletion in a transaction to ensure atomicity. + * It delegates the actual deletion to GuildDao.deleteGuild. + * + * @param guildId the ID of the guild to delete + * @return true if the guild was successfully deleted, false otherwise + */ @Override public boolean deleteGuild(int guildId) { - String sql = "DELETE FROM guild.guilds WHERE guild_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, guildId); - return stmt.executeUpdate() > 0; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return GuildDao.deleteGuild(conn, guildId); + }); } - // --------------------------------------------- - // RANKINGS - // --------------------------------------------- + /** + * Retrieves a list of guild rankings from the database. + * + * Guilds are ordered by their points in descending order, + * so the guild with the highest points appears first. + * + * Each GuildRanking object contains the guild's name, points, + * and visual mark information (mark, mark color, background, background color). + * + * @return a list of GuildRanking objects representing all guilds ordered by points + */ @Override public List getGuildRankings() { List rankings = new ArrayList<>(); diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index 77b5ec57..b2eebea4 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -407,22 +407,29 @@ CREATE TABLE IF NOT EXISTS guild.member ( PRIMARY KEY (guild_id, character_id) ); + CREATE TABLE IF NOT EXISTS guild.board_entry ( + id SERIAL PRIMARY KEY, guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, - entry_id SERIAL PRIMARY KEY, - poster_id INT NOT NULL, - poster_name TEXT, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + title TEXT, message TEXT NOT NULL, - timestamp TIMESTAMP NOT NULL + emoticon INT, + timestamp TIMESTAMP NOT NULL DEFAULT UTC_NOW(), + notice boolean DEFAULT false ); -CREATE TABLE IF NOT EXISTS guild.notice ( - guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, - poster_id INT NOT NULL, - poster_name TEXT, - message TEXT NOT NULL, - timestamp TIMESTAMP NOT NULL, - PRIMARY KEY (guild_id) +-- enforce only one TRUE notice per guild +CREATE UNIQUE INDEX IF NOT EXISTS unique_guild_notice +ON guild.board_entry(guild_id) +WHERE notice = TRUE; + +CREATE TABLE IF NOT EXISTS guild.board_entry_comment ( + id SERIAL PRIMARY KEY, + entry_id INT NOT NULL REFERENCES guild.board_entry(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + text TEXT NOT NULL, + timestamp TIMESTAMP NOT NULL DEFAULT UTC_NOW() ); diff --git a/src/main/java/kinoko/database/postgresql/type/AccountDao.java b/src/main/java/kinoko/database/postgresql/type/AccountDao.java new file mode 100644 index 00000000..714eda69 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/AccountDao.java @@ -0,0 +1,76 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.user.Account; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +public class AccountDao { + + /** + * Saves the given account and all related data to the database. + * Updates the main account fields (character slots, NX credit, prepaid, Maple points, trunk size, trunk money) + * in the accounts table and saves the associated trunk, wishlist, and locker data. + * All operations should be executed inside a transaction. If any database operation fails, + * an SQLException is thrown and the transaction can be rolled back by the caller. + * + * @param conn the database connection to use + * @param account the account object containing updated data to save + * @throws SQLException if any database operation fails + */ + public static void save(Connection conn, Account account) throws SQLException { + try (PreparedStatement stmt = conn.prepareStatement( + "UPDATE account.accounts SET character_slots = ?, nx_credit = ?, nx_prepaid = ?, maple_point = ?, trunk_size = ?, trunk_money = ? WHERE id = ?" + )) { + stmt.setInt(1, account.getSlotCount()); + stmt.setInt(2, account.getNxCredit()); + stmt.setInt(3, account.getNxPrepaid()); + stmt.setInt(4, account.getMaplePoint()); + stmt.setInt(5, account.getTrunk().getSize()); + stmt.setInt(6, account.getTrunk().getMoney()); + stmt.setInt(7, account.getId()); + stmt.executeUpdate(); + } + + // Save related tables + int accountId = account.getId(); + TrunkDao.save(conn, accountId, account.getTrunk()); + WishlistDao.save(conn, accountId, account.getWishlist()); + LockerDao.save(conn, accountId, account.getLocker()); + } + + public static String getHashedPassword(Connection conn, Account account, boolean secondary) throws SQLException { + String column = secondary ? "secondary_password" : "password"; + String sql = "SELECT " + column + " FROM account.accounts WHERE id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, account.getId()); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return rs.getString(column); + } + } + } + return null; + } + + public static Account load(Connection conn, ResultSet rs) throws SQLException { + final int accountId = rs.getInt("id"); + final String username = rs.getString("username"); + final String secondaryPassword = rs.getString("secondary_password"); + + final Account account = new Account(accountId, username); + account.setHasSecondaryPassword(secondaryPassword != null && !secondaryPassword.isEmpty()); + account.setSlotCount(rs.getInt("character_slots")); + account.setNxCredit(rs.getInt("nx_credit")); + account.setNxPrepaid(rs.getInt("nx_prepaid")); + account.setMaplePoint(rs.getInt("maple_point")); + + account.setTrunk(TrunkDao.load(conn, accountId)); + account.setLocker(LockerDao.load(conn, accountId)); + account.setWishlist(WishlistDao.load(conn, accountId)); + + return account; + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java b/src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java new file mode 100644 index 00000000..bf26794c --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java @@ -0,0 +1,121 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.guild.GuildBoardComment; +import kinoko.server.guild.GuildBoardEntry; + +import java.sql.*; +import java.util.List; +import java.util.stream.Collectors; + +public class BoardEntryCommentDao { + + /** + * Synchronizes the comments of a given guild board entry in the database + * with the comments currently in memory. + * + * Deletes comments that have been removed, inserts new comments, + * and ensures that the database matches the in-memory list. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param entry the board entry whose comments should be synchronized + * @throws SQLException if a database access error occurs + */ + public static void saveComments(Connection conn, GuildBoardEntry entry) throws SQLException { + List comments = entry.getComments(); + + if (comments == null || comments.isEmpty()) { + deleteAllComments(conn, entry.getEntryId()); + return; + } + + deleteRemovedComments(conn, entry, comments); + insertNewComments(conn, entry, comments); + } + + /** + * Deletes all comments for a given board entry from the database. + * + * This is typically used when there are no comments in memory, + * and the database should be cleared accordingly. + * + * @param conn the active SQL connection + * @param entryId the ID of the board entry whose comments should be deleted + * @throws SQLException if a database access error occurs + */ + private static void deleteAllComments(Connection conn, int entryId) throws SQLException { + try (PreparedStatement stmt = conn.prepareStatement( + "DELETE FROM guild.board_entry_comment WHERE entry_id = ?")) { + stmt.setInt(1, entryId); + stmt.executeUpdate(); + } + } + + /** + * Deletes comments that exist in the database but no longer exist + * in the current in-memory list of comments for a given entry. + * + * @param conn the active SQL connection + * @param entry the board entry whose comments are being synchronized + * @param comments the current list of comments that should remain + * @throws SQLException if a database access error occurs + */ + private static void deleteRemovedComments(Connection conn, GuildBoardEntry entry, List comments) throws SQLException { + List currentIds = comments.stream() + .filter(c -> !c.hasNoSN()) + .map(GuildBoardComment::getCommentSn) + .toList(); + + if (currentIds.isEmpty()) return; + + String placeholders = currentIds.stream().map(id -> "?").collect(Collectors.joining(",")); + String sql = "DELETE FROM guild.board_entry_comment WHERE entry_id = ? AND id NOT IN (" + placeholders + ")"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int idx = 1; + stmt.setInt(idx++, entry.getEntryId()); + for (Integer id : currentIds) stmt.setInt(idx++, id); + stmt.executeUpdate(); + } + } + + /** + * Inserts new comments for a given board entry into the database + * and sets their generated IDs. + * + * Only comments without a serial number (new comments) are inserted. + * + * @param conn the active SQL connection + * @param entry the board entry to which the comments belong + * @param comments the list of comments to insert + * @throws SQLException if a database access error occurs or + * the generated keys cannot be retrieved + */ + private static void insertNewComments(Connection conn, GuildBoardEntry entry, List comments) throws SQLException { + String insertSql = """ + INSERT INTO guild.board_entry_comment (entry_id, character_id, text, timestamp) + VALUES (?, ?, ?, ?) + """; + + try (PreparedStatement stmt = conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS)) { + List newComments = comments.stream().filter(GuildBoardComment::hasNoSN).toList(); + for (GuildBoardComment comment : newComments) { + stmt.setInt(1, entry.getEntryId()); + stmt.setInt(2, comment.getCharacterId()); + stmt.setString(3, comment.getText()); + stmt.setTimestamp(4, Timestamp.from(comment.getDate())); + stmt.addBatch(); + } + + stmt.executeBatch(); + + try (ResultSet generatedKeys = stmt.getGeneratedKeys()) { + for (GuildBoardComment comment : newComments) { + if (generatedKeys.next()) { + comment.setCommentSn(generatedKeys.getInt(1)); + } else { + throw new SQLException("Failed to retrieve generated commentSn for a new comment."); + } + } + } + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java b/src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java new file mode 100644 index 00000000..b06f4978 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java @@ -0,0 +1,209 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildBoardEntry; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + +public class BoardEntryDao { + + /** + * Synchronizes the guild's board entries in the database with the entries in memory. + * + * Deletes entries that are no longer present and inserts or updates + * existing ones, minimizing redundant operations. + * + * Also synchronizes comments for each board entry via BoardEntryCommentDao. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param guild the guild whose board entries should be synchronized + * @throws SQLException if a database access error occurs + */ + public static void saveBoardEntries(Connection conn, Guild guild) throws SQLException { + List entries = guild.getBoardEntries(); + + if (entries == null || entries.isEmpty()) { + deleteAllEntries(conn, guild.getGuildId()); + return; + } + + // Delete board entries that have been removed. + deleteRemovedBoardEntries(conn, guild, entries); + + // Split entries into new inserts and existing updates + List newEntries = entries.stream() + .filter(GuildBoardEntry::hasNoSN) + .toList(); + + List existingEntries = entries.stream() + .filter(entry -> !entry.hasNoSN()) + .toList(); + + // Insert new entries and assign generated SNs + insertBoardEntries(conn, guild.getGuildId(), newEntries); + + // Update existing entries + updateBoardEntries(conn, guild.getGuildId(), existingEntries); + + // Synchronize comments for each entry + for (GuildBoardEntry entry : entries) { + BoardEntryCommentDao.saveComments(conn, entry); + } + } + + /** + * Deletes all board entries for a given guild from the database. + * + * This is typically used when the guild has no entries in memory + * and we want to remove all corresponding database records. + * + * @param conn the active SQL connection + * @param guildId the ID of the guild whose entries should be deleted + * @throws SQLException if a database access error occurs + */ + private static void deleteAllEntries(Connection conn, int guildId) throws SQLException { + try (PreparedStatement stmt = conn.prepareStatement( + "DELETE FROM guild.board_entry WHERE guild_id = ?")) { + stmt.setInt(1, guildId); + stmt.executeUpdate(); + } + } + + /** + * Deletes board entries from the database that are no longer present + * in the provided in-memory list of entries. + * + * Only entries that exist in the database but not in the current list + * will be deleted. If the list is empty, all entries for the guild + * will be removed. + * + * @param conn the active SQL connection + * @param guild the guild whose entries are being synchronized + * @param entries the current list of board entries that should remain + * @throws SQLException if a database access error occurs + */ + private static void deleteRemovedBoardEntries(Connection conn, Guild guild, List entries) throws SQLException { + List currentIds = entries.stream() + .map(GuildBoardEntry::getEntryId) + .toList(); + + if (currentIds.isEmpty()) { + deleteAllEntries(conn, guild.getGuildId()); + return; + } + + String placeholders = currentIds.stream().map(id -> "?").collect(Collectors.joining(",")); + String sql = "DELETE FROM guild.board_entry WHERE guild_id = ? AND id NOT IN (" + placeholders + ")"; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int idx = 1; + stmt.setInt(idx++, guild.getGuildId()); + for (Integer id : currentIds) stmt.setInt(idx++, id); + stmt.executeUpdate(); + } + } + + /** + * Inserts new board entries into the database and sets their generated IDs. + * + * @param conn the SQL connection + * @param guildId the guild ID + * @param newEntries list of entries to insert + * @throws SQLException if a database error occurs + */ + private static void insertBoardEntries(Connection conn, int guildId, List newEntries) throws SQLException { + if (newEntries.isEmpty()) return; + + String sql = """ + INSERT INTO guild.board_entry (guild_id, character_id, title, message, emoticon) + VALUES (?, ?, ?, ?, ?) + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { + for (GuildBoardEntry entry : newEntries) { + stmt.setInt(1, guildId); + stmt.setInt(2, entry.getCharacterId()); + stmt.setString(3, entry.getTitle()); + stmt.setString(4, entry.getText()); + stmt.setInt(5, entry.getEmoticon()); + stmt.addBatch(); + } + + stmt.executeBatch(); + + try (ResultSet rs = stmt.getGeneratedKeys()) { + int i = 0; + while (rs.next()) { + newEntries.get(i++).setEntryId(rs.getInt(1)); + } + } + } + } + + /** + * Updates existing board entries in the database. + * + * @param conn the SQL connection + * @param guildId the guild ID + * @param existingEntries list of entries to update + * @throws SQLException if a database error occurs + */ + private static void updateBoardEntries(Connection conn, int guildId, List existingEntries) throws SQLException { + if (existingEntries.isEmpty()) return; + + String sql = """ + UPDATE guild.board_entry + SET character_id = ?, title = ?, message = ?, emoticon = ? + WHERE guild_id = ? AND id = ? + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (GuildBoardEntry entry : existingEntries) { + stmt.setInt(1, entry.getCharacterId()); + stmt.setString(2, entry.getTitle()); + stmt.setString(3, entry.getText()); + stmt.setInt(4, entry.getEmoticon()); + stmt.setInt(5, guildId); + stmt.setInt(6, entry.getEntryId()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Loads all board entries for a given guild from the database. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param guildId the guild ID whose board entries should be loaded + * @return a list of GuildBoardEntry objects + * @throws SQLException if a database access error occurs + */ + public static List loadBoardEntries(Connection conn, int guildId) throws SQLException { + List entries = new ArrayList<>(); + String sql = "SELECT id, character_id, title, message, timestamp, emoticon, notice " + + "FROM guild.board_entry WHERE guild_id = ?"; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + GuildBoardEntry entry = new GuildBoardEntry( + rs.getInt("id"), + rs.getInt("character_id"), + rs.getString("title"), + rs.getString("message"), + rs.getTimestamp("timestamp").toInstant(), + rs.getInt("emoticon") + ); + entries.add(entry); + } + } + } + + return entries; + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java b/src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java new file mode 100644 index 00000000..f215d584 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java @@ -0,0 +1,104 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildBoardEntry; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + + +public class BoardNoticeDao { + + /** + * Saves or updates the guild's notice entry in the database. + * + * Sets the `notice` column to TRUE for the given board entry and + * automatically ensures that all other entries for the guild are set to FALSE. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param guild the guild whose board notice should be saved + * @throws SQLException if a database access error occurs + */ + public static void saveBoardNotice(Connection conn, Guild guild) throws SQLException { + GuildBoardEntry notice = guild.getBoardNoticeEntry(); + + // Always unset the notice flag for all entries first + try (PreparedStatement unsetStmt = conn.prepareStatement( + "UPDATE guild.board_entry SET notice = FALSE WHERE guild_id = ?" + )) { + unsetStmt.setInt(1, guild.getGuildId()); + unsetStmt.executeUpdate(); + } + + // If there’s no notice to save, we’re done + if (notice == null) return; + + String sql = """ + INSERT INTO guild.board_entry (guild_id, character_id, title, message, emoticon, notice) + VALUES (?, ?, ?, ?, ?, TRUE) + ON CONFLICT (guild_id) WHERE notice = TRUE DO UPDATE SET + character_id = EXCLUDED.character_id, + title = EXCLUDED.title, + message = EXCLUDED.message, + emoticon = EXCLUDED.emoticon, + notice = TRUE + RETURNING id + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guild.getGuildId()); + stmt.setInt(2, notice.getCharacterId()); + stmt.setString(3, notice.getTitle()); + stmt.setString(4, notice.getText()); + stmt.setInt(5, notice.getEmoticon()); + + try (var rs = stmt.executeQuery()) { + if (rs.next()) { + notice.setEntryId(rs.getInt("id")); + } else { + throw new SQLException("Failed to retrieve generated entry_id for guild notice."); + } + } + } + } + + /** + * Loads the guild's current board notice from the database. + * + * Queries the `guild.board_entry` table for the entry marked as a notice. + * Assumes there is at most one notice per guild. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param guildId the guild ID whose notice should be loaded + * @return the GuildBoardEntry marked as notice, or null if none exists + * @throws SQLException if a database access error occurs + */ + public static GuildBoardEntry loadBoardNotice(Connection conn, int guildId) throws SQLException { + String sql = """ + SELECT id, character_id, title, message, timestamp, emoticon + FROM guild.board_entry + WHERE guild_id = ? AND notice = TRUE + LIMIT 1 + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return new GuildBoardEntry( + rs.getInt("id"), + rs.getInt("character_id"), + rs.getString("title"), + rs.getString("message"), + rs.getTimestamp("timestamp").toInstant(), + rs.getInt("emoticon") + ); + } + } + } + + return null; + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/EquipDataDao.java b/src/main/java/kinoko/database/postgresql/type/EquipDataDao.java index a83be181..2c3ddd17 100644 --- a/src/main/java/kinoko/database/postgresql/type/EquipDataDao.java +++ b/src/main/java/kinoko/database/postgresql/type/EquipDataDao.java @@ -2,8 +2,6 @@ import kinoko.world.item.EquipData; import kinoko.world.item.Item; -import org.apache.logging.log4j.LogManager; -import org.apache.logging.log4j.Logger; import java.sql.Connection; import java.sql.PreparedStatement; diff --git a/src/main/java/kinoko/database/postgresql/type/GuildDao.java b/src/main/java/kinoko/database/postgresql/type/GuildDao.java new file mode 100644 index 00000000..ca364837 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/GuildDao.java @@ -0,0 +1,212 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildBoardEntry; +import kinoko.server.guild.GuildMember; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +public class GuildDao { + + /** + * Checks if a guild name is available for use by verifying that it does not already exist in the database. + * + * Performs a case-insensitive lookup in the `guild.guilds` table using the provided connection. + * Returns true if no guild with the same name exists, false otherwise. + * + * @param conn the active SQL connection to use for the query + * @param name the guild name to check for availability + * @return true if the guild name is available; false if it already exists or an error occurs + */ + public static boolean checkGuildNameAvailable(Connection conn, String name) throws SQLException { + String sql = "SELECT 1 FROM guild.guilds WHERE LOWER(name) = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, name.toLowerCase()); + try (ResultSet rs = stmt.executeQuery()) { + return !rs.next(); + } + } + } + + /** + * Inserts a new guild record and all related data into the database within the provided transaction connection. + * + * This method first verifies that the guild name is available before inserting the guild into the `guild.guilds` table. + * After the main guild record is inserted, it saves the associated members, board entries, and notice using the same connection. + * If the guild name is already taken, the method returns false without modifying the database. + * + * @param conn the active SQL connection used for the transaction + * @param guild the guild object containing all information to insert + * @return true if the guild was successfully inserted; false if the guild name was unavailable + * @throws SQLException if a database error occurs during insertion or related saves + */ + public static synchronized boolean insertGuild(Connection conn, Guild guild) throws SQLException { + if (!checkGuildNameAvailable(conn, guild.getGuildName())) return false; + + String sql = "INSERT INTO guild.guilds (name, grade_names, member_max, mark_bg, mark_bg_color, mark, mark_color, notice, points, level, board_entry_counter) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, guild.getGuildName()); + stmt.setArray(2, conn.createArrayOf("text", guild.getGradeNames().toArray())); + stmt.setInt(3, guild.getMemberMax()); + stmt.setShort(4, guild.getMarkBg()); + stmt.setByte(5, guild.getMarkBgColor()); + stmt.setShort(6, guild.getMark()); + stmt.setByte(7, guild.getMarkColor()); + stmt.setString(8, guild.getNotice()); + stmt.setInt(9, guild.getPoints()); + stmt.setByte(10, guild.getLevel()); + stmt.setInt(11, guild.getBoardEntryCounter().get()); + + stmt.executeUpdate(); + + GuildMemberDao.saveMembers(conn, guild); + BoardEntryDao.saveBoardEntries(conn, guild); + BoardNoticeDao.saveBoardNotice(conn, guild); + return true; + } + } + + /** + * Updates an existing guild's information in the database using the provided connection. + * + * Modifies all relevant guild fields such as name, grade names, emblems, notice, points, and level. + * Also updates related members, board entries, and board notice after the main record update. + * + * @param conn the active SQL connection to use for the update + * @param guild the guild object containing updated data + * @return true if the update affected at least one row; false otherwise + * @throws SQLException if a database error occurs during the update + */ + public static boolean updateGuild(Connection conn, Guild guild) throws SQLException { + String sql = "UPDATE guild.guilds SET " + + "name = ?, " + + "grade_names = ?, " + + "member_max = ?, " + + "mark_bg = ?, " + + "mark_bg_color = ?, " + + "mark = ?, " + + "mark_color = ?, " + + "notice = ?, " + + "points = ?, " + + "level = ?, " + + "board_entry_counter = ? " + + "WHERE id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + + stmt.setString(1, guild.getGuildName()); + stmt.setArray(2, conn.createArrayOf("text", guild.getGradeNames().toArray())); + stmt.setInt(3, guild.getMemberMax()); + stmt.setShort(4, guild.getMarkBg()); + stmt.setByte(5, guild.getMarkBgColor()); + stmt.setShort(6, guild.getMark()); + stmt.setByte(7, guild.getMarkColor()); + stmt.setString(8, guild.getNotice()); + stmt.setInt(9, guild.getPoints()); + stmt.setByte(10, guild.getLevel()); + stmt.setInt(11, guild.getBoardEntryCounter().get()); + stmt.setInt(12, guild.getGuildId()); + + int rows = stmt.executeUpdate(); + + GuildMemberDao.saveMembers(conn, guild); + BoardEntryDao.saveBoardEntries(conn, guild); + BoardNoticeDao.saveBoardNotice(conn, guild); + + return rows > 0; + } + } + + /** + * Loads a Guild object from the provided ResultSet. + * + * Populates the guild's basic info, members, grades, board entries, + * and the board notice entry. Assumes the ResultSet is already positioned + * at the correct row. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param rs the ResultSet containing guild data + * @return a fully populated Guild object + * @throws SQLException if a database access error occurs + */ + public static Guild loadGuild(Connection conn, ResultSet rs) throws SQLException { + final int guildId = rs.getInt("id"); + final String guildName = rs.getString("name"); + Guild guild = new Guild(guildId, guildName); + + guild.setMemberMax(rs.getInt("member_max")); + guild.setMarkBg(rs.getShort("mark_bg")); + guild.setMarkBgColor(rs.getByte("mark_bg_color")); + guild.setMark(rs.getShort("mark")); + guild.setMarkColor(rs.getByte("mark_color")); + guild.setNotice(rs.getString("notice")); + guild.setPoints(rs.getInt("points")); + guild.setLevel(rs.getByte("level")); + + // Load members + List members = GuildMemberDao.loadMembers(conn, guildId); + for (GuildMember member : members) { + guild.addMember(member); + } + + // Load grade names + guild.setGradeNames(loadGrades(conn, guildId)); + + // Load board entries + List boardEntries = BoardEntryDao.loadBoardEntries(conn, guildId); + guild.getBoardEntries().addAll(boardEntries); + + // Load board notice entry + GuildBoardEntry noticeEntry = BoardNoticeDao.loadBoardNotice(conn, guildId); + guild.setBoardNoticeEntry(noticeEntry); + + return guild; + } + + /** + * Deletes a guild from the database along with all related data. + * + * This method expects an active SQL connection, allowing it to be part + * of a larger transaction. + * + * @param conn the active SQL connection (part of a transaction) + * @param guildId the ID of the guild to delete + * @return true if the guild was deleted, false otherwise + * @throws SQLException if a database access error occurs + */ + public static boolean deleteGuild(Connection conn, int guildId) throws SQLException { + String sql = "DELETE FROM guild.guilds WHERE guild_id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guildId); + return stmt.executeUpdate() > 0; + } + } + + /** + * Loads the grade names for a specific guild from the database. + * + * Retrieves all grade names associated with the given guild ID + * and returns them as a list of strings. The order of the grades + * is determined by the database query. + * + * @param conn the active SQL connection to use + * @param guildId the ID of the guild whose grades should be loaded + * @return a list of grade names for the specified guild + * @throws SQLException if a database access error occurs + */ + private static List loadGrades(Connection conn, int guildId) throws SQLException { + List grades = new ArrayList<>(); + String sql = "SELECT grade_name FROM guild.grade WHERE guild_id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + grades.add(rs.getString("grade_name")); + } + } + } + return grades; + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java b/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java new file mode 100644 index 00000000..6a8f9b4e --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java @@ -0,0 +1,169 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildMember; +import kinoko.server.guild.GuildRank; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.List; +import java.util.stream.Collectors; + + +public class GuildMemberDao { + + /** + * Synchronizes the members of a guild in the database with the current list in memory. + * + * Deletes members that are no longer in the guild and inserts or updates members + * that exist in the current guild member list, minimizing redundant database operations. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param guild the guild whose members should be synchronized + * @throws SQLException if a database access error occurs + */ + public static void saveMembers(Connection conn, Guild guild) throws SQLException { + List members = guild.getGuildMembers(); + + if (members == null || members.isEmpty()) { + // No members at all — clear any remaining entries + deleteAllMembers(conn, guild.getGuildId()); + return; + } + + deleteRemovedMembers(conn, guild, members); + upsertMembers(conn, guild, members); + } + + /** + * Deletes all members for a given guild from the database. + * + * @param conn the active SQL connection + * @param guildId the guild ID whose members should be deleted + * @throws SQLException if a database access error occurs + */ + public static void deleteAllMembers(Connection conn, int guildId) throws SQLException { + try (PreparedStatement stmt = conn.prepareStatement( + "DELETE FROM guild.member WHERE guild_id = ?")) { + stmt.setInt(1, guildId); + stmt.executeUpdate(); + } + } + + /** + * Deletes all guild members from the database that are no longer present + * in the provided in-memory guild member list. + * + * Only members that exist in the database but not in the current list + * will be deleted. + * + * @param conn the SQL connection + * @param guild the guild whose members are being synchronized + * @param members the current list of guild members that should remain + * @throws SQLException if a database access error occurs + */ + public static void deleteRemovedMembers(Connection conn, Guild guild, List members) throws SQLException { + List currentIds = members.stream() + .map(GuildMember::getCharacterId) + .toList(); + + // If there are no current members, remove all from DB + if (currentIds.isEmpty()) { + deleteAllMembers(conn, guild.getGuildId()); + return; + } + + String placeholders = currentIds.stream().map(id -> "?").collect(Collectors.joining(",")); + String sql = "DELETE FROM guild.member WHERE guild_id = ? AND character_id NOT IN (" + placeholders + ")"; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + int idx = 1; + stmt.setInt(idx++, guild.getGuildId()); + for (Integer id : currentIds) { + stmt.setInt(idx++, id); + } + stmt.executeUpdate(); + } + } + + /** + * Inserts or updates guild members in the database to match the provided in-memory list. + * Existing members are updated with their new grade; new members are inserted. + * + * @param conn the SQL connection + * @param guild the guild whose members are being synchronized + * @param members the list of members to insert or update + * @throws SQLException if a database access error occurs + */ + public static void upsertMembers(Connection conn, Guild guild, List members) throws SQLException { + String sql = """ + INSERT INTO guild.member (guild_id, character_id, grade) + VALUES (?, ?, ?) + ON CONFLICT (guild_id, character_id) DO UPDATE SET grade = EXCLUDED.grade + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (GuildMember member : members) { + stmt.setInt(1, guild.getGuildId()); + stmt.setInt(2, member.getCharacterId()); + stmt.setShort(3, (short) member.getGuildRank().getValue()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Loads all members of a given guild from the database. + * + * Retrieves character information, stats, guild rank, and online status + * for each member of the specified guild. + * + * @param conn the active SQL connection to use (part of a transaction if applicable) + * @param guildId the ID of the guild whose members should be loaded + * @return a list of GuildMember objects representing the current members + * @throws SQLException if a database access error occurs + */ + public static List loadMembers(Connection conn, int guildId) throws SQLException { + List members = new ArrayList<>(); + String sql = """ + SELECT c.character_id, c.character_name, s.job, s.level, + m.grade AS guildRank, NULL AS allianceRank, c.online + FROM guild.member m + JOIN player.characters c ON c.character_id = m.character_id + JOIN character.stats s ON s.character_id = c.character_id + WHERE m.guild_id = ? + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int charId = rs.getInt("character_id"); + String charName = rs.getString("character_name"); + int job = rs.getInt("job"); + int level = rs.getInt("level"); + boolean online = rs.getBoolean("online"); + int guildRankInt = rs.getInt("guildRank"); + Integer allianceRankInt = null; // no alliance rank yet + + members.add(new GuildMember( + charId, + charName, + job, + level, + online, + GuildRank.getByValue(guildRankInt), + allianceRankInt != null ? GuildRank.getByValue(allianceRankInt) : null + )); + } + } + } + + return members; + } + +} diff --git a/src/main/java/kinoko/database/postgresql/type/LockerDao.java b/src/main/java/kinoko/database/postgresql/type/LockerDao.java index c6e9bd22..86fd3616 100644 --- a/src/main/java/kinoko/database/postgresql/type/LockerDao.java +++ b/src/main/java/kinoko/database/postgresql/type/LockerDao.java @@ -89,4 +89,44 @@ private static void deleteUnusedItems(Connection conn, int accountId, Collection } } } + + /** + * Loads the Locker for a specific account. + * + * Retrieves all locker items joined with the item table. Constructs CashItemInfo + * objects and adds them to a Locker. The resulting Locker contains all saved items. + * + * @param conn the active database connection + * @param accountId the ID of the account whose locker should be loaded + * @return the Locker with all items for the account + * @throws SQLException if a database error occurs + */ + public static Locker load(Connection conn, int accountId) throws SQLException { + Locker locker = new Locker(); + String sql = """ + SELECT li.slot, li.item_sn, li.commodity_id, i.item_id, i.quantity + FROM account.locker_item li + JOIN item.items i ON li.item_sn = i.item_sn + WHERE li.account_id = ? ORDER BY li.slot + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + Item item = new Item(rs.getInt("item_id"), (short) rs.getInt("quantity")); + CashItemInfo info = new CashItemInfo( + item, + rs.getInt("commodity_id"), + accountId, // account owner + -1, // character owner unknown at this point + null + ); + locker.addCashItem(info); + } + } + } + + return locker; + } } diff --git a/src/main/java/kinoko/database/postgresql/type/TrunkDao.java b/src/main/java/kinoko/database/postgresql/type/TrunkDao.java index 3fd5cb86..e5cc8ea9 100644 --- a/src/main/java/kinoko/database/postgresql/type/TrunkDao.java +++ b/src/main/java/kinoko/database/postgresql/type/TrunkDao.java @@ -1,7 +1,6 @@ package kinoko.database.postgresql.type; -import kinoko.provider.item.ItemInfo; import kinoko.server.ServerConfig; import kinoko.world.item.*; diff --git a/src/main/java/kinoko/database/postgresql/type/WishlistDao.java b/src/main/java/kinoko/database/postgresql/type/WishlistDao.java index 2751f7ef..8e927a65 100644 --- a/src/main/java/kinoko/database/postgresql/type/WishlistDao.java +++ b/src/main/java/kinoko/database/postgresql/type/WishlistDao.java @@ -1,6 +1,8 @@ package kinoko.database.postgresql.type; import java.sql.*; +import java.util.ArrayList; +import java.util.Collections; import java.util.List; @@ -72,4 +74,36 @@ public static void deleteUnusedItems(Connection conn, int accountId, List load(Connection conn, int accountId) throws SQLException { + List wishlist = new ArrayList<>(); + String sql = "SELECT w.item_id FROM account.wishlist w WHERE w.account_id = ? ORDER BY w.slot"; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + wishlist.add(rs.getInt("item_id")); + } + } + } + + // Pad with zeros to always return 10 items + while (wishlist.size() < 10) { + wishlist.add(0); + } + + return Collections.unmodifiableList(wishlist); + } } diff --git a/src/main/java/kinoko/database/postgresql/util/SQLAction.java b/src/main/java/kinoko/database/postgresql/util/SQLAction.java new file mode 100644 index 00000000..2eb4f2d5 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/util/SQLAction.java @@ -0,0 +1,9 @@ +package kinoko.database.postgresql.util; + +import java.sql.Connection; +import java.sql.SQLException; + +@FunctionalInterface +public interface SQLAction { + void apply(Connection conn) throws SQLException; +} diff --git a/src/main/java/kinoko/database/postgresql/util/SQLBooleanAction.java b/src/main/java/kinoko/database/postgresql/util/SQLBooleanAction.java new file mode 100644 index 00000000..2f39b3e0 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/util/SQLBooleanAction.java @@ -0,0 +1,9 @@ +package kinoko.database.postgresql.util; + +import java.sql.Connection; +import java.sql.SQLException; + +@FunctionalInterface +public interface SQLBooleanAction { + boolean apply(Connection conn) throws SQLException; +} diff --git a/src/main/java/kinoko/server/guild/Guild.java b/src/main/java/kinoko/server/guild/Guild.java index 1f0d176b..2739a06a 100644 --- a/src/main/java/kinoko/server/guild/Guild.java +++ b/src/main/java/kinoko/server/guild/Guild.java @@ -1,5 +1,6 @@ package kinoko.server.guild; +import kinoko.database.DatabaseManager; import kinoko.server.packet.OutPacket; import kinoko.server.user.RemoteUser; import kinoko.util.Encodable; @@ -215,6 +216,11 @@ public List getBoardEntryList(int page) { } public int getNextBoardEntryId() { + if (DatabaseManager.isRelational()) { + // Let the relational database handle SN generation; return placeholder + return -1; + } + return boardEntryCounter.getAndIncrement(); } diff --git a/src/main/java/kinoko/server/guild/GuildBoardComment.java b/src/main/java/kinoko/server/guild/GuildBoardComment.java index 31c157e5..50fe1b74 100644 --- a/src/main/java/kinoko/server/guild/GuildBoardComment.java +++ b/src/main/java/kinoko/server/guild/GuildBoardComment.java @@ -6,7 +6,7 @@ import java.time.Instant; public final class GuildBoardComment implements Encodable { - private final int commentSn; + private int commentSn; private final int characterId; private String text; private Instant date; @@ -22,6 +22,10 @@ public int getCommentSn() { return commentSn; } + public void setCommentSn(int newCommentSn) { + this.commentSn = newCommentSn; + } + public int getCharacterId() { return characterId; } @@ -50,4 +54,17 @@ public void encode(OutPacket outPacket) { outPacket.encodeFT(date); // ftDate outPacket.encodeString(text); // sComment } + + /** + * Checks whether this GuildBoardComment has a valid comment serial number (SN). + * + * In relational databases, comment SNs are typically automatically generated. + * Returns true if the comment has no SN and therefore needs to be inserted + * into the database to obtain one. + * + * @return true if the comment SN is zero or negative, false otherwise + */ + public boolean hasNoSN() { + return getCommentSn() <= 0; + } } diff --git a/src/main/java/kinoko/server/guild/GuildBoardEntry.java b/src/main/java/kinoko/server/guild/GuildBoardEntry.java index df372be0..ea72e450 100644 --- a/src/main/java/kinoko/server/guild/GuildBoardEntry.java +++ b/src/main/java/kinoko/server/guild/GuildBoardEntry.java @@ -1,5 +1,6 @@ package kinoko.server.guild; +import kinoko.database.DatabaseManager; import kinoko.server.packet.OutPacket; import java.time.Instant; @@ -9,7 +10,7 @@ import java.util.concurrent.atomic.AtomicInteger; public final class GuildBoardEntry { - private final int entryId; + private int entryId; private final int characterId; private String title; private String text; @@ -33,6 +34,10 @@ public int getEntryId() { return entryId; } + public void setEntryId(int newEntryId) { + this.entryId = newEntryId; + } + public int getCharacterId() { return characterId; } @@ -89,6 +94,11 @@ public void setCommentSnCounter(AtomicInteger commentSnCounter) { // HELPER METHODS -------------------------------------------------------------------------------------------------- public int getNextCommentSn() { + if (DatabaseManager.isRelational()) { + // Let the relational database handle SN generation; return placeholder + return -1; + } + return commentSnCounter.getAndIncrement(); } @@ -132,4 +142,17 @@ public void encodeCurrent(OutPacket outPacket) { comment.encode(outPacket); // CUIGuildBBS::COMMENT } } + + /** + * Checks whether this GuildBoardEntry has a valid entry ID. + * + * In relational databases, entry IDs are typically automatically generated. + * Returns true if the entry has no ID and therefore needs to be inserted + * into the database to obtain one. + * + * @return true if the entry ID is zero or negative, false otherwise + */ + public boolean hasNoSN() { + return getEntryId() <= 0; + } } From 2a6d32ead8da9451ba65aa087bab4b9d440c665e Mon Sep 17 00:00:00 2001 From: MujyKun Date: Fri, 10 Oct 2025 20:38:40 -0400 Subject: [PATCH 40/83] Added which port a channel fails to bind to --- .../java/kinoko/server/node/ChannelServerNode.java | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/src/main/java/kinoko/server/node/ChannelServerNode.java b/src/main/java/kinoko/server/node/ChannelServerNode.java index aa6b9340..5078042b 100644 --- a/src/main/java/kinoko/server/node/ChannelServerNode.java +++ b/src/main/java/kinoko/server/node/ChannelServerNode.java @@ -257,8 +257,14 @@ public void initialize() throws InterruptedException, UnknownHostException { // Start channel server final ChannelServerNode self = this; - channelServerFuture = startServer(new PacketChannelInitializer(new ChannelPacketHandler(), self), channelPort); - channelServerFuture.sync(); + try { + channelServerFuture = startServer(new PacketChannelInitializer(new ChannelPacketHandler(), self), channelPort); + channelServerFuture.sync(); + } + catch(Exception e){ + log.error("Channel {} failed to bind to port {}", channelId + 1, channelPort); + throw e; + } log.info("Channel {} listening on port {}", channelId + 1, channelPort); // Start central client From d15f4b0baf61737e2d1b6923351d2dab7ab1de15 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Fri, 10 Oct 2025 21:26:02 -0400 Subject: [PATCH 41/83] Added batch file to kill ports --- utility/kill_ports.bat | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) create mode 100644 utility/kill_ports.bat diff --git a/utility/kill_ports.bat b/utility/kill_ports.bat new file mode 100644 index 00000000..3394e2b5 --- /dev/null +++ b/utility/kill_ports.bat @@ -0,0 +1,18 @@ +@echo off +setlocal + +REM Kills the default ports on Windows cuz wallahi I'm done trying to find out which processes are taking up my ports. + +REM List of ports +set PORTS=8282 8585 8586 8587 8588 8589 + +for %%P in (%PORTS%) do ( + echo Checking port %%P... + for /f "tokens=5" %%A in ('netstat -aon ^| findstr :%%P') do ( + echo Killing PID %%A on port %%P + taskkill /F /PID %%A + ) +) + +echo Done! +pause From df55a84c03fcf60c7d13d721a36f994c6112f249 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sat, 11 Oct 2025 02:20:15 -0400 Subject: [PATCH 42/83] Added Save to Guilds on server shutdown | Added Guild Roles | Added Boards/Notices | Added Board Comments --- .../java/kinoko/database/DatabaseManager.java | 7 ++ .../java/kinoko/database/GuildAccessor.java | 10 ++ .../postgresql/PostgresCharacterAccessor.java | 3 +- .../postgresql/PostgresGuildAccessor.java | 5 +- .../kinoko/database/postgresql/setup/init.sql | 12 +- .../postgresql/type/BoardEntryCommentDao.java | 70 ++++++++--- .../postgresql/type/BoardEntryDao.java | 111 +++++++----------- .../postgresql/type/BoardNoticeDao.java | 28 ++--- .../database/postgresql/type/GuildDao.java | 100 +++++++++++----- .../postgresql/type/GuildMemberDao.java | 14 +-- src/main/java/kinoko/server/ServerConfig.java | 2 +- src/main/java/kinoko/server/guild/Guild.java | 12 +- .../kinoko/server/guild/GuildBoardEntry.java | 13 -- .../kinoko/server/guild/GuildStorage.java | 11 ++ .../kinoko/server/node/CentralServerNode.java | 4 + 15 files changed, 239 insertions(+), 163 deletions(-) diff --git a/src/main/java/kinoko/database/DatabaseManager.java b/src/main/java/kinoko/database/DatabaseManager.java index baba218e..9d8ff108 100644 --- a/src/main/java/kinoko/database/DatabaseManager.java +++ b/src/main/java/kinoko/database/DatabaseManager.java @@ -3,6 +3,7 @@ import kinoko.database.postgresql.PostgresConnector; import kinoko.database.cassandra.CassandraConnector; import kinoko.server.ServerConstants; +import kinoko.server.guild.GuildStorage; import java.util.List; import java.util.Objects; @@ -65,6 +66,12 @@ else if (Objects.equals(ServerConstants.DATABASE_HOST, "postgres_kinoko") connector.initialize(); } + public static void saveAll() { + if (connector != null) { + + } + } + public static void shutdown() { if (connector != null) { connector.shutdown(); diff --git a/src/main/java/kinoko/database/GuildAccessor.java b/src/main/java/kinoko/database/GuildAccessor.java index dc326191..122ea49b 100644 --- a/src/main/java/kinoko/database/GuildAccessor.java +++ b/src/main/java/kinoko/database/GuildAccessor.java @@ -1,8 +1,10 @@ package kinoko.database; import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildBoardEntry; import kinoko.server.guild.GuildRanking; +import java.util.Collection; import java.util.List; import java.util.Optional; @@ -18,4 +20,12 @@ public interface GuildAccessor { boolean deleteGuild(int guildId); List getGuildRankings(); + + default void saveAll(Collection guilds){ + if (guilds == null || guilds.isEmpty()) return; + + for (Guild guild : guilds) { + saveGuild(guild); + } + } } \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java index 3ed7d4f9..091cb5c2 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -399,9 +399,10 @@ public boolean checkCharacterNameAvailable(String name) { @Override public Optional getCharacterById(int characterId) { String sql = """ - SELECT c.*, s.* + SELECT c.*, s.*, m.guild_id, m.grade FROM player.characters c LEFT JOIN player.stats s ON c.id = s.character_id + LEFT JOIN guild.member m ON m.character_id = c.id WHERE c.id = ? """; try (Connection conn = dataSource.getConnection(); diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java index 66eabb5c..f9029fb2 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -2,8 +2,11 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.GuildAccessor; +import kinoko.database.postgresql.type.BoardEntryDao; +import kinoko.database.postgresql.type.BoardNoticeDao; import kinoko.database.postgresql.type.GuildDao; import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildBoardEntry; import kinoko.server.guild.GuildRanking; import java.sql.*; @@ -28,7 +31,7 @@ public PostgresGuildAccessor(HikariDataSource dataSource) { */ @Override public Optional getGuildById(int guildId) { - String sql = "SELECT * FROM guild.guilds WHERE guild_id = ?"; + String sql = "SELECT * FROM guild.guilds WHERE id = ?"; try (Connection conn = getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index b2eebea4..ad435636 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -402,21 +402,22 @@ CREATE TABLE IF NOT EXISTS guild.member ( guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, grade SMALLINT NOT NULL, - join_date TIMESTAMP NOT NULL, + join_date TIMESTAMP NOT NULL DEFAULT UTC_NOW(), last_login TIMESTAMP, - PRIMARY KEY (guild_id, character_id) + PRIMARY KEY (character_id) ); CREATE TABLE IF NOT EXISTS guild.board_entry ( - id SERIAL PRIMARY KEY, + id INT NOT NULL, guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, title TEXT, message TEXT NOT NULL, emoticon INT, timestamp TIMESTAMP NOT NULL DEFAULT UTC_NOW(), - notice boolean DEFAULT false + notice boolean DEFAULT false, + PRIMARY KEY (guild_id, id) ); -- enforce only one TRUE notice per guild @@ -426,7 +427,8 @@ WHERE notice = TRUE; CREATE TABLE IF NOT EXISTS guild.board_entry_comment ( id SERIAL PRIMARY KEY, - entry_id INT NOT NULL REFERENCES guild.board_entry(id) ON DELETE CASCADE, + entry_id INT NOT NULL, + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, text TEXT NOT NULL, timestamp TIMESTAMP NOT NULL DEFAULT UTC_NOW() diff --git a/src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java b/src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java index bf26794c..97b1f63c 100644 --- a/src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java +++ b/src/main/java/kinoko/database/postgresql/type/BoardEntryCommentDao.java @@ -1,5 +1,6 @@ package kinoko.database.postgresql.type; +import kinoko.server.guild.Guild; import kinoko.server.guild.GuildBoardComment; import kinoko.server.guild.GuildBoardEntry; @@ -20,16 +21,16 @@ public class BoardEntryCommentDao { * @param entry the board entry whose comments should be synchronized * @throws SQLException if a database access error occurs */ - public static void saveComments(Connection conn, GuildBoardEntry entry) throws SQLException { + public static void saveComments(Connection conn, GuildBoardEntry entry, Guild guild) throws SQLException { List comments = entry.getComments(); if (comments == null || comments.isEmpty()) { - deleteAllComments(conn, entry.getEntryId()); + deleteAllComments(conn, entry.getEntryId(), guild.getGuildId()); return; } - deleteRemovedComments(conn, entry, comments); - insertNewComments(conn, entry, comments); + deleteRemovedComments(conn, entry, comments, guild.getGuildId()); + insertNewComments(conn, entry, comments, guild.getGuildId()); } /** @@ -42,10 +43,11 @@ public static void saveComments(Connection conn, GuildBoardEntry entry) throws S * @param entryId the ID of the board entry whose comments should be deleted * @throws SQLException if a database access error occurs */ - private static void deleteAllComments(Connection conn, int entryId) throws SQLException { + private static void deleteAllComments(Connection conn, int entryId, int guildId) throws SQLException { try (PreparedStatement stmt = conn.prepareStatement( - "DELETE FROM guild.board_entry_comment WHERE entry_id = ?")) { + "DELETE FROM guild.board_entry_comment WHERE entry_id = ? AND guild_id = ?")) { stmt.setInt(1, entryId); + stmt.setInt(2, guildId); stmt.executeUpdate(); } } @@ -59,7 +61,7 @@ private static void deleteAllComments(Connection conn, int entryId) throws SQLEx * @param comments the current list of comments that should remain * @throws SQLException if a database access error occurs */ - private static void deleteRemovedComments(Connection conn, GuildBoardEntry entry, List comments) throws SQLException { + private static void deleteRemovedComments(Connection conn, GuildBoardEntry entry, List comments, int guildId) throws SQLException { List currentIds = comments.stream() .filter(c -> !c.hasNoSN()) .map(GuildBoardComment::getCommentSn) @@ -68,10 +70,11 @@ private static void deleteRemovedComments(Connection conn, GuildBoardEntry entry if (currentIds.isEmpty()) return; String placeholders = currentIds.stream().map(id -> "?").collect(Collectors.joining(",")); - String sql = "DELETE FROM guild.board_entry_comment WHERE entry_id = ? AND id NOT IN (" + placeholders + ")"; + String sql = "DELETE FROM guild.board_entry_comment WHERE entry_id = ? AND guild_id = ? AND id NOT IN (" + placeholders + ")"; try (PreparedStatement stmt = conn.prepareStatement(sql)) { int idx = 1; stmt.setInt(idx++, entry.getEntryId()); + stmt.setInt(idx++, guildId); for (Integer id : currentIds) stmt.setInt(idx++, id); stmt.executeUpdate(); } @@ -89,19 +92,20 @@ private static void deleteRemovedComments(Connection conn, GuildBoardEntry entry * @throws SQLException if a database access error occurs or * the generated keys cannot be retrieved */ - private static void insertNewComments(Connection conn, GuildBoardEntry entry, List comments) throws SQLException { + private static void insertNewComments(Connection conn, GuildBoardEntry entry, List comments, int guildId) throws SQLException { String insertSql = """ - INSERT INTO guild.board_entry_comment (entry_id, character_id, text, timestamp) - VALUES (?, ?, ?, ?) + INSERT INTO guild.board_entry_comment (entry_id, guild_id, character_id, text, timestamp) + VALUES (?, ?, ?, ?, ?) """; try (PreparedStatement stmt = conn.prepareStatement(insertSql, Statement.RETURN_GENERATED_KEYS)) { List newComments = comments.stream().filter(GuildBoardComment::hasNoSN).toList(); for (GuildBoardComment comment : newComments) { stmt.setInt(1, entry.getEntryId()); - stmt.setInt(2, comment.getCharacterId()); - stmt.setString(3, comment.getText()); - stmt.setTimestamp(4, Timestamp.from(comment.getDate())); + stmt.setInt(2, guildId); + stmt.setInt(3, comment.getCharacterId()); + stmt.setString(4, comment.getText()); + stmt.setTimestamp(5, Timestamp.from(comment.getDate())); stmt.addBatch(); } @@ -118,4 +122,42 @@ private static void insertNewComments(Connection conn, GuildBoardEntry entry, Li } } } + + /** + * Loads all comments for a given guild from the database, grouped by board entry. + * + * @param conn the active SQL connection + * @param guildId the ID of the guild whose comments should be loaded + * @return a mapping from entryId to a list of GuildBoardComment objects + * @throws SQLException if a database access error occurs + */ + public static java.util.Map> loadComments(Connection conn, int guildId) throws SQLException { + String sql = """ + SELECT id, entry_id, guild_id, character_id, text, timestamp + FROM guild.board_entry_comment + WHERE guild_id = ? + ORDER BY entry_id ASC, timestamp ASC + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + java.util.Map> commentsByEntry = new java.util.HashMap<>(); + + while (rs.next()) { + int entryId = rs.getInt("entry_id"); + GuildBoardComment comment = new GuildBoardComment( + rs.getInt("id"), + rs.getInt("character_id"), + rs.getString("text"), + rs.getTimestamp("timestamp").toInstant() + ); + + commentsByEntry.computeIfAbsent(entryId, k -> new java.util.ArrayList<>()).add(comment); + } + + return commentsByEntry; + } + } + } } diff --git a/src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java b/src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java index b06f4978..ed80c739 100644 --- a/src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java +++ b/src/main/java/kinoko/database/postgresql/type/BoardEntryDao.java @@ -1,9 +1,11 @@ package kinoko.database.postgresql.type; import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildBoardComment; import kinoko.server.guild.GuildBoardEntry; import java.sql.*; +import java.time.Instant; import java.util.ArrayList; import java.util.List; import java.util.stream.Collectors; @@ -33,24 +35,12 @@ public static void saveBoardEntries(Connection conn, Guild guild) throws SQLExce // Delete board entries that have been removed. deleteRemovedBoardEntries(conn, guild, entries); - // Split entries into new inserts and existing updates - List newEntries = entries.stream() - .filter(GuildBoardEntry::hasNoSN) - .toList(); - - List existingEntries = entries.stream() - .filter(entry -> !entry.hasNoSN()) - .toList(); - - // Insert new entries and assign generated SNs - insertBoardEntries(conn, guild.getGuildId(), newEntries); - // Update existing entries - updateBoardEntries(conn, guild.getGuildId(), existingEntries); + upsertBoardEntries(conn, guild.getGuildId(), entries); // Synchronize comments for each entry for (GuildBoardEntry entry : entries) { - BoardEntryCommentDao.saveComments(conn, entry); + BoardEntryCommentDao.saveComments(conn, entry, guild); } } @@ -107,67 +97,41 @@ private static void deleteRemovedBoardEntries(Connection conn, Guild guild, List } /** - * Inserts new board entries into the database and sets their generated IDs. + * Inserts or updates guild board entries in the database. * - * @param conn the SQL connection - * @param guildId the guild ID - * @param newEntries list of entries to insert - * @throws SQLException if a database error occurs - */ - private static void insertBoardEntries(Connection conn, int guildId, List newEntries) throws SQLException { - if (newEntries.isEmpty()) return; - - String sql = """ - INSERT INTO guild.board_entry (guild_id, character_id, title, message, emoticon) - VALUES (?, ?, ?, ?, ?) - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql, Statement.RETURN_GENERATED_KEYS)) { - for (GuildBoardEntry entry : newEntries) { - stmt.setInt(1, guildId); - stmt.setInt(2, entry.getCharacterId()); - stmt.setString(3, entry.getTitle()); - stmt.setString(4, entry.getText()); - stmt.setInt(5, entry.getEmoticon()); - stmt.addBatch(); - } - - stmt.executeBatch(); - - try (ResultSet rs = stmt.getGeneratedKeys()) { - int i = 0; - while (rs.next()) { - newEntries.get(i++).setEntryId(rs.getInt(1)); - } - } - } - } - - /** - * Updates existing board entries in the database. + * If a board entry with the same guild_id and id already exists, this method updates + * its character_id, title, message, and emoticon fields. Otherwise, a new row is inserted. + * + * Because id is a per-guild serial, callers should ensure that new entries have a valid + * or newly assigned id value before invoking this method. * - * @param conn the SQL connection - * @param guildId the guild ID - * @param existingEntries list of entries to update + * @param conn the SQL connection + * @param guildId the guild ID associated with the board entries + * @param entries list of board entries to insert or update * @throws SQLException if a database error occurs */ - private static void updateBoardEntries(Connection conn, int guildId, List existingEntries) throws SQLException { - if (existingEntries.isEmpty()) return; + private static void upsertBoardEntries(Connection conn, int guildId, List entries) throws SQLException { + if (entries.isEmpty()) return; String sql = """ - UPDATE guild.board_entry - SET character_id = ?, title = ?, message = ?, emoticon = ? - WHERE guild_id = ? AND id = ? + INSERT INTO guild.board_entry (guild_id, id, character_id, title, message, emoticon) + VALUES (?, ?, ?, ?, ?, ?) + ON CONFLICT (guild_id, id) + DO UPDATE SET + character_id = EXCLUDED.character_id, + title = EXCLUDED.title, + message = EXCLUDED.message, + emoticon = EXCLUDED.emoticon """; try (PreparedStatement stmt = conn.prepareStatement(sql)) { - for (GuildBoardEntry entry : existingEntries) { - stmt.setInt(1, entry.getCharacterId()); - stmt.setString(2, entry.getTitle()); - stmt.setString(3, entry.getText()); - stmt.setInt(4, entry.getEmoticon()); - stmt.setInt(5, guildId); - stmt.setInt(6, entry.getEntryId()); + for (GuildBoardEntry entry : entries) { + stmt.setInt(1, guildId); + stmt.setInt(2, entry.getEntryId()); // must already be set or generated + stmt.setInt(3, entry.getCharacterId()); + stmt.setString(4, entry.getTitle()); + stmt.setString(5, entry.getText()); + stmt.setInt(6, entry.getEmoticon()); stmt.addBatch(); } stmt.executeBatch(); @@ -185,7 +149,7 @@ private static void updateBoardEntries(Connection conn, int guildId, List loadBoardEntries(Connection conn, int guildId) throws SQLException { List entries = new ArrayList<>(); String sql = "SELECT id, character_id, title, message, timestamp, emoticon, notice " + - "FROM guild.board_entry WHERE guild_id = ?"; + "FROM guild.board_entry WHERE guild_id = ? AND notice IS FALSE"; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); @@ -204,6 +168,19 @@ public static List loadBoardEntries(Connection conn, int guildI } } + + java.util.Map> entryComments = BoardEntryCommentDao.loadComments(conn, guildId); + + // Attach comments to the corresponding entries + for (GuildBoardEntry entry : entries) { + List comments = entryComments.get(entry.getEntryId()); + if (comments != null) { + for (GuildBoardComment comment : comments) { + entry.addComment(comment); + } + } + } + return entries; } } diff --git a/src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java b/src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java index f215d584..67845b82 100644 --- a/src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java +++ b/src/main/java/kinoko/database/postgresql/type/BoardNoticeDao.java @@ -36,31 +36,25 @@ public static void saveBoardNotice(Connection conn, Guild guild) throws SQLExcep if (notice == null) return; String sql = """ - INSERT INTO guild.board_entry (guild_id, character_id, title, message, emoticon, notice) - VALUES (?, ?, ?, ?, ?, TRUE) - ON CONFLICT (guild_id) WHERE notice = TRUE DO UPDATE SET + INSERT INTO guild.board_entry (guild_id, id, character_id, title, message, emoticon, notice) + VALUES (?, ?, ?, ?, ?, ?, TRUE) + ON CONFLICT (guild_id, id) + DO UPDATE SET character_id = EXCLUDED.character_id, title = EXCLUDED.title, message = EXCLUDED.message, emoticon = EXCLUDED.emoticon, notice = TRUE - RETURNING id """; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guild.getGuildId()); - stmt.setInt(2, notice.getCharacterId()); - stmt.setString(3, notice.getTitle()); - stmt.setString(4, notice.getText()); - stmt.setInt(5, notice.getEmoticon()); - - try (var rs = stmt.executeQuery()) { - if (rs.next()) { - notice.setEntryId(rs.getInt("id")); - } else { - throw new SQLException("Failed to retrieve generated entry_id for guild notice."); - } - } + stmt.setInt(2, notice.getEntryId()); + stmt.setInt(3, notice.getCharacterId()); + stmt.setString(4, notice.getTitle()); + stmt.setString(5, notice.getText()); + stmt.setInt(6, notice.getEmoticon()); + stmt.executeUpdate(); } } @@ -79,7 +73,7 @@ public static GuildBoardEntry loadBoardNotice(Connection conn, int guildId) thro String sql = """ SELECT id, character_id, title, message, timestamp, emoticon FROM guild.board_entry - WHERE guild_id = ? AND notice = TRUE + WHERE guild_id = ? AND notice IS TRUE LIMIT 1 """; diff --git a/src/main/java/kinoko/database/postgresql/type/GuildDao.java b/src/main/java/kinoko/database/postgresql/type/GuildDao.java index ca364837..f53fad84 100644 --- a/src/main/java/kinoko/database/postgresql/type/GuildDao.java +++ b/src/main/java/kinoko/database/postgresql/type/GuildDao.java @@ -6,6 +6,7 @@ import java.sql.*; import java.util.ArrayList; +import java.util.Arrays; import java.util.List; public class GuildDao { @@ -45,26 +46,35 @@ public static boolean checkGuildNameAvailable(Connection conn, String name) thro public static synchronized boolean insertGuild(Connection conn, Guild guild) throws SQLException { if (!checkGuildNameAvailable(conn, guild.getGuildName())) return false; - String sql = "INSERT INTO guild.guilds (name, grade_names, member_max, mark_bg, mark_bg_color, mark, mark_color, notice, points, level, board_entry_counter) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)"; + String sql = "INSERT INTO guild.guilds (name, member_max, mark_bg, mark_bg_color, mark, mark_color, notice, points, level) " + + "VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)" + + "RETURNING id"; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, guild.getGuildName()); - stmt.setArray(2, conn.createArrayOf("text", guild.getGradeNames().toArray())); - stmt.setInt(3, guild.getMemberMax()); - stmt.setShort(4, guild.getMarkBg()); - stmt.setByte(5, guild.getMarkBgColor()); - stmt.setShort(6, guild.getMark()); - stmt.setByte(7, guild.getMarkColor()); - stmt.setString(8, guild.getNotice()); - stmt.setInt(9, guild.getPoints()); - stmt.setByte(10, guild.getLevel()); - stmt.setInt(11, guild.getBoardEntryCounter().get()); - - stmt.executeUpdate(); + stmt.setInt(2, guild.getMemberMax()); + stmt.setShort(3, guild.getMarkBg()); + stmt.setByte(4, guild.getMarkBgColor()); + stmt.setShort(5, guild.getMark()); + stmt.setByte(6, guild.getMarkColor()); + stmt.setString(7, guild.getNotice()); + stmt.setInt(8, guild.getPoints()); + stmt.setByte(9, guild.getLevel()); + + try (ResultSet rs = stmt.executeQuery()) { // executeQuery because RETURNING returns a result set + if (rs.next()) { + int guildId = rs.getInt(1); // get the generated id + guild.setGuildId(guildId); + } + } + GuildMemberDao.saveMembers(conn, guild); - BoardEntryDao.saveBoardEntries(conn, guild); - BoardNoticeDao.saveBoardNotice(conn, guild); + BoardEntryDao.saveBoardEntries(conn, guild); // none should exist, but maybe we added some to the guild object. + BoardNoticeDao.saveBoardNotice(conn, guild); // it shouldn't exist, but maybe we added it to the guild object. + + List defaultGrades = Arrays.asList("Master", "Jr.Master", "Test1", "test2", "Member"); + guild.setGradeNames(defaultGrades); + upsertGrades(conn, guild.getGuildId(), defaultGrades); return true; } } @@ -83,7 +93,6 @@ public static synchronized boolean insertGuild(Connection conn, Guild guild) thr public static boolean updateGuild(Connection conn, Guild guild) throws SQLException { String sql = "UPDATE guild.guilds SET " + "name = ?, " + - "grade_names = ?, " + "member_max = ?, " + "mark_bg = ?, " + "mark_bg_color = ?, " + @@ -91,29 +100,27 @@ public static boolean updateGuild(Connection conn, Guild guild) throws SQLExcept "mark_color = ?, " + "notice = ?, " + "points = ?, " + - "level = ?, " + - "board_entry_counter = ? " + + "level = ? " + "WHERE id = ?"; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, guild.getGuildName()); - stmt.setArray(2, conn.createArrayOf("text", guild.getGradeNames().toArray())); - stmt.setInt(3, guild.getMemberMax()); - stmt.setShort(4, guild.getMarkBg()); - stmt.setByte(5, guild.getMarkBgColor()); - stmt.setShort(6, guild.getMark()); - stmt.setByte(7, guild.getMarkColor()); - stmt.setString(8, guild.getNotice()); - stmt.setInt(9, guild.getPoints()); - stmt.setByte(10, guild.getLevel()); - stmt.setInt(11, guild.getBoardEntryCounter().get()); - stmt.setInt(12, guild.getGuildId()); + stmt.setInt(2, guild.getMemberMax()); + stmt.setShort(3, guild.getMarkBg()); + stmt.setByte(4, guild.getMarkBgColor()); + stmt.setShort(5, guild.getMark()); + stmt.setByte(6, guild.getMarkColor()); + stmt.setString(7, guild.getNotice()); + stmt.setInt(8, guild.getPoints()); + stmt.setByte(9, guild.getLevel()); + stmt.setInt(10, guild.getGuildId()); int rows = stmt.executeUpdate(); GuildMemberDao.saveMembers(conn, guild); BoardEntryDao.saveBoardEntries(conn, guild); BoardNoticeDao.saveBoardNotice(conn, guild); + upsertGrades(conn, guild.getGuildId(), guild.getGradeNames()); return rows > 0; } @@ -177,13 +184,44 @@ public static Guild loadGuild(Connection conn, ResultSet rs) throws SQLException * @throws SQLException if a database access error occurs */ public static boolean deleteGuild(Connection conn, int guildId) throws SQLException { - String sql = "DELETE FROM guild.guilds WHERE guild_id = ?"; + String sql = "DELETE FROM guild.guilds WHERE id = ?"; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, guildId); return stmt.executeUpdate() > 0; } } + /** + * Upsert a list of grades for a specific guild into the database. + * + * Each grade in the list is inserted with its corresponding index in the list. + * If a grade with the same guild_id and grade_index already exists, it will + * be replaced with the new grade_name. + * + * @param conn the active SQL connection to use + * @param guildId the ID of the guild for which grades should be inserted + * @param grades the list of grade names to insert + * @throws SQLException if a database access error occurs + */ + private static void upsertGrades(Connection conn, int guildId, List grades) throws SQLException { + String sql = """ + INSERT INTO guild.grade (guild_id, grade_index, grade_name) + VALUES (?, ?, ?) + ON CONFLICT (guild_id, grade_index) DO UPDATE + SET grade_name = EXCLUDED.grade_name + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (int i = 0; i < grades.size(); i++) { + stmt.setInt(1, guildId); + stmt.setInt(2, i); // grade_index corresponds to the list index + stmt.setString(3, grades.get(i)); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + /** * Loads the grade names for a specific guild from the database. * diff --git a/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java b/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java index 6a8f9b4e..bcb83161 100644 --- a/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java +++ b/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java @@ -102,7 +102,7 @@ public static void upsertMembers(Connection conn, Guild guild, List String sql = """ INSERT INTO guild.member (guild_id, character_id, grade) VALUES (?, ?, ?) - ON CONFLICT (guild_id, character_id) DO UPDATE SET grade = EXCLUDED.grade + ON CONFLICT (character_id) DO UPDATE SET grade = EXCLUDED.grade """; try (PreparedStatement stmt = conn.prepareStatement(sql)) { @@ -130,11 +130,11 @@ ON CONFLICT (guild_id, character_id) DO UPDATE SET grade = EXCLUDED.grade public static List loadMembers(Connection conn, int guildId) throws SQLException { List members = new ArrayList<>(); String sql = """ - SELECT c.character_id, c.character_name, s.job, s.level, + SELECT c.id, c.name, s.job, s.level, m.grade AS guildRank, NULL AS allianceRank, c.online FROM guild.member m - JOIN player.characters c ON c.character_id = m.character_id - JOIN character.stats s ON s.character_id = c.character_id + JOIN player.characters c ON c.id = m.character_id + JOIN player.stats s ON s.character_id = c.id WHERE m.guild_id = ? """; @@ -142,8 +142,8 @@ public static List loadMembers(Connection conn, int guildId) throws stmt.setInt(1, guildId); try (ResultSet rs = stmt.executeQuery()) { while (rs.next()) { - int charId = rs.getInt("character_id"); - String charName = rs.getString("character_name"); + int charId = rs.getInt("id"); + String charName = rs.getString("name"); int job = rs.getInt("job"); int level = rs.getInt("level"); boolean online = rs.getBoolean("online"); @@ -157,7 +157,7 @@ public static List loadMembers(Connection conn, int guildId) throws level, online, GuildRank.getByValue(guildRankInt), - allianceRankInt != null ? GuildRank.getByValue(allianceRankInt) : null + allianceRankInt != null ? GuildRank.getByValue(allianceRankInt) : GuildRank.NONE )); } } diff --git a/src/main/java/kinoko/server/ServerConfig.java b/src/main/java/kinoko/server/ServerConfig.java index 99c9b974..4178ebb7 100644 --- a/src/main/java/kinoko/server/ServerConfig.java +++ b/src/main/java/kinoko/server/ServerConfig.java @@ -15,7 +15,7 @@ public final class ServerConfig { public static final int SHUTDOWN_TIMEOUT = 30; public static final boolean AUTO_CREATE_ACCOUNT = Util.getEnv("AUTO_CREATE_ACCOUNT", true); - public static final boolean REQUIRE_SECONDARY_PASSWORD = Util.getEnv("REQUIRE_SECONDARY_PASSWORD", true); + public static final boolean REQUIRE_SECONDARY_PASSWORD = Util.getEnv("REQUIRE_SECONDARY_PASSWORD", false); public static final String WZ_DIRECTORY = Util.getEnv("WZ_DIRECTORY", "wz"); public static final String DATA_DIRECTORY = Util.getEnv("DATA_DIRECTORY", "data"); diff --git a/src/main/java/kinoko/server/guild/Guild.java b/src/main/java/kinoko/server/guild/Guild.java index 2739a06a..5c2cc2b9 100644 --- a/src/main/java/kinoko/server/guild/Guild.java +++ b/src/main/java/kinoko/server/guild/Guild.java @@ -21,7 +21,7 @@ public final class Guild implements Encodable, Lockable { .thenComparing(Comparator.comparing(GuildMember::getLevel).reversed()); private final Lock lock = new ReentrantLock(); - private final int guildId; + private int guildId; private final String guildName; private final List gradeNames; private final Map guildMembers; // character ID -> guild member @@ -60,6 +60,10 @@ public int getGuildId() { return guildId; } + public void setGuildId(int newGuildId) { + this.guildId = newGuildId; + } + public String getGuildName() { return guildName; } @@ -216,11 +220,7 @@ public List getBoardEntryList(int page) { } public int getNextBoardEntryId() { - if (DatabaseManager.isRelational()) { - // Let the relational database handle SN generation; return placeholder - return -1; - } - + // Board Entries SN are composite keys with Guild IDs, so we don't need to return -1 for Relational DBs. return boardEntryCounter.getAndIncrement(); } diff --git a/src/main/java/kinoko/server/guild/GuildBoardEntry.java b/src/main/java/kinoko/server/guild/GuildBoardEntry.java index ea72e450..16c60881 100644 --- a/src/main/java/kinoko/server/guild/GuildBoardEntry.java +++ b/src/main/java/kinoko/server/guild/GuildBoardEntry.java @@ -142,17 +142,4 @@ public void encodeCurrent(OutPacket outPacket) { comment.encode(outPacket); // CUIGuildBBS::COMMENT } } - - /** - * Checks whether this GuildBoardEntry has a valid entry ID. - * - * In relational databases, entry IDs are typically automatically generated. - * Returns true if the entry has no ID and therefore needs to be inserted - * into the database to obtain one. - * - * @return true if the entry ID is zero or negative, false otherwise - */ - public boolean hasNoSN() { - return getEntryId() <= 0; - } } diff --git a/src/main/java/kinoko/server/guild/GuildStorage.java b/src/main/java/kinoko/server/guild/GuildStorage.java index 6ec8d53c..f069edb7 100644 --- a/src/main/java/kinoko/server/guild/GuildStorage.java +++ b/src/main/java/kinoko/server/guild/GuildStorage.java @@ -2,6 +2,7 @@ import kinoko.database.DatabaseManager; +import java.util.Collection; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; @@ -38,4 +39,14 @@ public Optional getGuildById(int guildId) { guildResult.ifPresent(guild -> guildMap.put(guildId, guild)); return guildResult; } + + /** + * Retrieves all guilds currently stored in memory. + * + * @return a collection of all guilds in the cache + */ + public Collection getAllGuilds() { + return guildMap.values(); + } + } diff --git a/src/main/java/kinoko/server/node/CentralServerNode.java b/src/main/java/kinoko/server/node/CentralServerNode.java index 5a620ede..a0360ed6 100644 --- a/src/main/java/kinoko/server/node/CentralServerNode.java +++ b/src/main/java/kinoko/server/node/CentralServerNode.java @@ -3,6 +3,7 @@ import io.netty.channel.ChannelFuture; import io.netty.channel.ChannelInitializer; import io.netty.channel.socket.SocketChannel; +import kinoko.database.DatabaseManager; import kinoko.packet.CentralPacket; import kinoko.server.guild.Guild; import kinoko.server.guild.GuildMember; @@ -221,6 +222,9 @@ protected void initChannel(SocketChannel ch) { @Override public void shutdown() throws InterruptedException { + // Save All Guilds + DatabaseManager.guildAccessor().saveAll(guildStorage.getAllGuilds()); + // Shutdown login server node final Instant start = Instant.now(); serverStorage.getLoginServerNode().ifPresent((serverNode) -> serverNode.write(CentralPacket.shutdownRequest())); From d5238cf1978ac6a8a47884fe83cac988299ce40f Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sat, 11 Oct 2025 02:22:39 -0400 Subject: [PATCH 43/83] put default value back --- src/main/java/kinoko/server/ServerConfig.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/kinoko/server/ServerConfig.java b/src/main/java/kinoko/server/ServerConfig.java index 4178ebb7..99c9b974 100644 --- a/src/main/java/kinoko/server/ServerConfig.java +++ b/src/main/java/kinoko/server/ServerConfig.java @@ -15,7 +15,7 @@ public final class ServerConfig { public static final int SHUTDOWN_TIMEOUT = 30; public static final boolean AUTO_CREATE_ACCOUNT = Util.getEnv("AUTO_CREATE_ACCOUNT", true); - public static final boolean REQUIRE_SECONDARY_PASSWORD = Util.getEnv("REQUIRE_SECONDARY_PASSWORD", false); + public static final boolean REQUIRE_SECONDARY_PASSWORD = Util.getEnv("REQUIRE_SECONDARY_PASSWORD", true); public static final String WZ_DIRECTORY = Util.getEnv("WZ_DIRECTORY", "wz"); public static final String DATA_DIRECTORY = Util.getEnv("DATA_DIRECTORY", "data"); From 1f1a173667f05d0b01d8447be1e57bc1f9e31d32 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sat, 11 Oct 2025 02:32:42 -0400 Subject: [PATCH 44/83] . --- wz/.gitkeep | 0 1 file changed, 0 insertions(+), 0 deletions(-) delete mode 100644 wz/.gitkeep diff --git a/wz/.gitkeep b/wz/.gitkeep deleted file mode 100644 index e69de29b..00000000 From b6f64f11f12f8dfb45f8c1453951f5a9d1ecc08d Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sun, 12 Oct 2025 02:05:24 -0400 Subject: [PATCH 45/83] modifications to player shop & extendsp --- .../kinoko/database/postgresql/setup/init.sql | 6 ++---- .../postgresql/type/GuildMemberDao.java | 5 ++--- .../database/postgresql/type/InventoryDao.java | 4 ++-- src/main/java/kinoko/server/Server.java | 4 ++++ .../server/dialog/miniroom/PersonalShop.java | 6 ++++-- .../server/netty/CentralServerHandler.java | 1 + src/main/java/kinoko/world/item/Item.java | 18 ++++++++++++++++++ .../kinoko/world/user/stat/CharacterStat.java | 3 --- 8 files changed, 33 insertions(+), 14 deletions(-) diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index ad435636..658f6bdf 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -177,8 +177,7 @@ CREATE TABLE IF NOT EXISTS player.characters ( party_id INT, guild_id INT, creation_time TIMESTAMP NOT NULL DEFAULT UTC_NOW(), - max_level_time TIMESTAMP, - online BOOLEAN NOT NULL DEFAULT FALSE + max_level_time TIMESTAMP ); CREATE TABLE IF NOT EXISTS player.stats ( @@ -234,7 +233,7 @@ CREATE TABLE IF NOT EXISTS player.inventory ( item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, inventory_type inventory_type_enum NOT NULL, slot INT NOT NULL, - PRIMARY KEY (character_id, item_sn) + PRIMARY KEY (item_sn) ); CREATE INDEX IF NOT EXISTS idx_inventory_item_sn @@ -403,7 +402,6 @@ CREATE TABLE IF NOT EXISTS guild.member ( character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, grade SMALLINT NOT NULL, join_date TIMESTAMP NOT NULL DEFAULT UTC_NOW(), - last_login TIMESTAMP, PRIMARY KEY (character_id) ); diff --git a/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java b/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java index bcb83161..b1bd949c 100644 --- a/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java +++ b/src/main/java/kinoko/database/postgresql/type/GuildMemberDao.java @@ -131,7 +131,7 @@ public static List loadMembers(Connection conn, int guildId) throws List members = new ArrayList<>(); String sql = """ SELECT c.id, c.name, s.job, s.level, - m.grade AS guildRank, NULL AS allianceRank, c.online + m.grade AS guildRank, NULL AS allianceRank -- waiting to implement alliances FROM guild.member m JOIN player.characters c ON c.id = m.character_id JOIN player.stats s ON s.character_id = c.id @@ -146,7 +146,6 @@ public static List loadMembers(Connection conn, int guildId) throws String charName = rs.getString("name"); int job = rs.getInt("job"); int level = rs.getInt("level"); - boolean online = rs.getBoolean("online"); int guildRankInt = rs.getInt("guildRank"); Integer allianceRankInt = null; // no alliance rank yet @@ -155,7 +154,7 @@ public static List loadMembers(Connection conn, int guildId) throws charName, job, level, - online, + false, // CentralServerHandler will deal with this value. GuildRank.getByValue(guildRankInt), allianceRankInt != null ? GuildRank.getByValue(allianceRankInt) : GuildRank.NONE )); diff --git a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java index 54fa6059..f4760582 100644 --- a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java +++ b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java @@ -30,8 +30,8 @@ public static void saveCharacter(Connection conn, CharacterData characterData) t String sqlInventory = """ INSERT INTO player.inventory (character_id, inventory_type, slot, item_sn) VALUES (?, ?, ?, ?) - ON CONFLICT (character_id, item_sn) - DO UPDATE SET slot = EXCLUDED.slot, inventory_type = EXCLUDED.inventory_type + ON CONFLICT (item_sn) + DO UPDATE SET slot = EXCLUDED.slot, inventory_type = EXCLUDED.inventory_type, character_id = EXCLUDED.character_id """; int characterId = characterData.getCharacterId(); diff --git a/src/main/java/kinoko/server/Server.java b/src/main/java/kinoko/server/Server.java index 2054053a..96b21950 100644 --- a/src/main/java/kinoko/server/Server.java +++ b/src/main/java/kinoko/server/Server.java @@ -25,6 +25,10 @@ public static void main(String[] args) throws Exception { Server.initialize(); } + public static CentralServerNode getCentralServerNode() { + return centralServerNode; + } + private static void initialize() throws Exception { // Initialize providers Instant start = Instant.now(); diff --git a/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java b/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java index 0cb3a45a..ef416a88 100644 --- a/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java +++ b/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java @@ -103,12 +103,14 @@ public void handlePacket(User user, MiniRoomProtocol mrp, InPacket inPacket) { user.dispose(); return; } + + int originalInventoryQuantity = item.getQuantity(); // Move item from inventory to shop - final Optional removeItemResult = user.getInventoryManager().removeItem(targetPosition, item, totalCount); + final Optional removeItemResult = user.getInventoryManager().removeItem(targetPosition, item, totalCount); // updates player inventory item quantity if (removeItemResult.isEmpty()) { throw new IllegalStateException("Could not remove item from inventory"); } - if (item.getQuantity() > totalCount) { + if (originalInventoryQuantity > totalCount) { final Item partialItem = new Item(item); partialItem.setItemSn(user.getNextItemSn()); partialItem.setQuantity((short) totalCount); diff --git a/src/main/java/kinoko/server/netty/CentralServerHandler.java b/src/main/java/kinoko/server/netty/CentralServerHandler.java index fade8137..5352077b 100644 --- a/src/main/java/kinoko/server/netty/CentralServerHandler.java +++ b/src/main/java/kinoko/server/netty/CentralServerHandler.java @@ -515,6 +515,7 @@ private void handlePartyRequest(RemoteServerNode remoteServerNode, InPacket inPa final int inviterId = partyRequest.getCharacterId(); final Optional inviterResult = centralServerNode.getUserByCharacterId(inviterId); if (inviterResult.isEmpty()) { + // The inviter is not online. remoteServerNode.write(CentralPacket.userPacketReceive(remoteUser.getCharacterId(), PartyPacket.of(PartyResultType.JoinParty_Unknown))); // Your request for a party didn't work due to an unexpected error. return; } diff --git a/src/main/java/kinoko/world/item/Item.java b/src/main/java/kinoko/world/item/Item.java index 40dc045c..552a4f10 100644 --- a/src/main/java/kinoko/world/item/Item.java +++ b/src/main/java/kinoko/world/item/Item.java @@ -1,5 +1,6 @@ package kinoko.world.item; +import kinoko.database.DatabaseManager; import kinoko.server.packet.OutPacket; import kinoko.util.Encodable; @@ -226,4 +227,21 @@ public void setPossibleTrading(boolean set) { public boolean hasNoSN() { return getItemSn() <= 0; } + + /** + * Resets the item's serial number (SN) to -1. + * + * If checkIfRelational is true, the SN is reset only if the underlying + * database is relational. If false, the SN is always reset regardless of database type. + * + * This can be used to mark the item as needing a new SN before inserting it into + * a database. Especially useful when dropping/trading items in bulk that split an item. + * + * @param checkIfRelational whether to check if the database is relational before resetting + */ + public void resetSN(boolean checkIfRelational) { + if (!checkIfRelational || DatabaseManager.isRelational()) { + setItemSn(-1); + } + } } diff --git a/src/main/java/kinoko/world/user/stat/CharacterStat.java b/src/main/java/kinoko/world/user/stat/CharacterStat.java index 0dd20bd7..499b4a83 100644 --- a/src/main/java/kinoko/world/user/stat/CharacterStat.java +++ b/src/main/java/kinoko/world/user/stat/CharacterStat.java @@ -7,7 +7,6 @@ import kinoko.world.job.JobConstants; import java.util.EnumMap; -import java.util.HashMap; import java.util.Map; public final class CharacterStat implements Encodable { @@ -73,8 +72,6 @@ public CharacterStat(int id, String name, byte gender, byte skin, int face, int this.petSn1 = petSn1; this.petSn2 = petSn2; this.petSn3 = petSn3; - // TODO: give this a legit value. - this.sp = new ExtendSp(new HashMap<>()); } public int getId() { From 3286c4914bfd7b3db3a5da3ae4fb99bb0566a321 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Mon, 13 Oct 2025 17:49:10 -0400 Subject: [PATCH 46/83] Fixed Pets, Cash Shop Locker --- src/main/java/kinoko/database/IdAccessor.java | 9 + .../postgresql/PostgresAccountAccessor.java | 1 + .../postgresql/PostgresCharacterAccessor.java | 172 +++++++++++++----- .../postgresql/PostgresIdAccessor.java | 16 ++ .../kinoko/database/postgresql/setup/init.sql | 92 ++++++---- .../database/postgresql/type/ExtendSpDao.java | 60 ++++++ .../database/postgresql/type/ItemDao.java | 31 ++-- .../database/postgresql/type/PetDataDao.java | 47 +++++ .../database/postgresql/type/RingDataDao.java | 39 ++++ .../postgresql/type/SkillMacrosDao.java | 73 ++++++++ .../kinoko/handler/stage/CashShopHandler.java | 9 + .../handler/stage/MigrationHandler.java | 2 + .../java/kinoko/handler/user/PetHandler.java | 6 + src/main/java/kinoko/server/Server.java | 3 - src/main/java/kinoko/world/user/Locker.java | 3 + src/main/java/kinoko/world/user/User.java | 13 +- .../kinoko/world/user/stat/CharacterStat.java | 5 + 17 files changed, 480 insertions(+), 101 deletions(-) create mode 100644 src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/PetDataDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/RingDataDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/SkillMacrosDao.java diff --git a/src/main/java/kinoko/database/IdAccessor.java b/src/main/java/kinoko/database/IdAccessor.java index 399887c9..c4a930d2 100644 --- a/src/main/java/kinoko/database/IdAccessor.java +++ b/src/main/java/kinoko/database/IdAccessor.java @@ -1,5 +1,7 @@ package kinoko.database; +import kinoko.world.item.Item; + import java.util.Optional; public interface IdAccessor { @@ -12,4 +14,11 @@ public interface IdAccessor { Optional nextGuildId(); Optional nextMemoId(); + + default public boolean generateItemSn(Item item){ + if (DatabaseManager.isRelational()){ + throw new UnsupportedOperationException("generateItemSn() needs to be implemented for this database."); + } + return true; + } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java index 8efa91b4..a351229f 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java @@ -4,6 +4,7 @@ import kinoko.database.AccountAccessor; import kinoko.database.DatabaseManager; import kinoko.database.postgresql.type.AccountDao; +import kinoko.database.postgresql.type.LockerDao; import kinoko.server.ServerConfig; import kinoko.world.user.Account; import org.mindrot.jbcrypt.BCrypt; diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java index 091cb5c2..863c1159 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -3,7 +3,9 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.CharacterAccessor; import kinoko.database.CharacterInfo; +import kinoko.database.postgresql.type.ExtendSpDao; import kinoko.database.postgresql.type.InventoryDao; +import kinoko.database.postgresql.type.SkillMacrosDao; import kinoko.server.rank.CharacterRank; import kinoko.world.GameConstants; import kinoko.world.item.*; @@ -17,6 +19,7 @@ import kinoko.world.user.CharacterData; import kinoko.world.user.data.*; import kinoko.world.user.stat.CharacterStat; +import kinoko.world.user.stat.ExtendSp; import org.postgresql.util.PGobject; import java.io.*; @@ -78,9 +81,13 @@ private CharacterData loadCharacterData(ResultSet rs) throws SQLException, IOExc rs.getLong("pet_2"), rs.getLong("pet_3") ); + cd.setCharacterStat(cs); try (Connection conn = dataSource.getConnection()) { + cs.setSp(ExtendSpDao.loadExtendSp(conn, characterID)); // TODO: put this in a proper connection block. + + InventoryManager im = InventoryDao.loadInventoryManager(conn, characterID); cd.setInventoryManager(im); @@ -301,7 +308,9 @@ private ConfigManager loadConfig(int characterId) throws SQLException { quickslotKeyMap = Arrays.copyOf(GameConstants.DEFAULT_QUICKSLOT_KEY_MAP, GameConstants.QUICKSLOT_KEY_MAP_SIZE); } - return new ConfigManager(petConsumeItem, petConsumeMpItem, petExceptionList, funcKeyMap, quickslotKeyMap); + ConfigManager cm = new ConfigManager(petConsumeItem, petConsumeMpItem, petExceptionList, funcKeyMap, quickslotKeyMap); + cm.updateMacroSysData(SkillMacrosDao.loadMacros(conn, characterId)); + return cm; } } } @@ -538,6 +547,7 @@ public List getAvatarDataByAccountId(int accountId) { // For inventory, query normalized player.inventory table separately Inventory equipped = loadEquippedInventory(cs.getId()); +// Inventory cash = loadCashInventory(cs.getId()); list.add(AvatarData.from(cs, equipped)); } @@ -549,8 +559,8 @@ public List getAvatarDataByAccountId(int accountId) { return list; } - private Inventory loadEquippedInventory(int characterId) throws SQLException { - Inventory equipped = new Inventory(24, InventoryType.EQUIPPED); // default equipped size + private Inventory loadCashInventory(int characterId) throws SQLException { + Inventory equipped = new Inventory(24, InventoryType.CASH); // default cash size String sql = """ SELECT f.*, i.slot @@ -559,6 +569,117 @@ private Inventory loadEquippedInventory(int characterId) throws SQLException { WHERE i.character_id = ? AND i.inventory_type = ? """; + try (Connection conn = dataSource.getConnection(); + PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + + PGobject enumValue = new PGobject(); + enumValue.setType("inventory_type_enum"); + enumValue.setValue(InventoryType.CASH.name()); + stmt.setObject(2, enumValue); + + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + long itemSn = rs.getLong("item_sn"); + int slot = rs.getInt("slot"); + int itemId = rs.getInt("item_id"); + short quantity = rs.getShort("quantity"); + short attribute = rs.getShort("attribute"); + String title = rs.getString("title"); + Timestamp dateExpireTs = rs.getTimestamp("date_expire"); + + // Build EquipData if applicable + EquipData equipData = null; + if (rs.getObject("inc_str") != null) { + equipData = new EquipData( + rs.getShort("inc_str"), + rs.getShort("inc_dex"), + rs.getShort("inc_int"), + rs.getShort("inc_luk"), + rs.getShort("inc_max_hp"), + rs.getShort("inc_max_mp"), + rs.getShort("inc_pad"), + rs.getShort("inc_mad"), + rs.getShort("inc_pdd"), + rs.getShort("inc_mdd"), + rs.getShort("inc_acc"), + rs.getShort("inc_eva"), + rs.getShort("inc_craft"), + rs.getShort("inc_speed"), + rs.getShort("inc_jump"), + rs.getByte("ruc"), + rs.getByte("cuc"), + rs.getInt("iuc"), + rs.getByte("chuc"), + rs.getByte("grade"), + rs.getShort("option_1"), + rs.getShort("option_2"), + rs.getShort("option_3"), + rs.getShort("socket_1"), + rs.getShort("socket_2"), + rs.getByte("level_up_type"), + rs.getByte("level"), + rs.getInt("exp"), + rs.getInt("durability") + ); + } + + // Build PetData if applicable + PetData petData = null; + if (rs.getObject("pet_name") != null) { + petData = new PetData( + rs.getString("pet_name"), + rs.getByte("pet_level"), + rs.getByte("fullness"), + rs.getShort("tameness"), + rs.getShort("pet_skill"), + rs.getShort("pet_attribute"), + rs.getInt("remain_life") + ); + } + + // Build RingData if applicable + RingData ringData = null; + if (rs.getObject("pair_character_id") != null) { + ringData = new RingData( + rs.getInt("pair_character_id"), + rs.getString("pair_character_name"), + rs.getLong("pair_item_sn") + ); + } + + Item item = new Item( + itemId, + quantity, + itemSn, + false, // cash flag, adjust if you have it + attribute, + title, + dateExpireTs != null ? dateExpireTs.toInstant() : null, + equipData, + petData, + ringData + ); + + equipped.putItem(slot, item); + } + } + } + + return equipped; + } + + + private Inventory loadEquippedInventory(int characterId) throws SQLException { + Inventory equipped = new Inventory(24, InventoryType.EQUIPPED); // default equipped size + + String sql = """ + SELECT f.*, i.slot + FROM player.inventory i + JOIN item.full_item f ON i.item_sn = f.item_sn + WHERE i.character_id = ? AND i.inventory_type = ? + """; + try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, characterId); @@ -705,8 +826,8 @@ public synchronized boolean newCharacter(CharacterData characterData) { saveCharacterSkills(conn, characterData); saveCharacterQuests(conn, characterData); saveCharacterConfig(conn, characterData); - saveCharacterMacros(conn, characterData); saveCharacterPopularity(conn, characterData); + ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); conn.commit(); success = true; @@ -776,43 +897,10 @@ ON CONFLICT (character_id) DO UPDATE stmt.executeUpdate(); } + // save skill macros + SkillMacrosDao.upsertMacros(conn, characterData.getCharacterId(), config.getMacroSysData()); } - - - private void saveCharacterMacros(Connection conn, CharacterData characterData) throws SQLException { - List macros = characterData.getConfigManager().getMacroSysData(); - - String sql = """ - INSERT INTO player.character_macro - (character_id, macro_index, name, mute, skills) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT (character_id, macro_index) DO UPDATE - SET name = EXCLUDED.name, - mute = EXCLUDED.mute, - skills = EXCLUDED.skills - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - for (int i = 0; i < macros.size(); i++) { - SingleMacro macro = macros.get(i); - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, i); // macro_index - stmt.setString(3, macro.getName()); - stmt.setBoolean(4, macro.isMute()); - - // skills array -> Integer[] - int[] skills = macro.getSkills(); - Integer[] skillArray = Arrays.stream(skills).boxed().toArray(Integer[]::new); - stmt.setArray(5, conn.createArrayOf("INT", skillArray)); - - stmt.addBatch(); - } - stmt.executeBatch(); - } - } - - private void saveCharacterPopularity(Connection conn, CharacterData characterData) throws SQLException { String sql = """ INSERT INTO player.popularity (character_id, other_character_id, timestamp) @@ -851,11 +939,7 @@ ON CONFLICT (character_id, skill_id) stmt.setInt(1, characterData.getCharacterId()); stmt.setInt(2, sr.getSkillId()); stmt.setInt(3, sr.getSkillLevel()); - if (sr.getMasterLevel() > 0) { stmt.setInt(4, sr.getMasterLevel()); - } else { - stmt.setNull(4, java.sql.Types.INTEGER); - } stmt.addBatch(); } stmt.executeBatch(); @@ -946,8 +1030,8 @@ public boolean saveCharacter(CharacterData characterData) { saveCharacterSkills(conn, characterData); saveCharacterQuests(conn, characterData); saveCharacterConfig(conn, characterData); - saveCharacterMacros(conn, characterData); saveCharacterPopularity(conn, characterData); + ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); conn.commit(); return true; diff --git a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java index 8200cdec..16975ffd 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java @@ -2,7 +2,9 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.IdAccessor; +import kinoko.database.postgresql.type.AccountDao; import kinoko.database.postgresql.type.ItemDao; +import kinoko.world.item.Item; import java.sql.Connection; import java.sql.SQLException; @@ -47,4 +49,18 @@ public synchronized Optional nextGuildId() { public synchronized Optional nextMemoId() { return getNextId("memo_id"); } + + @Override + public boolean generateItemSn(Item item) { + if (!item.hasNoSN()){ + return true; + } + + try { + return withTransaction(getConnection(), c -> ItemDao.createNewItem(c, item)); + } catch (SQLException e) { + e.printStackTrace(); + return false; + } + } } diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index 658f6bdf..6a763f94 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -28,6 +28,7 @@ $function$; CREATE TABLE item.items ( item_sn BIGSERIAL PRIMARY KEY, -- auto-increment unique ID for every item instance item_id INT NOT NULL, + cash BOOLEAN NOT NULL DEFAULT FALSE, quantity INT NOT NULL DEFAULT 1, attribute SMALLINT DEFAULT 0, title TEXT DEFAULT '', @@ -41,7 +42,7 @@ CREATE INDEX IF NOT EXISTS idx_items_date_expire ON item.items(date_expire); CREATE TABLE IF NOT EXISTS item.equip_data ( - item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE, -- unique for every equip item instance + item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE ON UPDATE CASCADE, -- unique for every equip item instance inc_str SMALLINT DEFAULT 0, inc_dex SMALLINT DEFAULT 0, inc_int SMALLINT DEFAULT 0, @@ -78,7 +79,7 @@ CREATE INDEX IF NOT EXISTS idx_equip_data_item_sn CREATE TABLE IF NOT EXISTS item.pet_data ( - item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE, + item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE ON UPDATE CASCADE , pet_name TEXT, level SMALLINT DEFAULT 0, fullness SMALLINT DEFAULT 0, @@ -93,7 +94,7 @@ CREATE INDEX IF NOT EXISTS idx_pet_data_item_sn CREATE TABLE IF NOT EXISTS item.ring_data ( - item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE, + item_sn BIGINT PRIMARY KEY REFERENCES item.items(item_sn) ON DELETE CASCADE ON UPDATE CASCADE , pair_character_id INT, pair_character_name TEXT, pair_item_sn BIGINT @@ -121,8 +122,8 @@ CREATE TABLE IF NOT EXISTS account.accounts ( ); CREATE TABLE IF NOT EXISTS account.trunk_item ( - account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, - item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE ON UPDATE CASCADE , + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE ON UPDATE CASCADE , slot INT NOT NULL, PRIMARY KEY (account_id, slot) ); @@ -135,8 +136,8 @@ CREATE INDEX IF NOT EXISTS idx_trunk_item_account_item CREATE TABLE IF NOT EXISTS account.locker_item ( - account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, - item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE ON UPDATE CASCADE , + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE ON UPDATE CASCADE , slot INT NOT NULL, commodity_id INT, PRIMARY KEY (account_id, slot) @@ -150,7 +151,7 @@ CREATE INDEX IF NOT EXISTS idx_locker_item_account_item CREATE TABLE IF NOT EXISTS account.wishlist ( - account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE ON UPDATE CASCADE , item_id BIGINT NOT NULL, slot INT NOT NULL, PRIMARY KEY (account_id, slot) @@ -169,7 +170,7 @@ CREATE INDEX IF NOT EXISTS idx_wishlist_account_item CREATE TABLE IF NOT EXISTS player.characters ( id SERIAL PRIMARY KEY, - account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE, + account_id INT NOT NULL REFERENCES account.accounts(id) ON DELETE CASCADE ON UPDATE CASCADE , name TEXT NOT NULL, money INT NOT NULL DEFAULT 0, ext_slot_expire TIMESTAMP, @@ -181,7 +182,7 @@ CREATE TABLE IF NOT EXISTS player.characters ( ); CREATE TABLE IF NOT EXISTS player.stats ( - character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , gender SMALLINT NOT NULL, skin SMALLINT NOT NULL, face INT NOT NULL, @@ -207,11 +208,11 @@ CREATE TABLE IF NOT EXISTS player.stats ( pet_3 BIGINT ); -CREATE TABLE IF NOT EXISTS player.skill_points ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, - skill_id INT NOT NULL, - points INT NOT NULL DEFAULT 0, - PRIMARY KEY (character_id, skill_id) +CREATE TABLE IF NOT EXISTS player.extend_sp ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , + job_level SMALLINT NOT NULL, + sp SMALLINT DEFAULT 0, + PRIMARY KEY (character_id, job_level) ); DO $$ @@ -229,8 +230,8 @@ BEGIN END$$; CREATE TABLE IF NOT EXISTS player.inventory ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, - item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE ON UPDATE CASCADE , inventory_type inventory_type_enum NOT NULL, slot INT NOT NULL, PRIMARY KEY (item_sn) @@ -244,7 +245,7 @@ CREATE INDEX IF NOT EXISTS idx_inventory_char_item CREATE TABLE IF NOT EXISTS player.skill_cooltime ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , skill_id INT NOT NULL, cooldown_end TIMESTAMP NOT NULL, PRIMARY KEY (character_id, skill_id) @@ -252,7 +253,7 @@ CREATE TABLE IF NOT EXISTS player.skill_cooltime ( CREATE TABLE IF NOT EXISTS player.skill_record ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , skill_id INT NOT NULL, level INT NOT NULL, master_level INT, @@ -260,7 +261,7 @@ CREATE TABLE IF NOT EXISTS player.skill_record ( ); CREATE TABLE IF NOT EXISTS player.quest_record ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , quest_id INT NOT NULL, status INT NOT NULL, progress TEXT, @@ -269,7 +270,7 @@ CREATE TABLE IF NOT EXISTS player.quest_record ( ); CREATE TABLE IF NOT EXISTS player.config ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , pet_consume_item INT NOT NULL DEFAULT 0, pet_consume_mp_item INT NOT NULL DEFAULT 0, pet_exception_list INT[] NOT NULL DEFAULT '{}', @@ -279,8 +280,18 @@ CREATE TABLE IF NOT EXISTS player.config ( PRIMARY KEY (character_id) ); +CREATE TABLE IF NOT EXISTS player.skill_macros ( + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , + macro_index SMALLINT NOT NULL, -- 0–4 + name TEXT NOT NULL, + mute BOOLEAN NOT NULL DEFAULT FALSE, + skills INT[] NOT NULL, + PRIMARY KEY (character_id, macro_index) +); + + CREATE TABLE IF NOT EXISTS player.character_macro ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , macro_index INT NOT NULL, -- index/order of the macro name TEXT NOT NULL, mute BOOLEAN NOT NULL DEFAULT FALSE, @@ -289,14 +300,14 @@ CREATE TABLE IF NOT EXISTS player.character_macro ( ); CREATE TABLE IF NOT EXISTS player.popularity ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , other_character_id INT NOT NULL, timestamp TIMESTAMP NOT NULL, PRIMARY KEY (character_id, other_character_id) ); CREATE TABLE IF NOT EXISTS player.minigame ( - character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , omok_wins INT NOT NULL DEFAULT 0, omok_ties INT NOT NULL DEFAULT 0, omok_losses INT NOT NULL DEFAULT 0, @@ -308,24 +319,24 @@ CREATE TABLE IF NOT EXISTS player.minigame ( ); CREATE TABLE IF NOT EXISTS player.map_transfer ( - character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , map_id INT NOT NULL, old_map_id INT NOT NULL ); CREATE TABLE IF NOT EXISTS player.wild_hunter ( - character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , riding_type INT NOT NULL DEFAULT 0 ); CREATE TABLE IF NOT EXISTS player.wild_hunter_mob ( - character_id INT REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , mob_id INT NOT NULL, PRIMARY KEY (character_id, mob_id) ); CREATE TABLE IF NOT EXISTS player.config ( - character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , config_key TEXT NOT NULL, config_value TEXT ); @@ -336,8 +347,8 @@ CREATE TABLE IF NOT EXISTS player.config ( ------------------------------------------ CREATE TABLE IF NOT EXISTS friend.friends ( - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, - friend_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , + friend_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , friend_name TEXT NOT NULL, friend_group TEXT, friend_status INT NOT NULL DEFAULT 0, @@ -353,8 +364,8 @@ CREATE INDEX IF NOT EXISTS idx_friend_friend_id ------------------------------------------ CREATE TABLE IF NOT EXISTS gift.gifts ( - item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE, - receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + item_sn BIGINT NOT NULL REFERENCES item.items(item_sn) ON DELETE CASCADE ON UPDATE CASCADE , + receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , commodity_id INT, sender_id INT, sender_name TEXT, @@ -391,15 +402,15 @@ CREATE TABLE IF NOT EXISTS guild.guilds ( ); CREATE TABLE IF NOT EXISTS guild.grade ( - guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE ON UPDATE CASCADE , grade_index INT NOT NULL, grade_name TEXT NOT NULL, PRIMARY KEY (guild_id, grade_index) ); CREATE TABLE IF NOT EXISTS guild.member ( - guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE ON UPDATE CASCADE , + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , grade SMALLINT NOT NULL, join_date TIMESTAMP NOT NULL DEFAULT UTC_NOW(), PRIMARY KEY (character_id) @@ -408,8 +419,8 @@ CREATE TABLE IF NOT EXISTS guild.member ( CREATE TABLE IF NOT EXISTS guild.board_entry ( id INT NOT NULL, - guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE ON UPDATE CASCADE , + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , title TEXT, message TEXT NOT NULL, emoticon INT, @@ -426,8 +437,8 @@ WHERE notice = TRUE; CREATE TABLE IF NOT EXISTS guild.board_entry_comment ( id SERIAL PRIMARY KEY, entry_id INT NOT NULL, - guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE, - character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + guild_id INT NOT NULL REFERENCES guild.guilds(id) ON DELETE CASCADE ON UPDATE CASCADE , + character_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , text TEXT NOT NULL, timestamp TIMESTAMP NOT NULL DEFAULT UTC_NOW() ); @@ -439,7 +450,7 @@ CREATE TABLE IF NOT EXISTS guild.board_entry_comment ( CREATE TABLE IF NOT EXISTS memo.memo ( id SERIAL PRIMARY KEY, - receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE, + receiver_id INT NOT NULL REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , memo_type INT NOT NULL, memo_content TEXT NOT NULL, sender_name TEXT, @@ -457,6 +468,7 @@ CREATE OR REPLACE VIEW item.full_item AS SELECT i.item_sn, i.item_id, + i.cash, i.quantity, i.attribute, i.title, diff --git a/src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java b/src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java new file mode 100644 index 00000000..2ec92ba7 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java @@ -0,0 +1,60 @@ +package kinoko.database.postgresql.type; + + +import kinoko.world.user.stat.ExtendSp; + +import java.sql.*; +import java.util.HashMap; +import java.util.Map; + +public final class ExtendSpDao { + /** + * Upserts all entries from ExtendSp into player.extend_sp. + */ + public static void upsertExtendSp(Connection conn, int characterId, ExtendSp extendSp) throws SQLException { + if (extendSp == null || extendSp.getMap().isEmpty()) { + return; + } + + String sql = """ + INSERT INTO player.extend_sp (character_id, job_level, sp) + VALUES (?, ?, ?) + ON CONFLICT (character_id, job_level) + DO UPDATE SET sp = EXCLUDED.sp + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (Map.Entry entry : extendSp.getMap().entrySet()) { + stmt.setInt(1, characterId); + stmt.setInt(2, entry.getKey()); + stmt.setInt(3, entry.getValue()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Loads ExtendSp for a given character. + */ + public static ExtendSp loadExtendSp(Connection conn, int characterId) throws SQLException { + String sql = """ + SELECT job_level, sp + FROM player.extend_sp + WHERE character_id = ? + """; + + Map map = new HashMap<>(); + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + map.put(rs.getInt("job_level"), rs.getInt("sp")); + } + } + } + + return ExtendSp.from(map); + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/ItemDao.java b/src/main/java/kinoko/database/postgresql/type/ItemDao.java index 8f30cd67..93cc497d 100644 --- a/src/main/java/kinoko/database/postgresql/type/ItemDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ItemDao.java @@ -44,6 +44,8 @@ public static long createNewItem(Connection conn, Item item) throws SQLException long generatedSn = rs.getLong("item_sn"); item.setItemSn(generatedSn); // store it in the item object EquipDataDao.upsertEquipData(conn, generatedSn, item.getEquipData()); + PetDataDao.upsertPetData(conn, generatedSn, item.getPetData()); + RingDataDao.upsertRingData(conn, generatedSn, item.getRingData()); return generatedSn; } else { throw new SQLException("Failed to generate item_sn for new item"); @@ -69,13 +71,13 @@ public static void saveItemsBatch(Connection conn, Collection items) throw if (items.isEmpty()) return; String sqlInsert = """ - INSERT INTO item.items (item_sn, item_id, quantity, attribute, title, date_expire) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO item.items (item_sn, item_id, cash, quantity, attribute, title, date_expire) + VALUES (?, ?, ?, ?, ?, ?, ?) """; String sqlUpdate = """ UPDATE item.items - SET quantity = ?, attribute = ?, title = ?, date_expire = ? + SET cash = ?, quantity = ?, attribute = ?, title = ?, date_expire = ? WHERE item_sn = ? """; @@ -95,18 +97,20 @@ public static void saveItemsBatch(Connection conn, Collection items) throw stmtInsert.setLong(1, itemSn); stmtInsert.setInt(2, item.getItemId()); - stmtInsert.setInt(3, item.getQuantity()); - stmtInsert.setShort(4, item.getAttribute()); - stmtInsert.setString(5, item.getTitle()); - stmtInsert.setTimestamp(6, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); + stmtInsert.setBoolean(3, item.isCash()); + stmtInsert.setInt(4, item.getQuantity()); + stmtInsert.setShort(5, item.getAttribute()); + stmtInsert.setString(6, item.getTitle()); + stmtInsert.setTimestamp(7, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); stmtInsert.addBatch(); } else { try (PreparedStatement stmtUpdate = conn.prepareStatement(sqlUpdate)) { - stmtUpdate.setInt(1, item.getQuantity()); - stmtUpdate.setShort(2, item.getAttribute()); - stmtUpdate.setString(3, item.getTitle()); - stmtUpdate.setTimestamp(4, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); - stmtUpdate.setLong(5, itemSn); + stmtUpdate.setBoolean(1, item.isCash()); + stmtUpdate.setInt(2, item.getQuantity()); + stmtUpdate.setShort(3, item.getAttribute()); + stmtUpdate.setString(4, item.getTitle()); + stmtUpdate.setTimestamp(5, item.getDateExpire() != null ? Timestamp.from(item.getDateExpire()) : null); + stmtUpdate.setLong(6, itemSn); stmtUpdate.executeUpdate(); } } @@ -122,6 +126,7 @@ public static void saveItemsBatch(Connection conn, Collection items) throw public static Item from(ResultSet rs) throws SQLException { long itemSn = rs.getLong("item_sn"); int itemId = rs.getInt("item_id"); + boolean isCashItem = rs.getBoolean("cash"); short quantity = rs.getShort("quantity"); short attribute = rs.getShort("attribute"); String title = rs.getString("title"); @@ -186,7 +191,7 @@ public static Item from(ResultSet rs) throws SQLException { itemId, quantity, itemSn, - false, // cash flag + isCashItem, attribute, title, dateExpireTs != null ? dateExpireTs.toInstant() : null, diff --git a/src/main/java/kinoko/database/postgresql/type/PetDataDao.java b/src/main/java/kinoko/database/postgresql/type/PetDataDao.java new file mode 100644 index 00000000..506969a3 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/PetDataDao.java @@ -0,0 +1,47 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.item.PetData; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +public final class PetDataDao { + /** + * Inserts or updates PetData for a given item_sn. + * If an entry already exists, it will be updated instead. + */ + public static void upsertPetData(Connection conn, long itemSn, PetData petData) throws SQLException { + if (petData == null) { + return; + } + + String sql = """ + INSERT INTO item.pet_data ( + item_sn, pet_name, level, fullness, tameness, pet_skill, pet_attribute, remain_life + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (item_sn) + DO UPDATE SET + pet_name = EXCLUDED.pet_name, + level = EXCLUDED.level, + fullness = EXCLUDED.fullness, + tameness = EXCLUDED.tameness, + pet_skill = EXCLUDED.pet_skill, + pet_attribute = EXCLUDED.pet_attribute, + remain_life = EXCLUDED.remain_life + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, itemSn); + stmt.setString(2, petData.getPetName()); + stmt.setByte(3, petData.getLevel()); + stmt.setByte(4, petData.getFullness()); + stmt.setShort(5, petData.getTameness()); + stmt.setShort(6, petData.getPetSkill()); + stmt.setShort(7, petData.getPetAttribute()); + stmt.setInt(8, petData.getRemainLife()); + stmt.executeUpdate(); + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/RingDataDao.java b/src/main/java/kinoko/database/postgresql/type/RingDataDao.java new file mode 100644 index 00000000..3908dbd9 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/RingDataDao.java @@ -0,0 +1,39 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.item.RingData; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.SQLException; + +public final class RingDataDao { + /** + * Inserts or updates RingData for a given item_sn. + * If an entry already exists, it will be updated instead. + */ + public static void upsertRingData(Connection conn, long itemSn, RingData ringData) throws SQLException { + if (ringData == null) { + return; // nothing to save + } + + String sql = """ + INSERT INTO item.ring_data ( + item_sn, pair_character_id, pair_character_name, pair_item_sn + ) + VALUES (?, ?, ?, ?) + ON CONFLICT (item_sn) + DO UPDATE SET + pair_character_id = EXCLUDED.pair_character_id, + pair_character_name = EXCLUDED.pair_character_name, + pair_item_sn = EXCLUDED.pair_item_sn + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, itemSn); + stmt.setInt(2, ringData.getPairCharacterId()); + stmt.setString(3, ringData.getPairCharacterName()); + stmt.setLong(4, ringData.getPairItemSn()); + stmt.executeUpdate(); + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/SkillMacrosDao.java b/src/main/java/kinoko/database/postgresql/type/SkillMacrosDao.java new file mode 100644 index 00000000..3582ae68 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/SkillMacrosDao.java @@ -0,0 +1,73 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.GameConstants; +import kinoko.world.user.data.SingleMacro; + +import java.sql.*; +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; + +public final class SkillMacrosDao { + /** + * Upserts a list of macros for a character. + */ + public static void upsertMacros(Connection conn, int characterId, List macros) throws SQLException { + if (macros == null || macros.isEmpty()) { + return; + } + + String sql = """ + INSERT INTO player.skill_macros (character_id, macro_index, name, mute, skills) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (character_id, macro_index) + DO UPDATE SET name = EXCLUDED.name, + mute = EXCLUDED.mute, + skills = EXCLUDED.skills + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (int i = 0; i < macros.size(); i++) { + SingleMacro macro = macros.get(i); + stmt.setInt(1, characterId); + stmt.setInt(2, i); // macro_index 0-4 + stmt.setString(3, macro.getName()); + stmt.setBoolean(4, macro.isMute()); + stmt.setArray(5, conn.createArrayOf("int", + Arrays.stream(macro.getSkills()).boxed().toArray(Integer[]::new))); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Loads all macros for a character. + */ + public static List loadMacros(Connection conn, int characterId) throws SQLException { + String sql = "SELECT macro_index, name, mute, skills FROM player.skill_macros WHERE character_id = ?"; + SingleMacro[] macros = new SingleMacro[5]; + + // Initialize all slots with default blank macros + for (int i = 0; i < GameConstants.MACRO_SYS_DATA_SIZE; i++) { + macros[i] = new SingleMacro("", false, new int[GameConstants.MACRO_SKILL_COUNT]); + } + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + int index = rs.getInt("macro_index"); + if (index < 0 || index >= GameConstants.MACRO_SYS_DATA_SIZE) continue; // safety check + String name = rs.getString("name"); + boolean mute = rs.getBoolean("mute"); + Integer[] skillsArray = (Integer[]) rs.getArray("skills").getArray(); + int[] skills = Arrays.stream(skillsArray).mapToInt(Integer::intValue).toArray(); + macros[index] = new SingleMacro(name, mute, skills); + } + } + } + + return Arrays.asList(macros); + } +} diff --git a/src/main/java/kinoko/handler/stage/CashShopHandler.java b/src/main/java/kinoko/handler/stage/CashShopHandler.java index a1cf47f8..1c57f3da 100644 --- a/src/main/java/kinoko/handler/stage/CashShopHandler.java +++ b/src/main/java/kinoko/handler/stage/CashShopHandler.java @@ -91,10 +91,19 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { throw new IllegalStateException("Could not deduct price for cash item"); } + // Generate an item SN for the locker item - (Relational DBs) - Safe for NoSQL DBs. + if (!DatabaseManager.idAccessor().generateItemSn(cashItemInfo.getItem())){ + user.write(CashShopPacket.fail(CashItemResultType.Buy_Failed, CashItemFailReason.Unknown)); // Due to an unknown error, the request for Cash Shop has failed. + log.error("Could not generate SN for Item ID: {}", cashItemInfo.getItem().getItemId()); + return; + } + // Add to locker and update client account.getLocker().addCashItem(cashItemInfo); user.write(CashShopPacket.queryCashResult(account)); user.write(CashShopPacket.buyDone(cashItemInfo)); + + } case Gift, GiftPackage -> { // CCashShop::SendGiftsPacket diff --git a/src/main/java/kinoko/handler/stage/MigrationHandler.java b/src/main/java/kinoko/handler/stage/MigrationHandler.java index a8a8457c..a07a4d46 100644 --- a/src/main/java/kinoko/handler/stage/MigrationHandler.java +++ b/src/main/java/kinoko/handler/stage/MigrationHandler.java @@ -320,6 +320,7 @@ public static void handleUserMigrateToCashShopRequest(User user, InPacket inPack // Remove user from field user.getField().removeUser(user); + // Load gifts final List gifts = DatabaseManager.giftAccessor().getGiftsByCharacterId(user.getCharacterId()); @@ -330,6 +331,7 @@ public static void handleUserMigrateToCashShopRequest(User user, InPacket inPack user.write(CashShopPacket.loadLockerDone(account)); user.write(CashShopPacket.loadWishDone(account.getWishlist())); user.write(CashShopPacket.queryCashResult(account)); + user.setInCashShop(true); } private static boolean isWhitelistedTransferField(int currentFieldId, int targetFieldId) { diff --git a/src/main/java/kinoko/handler/user/PetHandler.java b/src/main/java/kinoko/handler/user/PetHandler.java index 59617d5d..329e22c8 100644 --- a/src/main/java/kinoko/handler/user/PetHandler.java +++ b/src/main/java/kinoko/handler/user/PetHandler.java @@ -112,6 +112,12 @@ public static void handleUserActivatePetRequest(User user, InPacket inPacket) { @Handler(InHeader.PetMove) public static void handlePetMove(User user, InPacket inPacket) { + if (user.isInCashShop()){ + // If a player attempts to preview a pet, the client for some reason spams this packet that is NOT needed. + // It is attempting to access the character's actual pets. + return; + } + final long petSn = inPacket.decodeLong(); // liPetLockerSN final MovePath movePath = MovePath.decode(inPacket); final Optional petIndexResult = user.getPetIndex(petSn); diff --git a/src/main/java/kinoko/server/Server.java b/src/main/java/kinoko/server/Server.java index 96b21950..ab6e3916 100644 --- a/src/main/java/kinoko/server/Server.java +++ b/src/main/java/kinoko/server/Server.java @@ -25,9 +25,6 @@ public static void main(String[] args) throws Exception { Server.initialize(); } - public static CentralServerNode getCentralServerNode() { - return centralServerNode; - } private static void initialize() throws Exception { // Initialize providers diff --git a/src/main/java/kinoko/world/user/Locker.java b/src/main/java/kinoko/world/user/Locker.java index 6d047b50..c0f1a799 100644 --- a/src/main/java/kinoko/world/user/Locker.java +++ b/src/main/java/kinoko/world/user/Locker.java @@ -1,8 +1,11 @@ package kinoko.world.user; +import kinoko.database.DatabaseManager; import kinoko.server.cashshop.CashItemInfo; import kinoko.world.GameConstants; +import kinoko.world.item.Item; +import java.awt.image.DataBuffer; import java.util.ArrayList; import java.util.List; diff --git a/src/main/java/kinoko/world/user/User.java b/src/main/java/kinoko/world/user/User.java index e613cf57..bdda96d1 100644 --- a/src/main/java/kinoko/world/user/User.java +++ b/src/main/java/kinoko/world/user/User.java @@ -68,6 +68,7 @@ public final class User extends Life { private final Map schedules = new HashMap<>(); private final AtomicInteger fieldKey = new AtomicInteger(0); + private int messengerId; private PartyInfo partyInfo; private GuildInfo guildInfo; @@ -78,6 +79,7 @@ public final class User extends Life { private OpenGate openGate; private int effectItemId; private int portableChairId; + private boolean inCashShop = false; private String adBoard; private boolean inTransfer; private Instant nextCheckItemExpire; @@ -807,7 +809,8 @@ public void write(OutPacket outPacket) { } public void dispose() { - write(WvsContext.statChanged(Map.of(), true)); + OutPacket outpacket = WvsContext.statChanged(Map.of(), true); + write(outpacket); } public void logout(boolean disconnect) { @@ -853,6 +856,14 @@ public void logout(boolean disconnect) { } } + public boolean isInCashShop() { + return inCashShop; + } + + public void setInCashShop(boolean inCashShop) { + this.inCashShop = inCashShop; + } + // OVERRIDES ------------------------------------------------------------------------------------------------------- diff --git a/src/main/java/kinoko/world/user/stat/CharacterStat.java b/src/main/java/kinoko/world/user/stat/CharacterStat.java index 499b4a83..fc5c6f7c 100644 --- a/src/main/java/kinoko/world/user/stat/CharacterStat.java +++ b/src/main/java/kinoko/world/user/stat/CharacterStat.java @@ -7,6 +7,10 @@ import kinoko.world.job.JobConstants; import java.util.EnumMap; +import java.util.HashMap; +import java.util.Map; +import java.sql.*; +import java.util.HashMap; import java.util.Map; public final class CharacterStat implements Encodable { @@ -72,6 +76,7 @@ public CharacterStat(int id, String name, byte gender, byte skin, int face, int this.petSn1 = petSn1; this.petSn2 = petSn2; this.petSn3 = petSn3; + this.sp = ExtendSp.from(new HashMap<>()); // empty on init. } public int getId() { From 3b75bdaf59781fc47425de2c24dfdb642a190930 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Mon, 13 Oct 2025 21:27:18 -0400 Subject: [PATCH 47/83] Added marriage/friend ring compatibility to postgresql --- .env.example | 2 + .../kinoko/database/DatabaseConnector.java | 2 + src/main/java/kinoko/database/IdAccessor.java | 37 +++++- .../java/kinoko/database/ItemAccessor.java | 13 +++ .../cassandra/CassandraConnector.java | 8 ++ .../postgresql/PostgresConnector.java | 3 + .../postgresql/PostgresGiftAccessor.java | 23 ++-- .../postgresql/PostgresIdAccessor.java | 8 +- .../postgresql/PostgresItemAccessor.java | 42 +++++++ .../postgresql/PostgresMemoAccessor.java | 98 +++++++--------- .../kinoko/database/postgresql/setup/init.sql | 1 + .../database/postgresql/type/ItemDao.java | 4 + .../database/postgresql/type/MemoDao.java | 110 ++++++++++++++++++ .../database/postgresql/type/PetDataDao.java | 53 +++++++++ .../database/postgresql/type/RingDataDao.java | 45 +++++++ .../kinoko/handler/stage/CashShopHandler.java | 25 +++- .../kinoko/server/cashshop/Commodity.java | 5 + 17 files changed, 396 insertions(+), 83 deletions(-) create mode 100644 src/main/java/kinoko/database/ItemAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/type/MemoDao.java diff --git a/.env.example b/.env.example index a9edac1c..185c09f4 100644 --- a/.env.example +++ b/.env.example @@ -28,3 +28,5 @@ DB_NAME=kinoko DB_USER=postgres DB_PASS=admin + +# Any other env settings you want to modify: \ No newline at end of file diff --git a/src/main/java/kinoko/database/DatabaseConnector.java b/src/main/java/kinoko/database/DatabaseConnector.java index 9b4bf0e0..35555959 100644 --- a/src/main/java/kinoko/database/DatabaseConnector.java +++ b/src/main/java/kinoko/database/DatabaseConnector.java @@ -15,6 +15,8 @@ public interface DatabaseConnector { MemoAccessor getMemoAccessor(); + ItemAccessor getItemAccessor(); + void initialize(); void shutdown(); diff --git a/src/main/java/kinoko/database/IdAccessor.java b/src/main/java/kinoko/database/IdAccessor.java index c4a930d2..9085e957 100644 --- a/src/main/java/kinoko/database/IdAccessor.java +++ b/src/main/java/kinoko/database/IdAccessor.java @@ -5,17 +5,42 @@ import java.util.Optional; public interface IdAccessor { - Optional nextAccountId(); + default Optional nextAccountId(){ + if (!DatabaseManager.isRelational()){ + throw new UnsupportedOperationException("nextAccountId needs to be implemented for this database."); + } + return Optional.of(-1); + } - Optional nextCharacterId(); + default Optional nextCharacterId(){ + if (!DatabaseManager.isRelational()){ + throw new UnsupportedOperationException("nextCharacterId needs to be implemented for this database."); + } + return Optional.of(-1); + } - Optional nextPartyId(); + default Optional nextPartyId(){ + if (!DatabaseManager.isRelational()){ + throw new UnsupportedOperationException("nextPartyId needs to be implemented for this database."); + } + return Optional.of(-1); + } - Optional nextGuildId(); + default Optional nextGuildId(){ + if (!DatabaseManager.isRelational()){ + throw new UnsupportedOperationException("nextGuildId needs to be implemented for this database."); + } + return Optional.of(-1); + } - Optional nextMemoId(); + default Optional nextMemoId(){ + if (!DatabaseManager.isRelational()){ + throw new UnsupportedOperationException("nextMemoId needs to be implemented for this database."); + } + return Optional.of(-1); + } - default public boolean generateItemSn(Item item){ + default boolean generateItemId(Item item){ if (DatabaseManager.isRelational()){ throw new UnsupportedOperationException("generateItemSn() needs to be implemented for this database."); } diff --git a/src/main/java/kinoko/database/ItemAccessor.java b/src/main/java/kinoko/database/ItemAccessor.java new file mode 100644 index 00000000..f39f08b2 --- /dev/null +++ b/src/main/java/kinoko/database/ItemAccessor.java @@ -0,0 +1,13 @@ +package kinoko.database; + +import kinoko.world.item.Item; + + +public interface ItemAccessor { + default boolean saveItem(Item item){ + if (DatabaseManager.isRelational()){ + throw new UnsupportedOperationException("saveItem() needs to be implemented for this database."); + } + return true; + } +} diff --git a/src/main/java/kinoko/database/cassandra/CassandraConnector.java b/src/main/java/kinoko/database/cassandra/CassandraConnector.java index 9960261e..8aec501a 100644 --- a/src/main/java/kinoko/database/cassandra/CassandraConnector.java +++ b/src/main/java/kinoko/database/cassandra/CassandraConnector.java @@ -49,6 +49,8 @@ public final class CassandraConnector implements DatabaseConnector { private GuildAccessor guildAccessor; private GiftAccessor giftAccessor; private MemoAccessor memoAccessor; + private ItemAccessor itemAccessor; + public boolean createKeyspace(CqlSession session, String keyspace) { try { @@ -112,6 +114,11 @@ public MemoAccessor getMemoAccessor() { return memoAccessor; } + @Override + public ItemAccessor getItemAccessor() { + return itemAccessor; + } + @Override public void initialize() { // Create Config @@ -190,6 +197,7 @@ public void initialize() { guildAccessor = new CassandraGuildAccessor(cqlSession, DATABASE_KEYSPACE); giftAccessor = new CassandraGiftAccessor(cqlSession, DATABASE_KEYSPACE); memoAccessor = new CassandraMemoAccessor(cqlSession, DATABASE_KEYSPACE); + itemAccessor = new ItemAccessor() {}; // Not needed for Cassandra. } @Override diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java index 6b9c736d..91d32ec9 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresConnector.java +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -17,6 +17,7 @@ public final class PostgresConnector implements DatabaseConnector { private GuildAccessor guildAccessor; private GiftAccessor giftAccessor; private MemoAccessor memoAccessor; + private ItemAccessor itemAccessor; @Override public void initialize() { @@ -57,6 +58,7 @@ public void initialize() { guildAccessor = new PostgresGuildAccessor(dataSource); giftAccessor = new PostgresGiftAccessor(dataSource); memoAccessor = new PostgresMemoAccessor(dataSource); + itemAccessor = new PostgresItemAccessor(dataSource); @@ -81,4 +83,5 @@ public void shutdown() { @Override public GuildAccessor getGuildAccessor() { return guildAccessor; } @Override public GiftAccessor getGiftAccessor() { return giftAccessor; } @Override public MemoAccessor getMemoAccessor() { return memoAccessor; } + @Override public ItemAccessor getItemAccessor() {return itemAccessor; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java index 50c938be..8073b429 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java @@ -26,7 +26,7 @@ private Gift loadGift(ResultSet rs) throws SQLException { rs.getInt("sender_id"), rs.getString("sender_name"), rs.getString("sender_message"), - rs.getLong("pair_item_sn") + rs.getLong("pair_gift_sn") ); } @@ -35,7 +35,7 @@ public List getGiftsByCharacterId(int characterId) { List gifts = new ArrayList<>(); String sql = """ SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, - g.sender_name, g.sender_message, fi.pair_item_sn + g.sender_name, g.sender_message, g.pair_gift_sn FROM gift.gifts g JOIN item.full_item fi ON fi.item_sn = g.item_sn WHERE g.receiver_id = ? @@ -57,7 +57,7 @@ public List getGiftsByCharacterId(int characterId) { public Optional getGiftByItemSn(long itemSn) { String sql = """ SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, - g.sender_name, g.sender_message, fi.pair_item_sn FROM gift.gifts g + g.sender_name, g.sender_message, g.pair_gift_sn FROM gift.gifts g JOIN item.full_item fi ON fi.item_sn = g.item_sn WHERE g.item_sn = ? """; @@ -79,24 +79,29 @@ public Optional getGiftByItemSn(long itemSn) { @Override public boolean newGift(Gift gift, int receiverId) { String sql = """ - INSERT INTO gift.gifts (item_sn, receiver_id, commodity_id, sender_id, sender_name, sender_message) - VALUES (?, ?, ?, ?, ?, ?) + INSERT INTO gift.gifts (item_sn, receiver_id, commodity_id, sender_id, sender_name, sender_message, pair_gift_sn) + VALUES (?, ?, ?, ?, ?, ?, ?) ON CONFLICT (item_sn) DO NOTHING """; try (Connection conn = dataSource.getConnection(); PreparedStatement stmt = conn.prepareStatement(sql)) { - // We need a new item created. - Item basicItem = new Item(gift.getItemId(), (short) 1); - ItemDao.createNewItem(conn, basicItem); + long itemSN = gift.getGiftSn(); + if (itemSN <= 0) { + // We need a new item created. + Item basicItem = new Item(gift.getItemId(), (short) 1); + ItemDao.createNewItem(conn, basicItem); + itemSN = basicItem.getItemSn(); + } - stmt.setLong(1, basicItem.getItemSn()); // item_sn is now the primary key + stmt.setLong(1, itemSN); // item_sn is now the primary key stmt.setInt(2, receiverId); stmt.setInt(3, gift.getCommodityId()); stmt.setInt(4, gift.getSenderId()); stmt.setString(5, gift.getSenderName()); stmt.setString(6, gift.getSenderMessage()); + stmt.setLong(7, gift.getPairItemSn()); return stmt.executeUpdate() > 0; diff --git a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java index 16975ffd..00022cdc 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java @@ -14,12 +14,6 @@ public final class PostgresIdAccessor extends PostgresAccessor implements IdAcce public PostgresIdAccessor(HikariDataSource dataSource) { super(dataSource); - - try (Connection conn = getConnection()){ - ItemDao.cleanupInvalidItems(conn); - } catch (SQLException e) { - e.printStackTrace(); - } } private Optional getNextId(String type) { @@ -51,7 +45,7 @@ public synchronized Optional nextMemoId() { } @Override - public boolean generateItemSn(Item item) { + public boolean generateItemId(Item item) { if (!item.hasNoSN()){ return true; } diff --git a/src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java new file mode 100644 index 00000000..37f1365f --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java @@ -0,0 +1,42 @@ +package kinoko.database.postgresql; + +import com.zaxxer.hikari.HikariDataSource; +import kinoko.database.IdAccessor; +import kinoko.database.ItemAccessor; +import kinoko.database.postgresql.type.GuildDao; +import kinoko.database.postgresql.type.ItemDao; +import kinoko.world.item.Item; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Collections; +import java.util.Optional; + +public final class PostgresItemAccessor extends PostgresAccessor implements ItemAccessor { + public PostgresItemAccessor(HikariDataSource dataSource) { + super(dataSource); + + try (Connection conn = getConnection()){ + ItemDao.cleanupInvalidItems(conn); + } catch (SQLException e) { + e.printStackTrace(); + } + } + + /** + * Saves a single item to the database within a transaction. + * If the item does not already exist, it will be created. + * This method delegates to ItemDao.saveItemsBatch for consistency with batch operations. + * + * @param item the item to be saved or created + * @return true if the transaction completes successfully; false if the transaction fails + */ + @Override + public boolean saveItem(Item item) { + return withTransaction(conn -> { + // will also create any items that don't exist. + ItemDao.saveItemsBatch(conn, Collections.singletonList(item)); + return true; + }); + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java index 3b0e51c8..131092eb 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java @@ -2,11 +2,13 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.MemoAccessor; +import kinoko.database.postgresql.type.MemoDao; import kinoko.server.memo.Memo; import kinoko.server.memo.MemoType; import java.sql.*; import java.util.ArrayList; +import java.util.Collections; import java.util.List; public final class PostgresMemoAccessor extends PostgresAccessor implements MemoAccessor { @@ -15,80 +17,64 @@ public PostgresMemoAccessor(HikariDataSource dataSource) { super(dataSource); } + /** + * Retrieves all memos for a given character ID. + * + * @param characterId the ID of the character + * @return list of memos for the character + */ @Override public List getMemosByCharacterId(int characterId) { - List memos = new ArrayList<>(); - String sql = "SELECT id, memo_type, memo_content, sender_name, date_sent " + - "FROM memo.memo WHERE receiver_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - MemoType type = MemoType.getByValue(rs.getInt("memo_type")); - Memo memo = new Memo( - type != null ? type : MemoType.DEFAULT, - rs.getInt("id"), - rs.getString("sender_name"), - rs.getString("memo_content"), - rs.getTimestamp("date_sent").toInstant() - ); - memos.add(memo); - } - } - } catch (SQLException e) { + try (Connection conn = getConnection()) { + return MemoDao.getMemosByReceiverId(conn, characterId); + } + catch (SQLException e) { e.printStackTrace(); + return Collections.emptyList(); // fallback } - return memos; } + /** + * Checks if a character has any memos. + * + * @param characterId the ID of the character + * @return true if the character has at least one memo, false otherwise + */ @Override public boolean hasMemo(int characterId) { - String sql = "SELECT 1 FROM memo.memo WHERE receiver_id = ? LIMIT 1"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - try (ResultSet rs = stmt.executeQuery()) { - return rs.next(); - } + try (Connection conn = getConnection()) { + return MemoDao.hasMemo(conn, characterId); } catch (SQLException e) { e.printStackTrace(); + return false; // fallback } - return false; } + /** + * Creates a new memo for the given receiver. + * + * @param memo the memo to be created + * @param receiverId the ID of the receiver + * @return true if the memo was successfully created, false otherwise + */ @Override public boolean newMemo(Memo memo, int receiverId) { - // `id` is SERIAL, no need to provide it manually - String sql = "INSERT INTO memo.memo (receiver_id, memo_type, memo_content, sender_name, date_sent) " + - "VALUES (?, ?, ?, ?, ?)"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, receiverId); - stmt.setInt(2, memo.getType().getValue()); - stmt.setString(3, memo.getContent()); - stmt.setString(4, memo.getSender()); - stmt.setTimestamp(5, memo.getDateSent() != null ? Timestamp.from(memo.getDateSent()) : Timestamp.from(java.time.Instant.now())); - stmt.executeUpdate(); - return true; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return MemoDao.insertMemo(conn, memo, receiverId); + }); } + /** + * Deletes a memo by its ID and receiver ID. + * + * @param memoId the ID of the memo + * @param receiverId the ID of the receiver + * @return true if the memo was successfully deleted, false otherwise + */ @Override public boolean deleteMemo(int memoId, int receiverId) { - String sql = "DELETE FROM memo.memo WHERE id = ? AND receiver_id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, memoId); - stmt.setInt(2, receiverId); - int affected = stmt.executeUpdate(); - return affected > 0; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return MemoDao.deleteMemo(conn, memoId, receiverId); + }); } } diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index 6a763f94..660a4991 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -370,6 +370,7 @@ CREATE TABLE IF NOT EXISTS gift.gifts ( sender_id INT, sender_name TEXT, sender_message TEXT, + pair_gift_sn BIGINT, PRIMARY KEY (item_sn) ); diff --git a/src/main/java/kinoko/database/postgresql/type/ItemDao.java b/src/main/java/kinoko/database/postgresql/type/ItemDao.java index 93cc497d..ff43c05c 100644 --- a/src/main/java/kinoko/database/postgresql/type/ItemDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ItemDao.java @@ -120,6 +120,10 @@ public static void saveItemsBatch(Connection conn, Collection items) throw stmtInsert.executeBatch(); // Update all EquipData EquipDataDao.saveEquipDataBatch(conn, items); + // Update all PetData + PetDataDao.upsertPetDataBatch(conn, items); + // Update all RingData + RingDataDao.upsertRingDataBatch(conn, items); } } diff --git a/src/main/java/kinoko/database/postgresql/type/MemoDao.java b/src/main/java/kinoko/database/postgresql/type/MemoDao.java new file mode 100644 index 00000000..2981917d --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/MemoDao.java @@ -0,0 +1,110 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.memo.Memo; +import kinoko.server.memo.MemoType; + +import java.sql.*; +import java.time.Instant; +import java.util.ArrayList; +import java.util.Collection; +import java.util.List; + + +public final class MemoDao { + /** + * Inserts a new memo for a specific receiver. + * + * @param conn active SQL connection + * @param memo the memo object containing content and sender info + * @param receiverId the ID of the character receiving the memo + * @throws SQLException if any SQL error occurs + */ + public static boolean insertMemo(Connection conn, Memo memo, int receiverId) throws SQLException { + String sql = """ + INSERT INTO memo.memo (receiver_id, memo_type, memo_content, sender_name, date_sent) + VALUES (?, ?, ?, ?, ?) + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, receiverId); + stmt.setInt(2, memo.getType().getValue()); + stmt.setString(3, memo.getContent()); + stmt.setString(4, memo.getSender()); + stmt.setTimestamp(5, memo.getDateSent() != null ? Timestamp.from(memo.getDateSent()) : Timestamp.from(java.time.Instant.now())); + stmt.executeUpdate(); + } + return true; + } + + + /** + * Retrieves all memos for a given receiver ID. + * + * @param conn active SQL connection + * @param receiverId the character ID to fetch memos for + * @return list of memos belonging to the receiver + * @throws SQLException if any SQL error occurs + */ + public static List getMemosByReceiverId(Connection conn, int receiverId) throws SQLException { + List memos = new ArrayList<>(); + String sql = """ + SELECT id, memo_type, memo_content, sender_name, date_sent + FROM memo.memo + WHERE receiver_id = ? + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, receiverId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + MemoType type = MemoType.getByValue(rs.getInt("memo_type")); + Memo memo = new Memo( + type != null ? type : MemoType.DEFAULT, + rs.getInt("id"), + rs.getString("sender_name"), + rs.getString("memo_content"), + rs.getTimestamp("date_sent").toInstant() + ); + memos.add(memo); + } + } + } + return memos; + } + + /** + * Deletes a memo by its ID and receiver ID. + * + * @param conn active SQL connection + * @param memoId the memo ID to delete + * @param receiverId the receiver ID to verify ownership + * @return true if the memo was deleted; false otherwise + * @throws SQLException if any SQL error occurs + */ + public static boolean deleteMemo(Connection conn, int memoId, int receiverId) throws SQLException { + String sql = "DELETE FROM memo.memo WHERE id = ? AND receiver_id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, memoId); + stmt.setInt(2, receiverId); + return stmt.executeUpdate() > 0; + } + } + + /** + * Checks whether a receiver has at least one memo. + * + * @param conn active SQL connection + * @param receiverId the receiver ID to check + * @return true if at least one memo exists for the receiver; false otherwise + * @throws SQLException if any SQL error occurs + */ + public static boolean hasMemo(Connection conn, int receiverId) throws SQLException { + String sql = "SELECT 1 FROM memo.memo WHERE receiver_id = ? LIMIT 1"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, receiverId); + try (ResultSet rs = stmt.executeQuery()) { + return rs.next(); + } + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/PetDataDao.java b/src/main/java/kinoko/database/postgresql/type/PetDataDao.java index 506969a3..1ba14a33 100644 --- a/src/main/java/kinoko/database/postgresql/type/PetDataDao.java +++ b/src/main/java/kinoko/database/postgresql/type/PetDataDao.java @@ -1,10 +1,12 @@ package kinoko.database.postgresql.type; +import kinoko.world.item.Item; import kinoko.world.item.PetData; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; +import java.util.Collection; public final class PetDataDao { /** @@ -44,4 +46,55 @@ ON CONFLICT (item_sn) stmt.executeUpdate(); } } + + /** + * Batch upserts PetData for multiple items. + * For each item, if PetData exists, it is inserted or updated in the database. + * Existing rows are updated and missing rows are inserted. + * Uses a single PreparedStatement batch for efficiency. + * + * @param conn active SQL connection + * @param items collection of items that may contain PetData + * @throws SQLException if any SQL error occurs + */ + public static void upsertPetDataBatch(Connection conn, Collection items) throws SQLException { + if (items == null || items.isEmpty()) return; + + String sql = """ + INSERT INTO item.pet_data ( + item_sn, pet_name, level, fullness, tameness, pet_skill, pet_attribute, remain_life + ) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (item_sn) + DO UPDATE SET + pet_name = EXCLUDED.pet_name, + level = EXCLUDED.level, + fullness = EXCLUDED.fullness, + tameness = EXCLUDED.tameness, + pet_skill = EXCLUDED.pet_skill, + pet_attribute = EXCLUDED.pet_attribute, + remain_life = EXCLUDED.remain_life + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (Item item : items) { + PetData petData = item.getPetData(); + if (petData == null) continue; + + int idx = 1; + stmt.setLong(idx++, item.getItemSn()); + stmt.setString(idx++, petData.getPetName()); + stmt.setByte(idx++, petData.getLevel()); + stmt.setByte(idx++, petData.getFullness()); + stmt.setShort(idx++, petData.getTameness()); + stmt.setShort(idx++, petData.getPetSkill()); + stmt.setShort(idx++, petData.getPetAttribute()); + stmt.setInt(idx, petData.getRemainLife()); + + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + } diff --git a/src/main/java/kinoko/database/postgresql/type/RingDataDao.java b/src/main/java/kinoko/database/postgresql/type/RingDataDao.java index 3908dbd9..39e66445 100644 --- a/src/main/java/kinoko/database/postgresql/type/RingDataDao.java +++ b/src/main/java/kinoko/database/postgresql/type/RingDataDao.java @@ -1,10 +1,12 @@ package kinoko.database.postgresql.type; +import kinoko.world.item.Item; import kinoko.world.item.RingData; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.SQLException; +import java.util.Collection; public final class RingDataDao { /** @@ -36,4 +38,47 @@ ON CONFLICT (item_sn) stmt.executeUpdate(); } } + + + /** + * Batch upserts RingData for multiple items. + * For each item, if RingData exists, it is inserted or updated in the database. + * Existing rows are updated and missing rows are inserted. + * Uses a single PreparedStatement batch for efficiency. + * + * @param conn active SQL connection + * @param items collection of items that may contain RingData + * @throws SQLException if any SQL error occurs + */ + public static void upsertRingDataBatch(Connection conn, Collection items) throws SQLException { + if (items == null || items.isEmpty()) return; + + String sql = """ + INSERT INTO item.ring_data ( + item_sn, pair_character_id, pair_character_name, pair_item_sn + ) + VALUES (?, ?, ?, ?) + ON CONFLICT (item_sn) + DO UPDATE SET + pair_character_id = EXCLUDED.pair_character_id, + pair_character_name = EXCLUDED.pair_character_name, + pair_item_sn = EXCLUDED.pair_item_sn + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (Item item : items) { + RingData ringData = item.getRingData(); + if (ringData == null) continue; + + int idx = 1; + stmt.setLong(idx++, item.getItemSn()); + stmt.setInt(idx++, ringData.getPairCharacterId()); + stmt.setString(idx++, ringData.getPairCharacterName()); + stmt.setLong(idx, ringData.getPairItemSn()); + + stmt.addBatch(); + } + stmt.executeBatch(); + } + } } diff --git a/src/main/java/kinoko/handler/stage/CashShopHandler.java b/src/main/java/kinoko/handler/stage/CashShopHandler.java index 1c57f3da..f8ca7bab 100644 --- a/src/main/java/kinoko/handler/stage/CashShopHandler.java +++ b/src/main/java/kinoko/handler/stage/CashShopHandler.java @@ -8,6 +8,7 @@ import kinoko.packet.world.WvsContext; import kinoko.provider.ItemProvider; import kinoko.provider.item.ItemInfo; +import kinoko.server.ServerConfig; import kinoko.server.cashshop.*; import kinoko.server.header.InHeader; import kinoko.server.memo.Memo; @@ -92,7 +93,7 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { } // Generate an item SN for the locker item - (Relational DBs) - Safe for NoSQL DBs. - if (!DatabaseManager.idAccessor().generateItemSn(cashItemInfo.getItem())){ + if (!DatabaseManager.idAccessor().generateItemId(cashItemInfo.getItem())){ user.write(CashShopPacket.fail(CashItemResultType.Buy_Failed, CashItemFailReason.Unknown)); // Due to an unknown error, the request for Cash Shop has failed. log.error("Could not generate SN for Item ID: {}", cashItemInfo.getItem().getItemId()); return; @@ -120,7 +121,7 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { final String giftMessage = inPacket.decodeString(); // Check secondary password - if (!DatabaseManager.accountAccessor().checkPassword(user.getAccount(), secondaryPassword, true)) { + if (ServerConfig.REQUIRE_SECONDARY_PASSWORD && !DatabaseManager.accountAccessor().checkPassword(user.getAccount(), secondaryPassword, true)) { user.write(CashShopPacket.fail(CashItemResultType.Gift_Failed, CashItemFailReason.InvalidBirthDate)); // Check your PIC password and\r\nplease try again return; } @@ -529,7 +530,7 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { final String giftMessage = inPacket.decodeString(); // Check secondary password - if (!DatabaseManager.accountAccessor().checkPassword(user.getAccount(), secondaryPassword, true)) { + if (ServerConfig.REQUIRE_SECONDARY_PASSWORD && !DatabaseManager.accountAccessor().checkPassword(user.getAccount(), secondaryPassword, true)) { user.write(CashShopPacket.fail(CashItemResultType.Couple_Failed, CashItemFailReason.InvalidBirthDate)); // Check your PIC password and\r\nplease try again return; } @@ -576,8 +577,8 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { } // Generate item SN for both rings - final long selfItemSn = user.getNextItemSn(); - final long pairItemSn = user.getNextItemSn(); + long selfItemSn = user.getNextItemSn(); + long pairItemSn = user.getNextItemSn(); // Create CashItemInfo and set RingData final Optional cashItemInfoResult = commodity.createCashItemInfo(selfItemSn, user.getAccountId(), user.getCharacterId(), ""); @@ -587,6 +588,20 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { return; } final CashItemInfo cashItemInfo = cashItemInfoResult.get(); + + if (DatabaseManager.isRelational()){ + // Generate SN for the pair item (Relational DBs) + selfItemSn = cashItemInfo.getItem().getItemSn(); + Item pairItem = new Item(cashItemInfo.getItem()); + pairItem.resetSN(false); + if (!DatabaseManager.idAccessor().generateItemId(pairItem)){ + user.write(CashShopPacket.fail(CashItemResultType.Couple_Failed, CashItemFailReason.Unknown)); // Due to an unknown error, the request for Cash Shop has failed. + log.error("Could not generate SN for Item ID: {}", cashItemInfo.getItem().getItemId()); + return; + } + pairItemSn = pairItem.getItemSn(); + } + final RingData selfRingData = new RingData(); selfRingData.setPairCharacterId(receiverCharacterId); selfRingData.setPairCharacterName(receiverCharacterName); diff --git a/src/main/java/kinoko/server/cashshop/Commodity.java b/src/main/java/kinoko/server/cashshop/Commodity.java index 41086408..14d42e48 100644 --- a/src/main/java/kinoko/server/cashshop/Commodity.java +++ b/src/main/java/kinoko/server/cashshop/Commodity.java @@ -1,5 +1,6 @@ package kinoko.server.cashshop; +import kinoko.database.DatabaseManager; import kinoko.provider.ItemProvider; import kinoko.provider.item.ItemInfo; import kinoko.provider.item.ItemInfoType; @@ -81,6 +82,10 @@ public Optional createCashItemInfo(long itemSn, int accountId, int item.setDateExpire(Instant.now().plus(getPeriod(), ChronoUnit.DAYS)); } } + + // Generate an item SN for the cash item - (Relational DBs) - Safe for NoSQL DBs. + DatabaseManager.idAccessor().generateItemId(item); + final CashItemInfo cashItemInfo = new CashItemInfo( item, getCommodityId(), From 572e4fd74da2da1cbc2fc0e4d6f378490a47dde1 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 14 Oct 2025 02:11:18 -0400 Subject: [PATCH 48/83] cleanup --- src/main/java/kinoko/database/IdAccessor.java | 2 +- .../cassandra/CassandraCharacterAccessor.java | 36 +- .../database/postgresql/PostgresAccessor.java | 1 - .../postgresql/PostgresCharacterAccessor.java | 1271 ++--------------- .../postgresql/PostgresConnector.java | 4 +- .../postgresql/PostgresFriendAccessor.java | 111 +- .../postgresql/PostgresGiftAccessor.java | 132 +- .../postgresql/PostgresGuildAccessor.java | 36 +- .../postgresql/PostgresIdAccessor.java | 41 +- .../postgresql/PostgresItemAccessor.java | 3 - .../postgresql/PostgresMemoAccessor.java | 2 - .../database/postgresql/type/AccountDao.java | 43 + .../postgresql/type/AvatarDataDao.java | 98 ++ .../postgresql/type/CharacterDataDao.java | 554 +++++++ .../postgresql/type/CharacterInfoDao.java | 39 + .../postgresql/type/CharacterRankDao.java | 78 + .../postgresql/type/ConfigManagerDao.java | 92 ++ .../database/postgresql/type/FriendDao.java | 124 ++ .../database/postgresql/type/GiftDao.java | 142 ++ .../database/postgresql/type/GuildDao.java | 67 +- .../postgresql/type/InventoryDao.java | 133 ++ .../postgresql/type/MapTransferInfoDao.java | 41 + .../database/postgresql/type/MemoDao.java | 2 - .../postgresql/type/MiniGameRecordDao.java | 51 + .../postgresql/type/PopularityRecordDao.java | 44 + .../postgresql/type/QuestManagerDao.java | 54 + .../postgresql/type/SkillManagerDao.java | 57 + .../database/postgresql/type/UserDao.java | 26 + .../postgresql/type/WildHunterInfoDao.java | 48 + .../database/types/CharacterRankData.java | 11 + .../kinoko/handler/stage/CashShopHandler.java | 4 +- .../kinoko/server/cashshop/Commodity.java | 2 +- 32 files changed, 1917 insertions(+), 1432 deletions(-) create mode 100644 src/main/java/kinoko/database/postgresql/type/AvatarDataDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/CharacterInfoDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/CharacterRankDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/FriendDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/GiftDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/MapTransferInfoDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/MiniGameRecordDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/PopularityRecordDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/QuestManagerDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/SkillManagerDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/UserDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java create mode 100644 src/main/java/kinoko/database/types/CharacterRankData.java diff --git a/src/main/java/kinoko/database/IdAccessor.java b/src/main/java/kinoko/database/IdAccessor.java index 9085e957..f99b7531 100644 --- a/src/main/java/kinoko/database/IdAccessor.java +++ b/src/main/java/kinoko/database/IdAccessor.java @@ -40,7 +40,7 @@ default Optional nextMemoId(){ return Optional.of(-1); } - default boolean generateItemId(Item item){ + default boolean generateItemSn(Item item){ if (DatabaseManager.isRelational()){ throw new UnsupportedOperationException("generateItemSn() needs to be implemented for this database."); } diff --git a/src/main/java/kinoko/database/cassandra/CassandraCharacterAccessor.java b/src/main/java/kinoko/database/cassandra/CassandraCharacterAccessor.java index cb27c8be..e7b1663d 100644 --- a/src/main/java/kinoko/database/cassandra/CassandraCharacterAccessor.java +++ b/src/main/java/kinoko/database/cassandra/CassandraCharacterAccessor.java @@ -7,6 +7,7 @@ import kinoko.database.CharacterAccessor; import kinoko.database.CharacterInfo; import kinoko.database.cassandra.table.CharacterTable; +import kinoko.database.types.CharacterRankData; import kinoko.server.rank.CharacterRank; import kinoko.world.item.Inventory; import kinoko.world.item.InventoryManager; @@ -297,12 +298,12 @@ public Map getCharacterRanks() { )); } // Sort and process rank data - rankDataList.sort(Comparator.comparing(CharacterRankData::getCumulativeExp).reversed().thenComparing(CharacterRankData::getMaxLevelTime)); + rankDataList.sort(Comparator.comparing(CharacterRankData::cumulativeExp).reversed().thenComparing(CharacterRankData::maxLevelTime)); final Map jobRanks = new HashMap<>(); // job rank counter final Map characterRanks = new HashMap<>(); // character id -> character rank for (CharacterRankData rankData : rankDataList) { - final int characterId = rankData.getCharacterId(); - final int jobCategory = rankData.getJobCategory(); + final int characterId = rankData.characterId(); + final int jobCategory = rankData.jobCategory(); final int worldRank = characterRanks.size() + 1; final int jobRank = jobRanks.getOrDefault(jobCategory, 0) + 1; jobRanks.put(jobCategory, jobRank); @@ -315,33 +316,4 @@ public Map getCharacterRanks() { return characterRanks; } - private static class CharacterRankData { - private final int characterId; - private final int jobCategory; - private final long cumulativeExp; - private final Instant maxLevelTime; - - private CharacterRankData(int characterId, int jobCategory, long cumulativeExp, Instant maxLevelTime) { - this.characterId = characterId; - this.jobCategory = jobCategory; - this.cumulativeExp = cumulativeExp; - this.maxLevelTime = maxLevelTime; - } - - public int getCharacterId() { - return characterId; - } - - public int getJobCategory() { - return jobCategory; - } - - public long getCumulativeExp() { - return cumulativeExp; - } - - public Instant getMaxLevelTime() { - return maxLevelTime != null ? maxLevelTime : Instant.MAX; - } - } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java index 8dbea1e2..2f7786c7 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java @@ -6,7 +6,6 @@ import java.sql.Connection; import java.sql.SQLException; -import java.util.function.Supplier; diff --git a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java index 863c1159..b43e3a99 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresCharacterAccessor.java @@ -3,1223 +3,172 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.CharacterAccessor; import kinoko.database.CharacterInfo; -import kinoko.database.postgresql.type.ExtendSpDao; -import kinoko.database.postgresql.type.InventoryDao; -import kinoko.database.postgresql.type.SkillMacrosDao; +import kinoko.database.postgresql.type.*; import kinoko.server.rank.CharacterRank; -import kinoko.world.GameConstants; -import kinoko.world.item.*; -import kinoko.world.job.JobConstants; -import kinoko.world.quest.QuestManager; -import kinoko.world.quest.QuestRecord; -import kinoko.world.quest.QuestState; -import kinoko.world.skill.SkillManager; -import kinoko.world.skill.SkillRecord; import kinoko.world.user.AvatarData; import kinoko.world.user.CharacterData; -import kinoko.world.user.data.*; -import kinoko.world.user.stat.CharacterStat; -import kinoko.world.user.stat.ExtendSp; -import org.postgresql.util.PGobject; -import java.io.*; import java.sql.*; -import java.time.Instant; import java.util.*; -import java.util.concurrent.atomic.AtomicInteger; - -public final class PostgresCharacterAccessor implements CharacterAccessor { - private final HikariDataSource dataSource; +public final class PostgresCharacterAccessor extends PostgresAccessor implements CharacterAccessor { public PostgresCharacterAccessor(HikariDataSource dataSource) { - this.dataSource = dataSource; - } - - private byte[] serialize(Object obj) throws IOException { - try (ByteArrayOutputStream baos = new ByteArrayOutputStream(); - ObjectOutputStream oos = new ObjectOutputStream(baos)) { - oos.writeObject(obj); - return baos.toByteArray(); - } - } - - private T deserialize(byte[] bytes, Class clazz) throws IOException, ClassNotFoundException { - try (ByteArrayInputStream bais = new ByteArrayInputStream(bytes); - ObjectInputStream ois = new ObjectInputStream(bais)) { - return clazz.cast(ois.readObject()); - } - } - - private CharacterData loadCharacterData(ResultSet rs) throws SQLException, IOException, ClassNotFoundException { - int accountId = rs.getInt("account_id"); - CharacterData cd = new CharacterData(accountId); - int characterID = rs.getInt("id"); - CharacterStat cs = new CharacterStat( - characterID, - rs.getString("name"), - rs.getByte("gender"), - rs.getByte("skin"), - rs.getInt("face"), - rs.getInt("hair"), - rs.getShort("level"), - rs.getShort("job"), - rs.getShort("sub_job"), - rs.getShort("base_str"), - rs.getShort("base_dex"), - rs.getShort("base_int"), - rs.getShort("base_luk"), - rs.getInt("hp"), - rs.getInt("max_hp"), - rs.getInt("mp"), - rs.getInt("max_mp"), - rs.getShort("ap"), - rs.getInt("exp"), - rs.getShort("pop"), - rs.getInt("pos_map"), - rs.getByte("portal"), - rs.getLong("pet_1"), - rs.getLong("pet_2"), - rs.getLong("pet_3") - ); - - cd.setCharacterStat(cs); - - try (Connection conn = dataSource.getConnection()) { - cs.setSp(ExtendSpDao.loadExtendSp(conn, characterID)); // TODO: put this in a proper connection block. - - - InventoryManager im = InventoryDao.loadInventoryManager(conn, characterID); - - cd.setInventoryManager(im); - im.setMoney(rs.getInt("money")); - - - Timestamp extSlotExpireTs = rs.getTimestamp("ext_slot_expire"); - im.setExtSlotExpire(extSlotExpireTs != null ? extSlotExpireTs.toInstant() : null); - cd.setInventoryManager(im); - - cd.setCoupleRecord(CoupleRecord.from( - im.getEquipped(), im.getEquipInventory() - )); - } - - - SkillManager sm = loadSkillCooltimesAndRecords(characterID); - - cd.setSkillManager(sm); - - QuestManager qm = loadQuestRecords(characterID); - - cd.setQuestManager(qm); - - ConfigManager cm = loadConfig(characterID); - cd.setConfigManager(cm); - - PopularityRecord pr = loadPopularityRecord(characterID); - cd.setPopularityRecord(pr); - - MiniGameRecord mgr = loadMiniGameRecord(characterID); - cd.setMiniGameRecord(mgr); - - MapTransferInfo mto = loadMapTransferInfo(characterID); - cd.setMapTransferInfo(mto); - - WildHunterInfo whi = loadWildHunterInfo(characterID); - cd.setWildHunterInfo(whi); - - cd.setItemSnCounter(new AtomicInteger(-1)); // Let Postgres handle item sn - - cd.setFriendMax(rs.getInt("friend_max")); - cd.setPartyId(rs.getInt("party_id")); - cd.setGuildId(rs.getInt("guild_id")); - - Timestamp creationTs = rs.getTimestamp("creation_time"); - cd.setCreationTime(creationTs != null ? creationTs.toInstant() : null); - Timestamp maxLevelTs = rs.getTimestamp("max_level_time"); - cd.setMaxLevelTime(maxLevelTs != null ? maxLevelTs.toInstant() : null); - return cd; - } - - private WildHunterInfo loadWildHunterInfo(int characterId) throws SQLException { - WildHunterInfo wh = new WildHunterInfo(); - - String sqlRiding = "SELECT riding_type FROM player.wild_hunter WHERE character_id = ?"; - String sqlMobs = "SELECT mob_id FROM player.wild_hunter_mob WHERE character_id = ?"; - - try (Connection conn = dataSource.getConnection()) { - // Load riding_type - try (PreparedStatement stmt = conn.prepareStatement(sqlRiding)) { - stmt.setInt(1, characterId); - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - wh.setRidingType(rs.getInt("riding_type")); - } - } - } - - // Load captured mobs - try (PreparedStatement stmt = conn.prepareStatement(sqlMobs)) { - stmt.setInt(1, characterId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - wh.getCapturedMobs().add(rs.getInt("mob_id")); - if (wh.getCapturedMobs().size() >= 5) break; // enforce max 5 - } - } - } - } - - return wh; - } - - - private MapTransferInfo loadMapTransferInfo(int characterId) throws SQLException { - MapTransferInfo mti = new MapTransferInfo(); - - String sql = "SELECT map_id, old_map_id FROM player.map_transfer WHERE character_id = ?"; - try (Connection con = dataSource.getConnection(); - PreparedStatement stmt = con.prepareStatement(sql)) { - stmt.setInt(1, characterId); - try (ResultSet mapRs = stmt.executeQuery()) { - if (mapRs.next()) { - int mapId = mapRs.getInt("map_id"); - int oldMapId = mapRs.getInt("old_map_id"); - - mti.getMapTransfer().add(mapId); // main list - mti.getMapTransferEx().add(oldMapId); // legacy/old map - } - } - } - - return mti; - } - - private MiniGameRecord loadMiniGameRecord(int characterId) throws SQLException { - MiniGameRecord record = new MiniGameRecord(); - - String sql = """ - SELECT omok_wins, omok_ties, omok_losses, omok_score, - memory_wins, memory_ties, memory_losses, memory_score - FROM player.minigame - WHERE character_id = ? - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)){ - stmt.setInt(1, characterId); - - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - record.setOmokGameWins(rs.getInt("omok_wins")); - record.setOmokGameTies(rs.getInt("omok_ties")); - record.setOmokGameLosses(rs.getInt("omok_losses")); - record.setOmokGameScore(rs.getDouble("omok_score")); - - record.setMemoryGameWins(rs.getInt("memory_wins")); - record.setMemoryGameTies(rs.getInt("memory_ties")); - record.setMemoryGameLosses(rs.getInt("memory_losses")); - record.setMemoryGameScore(rs.getDouble("memory_score")); - } - } - } - - return record; - } - - - private PopularityRecord loadPopularityRecord(int characterId) throws SQLException { - PopularityRecord pr = new PopularityRecord(); - - String sql = "SELECT other_character_id, timestamp FROM player.popularity WHERE character_id = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - int otherCharId = rs.getInt("other_character_id"); - Timestamp ts = rs.getTimestamp("timestamp"); - if (ts != null) { - pr.getRecords().put(otherCharId, ts.toInstant()); - } - } - } - } - - return pr; - } - - - private ConfigManager loadConfig(int characterId) throws SQLException { - String sql = """ - SELECT pet_consume_item, pet_consume_mp_item, pet_exception_list, - func_key_types, func_key_ids, quickslot_key_map - FROM player.config - WHERE character_id = ? - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - - try (ResultSet rs = stmt.executeQuery()) { - if (!rs.next()) { - return ConfigManager.defaults(); - } - - int petConsumeItem = rs.getInt("pet_consume_item"); - int petConsumeMpItem = rs.getInt("pet_consume_mp_item"); - - // --- Pet exception list --- - List petExceptionList; - var petExArr = rs.getArray("pet_exception_list"); - if (petExArr != null) { - Integer[] arr = (Integer[]) petExArr.getArray(); - petExceptionList = Arrays.asList(arr); - } else { - petExceptionList = List.of(); - } - - // --- Function key map --- - FuncKeyMapped[] funcKeyMap = new FuncKeyMapped[GameConstants.FUNC_KEY_MAP_SIZE]; - var funcTypeArr = rs.getArray("func_key_types"); - var funcIdArr = rs.getArray("func_key_ids"); - - if (funcTypeArr != null && funcIdArr != null) { - Short[] typeValues = (Short[]) funcTypeArr.getArray(); - Integer[] idValues = (Integer[]) funcIdArr.getArray(); - - for (int i = 0; i < funcKeyMap.length; i++) { - FuncKeyType type = FuncKeyType.getByValue(typeValues[i].byteValue()); - int id = idValues[i]; - funcKeyMap[i] = FuncKeyMapped.of(type, id); - } - } else { - funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); - } - - // --- Quickslot key map --- - int[] quickslotKeyMap; - var quickArr = rs.getArray("quickslot_key_map"); - if (quickArr != null) { - Integer[] arr = (Integer[]) quickArr.getArray(); - quickslotKeyMap = Arrays.stream(arr).mapToInt(Integer::intValue).toArray(); - } else { - quickslotKeyMap = Arrays.copyOf(GameConstants.DEFAULT_QUICKSLOT_KEY_MAP, GameConstants.QUICKSLOT_KEY_MAP_SIZE); - } - - ConfigManager cm = new ConfigManager(petConsumeItem, petConsumeMpItem, petExceptionList, funcKeyMap, quickslotKeyMap); - cm.updateMacroSysData(SkillMacrosDao.loadMacros(conn, characterId)); - return cm; - } - } - } - - - private QuestManager loadQuestRecords(int characterId) throws SQLException { - QuestManager qm = new QuestManager(); - String sql = "SELECT quest_id, status, progress, completed_time FROM player.quest_record WHERE character_id = ?"; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - int questId = rs.getInt("quest_id"); - int statusInt = rs.getInt("status"); - QuestState state = QuestState.getByValue(statusInt); // map int -> QuestState - String value = rs.getString("progress"); - Timestamp completedTs = rs.getTimestamp("completed_time"); - Instant completedTime = completedTs != null ? completedTs.toInstant() : null; - QuestRecord record = new QuestRecord(questId, state, value, completedTime); - qm.addQuestRecord(record); - } - } - } - - return qm; - } - - - private SkillManager loadSkillCooltimesAndRecords(int characterId) throws SQLException { - SkillManager sm = new SkillManager(); - - // Load skill cooldowns - String cooldownSql = "SELECT skill_id, cooldown_end FROM player.skill_cooltime WHERE character_id = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(cooldownSql)) { - stmt.setInt(1, characterId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - int skillId = rs.getInt("skill_id"); - Timestamp cooldownEnd = rs.getTimestamp("cooldown_end"); - if (cooldownEnd != null) { - sm.getSkillCooltimes().put(skillId, cooldownEnd.toInstant()); - } - } - } - } - - // Load skill records - String recordSql = "SELECT skill_id, level, master_level FROM player.skill_record WHERE character_id = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(recordSql)) { - stmt.setInt(1, characterId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - int skillId = rs.getInt("skill_id"); - int level = rs.getInt("level"); - int masterLevel = rs.getInt("master_level"); - SkillRecord record = new SkillRecord(skillId, level, masterLevel); - sm.addSkill(record); - } - } - } - - return sm; - } - - - - private String lowerName(String name) { - return name.toLowerCase(); + super(dataSource); } + /** + * Checks if a character name is available for creation. + * + * @param name the character name to check + * @return true if the name is not already taken, false otherwise + */ @Override public boolean checkCharacterNameAvailable(String name) { - String sql = "SELECT COUNT(*) > 0 AS exists FROM player.characters WHERE name ILIKE ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setString(1, name); // original name - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - boolean exists = rs.getBoolean("exists"); - return !exists; // available if it does NOT exist - } - } - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return CharacterDataDao.checkCharacterNameAvailable(conn, name); + }); } - + /** + * Retrieves a fully populated CharacterData by character ID. + * + * @param characterId the ID of the character + * @return an Optional containing the CharacterData if found, empty otherwise + */ @Override public Optional getCharacterById(int characterId) { - String sql = """ - SELECT c.*, s.*, m.guild_id, m.grade - FROM player.characters c - LEFT JOIN player.stats s ON c.id = s.character_id - LEFT JOIN guild.member m ON m.character_id = c.id - WHERE c.id = ? - """; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - ResultSet rs = stmt.executeQuery(); - if (rs.next()) { - return Optional.of(loadCharacterData(rs)); - } - } catch (Exception e) { + try (Connection conn = getConnection()) { + return CharacterDataDao.getCharacterById(conn, characterId); + } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } + /** + * Retrieves a fully populated CharacterData by character name (case-insensitive). + * + * @param name the name of the character + * @return an Optional containing the CharacterData if found, empty otherwise + */ @Override public Optional getCharacterByName(String name) { - String sql = "SELECT * FROM player.characters WHERE name ILIKE ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setString(1, lowerName(name)); - ResultSet rs = stmt.executeQuery(); - if (rs.next()) { - return Optional.of(loadCharacterData(rs)); - } - } catch (Exception e) { + try (Connection conn = getConnection()) { + return CharacterDataDao.getCharacterByName(conn, name); + } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } + /** + * Retrieves basic CharacterInfo by name (case-insensitive) without loading full data. + * + * @param name the character name + * @return an Optional containing CharacterInfo if found, empty otherwise + */ @Override public Optional getCharacterInfoByName(String name) { - String sql = "SELECT account_id, id, name FROM player.characters WHERE name ILIKE ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setString(1, lowerName(name)); - ResultSet rs = stmt.executeQuery(); - if (rs.next()) { - return Optional.of(new CharacterInfo( - rs.getInt("account_id"), - rs.getInt("id"), - rs.getString("name") - )); - } + try (Connection conn = getConnection()) { + return CharacterInfoDao.getCharacterInfoByName(conn, name); } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } + /** + * Retrieves the account ID associated with a character ID. + * + * @param characterId the character ID + * @return an Optional containing the account ID if found, empty otherwise + */ @Override public Optional getAccountIdByCharacterId(int characterId) { - String sql = "SELECT account_id FROM player.characters WHERE id = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - ResultSet rs = stmt.executeQuery(); - if (rs.next()) return Optional.of(rs.getInt("account_id")); + try (Connection conn = getConnection()) { + return AccountDao.getAccountIdByCharacterId(conn, characterId); } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } + /** + * Retrieves all AvatarData for a specific account ID. + * + * @param accountId the account ID + * @return a list of AvatarData objects; empty if none are found or an error occurs + */ @Override public List getAvatarDataByAccountId(int accountId) { - List list = new ArrayList<>(); - String sql = """ - SELECT c.id AS character_id, - c.name AS character_name, - c.money, - s.gender, - s.skin, - s.face, - s.hair, - s.level, - s.job, - s.sub_job, - s.base_str, - s.base_dex, - s.base_int, - s.base_luk, - s.hp, - s.max_hp, - s.mp, - s.max_mp, - s.ap, - s.exp, - s.pop, - s.pos_map, - s.portal, - s.pet_1, - s.pet_2, - s.pet_3 - FROM player.characters c - JOIN player.stats s ON c.id = s.character_id - WHERE c.account_id = ? - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, accountId); - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - CharacterStat cs = new CharacterStat( - rs.getInt("character_id"), - rs.getString("character_name"), - rs.getByte("gender"), - rs.getByte("skin"), - rs.getInt("face"), - rs.getInt("hair"), - rs.getShort("level"), - rs.getShort("job"), - rs.getShort("sub_job"), - rs.getShort("base_str"), - rs.getShort("base_dex"), - rs.getShort("base_int"), - rs.getShort("base_luk"), - rs.getInt("hp"), - rs.getInt("max_hp"), - rs.getInt("mp"), - rs.getInt("max_mp"), - rs.getShort("ap"), - rs.getInt("exp"), - rs.getShort("pop"), - rs.getInt("pos_map"), - rs.getByte("portal"), - rs.getLong("pet_1"), - rs.getLong("pet_2"), - rs.getLong("pet_3") - ); - - // For inventory, query normalized player.inventory table separately - Inventory equipped = loadEquippedInventory(cs.getId()); -// Inventory cash = loadCashInventory(cs.getId()); - - list.add(AvatarData.from(cs, equipped)); - } - } + try (Connection conn = getConnection()) { + return AvatarDataDao.getAvatarDataByAccountId(conn, accountId); } catch (SQLException e) { e.printStackTrace(); + return new ArrayList<>(); } - - return list; } - private Inventory loadCashInventory(int characterId) throws SQLException { - Inventory equipped = new Inventory(24, InventoryType.CASH); // default cash size - - String sql = """ - SELECT f.*, i.slot - FROM player.inventory i - JOIN item.full_item f ON i.item_sn = f.item_sn - WHERE i.character_id = ? AND i.inventory_type = ? - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - - PGobject enumValue = new PGobject(); - enumValue.setType("inventory_type_enum"); - enumValue.setValue(InventoryType.CASH.name()); - stmt.setObject(2, enumValue); - - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - long itemSn = rs.getLong("item_sn"); - int slot = rs.getInt("slot"); - int itemId = rs.getInt("item_id"); - short quantity = rs.getShort("quantity"); - short attribute = rs.getShort("attribute"); - String title = rs.getString("title"); - Timestamp dateExpireTs = rs.getTimestamp("date_expire"); - - // Build EquipData if applicable - EquipData equipData = null; - if (rs.getObject("inc_str") != null) { - equipData = new EquipData( - rs.getShort("inc_str"), - rs.getShort("inc_dex"), - rs.getShort("inc_int"), - rs.getShort("inc_luk"), - rs.getShort("inc_max_hp"), - rs.getShort("inc_max_mp"), - rs.getShort("inc_pad"), - rs.getShort("inc_mad"), - rs.getShort("inc_pdd"), - rs.getShort("inc_mdd"), - rs.getShort("inc_acc"), - rs.getShort("inc_eva"), - rs.getShort("inc_craft"), - rs.getShort("inc_speed"), - rs.getShort("inc_jump"), - rs.getByte("ruc"), - rs.getByte("cuc"), - rs.getInt("iuc"), - rs.getByte("chuc"), - rs.getByte("grade"), - rs.getShort("option_1"), - rs.getShort("option_2"), - rs.getShort("option_3"), - rs.getShort("socket_1"), - rs.getShort("socket_2"), - rs.getByte("level_up_type"), - rs.getByte("level"), - rs.getInt("exp"), - rs.getInt("durability") - ); - } - - // Build PetData if applicable - PetData petData = null; - if (rs.getObject("pet_name") != null) { - petData = new PetData( - rs.getString("pet_name"), - rs.getByte("pet_level"), - rs.getByte("fullness"), - rs.getShort("tameness"), - rs.getShort("pet_skill"), - rs.getShort("pet_attribute"), - rs.getInt("remain_life") - ); - } - - // Build RingData if applicable - RingData ringData = null; - if (rs.getObject("pair_character_id") != null) { - ringData = new RingData( - rs.getInt("pair_character_id"), - rs.getString("pair_character_name"), - rs.getLong("pair_item_sn") - ); - } - - Item item = new Item( - itemId, - quantity, - itemSn, - false, // cash flag, adjust if you have it - attribute, - title, - dateExpireTs != null ? dateExpireTs.toInstant() : null, - equipData, - petData, - ringData - ); - - equipped.putItem(slot, item); - } - } - } - - return equipped; - } - - - private Inventory loadEquippedInventory(int characterId) throws SQLException { - Inventory equipped = new Inventory(24, InventoryType.EQUIPPED); // default equipped size - - String sql = """ - SELECT f.*, i.slot - FROM player.inventory i - JOIN item.full_item f ON i.item_sn = f.item_sn - WHERE i.character_id = ? AND i.inventory_type = ? - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - - PGobject enumValue = new PGobject(); - enumValue.setType("inventory_type_enum"); - enumValue.setValue(InventoryType.EQUIPPED.name()); - stmt.setObject(2, enumValue); - - try (ResultSet rs = stmt.executeQuery()) { - while (rs.next()) { - long itemSn = rs.getLong("item_sn"); - int slot = rs.getInt("slot"); - int itemId = rs.getInt("item_id"); - short quantity = rs.getShort("quantity"); - short attribute = rs.getShort("attribute"); - String title = rs.getString("title"); - Timestamp dateExpireTs = rs.getTimestamp("date_expire"); - - // Build EquipData if applicable - EquipData equipData = null; - if (rs.getObject("inc_str") != null) { - equipData = new EquipData( - rs.getShort("inc_str"), - rs.getShort("inc_dex"), - rs.getShort("inc_int"), - rs.getShort("inc_luk"), - rs.getShort("inc_max_hp"), - rs.getShort("inc_max_mp"), - rs.getShort("inc_pad"), - rs.getShort("inc_mad"), - rs.getShort("inc_pdd"), - rs.getShort("inc_mdd"), - rs.getShort("inc_acc"), - rs.getShort("inc_eva"), - rs.getShort("inc_craft"), - rs.getShort("inc_speed"), - rs.getShort("inc_jump"), - rs.getByte("ruc"), - rs.getByte("cuc"), - rs.getInt("iuc"), - rs.getByte("chuc"), - rs.getByte("grade"), - rs.getShort("option_1"), - rs.getShort("option_2"), - rs.getShort("option_3"), - rs.getShort("socket_1"), - rs.getShort("socket_2"), - rs.getByte("level_up_type"), - rs.getByte("level"), - rs.getInt("exp"), - rs.getInt("durability") - ); - } - - // Build PetData if applicable - PetData petData = null; - if (rs.getObject("pet_name") != null) { - petData = new PetData( - rs.getString("pet_name"), - rs.getByte("pet_level"), - rs.getByte("fullness"), - rs.getShort("tameness"), - rs.getShort("pet_skill"), - rs.getShort("pet_attribute"), - rs.getInt("remain_life") - ); - } - - // Build RingData if applicable - RingData ringData = null; - if (rs.getObject("pair_character_id") != null) { - ringData = new RingData( - rs.getInt("pair_character_id"), - rs.getString("pair_character_name"), - rs.getLong("pair_item_sn") - ); - } - - Item item = new Item( - itemId, - quantity, - itemSn, - false, // cash flag, adjust if you have it - attribute, - title, - dateExpireTs != null ? dateExpireTs.toInstant() : null, - equipData, - petData, - ringData - ); - - equipped.putItem(slot, item); - } - } - } - - return equipped; - } - - + /** + * Creates a new character in the database. + * + * Performs all dependent inserts (stats, inventory, skills, quests, config, popularity) + * using a single transaction. + * + * @param characterData the character data to insert + * @return true if creation was successful, false otherwise + */ @Override public synchronized boolean newCharacter(CharacterData characterData) { - if (!checkCharacterNameAvailable(characterData.getCharacterName())) return false; - - String sql = """ - INSERT INTO player.characters - (account_id, name, money, ext_slot_expire, friend_max, party_id, guild_id, creation_time, max_level_time) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id - """; - - Connection conn = null; - boolean success = false; - - try { - conn = dataSource.getConnection(); - conn.setAutoCommit(false); - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterData.getAccountId()); - stmt.setString(2, characterData.getCharacterName()); - stmt.setInt(3, characterData.getInventoryManager().getMoney()); - stmt.setTimestamp(4, characterData.getInventoryManager().getExtSlotExpire() != null ? - Timestamp.from(characterData.getInventoryManager().getExtSlotExpire()) : null); - stmt.setInt(5, characterData.getFriendMax()); - stmt.setInt(6, characterData.getPartyId()); - stmt.setInt(7, characterData.getGuildId()); - stmt.setTimestamp(8, characterData.getCreationTime() != null ? Timestamp.from(characterData.getCreationTime()) : null); - stmt.setTimestamp(9, characterData.getMaxLevelTime() != null ? Timestamp.from(characterData.getMaxLevelTime()) : null); - - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - int newCharacterId = rs.getInt(1); - characterData.getCharacterStat().setId(newCharacterId); - } else { - throw new SQLException("Failed to insert new character"); - } - } - } - - // Pass the same connection to all dependent methods - saveCharacterStats(conn, characterData); - saveCharacterInventory(conn, characterData); - saveCharacterSkills(conn, characterData); - saveCharacterQuests(conn, characterData); - saveCharacterConfig(conn, characterData); - saveCharacterPopularity(conn, characterData); - ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); - - conn.commit(); - success = true; - } catch (Exception e) { - e.printStackTrace(); - if (conn != null) { - try { conn.rollback(); } catch (SQLException ex) { ex.printStackTrace(); } - } - } finally { - if (conn != null) { - try { conn.setAutoCommit(true); conn.close(); } catch (SQLException ex) { ex.printStackTrace(); } - } - } - - return success; - } - - - private void saveCharacterConfig(Connection conn, CharacterData characterData) throws SQLException { - ConfigManager config = characterData.getConfigManager(); - - String sql = """ - INSERT INTO player.config - (character_id, pet_consume_item, pet_consume_mp_item, pet_exception_list, func_key_types, func_key_ids, quickslot_key_map) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (character_id) DO UPDATE - SET pet_consume_item = EXCLUDED.pet_consume_item, - pet_consume_mp_item = EXCLUDED.pet_consume_mp_item, - pet_exception_list = EXCLUDED.pet_exception_list, - func_key_types = EXCLUDED.func_key_types, - func_key_ids = EXCLUDED.func_key_ids, - quickslot_key_map = EXCLUDED.quickslot_key_map - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, config.getPetConsumeItem()); - stmt.setInt(3, config.getPetConsumeMpItem()); - - // pet_exception_list -> List -> Integer[] - List exceptionList = config.getPetExceptionList(); - Integer[] exceptionArray = exceptionList != null ? exceptionList.toArray(new Integer[0]) : new Integer[0]; - stmt.setArray(4, conn.createArrayOf("integer", exceptionArray)); - - // func_key_types & func_key_ids from FuncKeyMapped[] - FuncKeyMapped[] funcKeyMap = config.getFuncKeyMap(); - if (funcKeyMap == null) { - funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); - } - - Short[] funcTypes = Arrays.stream(funcKeyMap) - .map(f -> (short) f.getType().getValue()) - .toArray(Short[]::new); - Integer[] funcIds = Arrays.stream(funcKeyMap) - .map(FuncKeyMapped::getId) - .toArray(Integer[]::new); - - stmt.setArray(5, conn.createArrayOf("smallint", funcTypes)); // func_key_types - stmt.setArray(6, conn.createArrayOf("integer", funcIds)); // func_key_ids - - // quickslot_key_map -> int[] -> Integer[] - int[] quickslot = config.getQuickslotKeyMap(); - Integer[] quickslotKeys = quickslot != null - ? Arrays.stream(quickslot).boxed().toArray(Integer[]::new) - : new Integer[0]; - stmt.setArray(7, conn.createArrayOf("integer", quickslotKeys)); - - stmt.executeUpdate(); - } - // save skill macros - SkillMacrosDao.upsertMacros(conn, characterData.getCharacterId(), config.getMacroSysData()); - } - - private void saveCharacterPopularity(Connection conn, CharacterData characterData) throws SQLException { - String sql = """ - INSERT INTO player.popularity (character_id, other_character_id, timestamp) - VALUES (?, ?, ?) - ON CONFLICT (character_id, other_character_id) - DO UPDATE SET timestamp = EXCLUDED.timestamp - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - PopularityRecord pr = characterData.getPopularityRecord(); - int charId = characterData.getCharacterId(); - - for (var entry : pr.getRecords().entrySet()) { - stmt.setInt(1, charId); - stmt.setInt(2, entry.getKey()); - stmt.setTimestamp(3, Timestamp.from(entry.getValue())); - stmt.addBatch(); - } - - stmt.executeBatch(); - } - } - - - - private void saveCharacterSkills(Connection conn, CharacterData characterData) throws SQLException { - String skillRecordSql = """ - INSERT INTO player.skill_record (character_id, skill_id, level, master_level) - VALUES (?, ?, ?, ?) - ON CONFLICT (character_id, skill_id) - DO UPDATE SET level = EXCLUDED.level, master_level = EXCLUDED.master_level - """; - - try (PreparedStatement stmt = conn.prepareStatement(skillRecordSql)) { - for (SkillRecord sr : characterData.getSkillManager().getSkillRecords()) { - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, sr.getSkillId()); - stmt.setInt(3, sr.getSkillLevel()); - stmt.setInt(4, sr.getMasterLevel()); - stmt.addBatch(); - } - stmt.executeBatch(); - } - - // Save skill cooltimes - String skillCooltimeSql = """ - INSERT INTO player.skill_cooltime (character_id, skill_id, cooldown_end) - VALUES (?, ?, ?) - ON CONFLICT (character_id, skill_id) - DO UPDATE SET cooldown_end = EXCLUDED.cooldown_end - """; - - try (PreparedStatement stmt = conn.prepareStatement(skillCooltimeSql)) { - for (var entry : characterData.getSkillManager().getSkillCooltimes().entrySet()) { - int skillId = entry.getKey(); - Instant endTime = entry.getValue(); - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, skillId); - stmt.setTimestamp(3, Timestamp.from(endTime)); - stmt.addBatch(); - } - stmt.executeBatch(); - } - } - - private void saveCharacterQuests(Connection conn, CharacterData characterData) throws SQLException { - String sql = """ - INSERT INTO player.quest_record (character_id, quest_id, status, progress, completed_time) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT (character_id, quest_id) - DO UPDATE SET status = EXCLUDED.status, - progress = EXCLUDED.progress, - completed_time = EXCLUDED.completed_time - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - for (QuestRecord qr : characterData.getQuestManager().getQuestRecords()) { - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, qr.getQuestId()); - stmt.setInt(3, qr.getState().getValue()); - stmt.setString(4, qr.getValue()); - stmt.setTimestamp(5, qr.getCompletedTime() != null ? Timestamp.from(qr.getCompletedTime()) : null); - stmt.addBatch(); - } - stmt.executeBatch(); - } - } - + return withTransaction(conn -> { + return CharacterDataDao.newCharacter(conn, characterData); + }); + } + + /** + * Saves an existing character's data to the database. + * + * Updates the character row and all dependent tables within a transaction. + * + * @param characterData the character data to save + * @return true if the save was successful, false otherwise + */ @Override public boolean saveCharacter(CharacterData characterData) { - String sql = "UPDATE player.characters SET account_id=?, name=?, money=?, ext_slot_expire=?, " + - "friend_max=?, party_id=?, guild_id=?, creation_time=?, max_level_time=? " + - "WHERE id=?"; - Connection conn = null; - boolean previousAutoCommit = true; - - try { - conn = dataSource.getConnection(); - - // save previous auto-commit state - previousAutoCommit = conn.getAutoCommit(); - conn.setAutoCommit(false); - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterData.getAccountId()); - stmt.setString(2, characterData.getCharacterName()); - stmt.setInt(3, characterData.getInventoryManager().getMoney()); - stmt.setTimestamp(4, characterData.getInventoryManager().getExtSlotExpire() != null ? - Timestamp.from(characterData.getInventoryManager().getExtSlotExpire()) : null); - stmt.setInt(5, characterData.getFriendMax()); - stmt.setInt(6, characterData.getPartyId()); - stmt.setInt(7, characterData.getGuildId()); - stmt.setTimestamp(8, characterData.getCreationTime() != null ? Timestamp.from(characterData.getCreationTime()) : null); - stmt.setTimestamp(9, characterData.getMaxLevelTime() != null ? Timestamp.from(characterData.getMaxLevelTime()) : null); - stmt.setInt(10, characterData.getCharacterId()); - - int updated = stmt.executeUpdate(); - if (updated == 0) { - conn.rollback(); - return false; - } - } - - // Save dependent tables using the same connection - saveCharacterStats(conn, characterData); - saveCharacterInventory(conn, characterData); - saveCharacterSkills(conn, characterData); - saveCharacterQuests(conn, characterData); - saveCharacterConfig(conn, characterData); - saveCharacterPopularity(conn, characterData); - ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); - - conn.commit(); - return true; - } catch (Exception e) { - if (conn != null) { - try { - conn.rollback(); - } catch (SQLException rollbackEx) { - rollbackEx.printStackTrace(); - } - } - e.printStackTrace(); - return false; - } finally { - if (conn != null) { - try { - conn.setAutoCommit(previousAutoCommit); - conn.close(); - } catch (SQLException ex) { - ex.printStackTrace(); - } - } - } - } - - - private void saveCharacterStats(Connection conn, CharacterData characterData) throws SQLException { - String sql = """ - INSERT INTO player.stats ( - character_id, gender, skin, face, hair, level, job, sub_job, - base_str, base_dex, base_int, base_luk, hp, max_hp, mp, max_mp, - ap, exp, pop, pos_map, portal, pet_1, pet_2, pet_3 - ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? - ) - ON CONFLICT (character_id) DO UPDATE SET - gender = EXCLUDED.gender, - skin = EXCLUDED.skin, - face = EXCLUDED.face, - hair = EXCLUDED.hair, - level = EXCLUDED.level, - job = EXCLUDED.job, - sub_job = EXCLUDED.sub_job, - base_str = EXCLUDED.base_str, - base_dex = EXCLUDED.base_dex, - base_int = EXCLUDED.base_int, - base_luk = EXCLUDED.base_luk, - hp = EXCLUDED.hp, - max_hp = EXCLUDED.max_hp, - mp = EXCLUDED.mp, - max_mp = EXCLUDED.max_mp, - ap = EXCLUDED.ap, - exp = EXCLUDED.exp, - pop = EXCLUDED.pop, - pos_map = EXCLUDED.pos_map, - portal = EXCLUDED.portal, - pet_1 = EXCLUDED.pet_1, - pet_2 = EXCLUDED.pet_2, - pet_3 = EXCLUDED.pet_3 - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - CharacterStat cs = characterData.getCharacterStat(); - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, cs.getGender()); - stmt.setInt(3, cs.getSkin()); - stmt.setInt(4, cs.getFace()); - stmt.setInt(5, cs.getHair()); - stmt.setInt(6, cs.getLevel()); - stmt.setInt(7, cs.getJob()); - stmt.setInt(8, cs.getSubJob()); - stmt.setInt(9, cs.getBaseStr()); - stmt.setInt(10, cs.getBaseDex()); - stmt.setInt(11, cs.getBaseInt()); - stmt.setInt(12, cs.getBaseLuk()); - stmt.setInt(13, cs.getHp()); - stmt.setInt(14, cs.getMaxHp()); - stmt.setInt(15, cs.getMp()); - stmt.setInt(16, cs.getMaxMp()); - stmt.setInt(17, cs.getAp()); - stmt.setInt(18, cs.getExp()); - stmt.setInt(19, cs.getPop()); - stmt.setInt(20, cs.getPosMap()); - stmt.setInt(21, cs.getPortal()); - stmt.setLong(22, cs.getPetSn1()); - stmt.setLong(23, cs.getPetSn2()); - stmt.setLong(24, cs.getPetSn3()); - - stmt.executeUpdate(); - } - } - - - private void saveCharacterInventory(Connection conn, CharacterData characterData) throws SQLException { - InventoryDao.saveCharacter(conn, characterData); - } - + return withTransaction(conn -> { + return CharacterDataDao.saveCharacter(conn, characterData); + }); + } + + /** + * Deletes a character associated with the given account ID. + * + * @param accountId the account ID + * @param characterId the character ID to delete + * @return true if the character was deleted, false otherwise + */ @Override public boolean deleteCharacter(int accountId, int characterId) { - String sql = "DELETE FROM player.characters WHERE id=? AND account_id=?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - stmt.setInt(2, accountId); - return stmt.executeUpdate() > 0; - } catch (SQLException e) { - e.printStackTrace(); - return false; - } - } - + return withTransaction(conn -> { + return UserDao.deleteCharacter(conn, accountId, characterId); + }); + } + + /** + * Retrieves the world and job-specific ranks of all characters. + * + * Characters with admin or manager jobs are excluded. Ranking is + * based on cumulative EXP, and ties are broken by earliest max level time. + * + * @return a map from character ID to CharacterRank; empty map if an error occurs + */ @Override public Map getCharacterRanks() { - Map ranks = new HashMap<>(); - String sql = """ - SELECT c.id, c.max_level_time, s.job, s.exp - FROM player.characters c - JOIN player.stats s ON c.id = s.character_id - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - ResultSet rs = stmt.executeQuery(); - List rankDataList = new ArrayList<>(); - - while (rs.next()) { - int characterId = rs.getInt("id"); - int jobId = rs.getInt("job"); - long cumulativeExp = rs.getLong("exp"); - Timestamp ts = rs.getTimestamp("max_level_time"); - - // skip admin/manager characters - if (JobConstants.isAdminJob(jobId) || JobConstants.isManagerJob(jobId)) { - continue; - } - - rankDataList.add(new CharacterRankData( - characterId, - JobConstants.getJobCategory(jobId), - cumulativeExp, - ts != null ? ts.toInstant() : Instant.MAX - )); - } - - // Sort by EXP (descending) and then by earliest max level time - rankDataList.sort( - Comparator.comparingLong(CharacterRankData::getCumulativeExp).reversed() - .thenComparing(CharacterRankData::getMaxLevelTime) - ); - - // Compute world rank and job rank - Map jobRanks = new HashMap<>(); - for (CharacterRankData data : rankDataList) { - int worldRank = ranks.size() + 1; - int jobRank = jobRanks.getOrDefault(data.getJobCategory(), 0) + 1; - jobRanks.put(data.getJobCategory(), jobRank); - - ranks.put(data.getCharacterId(), new CharacterRank( - data.getCharacterId(), - worldRank, - jobRank - )); - } - - } catch (Exception e) { + try (Connection conn = getConnection()) { + return CharacterRankDao.getCharacterRanks(conn); + } catch (SQLException e) { e.printStackTrace(); + return new HashMap<>(); } - - return ranks; - } - - - private static class CharacterRankData { - private final int characterId; - private final int jobCategory; - private final long cumulativeExp; - private final Instant maxLevelTime; - - private CharacterRankData(int characterId, int jobCategory, long cumulativeExp, Instant maxLevelTime) { - this.characterId = characterId; - this.jobCategory = jobCategory; - this.cumulativeExp = cumulativeExp; - this.maxLevelTime = maxLevelTime; - } - - public int getCharacterId() { return characterId; } - public int getJobCategory() { return jobCategory; } - public long getCumulativeExp() { return cumulativeExp; } - public Instant getMaxLevelTime() { return maxLevelTime != null ? maxLevelTime : Instant.MAX; } } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java index 91d32ec9..7daa02cf 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresConnector.java +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -2,7 +2,6 @@ import kinoko.database.*; -import java.sql.Connection; import java.util.TimeZone; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; @@ -38,10 +37,11 @@ public void initialize() { config.setConnectionTimeout(5000); // 5s config.setIdleTimeout(60000); // 60s config.setMaxLifetime(1800000); // 30min - config.setLeakDetectionThreshold(5000L); + config.setLeakDetectionThreshold(5000L); // Connection Leak detection. dataSource = new HikariDataSource(config); + // manually run init file. // Path initPath = Path.of("src/main/java/kinoko/database/postgresql/setup/init.sql"); // if (Files.exists(initPath)) { // String sql = Files.readString(initPath); diff --git a/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java index 88d3cb7f..b3236e3a 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresFriendAccessor.java @@ -2,100 +2,75 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.FriendAccessor; +import kinoko.database.postgresql.type.FriendDao; import kinoko.world.user.friend.Friend; -import kinoko.world.user.friend.FriendStatus; import java.sql.*; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; -public final class PostgresFriendAccessor implements FriendAccessor { - private final HikariDataSource dataSource; - +public final class PostgresFriendAccessor extends PostgresAccessor implements FriendAccessor { public PostgresFriendAccessor(HikariDataSource dataSource) { - this.dataSource = dataSource; - } - - private Friend loadFriend(ResultSet rs) throws SQLException { - int characterId = rs.getInt("character_id"); - int friendId = rs.getInt("friend_id"); - String friendName = rs.getString("friend_name"); - String friendGroup = rs.getString("friend_group"); - FriendStatus status = FriendStatus.getByValue(rs.getInt("friend_status")); - return new Friend(characterId, friendId, friendName, friendGroup, status); + super(dataSource); } + /** + * Retrieves all friends for a given character ID. + * + * @param characterId the ID of the character + * @return a list of Friend objects; empty list if none found or on error + */ @Override public List getFriendsByCharacterId(int characterId) { - List friends = new ArrayList<>(); - String sql = "SELECT * FROM friend.friends WHERE character_id = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - ResultSet rs = stmt.executeQuery(); - while (rs.next()) { - friends.add(loadFriend(rs)); - } + try (Connection conn = getConnection()) { + return FriendDao.getFriendsByCharacterId(conn, characterId); } catch (SQLException e) { e.printStackTrace(); + return Collections.emptyList(); } - return friends; } + /** + * Retrieves all friends where the given ID appears as the friend. + * + * @param friendId the ID of the friend + * @return a list of Friend objects; empty list if none found or on error + */ @Override public List getFriendsByFriendId(int friendId) { - List friends = new ArrayList<>(); - String sql = "SELECT * FROM friend.friends WHERE friend_id = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, friendId); - ResultSet rs = stmt.executeQuery(); - while (rs.next()) { - friends.add(loadFriend(rs)); - } + try (Connection conn = getConnection()) { + return FriendDao.getFriendsByFriendId(conn, friendId); } catch (SQLException e) { e.printStackTrace(); + return Collections.emptyList(); } - return friends; } - @Override + /** + * Saves a friend record to the database. + * If 'force' is true, existing records will be updated. + * + * @param friend the Friend object to save + * @param force whether to overwrite existing records + * @return true if the save operation succeeded, false otherwise + */ public boolean saveFriend(Friend friend, boolean force) { - String sql; - if (force) { - sql = "INSERT INTO friend.friends (character_id, friend_id, friend_name, friend_group, friend_status) " + - "VALUES (?, ?, ?, ?, ?) " + - "ON CONFLICT (character_id, friend_id) DO UPDATE SET friend_name = EXCLUDED.friend_name, " + - "friend_group = EXCLUDED.friend_group, friend_status = EXCLUDED.friend_status"; - } else { - sql = "INSERT INTO friend.friends (character_id, friend_id, friend_name, friend_group, friend_status) " + - "VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING"; - } - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, friend.getCharacterId()); - stmt.setInt(2, friend.getFriendId()); - stmt.setString(3, friend.getFriendName()); - stmt.setString(4, friend.getFriendGroup()); - stmt.setInt(5, friend.getStatus().getValue()); - return stmt.executeUpdate() > 0; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return FriendDao.saveFriend(conn, friend, force); + }); } + /** + * Deletes a friend record from the database. + * + * @param characterId the ID of the character + * @param friendId the ID of the friend to delete + * @return true if the deletion succeeded, false otherwise + */ @Override public boolean deleteFriend(int characterId, int friendId) { - String sql = "DELETE FROM friend.friends WHERE character_id = ? AND friend_id = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - stmt.setInt(2, friendId); - return stmt.executeUpdate() > 0; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return FriendDao.deleteFriend(conn, characterId, friendId); + }); } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java index 8073b429..d40d4c0f 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGiftAccessor.java @@ -2,125 +2,77 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.GiftAccessor; -import kinoko.database.postgresql.type.ItemDao; +import kinoko.database.postgresql.type.GiftDao; import kinoko.server.cashshop.Gift; -import kinoko.world.item.Item; import java.sql.*; -import java.util.ArrayList; +import java.util.Collections; import java.util.List; import java.util.Optional; -public final class PostgresGiftAccessor implements GiftAccessor { - private final HikariDataSource dataSource; - +public final class PostgresGiftAccessor extends PostgresAccessor implements GiftAccessor { public PostgresGiftAccessor(HikariDataSource dataSource) { - this.dataSource = dataSource; + super(dataSource); } - private Gift loadGift(ResultSet rs) throws SQLException { - return new Gift( - rs.getLong("item_sn"), - rs.getInt("item_id"), - rs.getInt("commodity_id"), - rs.getInt("sender_id"), - rs.getString("sender_name"), - rs.getString("sender_message"), - rs.getLong("pair_gift_sn") - ); - } + /** + * Retrieves all gifts received by a specific character. + * + * @param characterId the ID of the character + * @return a list of gifts for the given character, or an empty list if none exist or an error occurs + */ @Override public List getGiftsByCharacterId(int characterId) { - List gifts = new ArrayList<>(); - String sql = """ - SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, - g.sender_name, g.sender_message, g.pair_gift_sn - FROM gift.gifts g - JOIN item.full_item fi ON fi.item_sn = g.item_sn - WHERE g.receiver_id = ? - """; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterId); - ResultSet rs = stmt.executeQuery(); - while (rs.next()) { - gifts.add(loadGift(rs)); - } + try (Connection conn = getConnection()) { + return GiftDao.getGiftsByReceiverId(conn, characterId); } catch (SQLException e) { e.printStackTrace(); + return Collections.emptyList(); } - return gifts; } + /** + * Retrieves a gift by its item serial number. + * + * @param itemSn the item serial number of the gift + * @return an Optional containing the gift if found, or Optional.empty() if not found or an error occurs + */ @Override public Optional getGiftByItemSn(long itemSn) { - String sql = """ - SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, - g.sender_name, g.sender_message, g.pair_gift_sn FROM gift.gifts g - JOIN item.full_item fi ON fi.item_sn = g.item_sn - WHERE g.item_sn = ? - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setLong(1, itemSn); - ResultSet rs = stmt.executeQuery(); - if (rs.next()) { - return Optional.of(loadGift(rs)); - } + try (Connection conn = getConnection()) { + return GiftDao.getGiftByItemSn(conn, itemSn); } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } - + /** + * Creates a new gift for a specified receiver. + * If the gift requires a new item to be created, it will be handled within the same transaction. + * + * @param gift the gift to be created + * @param receiverId the ID of the receiver + * @return true if the gift was successfully created, false otherwise + */ @Override public boolean newGift(Gift gift, int receiverId) { - String sql = """ - INSERT INTO gift.gifts (item_sn, receiver_id, commodity_id, sender_id, sender_name, sender_message, pair_gift_sn) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (item_sn) DO NOTHING - """; - - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - - long itemSN = gift.getGiftSn(); - if (itemSN <= 0) { - // We need a new item created. - Item basicItem = new Item(gift.getItemId(), (short) 1); - ItemDao.createNewItem(conn, basicItem); - itemSN = basicItem.getItemSn(); - } - - stmt.setLong(1, itemSN); // item_sn is now the primary key - stmt.setInt(2, receiverId); - stmt.setInt(3, gift.getCommodityId()); - stmt.setInt(4, gift.getSenderId()); - stmt.setString(5, gift.getSenderName()); - stmt.setString(6, gift.getSenderMessage()); - stmt.setLong(7, gift.getPairItemSn()); - - return stmt.executeUpdate() > 0; - - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return GiftDao.insertGift(conn, gift, receiverId); + }); } + /** + * Deletes a specific gift from the database. + * + * @param gift the gift to delete + * @return true if the gift was successfully deleted, false otherwise + */ @Override public boolean deleteGift(Gift gift) { - String sql = "DELETE FROM gift.gifts WHERE item_sn = ?"; - try (Connection conn = dataSource.getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setLong(1, gift.getGiftSn()); - return stmt.executeUpdate() > 0; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return withTransaction(conn -> { + return GiftDao.deleteGift(conn, gift); + }); } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java index f9029fb2..a67a5a73 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresGuildAccessor.java @@ -2,11 +2,8 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.GuildAccessor; -import kinoko.database.postgresql.type.BoardEntryDao; -import kinoko.database.postgresql.type.BoardNoticeDao; import kinoko.database.postgresql.type.GuildDao; import kinoko.server.guild.Guild; -import kinoko.server.guild.GuildBoardEntry; import kinoko.server.guild.GuildRanking; import java.sql.*; @@ -31,19 +28,12 @@ public PostgresGuildAccessor(HikariDataSource dataSource) { */ @Override public Optional getGuildById(int guildId) { - String sql = "SELECT * FROM guild.guilds WHERE id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, guildId); - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - return Optional.of(GuildDao.loadGuild(conn, rs)); - } - } + try (Connection conn = getConnection()) { + return GuildDao.getGuildById(conn, guildId); } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } /** @@ -113,6 +103,7 @@ public boolean deleteGuild(int guildId) { }); } + /** * Retrieves a list of guild rankings from the database. * @@ -126,24 +117,11 @@ public boolean deleteGuild(int guildId) { */ @Override public List getGuildRankings() { - List rankings = new ArrayList<>(); - String sql = "SELECT name, points, mark, mark_color, mark_bg, mark_bg_color FROM guild.guilds ORDER BY points DESC"; - try (Connection conn = getConnection(); - Statement stmt = conn.createStatement(); - ResultSet rs = stmt.executeQuery(sql)) { - while (rs.next()) { - rankings.add(new GuildRanking( - rs.getString("name"), - rs.getInt("points"), - rs.getShort("mark"), - rs.getByte("mark_color"), - rs.getShort("mark_bg"), - rs.getByte("mark_bg_color") - )); - } + try (Connection conn = getConnection()) { + return GuildDao.getGuildRankings(conn); } catch (SQLException e) { e.printStackTrace(); + return Collections.emptyList(); } - return rankings; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java index 00022cdc..c8d4e245 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresIdAccessor.java @@ -2,13 +2,10 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.IdAccessor; -import kinoko.database.postgresql.type.AccountDao; import kinoko.database.postgresql.type.ItemDao; import kinoko.world.item.Item; -import java.sql.Connection; import java.sql.SQLException; -import java.util.Optional; public final class PostgresIdAccessor extends PostgresAccessor implements IdAccessor { @@ -16,36 +13,16 @@ public PostgresIdAccessor(HikariDataSource dataSource) { super(dataSource); } - private Optional getNextId(String type) { - return Optional.of(-1); // Postgres auto-generates IDs, so we return -1 as a placeholder - } - @Override - public synchronized Optional nextAccountId() { - return getNextId("account_id"); - } - - @Override - public synchronized Optional nextCharacterId() { - return getNextId("character_id"); - } - - @Override - public synchronized Optional nextPartyId() { - return getNextId("party_id"); - } - - @Override - public synchronized Optional nextGuildId() { - return getNextId("guild_id"); - } - - @Override - public synchronized Optional nextMemoId() { - return getNextId("memo_id"); - } - + /** + * Generates a new item SN for the given item if it does not already have one. + * If the item already has a serial number, this method returns true immediately. + * Otherwise, it creates a new item entry in the database and assigns the generated ID. + * + * @param item the item for which to generate an ID + * @return true if the item already had an ID or was successfully assigned one, false if an error occurred + */ @Override - public boolean generateItemId(Item item) { + public boolean generateItemSn(Item item) { if (!item.hasNoSN()){ return true; } diff --git a/src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java index 37f1365f..0155aab7 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresItemAccessor.java @@ -1,16 +1,13 @@ package kinoko.database.postgresql; import com.zaxxer.hikari.HikariDataSource; -import kinoko.database.IdAccessor; import kinoko.database.ItemAccessor; -import kinoko.database.postgresql.type.GuildDao; import kinoko.database.postgresql.type.ItemDao; import kinoko.world.item.Item; import java.sql.Connection; import java.sql.SQLException; import java.util.Collections; -import java.util.Optional; public final class PostgresItemAccessor extends PostgresAccessor implements ItemAccessor { public PostgresItemAccessor(HikariDataSource dataSource) { diff --git a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java index 131092eb..29a2a25e 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresMemoAccessor.java @@ -4,10 +4,8 @@ import kinoko.database.MemoAccessor; import kinoko.database.postgresql.type.MemoDao; import kinoko.server.memo.Memo; -import kinoko.server.memo.MemoType; import java.sql.*; -import java.util.ArrayList; import java.util.Collections; import java.util.List; diff --git a/src/main/java/kinoko/database/postgresql/type/AccountDao.java b/src/main/java/kinoko/database/postgresql/type/AccountDao.java index 714eda69..f358c7fe 100644 --- a/src/main/java/kinoko/database/postgresql/type/AccountDao.java +++ b/src/main/java/kinoko/database/postgresql/type/AccountDao.java @@ -6,6 +6,7 @@ import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.util.Optional; public class AccountDao { @@ -41,6 +42,16 @@ public static void save(Connection conn, Account account) throws SQLException { LockerDao.save(conn, accountId, account.getLocker()); } + /** + * Retrieves the hashed password (either primary or secondary) for the given account from the database. + * Determines which password column to query based on the {@code secondary} flag. + * + * @param conn the active database connection used to execute the query + * @param account the account whose password is being retrieved + * @param secondary true to fetch the secondary password, false to fetch the primary password + * @return the hashed password string if found, or null if no password exists for the account + * @throws SQLException if a database access error occurs + */ public static String getHashedPassword(Connection conn, Account account, boolean secondary) throws SQLException { String column = secondary ? "secondary_password" : "password"; String sql = "SELECT " + column + " FROM account.accounts WHERE id = ?"; @@ -55,6 +66,16 @@ public static String getHashedPassword(Connection conn, Account account, boolean return null; } + /** + * Loads an Account object and its related data from the database using the provided ResultSet and connection. + * Builds the Account instance from base fields such as ID, username, and secondary password, + * then loads associated data including the trunk, locker, and wishlist within the same connection. + * + * @param conn the active database connection used to load related account data + * @param rs the ResultSet containing the account information (positioned at a valid row) + * @return a fully initialized Account object + * @throws SQLException if a database access error occurs + */ public static Account load(Connection conn, ResultSet rs) throws SQLException { final int accountId = rs.getInt("id"); final String username = rs.getString("username"); @@ -73,4 +94,26 @@ public static Account load(Connection conn, ResultSet rs) throws SQLException { return account; } + + + /** + * Retrieves the account ID for the given character ID. + * + * @param conn the database connection to use + * @param characterId the ID of the character + * @return Optional containing the account ID if found, otherwise empty + * @throws SQLException if a database access error occurs + */ + public static Optional getAccountIdByCharacterId(Connection conn, int characterId) throws SQLException { + String sql = "SELECT account_id FROM player.characters WHERE id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(rs.getInt("account_id")); + } + } + } + return Optional.empty(); + } } diff --git a/src/main/java/kinoko/database/postgresql/type/AvatarDataDao.java b/src/main/java/kinoko/database/postgresql/type/AvatarDataDao.java new file mode 100644 index 00000000..b5de50b7 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/AvatarDataDao.java @@ -0,0 +1,98 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.item.Inventory; +import kinoko.world.user.AvatarData; +import kinoko.world.user.stat.CharacterStat; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +public final class AvatarDataDao { + + /** + * Retrieves all AvatarData for characters belonging to a given account. + * + * This includes character stats and equipped inventory. + * + * @param conn the database connection to use + * @param accountId the ID of the account + * @return a list of AvatarData for each character under the account + * @throws SQLException if a database access error occurs + */ + public static List getAvatarDataByAccountId(Connection conn, int accountId) throws SQLException { + List list = new ArrayList<>(); + String sql = """ + SELECT c.id AS character_id, + c.name AS character_name, + c.money, + s.gender, + s.skin, + s.face, + s.hair, + s.level, + s.job, + s.sub_job, + s.base_str, + s.base_dex, + s.base_int, + s.base_luk, + s.hp, + s.max_hp, + s.mp, + s.max_mp, + s.ap, + s.exp, + s.pop, + s.pos_map, + s.portal, + s.pet_1, + s.pet_2, + s.pet_3 + FROM player.characters c + JOIN player.stats s ON c.id = s.character_id + WHERE c.account_id = ? + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + CharacterStat cs = new CharacterStat( + rs.getInt("character_id"), + rs.getString("character_name"), + rs.getByte("gender"), + rs.getByte("skin"), + rs.getInt("face"), + rs.getInt("hair"), + rs.getShort("level"), + rs.getShort("job"), + rs.getShort("sub_job"), + rs.getShort("base_str"), + rs.getShort("base_dex"), + rs.getShort("base_int"), + rs.getShort("base_luk"), + rs.getInt("hp"), + rs.getInt("max_hp"), + rs.getInt("mp"), + rs.getInt("max_mp"), + rs.getShort("ap"), + rs.getInt("exp"), + rs.getShort("pop"), + rs.getInt("pos_map"), + rs.getByte("portal"), + rs.getLong("pet_1"), + rs.getLong("pet_2"), + rs.getLong("pet_3") + ); + + Inventory equipped = InventoryDao.loadEquippedInventory(conn, cs.getId()); + + list.add(AvatarData.from(cs, equipped)); + } + } + } + + return list; + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java b/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java new file mode 100644 index 00000000..fa88c4b3 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java @@ -0,0 +1,554 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.GameConstants; +import kinoko.world.item.InventoryManager; +import kinoko.world.quest.QuestRecord; +import kinoko.world.skill.SkillManager; +import kinoko.world.quest.QuestManager; +import kinoko.world.skill.SkillRecord; +import kinoko.world.user.CharacterData; +import kinoko.world.user.data.*; +import kinoko.world.user.stat.CharacterStat; + +import java.sql.*; +import java.time.Instant; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; +import java.util.concurrent.atomic.AtomicInteger; + +public class CharacterDataDao { + /** + * Constructs and loads a CharacterData object from the given ResultSet and database connection. + * + * This method initializes a CharacterData instance for a specific character, populates its + * CharacterStat, InventoryManager, SkillManager, QuestManager, ConfigManager, PopularityRecord, + * MiniGameRecord, MapTransferInfo, WildHunterInfo, and CoupleRecord. It also sets the item SN + * counter, friend limit, party ID, guild ID, and timestamps for creation and max level. + * + * All required additional data is loaded via DAOs or helper methods using the provided Connection. + * + * @param conn the database connection to use for loading related character data + * @param rs the ResultSet containing the character row data + * @return a fully populated CharacterData object + * @throws SQLException if a database access error occurs + */ + public static CharacterData loadCharacterData(Connection conn, ResultSet rs) throws SQLException { + int accountId = rs.getInt("account_id"); + CharacterData cd = new CharacterData(accountId); + int characterID = rs.getInt("id"); + + CharacterStat cs = new CharacterStat( + characterID, + rs.getString("name"), + rs.getByte("gender"), + rs.getByte("skin"), + rs.getInt("face"), + rs.getInt("hair"), + rs.getShort("level"), + rs.getShort("job"), + rs.getShort("sub_job"), + rs.getShort("base_str"), + rs.getShort("base_dex"), + rs.getShort("base_int"), + rs.getShort("base_luk"), + rs.getInt("hp"), + rs.getInt("max_hp"), + rs.getInt("mp"), + rs.getInt("max_mp"), + rs.getShort("ap"), + rs.getInt("exp"), + rs.getShort("pop"), + rs.getInt("pos_map"), + rs.getByte("portal"), + rs.getLong("pet_1"), + rs.getLong("pet_2"), + rs.getLong("pet_3") + ); + cd.setCharacterStat(cs); + + cs.setSp(ExtendSpDao.loadExtendSp(conn, characterID)); + + InventoryManager im = InventoryDao.loadInventoryManager(conn, characterID); + im.setMoney(rs.getInt("money")); + + Timestamp extSlotExpireTs = rs.getTimestamp("ext_slot_expire"); + im.setExtSlotExpire(extSlotExpireTs != null ? extSlotExpireTs.toInstant() : null); + + cd.setInventoryManager(im); + cd.setCoupleRecord(CoupleRecord.from(im.getEquipped(), im.getEquipInventory())); + + SkillManager sm = SkillManagerDao.loadSkillCooltimesAndRecords(conn, characterID); + cd.setSkillManager(sm); + + QuestManager qm = QuestManagerDao.loadQuestRecords(conn, characterID); + cd.setQuestManager(qm); + + ConfigManager cm = ConfigManagerDao.loadConfig(conn, characterID); + cd.setConfigManager(cm); + + PopularityRecord pr = PopularityRecordDao.loadPopularityRecord(conn, characterID); + cd.setPopularityRecord(pr); + + MiniGameRecord mgr = MiniGameRecordDao.loadMiniGameRecord(conn, characterID); + cd.setMiniGameRecord(mgr); + + MapTransferInfo mto = MapTransferInfoDao.loadMapTransferInfo(conn, characterID); + cd.setMapTransferInfo(mto); + + WildHunterInfo whi = WildHunterInfoDao.loadWildHunterInfo(conn, characterID); + cd.setWildHunterInfo(whi); + + cd.setItemSnCounter(new AtomicInteger(-1)); + + cd.setFriendMax(rs.getInt("friend_max")); + cd.setPartyId(rs.getInt("party_id")); + cd.setGuildId(rs.getInt("guild_id")); + + Timestamp creationTs = rs.getTimestamp("creation_time"); + cd.setCreationTime(creationTs != null ? creationTs.toInstant() : null); + + Timestamp maxLevelTs = rs.getTimestamp("max_level_time"); + cd.setMaxLevelTime(maxLevelTs != null ? maxLevelTs.toInstant() : null); + + return cd; + } + + /** + * Retrieves a CharacterData object for the given character ID. + * + * This method fetches the character's basic data, stats, and guild information, + * then delegates loading of inventory, skills, quests, configs, popularity, + * mini-games, map transfer, and wild hunter info to the appropriate DAOs. + * + * @param conn the database connection to use + * @param characterId the ID of the character + * @return an Optional containing the CharacterData if found, otherwise empty + * @throws SQLException if a database access error occurs + */ + public static Optional getCharacterById(Connection conn, int characterId) throws SQLException { + String sql = """ + SELECT c.*, s.*, m.guild_id, m.grade + FROM player.characters c + LEFT JOIN player.stats s ON c.id = s.character_id + LEFT JOIN guild.member m ON m.character_id = c.id + WHERE c.id = ? + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(loadCharacterData(conn, rs)); + } + } + } + + return Optional.empty(); + } + + /** + * Retrieves a CharacterData object by character name (case-insensitive). + * + * This method queries the characters table using ILIKE for case-insensitive matching, + * then delegates to loadCharacterData to populate all associated managers and records. + * + * @param conn the database connection to use + * @param name the name of the character + * @return an Optional containing the CharacterData if found, otherwise empty + * @throws SQLException if a database access error occurs + */ + public static Optional getCharacterByName(Connection conn, String name) throws SQLException { + String sql = "SELECT * FROM player.characters WHERE name ILIKE ?"; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, name.toLowerCase()); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(loadCharacterData(conn, rs)); + } + } + } + + return Optional.empty(); + } + + /** + * Creates a new character in the database along with all dependent data. + * + * This includes stats, inventory, skills, quests, config, popularity, + * and extended SP. The operation is performed within a single transaction. + * + * @param conn the database connection + * @param characterData the CharacterData to insert + * @return true if the character was successfully created, false otherwise + * @throws SQLException if a database error occurs + */ + public static boolean newCharacter(Connection conn, CharacterData characterData) throws SQLException { + if (!checkCharacterNameAvailable(conn, characterData.getCharacterName())) return false; + + String sql = """ + INSERT INTO player.characters + (account_id, name, money, ext_slot_expire, friend_max, party_id, guild_id, creation_time, max_level_time) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) RETURNING id + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterData.getAccountId()); + stmt.setString(2, characterData.getCharacterName()); + stmt.setInt(3, characterData.getInventoryManager().getMoney()); + stmt.setTimestamp(4, characterData.getInventoryManager().getExtSlotExpire() != null ? + Timestamp.from(characterData.getInventoryManager().getExtSlotExpire()) : null); + stmt.setInt(5, characterData.getFriendMax()); + stmt.setInt(6, characterData.getPartyId()); + stmt.setInt(7, characterData.getGuildId()); + stmt.setTimestamp(8, characterData.getCreationTime() != null ? Timestamp.from(characterData.getCreationTime()) : null); + stmt.setTimestamp(9, characterData.getMaxLevelTime() != null ? Timestamp.from(characterData.getMaxLevelTime()) : null); + + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + int newCharacterId = rs.getInt(1); + characterData.getCharacterStat().setId(newCharacterId); + } else { + throw new SQLException("Failed to insert new character"); + } + } + } + + // Save all dependent data using the same connection + saveCharacterStats(conn, characterData); + InventoryDao.saveCharacter(conn, characterData); + saveCharacterSkills(conn, characterData); + saveCharacterQuests(conn, characterData); + saveCharacterConfig(conn, characterData); + saveCharacterPopularity(conn, characterData); + ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); + + return true; + } + + /** + * Checks whether a character name is available (not already in use). + * + * Uses ILIKE to perform a case-insensitive check. + * + * @param conn the database connection + * @param name the character name to check + * @return true if the name is available, false if it already exists + * @throws SQLException if a database access error occurs + */ + public static boolean checkCharacterNameAvailable(Connection conn, String name) throws SQLException { + String sql = "SELECT COUNT(*) > 0 AS exists FROM player.characters WHERE name ILIKE ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, name); // original name + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + boolean exists = rs.getBoolean("exists"); + return !exists; // available if it does NOT exist + } + } + } + return false; + } + + /** + * Inserts or updates a character’s base statistics in the database. + * Uses UPSERT logic to ensure that stats are either created or updated as needed. + * + * @param conn the active database connection + * @param characterData the character whose stats should be saved + * @throws SQLException if a database access error occurs + */ + private static void saveCharacterStats(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.stats ( + character_id, gender, skin, face, hair, level, job, sub_job, + base_str, base_dex, base_int, base_luk, hp, max_hp, mp, max_mp, + ap, exp, pop, pos_map, portal, pet_1, pet_2, pet_3 + ) VALUES ( + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ) + ON CONFLICT (character_id) DO UPDATE SET + gender = EXCLUDED.gender, + skin = EXCLUDED.skin, + face = EXCLUDED.face, + hair = EXCLUDED.hair, + level = EXCLUDED.level, + job = EXCLUDED.job, + sub_job = EXCLUDED.sub_job, + base_str = EXCLUDED.base_str, + base_dex = EXCLUDED.base_dex, + base_int = EXCLUDED.base_int, + base_luk = EXCLUDED.base_luk, + hp = EXCLUDED.hp, + max_hp = EXCLUDED.max_hp, + mp = EXCLUDED.mp, + max_mp = EXCLUDED.max_mp, + ap = EXCLUDED.ap, + exp = EXCLUDED.exp, + pop = EXCLUDED.pop, + pos_map = EXCLUDED.pos_map, + portal = EXCLUDED.portal, + pet_1 = EXCLUDED.pet_1, + pet_2 = EXCLUDED.pet_2, + pet_3 = EXCLUDED.pet_3 + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + CharacterStat cs = characterData.getCharacterStat(); + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, cs.getGender()); + stmt.setInt(3, cs.getSkin()); + stmt.setInt(4, cs.getFace()); + stmt.setInt(5, cs.getHair()); + stmt.setInt(6, cs.getLevel()); + stmt.setInt(7, cs.getJob()); + stmt.setInt(8, cs.getSubJob()); + stmt.setInt(9, cs.getBaseStr()); + stmt.setInt(10, cs.getBaseDex()); + stmt.setInt(11, cs.getBaseInt()); + stmt.setInt(12, cs.getBaseLuk()); + stmt.setInt(13, cs.getHp()); + stmt.setInt(14, cs.getMaxHp()); + stmt.setInt(15, cs.getMp()); + stmt.setInt(16, cs.getMaxMp()); + stmt.setInt(17, cs.getAp()); + stmt.setInt(18, cs.getExp()); + stmt.setInt(19, cs.getPop()); + stmt.setInt(20, cs.getPosMap()); + stmt.setInt(21, cs.getPortal()); + stmt.setLong(22, cs.getPetSn1()); + stmt.setLong(23, cs.getPetSn2()); + stmt.setLong(24, cs.getPetSn3()); + + stmt.executeUpdate(); + } + } + + /** + * Saves or updates a character’s configuration data, including pet settings, key mappings, + * quickslot layout, and skill macros. + * Uses UPSERT logic to maintain up-to-date configuration for the given character. + * + * @param conn the active database connection + * @param characterData the character whose configuration should be saved + * @throws SQLException if a database access error occurs + */ + private static void saveCharacterConfig(Connection conn, CharacterData characterData) throws SQLException { + ConfigManager config = characterData.getConfigManager(); + + String sql = """ + INSERT INTO player.config + (character_id, pet_consume_item, pet_consume_mp_item, pet_exception_list, func_key_types, func_key_ids, quickslot_key_map) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (character_id) DO UPDATE + SET pet_consume_item = EXCLUDED.pet_consume_item, + pet_consume_mp_item = EXCLUDED.pet_consume_mp_item, + pet_exception_list = EXCLUDED.pet_exception_list, + func_key_types = EXCLUDED.func_key_types, + func_key_ids = EXCLUDED.func_key_ids, + quickslot_key_map = EXCLUDED.quickslot_key_map + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, config.getPetConsumeItem()); + stmt.setInt(3, config.getPetConsumeMpItem()); + + // pet_exception_list -> List -> Integer[] + List exceptionList = config.getPetExceptionList(); + Integer[] exceptionArray = exceptionList != null ? exceptionList.toArray(new Integer[0]) : new Integer[0]; + stmt.setArray(4, conn.createArrayOf("integer", exceptionArray)); + + // func_key_types & func_key_ids from FuncKeyMapped[] + FuncKeyMapped[] funcKeyMap = config.getFuncKeyMap(); + if (funcKeyMap == null) { + funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); + } + + Short[] funcTypes = Arrays.stream(funcKeyMap) + .map(f -> (short) f.getType().getValue()) + .toArray(Short[]::new); + Integer[] funcIds = Arrays.stream(funcKeyMap) + .map(FuncKeyMapped::getId) + .toArray(Integer[]::new); + + stmt.setArray(5, conn.createArrayOf("smallint", funcTypes)); // func_key_types + stmt.setArray(6, conn.createArrayOf("integer", funcIds)); // func_key_ids + + // quickslot_key_map -> int[] -> Integer[] + int[] quickslot = config.getQuickslotKeyMap(); + Integer[] quickslotKeys = quickslot != null + ? Arrays.stream(quickslot).boxed().toArray(Integer[]::new) + : new Integer[0]; + stmt.setArray(7, conn.createArrayOf("integer", quickslotKeys)); + + stmt.executeUpdate(); + } + // save skill macros + SkillMacrosDao.upsertMacros(conn, characterData.getCharacterId(), config.getMacroSysData()); + } + + /** + * Saves the character’s popularity (fame) relationships to other characters. + * Each entry represents a character that has received or given popularity points. + * Uses UPSERT logic to ensure timestamps are updated for existing records. + * + * @param conn the active database connection + * @param characterData the character whose popularity data should be saved + * @throws SQLException if a database access error occurs + */ + private static void saveCharacterPopularity(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.popularity (character_id, other_character_id, timestamp) + VALUES (?, ?, ?) + ON CONFLICT (character_id, other_character_id) + DO UPDATE SET timestamp = EXCLUDED.timestamp + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + PopularityRecord pr = characterData.getPopularityRecord(); + int charId = characterData.getCharacterId(); + + for (var entry : pr.getRecords().entrySet()) { + stmt.setInt(1, charId); + stmt.setInt(2, entry.getKey()); + stmt.setTimestamp(3, Timestamp.from(entry.getValue())); + stmt.addBatch(); + } + + stmt.executeBatch(); + } + } + + + /** + * Saves or updates all skill-related data for the given character, including: + * - Skill levels and master levels + * - Active skill cooldowns + * Uses UPSERT logic to maintain consistency between client and server skill data. + * + * @param conn the active database connection + * @param characterData the character whose skills should be saved + * @throws SQLException if a database access error occurs + */ + private static void saveCharacterSkills(Connection conn, CharacterData characterData) throws SQLException { + String skillRecordSql = """ + INSERT INTO player.skill_record (character_id, skill_id, level, master_level) + VALUES (?, ?, ?, ?) + ON CONFLICT (character_id, skill_id) + DO UPDATE SET level = EXCLUDED.level, master_level = EXCLUDED.master_level + """; + + try (PreparedStatement stmt = conn.prepareStatement(skillRecordSql)) { + for (SkillRecord sr : characterData.getSkillManager().getSkillRecords()) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, sr.getSkillId()); + stmt.setInt(3, sr.getSkillLevel()); + stmt.setInt(4, sr.getMasterLevel()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + + // Save skill cooltimes + String skillCooltimeSql = """ + INSERT INTO player.skill_cooltime (character_id, skill_id, cooldown_end) + VALUES (?, ?, ?) + ON CONFLICT (character_id, skill_id) + DO UPDATE SET cooldown_end = EXCLUDED.cooldown_end + """; + try (PreparedStatement stmt = conn.prepareStatement(skillCooltimeSql)) { + for (var entry : characterData.getSkillManager().getSkillCooltimes().entrySet()) { + int skillId = entry.getKey(); + Instant endTime = entry.getValue(); + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, skillId); + stmt.setTimestamp(3, Timestamp.from(endTime)); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Saves or updates the character’s quest progress records. + * Each entry includes the quest ID, its current state, progress string, and completion timestamp. + * Uses UPSERT logic to handle both new and existing quest records efficiently. + * + * @param conn the active database connection + * @param characterData the character whose quest data should be saved + * @throws SQLException if a database access error occurs + */ + private static void saveCharacterQuests(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.quest_record (character_id, quest_id, status, progress, completed_time) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (character_id, quest_id) + DO UPDATE SET status = EXCLUDED.status, + progress = EXCLUDED.progress, + completed_time = EXCLUDED.completed_time + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (QuestRecord qr : characterData.getQuestManager().getQuestRecords()) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, qr.getQuestId()); + stmt.setInt(3, qr.getState().getValue()); + stmt.setString(4, qr.getValue()); + stmt.setTimestamp(5, qr.getCompletedTime() != null ? Timestamp.from(qr.getCompletedTime()) : null); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } + + /** + * Saves/updates a CharacterData object to the database. + * + * Updates the main character row and all dependent tables (stats, inventory, skills, + * quests, config, popularity, extend SP) using the same connection and transaction. + * + * @param conn the database connection + * @param characterData the CharacterData to save + * @return true if the save succeeded, false if the update failed + * @throws SQLException if a database access error occurs + */ + public static boolean saveCharacter(Connection conn, CharacterData characterData) throws SQLException { + String sql = "UPDATE player.characters SET account_id=?, name=?, money=?, ext_slot_expire=?, " + + "friend_max=?, party_id=?, guild_id=?, creation_time=?, max_level_time=? " + + "WHERE id=?"; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterData.getAccountId()); + stmt.setString(2, characterData.getCharacterName()); + stmt.setInt(3, characterData.getInventoryManager().getMoney()); + stmt.setTimestamp(4, characterData.getInventoryManager().getExtSlotExpire() != null ? + Timestamp.from(characterData.getInventoryManager().getExtSlotExpire()) : null); + stmt.setInt(5, characterData.getFriendMax()); + stmt.setInt(6, characterData.getPartyId()); + stmt.setInt(7, characterData.getGuildId()); + stmt.setTimestamp(8, characterData.getCreationTime() != null ? + Timestamp.from(characterData.getCreationTime()) : null); + stmt.setTimestamp(9, characterData.getMaxLevelTime() != null ? + Timestamp.from(characterData.getMaxLevelTime()) : null); + stmt.setInt(10, characterData.getCharacterId()); + + int updated = stmt.executeUpdate(); + if (updated == 0) { + return false; // will rollback in a transaction. + } + } + + // Save dependent tables using the same connection + saveCharacterStats(conn, characterData); + InventoryDao.saveCharacter(conn, characterData); + saveCharacterSkills(conn, characterData); + saveCharacterQuests(conn, characterData); + saveCharacterConfig(conn, characterData); + saveCharacterPopularity(conn, characterData); + ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); + + return true; + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/CharacterInfoDao.java b/src/main/java/kinoko/database/postgresql/type/CharacterInfoDao.java new file mode 100644 index 00000000..2816855d --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/CharacterInfoDao.java @@ -0,0 +1,39 @@ +package kinoko.database.postgresql.type; + +import kinoko.database.CharacterInfo; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.Optional; + +public final class CharacterInfoDao { + /** + * Retrieves CharacterInfo by character name. + * + * Performs a case-insensitive search using ILIKE. + * Returns an Optional containing CharacterInfo if found, otherwise empty. + * + * @param conn the database connection to use + * @param name the name of the character + * @return Optional containing CharacterInfo if the character exists + * @throws SQLException if a database access error occurs + */ + public static Optional getCharacterInfoByName(Connection conn, String name) throws SQLException { + String sql = "SELECT account_id, id, name FROM player.characters WHERE name ILIKE ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, name.toLowerCase()); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(new CharacterInfo( + rs.getInt("account_id"), + rs.getInt("id"), + rs.getString("name") + )); + } + } + } + return Optional.empty(); + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/CharacterRankDao.java b/src/main/java/kinoko/database/postgresql/type/CharacterRankDao.java new file mode 100644 index 00000000..3449c2ff --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/CharacterRankDao.java @@ -0,0 +1,78 @@ +package kinoko.database.postgresql.type; + + +import kinoko.database.types.CharacterRankData; +import kinoko.server.rank.CharacterRank; +import kinoko.world.job.JobConstants; + +import java.sql.*; +import java.time.Instant; +import java.util.*; + +public final class CharacterRankDao { + + /** + * Retrieves all character ranks (world and job-specific) using the provided connection. + * + * Characters with admin/manager jobs are skipped. Ranks are sorted by cumulative + * EXP descending, and for ties, by earliest max level time. + * + * @param conn an active SQL connection + * @return a map from character ID to CharacterRank + * @throws SQLException if a database access error occurs + */ + public static Map getCharacterRanks(Connection conn) throws SQLException { + Map ranks = new HashMap<>(); + String sql = """ + SELECT c.id, c.max_level_time, s.job, s.exp + FROM player.characters c + JOIN player.stats s ON c.id = s.character_id + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql); + ResultSet rs = stmt.executeQuery()) { + + List rankDataList = new ArrayList<>(); + + while (rs.next()) { + int characterId = rs.getInt("id"); + int jobId = rs.getInt("job"); + long cumulativeExp = rs.getLong("exp"); + Timestamp ts = rs.getTimestamp("max_level_time"); + + if (JobConstants.isAdminJob(jobId) || JobConstants.isManagerJob(jobId)) { + continue; + } + + rankDataList.add(new CharacterRankData( + characterId, + JobConstants.getJobCategory(jobId), + cumulativeExp, + ts != null ? ts.toInstant() : Instant.MAX + )); + } + + // Sort by EXP (descending) and then by earliest max level time + rankDataList.sort( + Comparator.comparingLong(CharacterRankData::cumulativeExp).reversed() + .thenComparing(CharacterRankData::maxLevelTime) + ); + + // compute world rank and job rank + Map jobRanks = new HashMap<>(); + for (CharacterRankData data : rankDataList) { + int worldRank = ranks.size() + 1; + int jobRank = jobRanks.getOrDefault(data.jobCategory(), 0) + 1; + jobRanks.put(data.jobCategory(), jobRank); + + ranks.put(data.characterId(), new CharacterRank( + data.characterId(), + worldRank, + jobRank + )); + } + } + + return ranks; + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java b/src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java new file mode 100644 index 00000000..1cb378e8 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java @@ -0,0 +1,92 @@ +package kinoko.database.postgresql.type; + + +import kinoko.world.GameConstants; +import kinoko.world.user.data.ConfigManager; +import kinoko.world.user.data.FuncKeyMapped; +import kinoko.world.user.data.FuncKeyType; + +import java.sql.*; +import java.util.Arrays; +import java.util.List; + +public final class ConfigManagerDao { + + /** + * Loads the ConfigManager for the specified character. + * + * Retrieves pet consume settings, pet exception list, function key mapping, + * quickslot key map, and associated macros. + * + * @param conn the database connection to use + * @param characterId the ID of the character + * @return a fully populated ConfigManager object + * @throws SQLException if a database access error occurs + */ + public static ConfigManager loadConfig(Connection conn, int characterId) throws SQLException { + String sql = """ + SELECT pet_consume_item, pet_consume_mp_item, pet_exception_list, + func_key_types, func_key_ids, quickslot_key_map + FROM player.config + WHERE character_id = ? + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + + try (ResultSet rs = stmt.executeQuery()) { + if (!rs.next()) { + return ConfigManager.defaults(); + } + + int petConsumeItem = rs.getInt("pet_consume_item"); + int petConsumeMpItem = rs.getInt("pet_consume_mp_item"); + + // --- Pet exception list --- + List petExceptionList; + Array petExArr = rs.getArray("pet_exception_list"); + if (petExArr != null) { + Integer[] arr = (Integer[]) petExArr.getArray(); + petExceptionList = Arrays.asList(arr); + } else { + petExceptionList = List.of(); + } + + // --- Function key map --- + FuncKeyMapped[] funcKeyMap = new FuncKeyMapped[GameConstants.FUNC_KEY_MAP_SIZE]; + Array funcTypeArr = rs.getArray("func_key_types"); + Array funcIdArr = rs.getArray("func_key_ids"); + + if (funcTypeArr != null && funcIdArr != null) { + Short[] typeValues = (Short[]) funcTypeArr.getArray(); + Integer[] idValues = (Integer[]) funcIdArr.getArray(); + + for (int i = 0; i < funcKeyMap.length; i++) { + FuncKeyType type = FuncKeyType.getByValue(typeValues[i].byteValue()); + int id = idValues[i]; + funcKeyMap[i] = FuncKeyMapped.of(type, id); + } + } else { + funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); + } + + // --- Quickslot key map --- + int[] quickslotKeyMap; + Array quickArr = rs.getArray("quickslot_key_map"); + if (quickArr != null) { + Integer[] arr = (Integer[]) quickArr.getArray(); + quickslotKeyMap = Arrays.stream(arr).mapToInt(Integer::intValue).toArray(); + } else { + quickslotKeyMap = Arrays.copyOf(GameConstants.DEFAULT_QUICKSLOT_KEY_MAP, GameConstants.QUICKSLOT_KEY_MAP_SIZE); + } + + ConfigManager cm = new ConfigManager(petConsumeItem, petConsumeMpItem, petExceptionList, funcKeyMap, quickslotKeyMap); + + // Load macros from SkillMacrosDao + cm.updateMacroSysData(SkillMacrosDao.loadMacros(conn, characterId)); + + return cm; + } + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/FriendDao.java b/src/main/java/kinoko/database/postgresql/type/FriendDao.java new file mode 100644 index 00000000..1c592743 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/FriendDao.java @@ -0,0 +1,124 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.user.friend.Friend; +import kinoko.world.user.friend.FriendStatus; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; + +public final class FriendDao { + /** + * Creates a Friend object from the current row of a ResultSet. + * + * Extracts the character ID, friend ID, friend name, friend group, + * and friend status from the ResultSet and constructs a corresponding + * Friend instance. + * + * @param rs the ResultSet positioned at the row to load + * @return a Friend object representing the data in the current row + * @throws SQLException if a database access error occurs + */ + private static Friend loadFriend(ResultSet rs) throws SQLException { + int characterId = rs.getInt("character_id"); + int friendId = rs.getInt("friend_id"); + String friendName = rs.getString("friend_name"); + String friendGroup = rs.getString("friend_group"); + FriendStatus status = FriendStatus.getByValue(rs.getInt("friend_status")); + return new Friend(characterId, friendId, friendName, friendGroup, status); + } + + /** + * Retrieves all friends for a specific character ID. + * + * @param conn the active SQL connection + * @param characterId the character's ID + * @return a list of Friend objects + * @throws SQLException if a database error occurs + */ + public static List getFriendsByCharacterId(Connection conn, int characterId) throws SQLException { + List friends = new ArrayList<>(); + String sql = "SELECT * FROM friend.friends WHERE character_id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + friends.add(loadFriend(rs)); + } + } + } + return friends; + } + + /** + * Retrieves all friends where the given friend ID appears. + * + * @param conn the active SQL connection + * @param friendId the friend's ID + * @return a list of Friend objects + * @throws SQLException if a database error occurs + */ + public static List getFriendsByFriendId(Connection conn, int friendId) throws SQLException { + List friends = new ArrayList<>(); + String sql = "SELECT * FROM friend.friends WHERE friend_id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, friendId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + friends.add(loadFriend(rs)); + } + } + } + return friends; + } + + /** + * Inserts or updates a friend record in the database. + * + * @param conn the active SQL connection + * @param friend the Friend object to save + * @param force if true, update existing record; if false, do nothing on conflict + * @return true if the record was inserted or updated, false otherwise + * @throws SQLException if a database error occurs + */ + public static boolean saveFriend(Connection conn, Friend friend, boolean force) throws SQLException { + String sql; + if (force) { + sql = "INSERT INTO friend.friends (character_id, friend_id, friend_name, friend_group, friend_status) " + + "VALUES (?, ?, ?, ?, ?) " + + "ON CONFLICT (character_id, friend_id) DO UPDATE SET " + + "friend_name = EXCLUDED.friend_name, " + + "friend_group = EXCLUDED.friend_group, " + + "friend_status = EXCLUDED.friend_status"; + } else { + sql = "INSERT INTO friend.friends (character_id, friend_id, friend_name, friend_group, friend_status) " + + "VALUES (?, ?, ?, ?, ?) ON CONFLICT DO NOTHING"; + } + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, friend.getCharacterId()); + stmt.setInt(2, friend.getFriendId()); + stmt.setString(3, friend.getFriendName()); + stmt.setString(4, friend.getFriendGroup()); + stmt.setInt(5, friend.getStatus().getValue()); + return stmt.executeUpdate() > 0; + } + } + + /** + * Deletes a friend record from the database. + * + * @param conn the active SQL connection + * @param characterId the character's ID + * @param friendId the friend's ID + * @return true if the record was deleted, false otherwise + * @throws SQLException if a database error occurs + */ + public static boolean deleteFriend(Connection conn, int characterId, int friendId) throws SQLException { + String sql = "DELETE FROM friend.friends WHERE character_id = ? AND friend_id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + stmt.setInt(2, friendId); + return stmt.executeUpdate() > 0; + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/GiftDao.java b/src/main/java/kinoko/database/postgresql/type/GiftDao.java new file mode 100644 index 00000000..e0696972 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/GiftDao.java @@ -0,0 +1,142 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.cashshop.Gift; +import kinoko.world.item.Item; + +import java.sql.*; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + + +public final class GiftDao { + /** + * Loads a Gift object from the current row of the ResultSet. + * + * @param rs the ResultSet positioned at the gift row + * @return the loaded Gift object + * @throws SQLException if a database access error occurs + */ + public static Gift loadGift(ResultSet rs) throws SQLException { + return new Gift( + rs.getLong("item_sn"), + rs.getInt("item_id"), + rs.getInt("commodity_id"), + rs.getInt("sender_id"), + rs.getString("sender_name"), + rs.getString("sender_message"), + rs.getLong("pair_gift_sn") + ); + } + + + /** + * Retrieves all gifts for a given receiver ID. + * + * @param conn the active SQL connection + * @param receiverId the ID of the character receiving gifts + * @return a list of Gift objects for the receiver + * @throws SQLException if a database access error occurs + */ + public static List getGiftsByReceiverId(Connection conn, int receiverId) throws SQLException { + List gifts = new ArrayList<>(); + String sql = """ + SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, + g.sender_name, g.sender_message, g.pair_gift_sn + FROM gift.gifts g + JOIN item.full_item fi ON fi.item_sn = g.item_sn + WHERE g.receiver_id = ? + """; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, receiverId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + gifts.add(loadGift(rs)); + } + } + } + return gifts; + } + + /** + * Retrieves a gift by its item serial number. + * + * @param conn the active SQL connection + * @param itemSn the item serial number of the gift + * @return an Optional containing the Gift if found, or empty if not + * @throws SQLException if a database access error occurs + */ + public static Optional getGiftByItemSn(Connection conn, long itemSn) throws SQLException { + String sql = """ + SELECT g.item_sn, fi.item_id, g.commodity_id, g.sender_id, + g.sender_name, g.sender_message, g.pair_gift_sn + FROM gift.gifts g + JOIN item.full_item fi ON fi.item_sn = g.item_sn + WHERE g.item_sn = ? + """; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, itemSn); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(loadGift(rs)); + } + } + } + return Optional.empty(); + } + + /** + * Inserts a new gift into the database. + * + * If the gift does not have a valid item serial number, a new item will be created first. + * + * @param conn the active SQL connection + * @param gift the gift to insert + * @param receiverId the ID of the receiver + * @return true if the gift was successfully inserted, false otherwise + * @throws SQLException if a database access error occurs + */ + public static boolean insertGift(Connection conn, Gift gift, int receiverId) throws SQLException { + String sql = """ + INSERT INTO gift.gifts (item_sn, receiver_id, commodity_id, sender_id, sender_name, sender_message, pair_gift_sn) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (item_sn) DO NOTHING + """; + + long itemSN = gift.getGiftSn(); + if (itemSN <= 0) { // create a new item. + Item basicItem = new Item(gift.getItemId(), (short) 1); + ItemDao.createNewItem(conn, basicItem); + itemSN = basicItem.getItemSn(); + } + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, itemSN); + stmt.setInt(2, receiverId); + stmt.setInt(3, gift.getCommodityId()); + stmt.setInt(4, gift.getSenderId()); + stmt.setString(5, gift.getSenderName()); + stmt.setString(6, gift.getSenderMessage()); + stmt.setLong(7, gift.getPairItemSn()); + + return stmt.executeUpdate() > 0; + } + } + + /** + * Deletes a gift from the database by its item serial number. + * + * @param conn the active SQL connection + * @param gift the gift to delete + * @return true if the gift was successfully deleted, false otherwise + * @throws SQLException if a database access error occurs + */ + public static boolean deleteGift(Connection conn, Gift gift) throws SQLException { + String sql = "DELETE FROM gift.gifts WHERE item_sn = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setLong(1, gift.getGiftSn()); + return stmt.executeUpdate() > 0; + } + } +} + diff --git a/src/main/java/kinoko/database/postgresql/type/GuildDao.java b/src/main/java/kinoko/database/postgresql/type/GuildDao.java index f53fad84..6ee70c49 100644 --- a/src/main/java/kinoko/database/postgresql/type/GuildDao.java +++ b/src/main/java/kinoko/database/postgresql/type/GuildDao.java @@ -3,11 +3,13 @@ import kinoko.server.guild.Guild; import kinoko.server.guild.GuildBoardEntry; import kinoko.server.guild.GuildMember; +import kinoko.server.guild.GuildRanking; import java.sql.*; import java.util.ArrayList; import java.util.Arrays; import java.util.List; +import java.util.Optional; public class GuildDao { @@ -85,7 +87,7 @@ public static synchronized boolean insertGuild(Connection conn, Guild guild) thr * Modifies all relevant guild fields such as name, grade names, emblems, notice, points, and level. * Also updates related members, board entries, and board notice after the main record update. * - * @param conn the active SQL connection to use for the update + * @param conn the active SQL connection to use for the update * @param guild the guild object containing updated data * @return true if the update affected at least one row; false otherwise * @throws SQLException if a database error occurs during the update @@ -205,11 +207,11 @@ public static boolean deleteGuild(Connection conn, int guildId) throws SQLExcept */ private static void upsertGrades(Connection conn, int guildId, List grades) throws SQLException { String sql = """ - INSERT INTO guild.grade (guild_id, grade_index, grade_name) - VALUES (?, ?, ?) - ON CONFLICT (guild_id, grade_index) DO UPDATE - SET grade_name = EXCLUDED.grade_name - """; + INSERT INTO guild.grade (guild_id, grade_index, grade_name) + VALUES (?, ?, ?) + ON CONFLICT (guild_id, grade_index) DO UPDATE + SET grade_name = EXCLUDED.grade_name + """; try (PreparedStatement stmt = conn.prepareStatement(sql)) { for (int i = 0; i < grades.size(); i++) { @@ -247,4 +249,57 @@ private static List loadGrades(Connection conn, int guildId) throws SQLE } return grades; } + + /** + * Retrieves a list of guild rankings from the database. + * + * Guilds are ordered by their points in descending order, so the guild with the highest points appears first. + * Each GuildRanking object contains the guild's name, points, and visual mark information (mark, mark color, background, background color). + * + * @param conn the active SQL connection to use for the query + * @return a list of GuildRanking objects representing all guilds ordered by points + * @throws SQLException if a database access error occurs + */ + public static List getGuildRankings(Connection conn) throws SQLException { + List rankings = new ArrayList<>(); + String sql = "SELECT name, points, mark, mark_color, mark_bg, mark_bg_color FROM guild.guilds ORDER BY points DESC"; + try (Statement stmt = conn.createStatement(); + ResultSet rs = stmt.executeQuery(sql)) { + while (rs.next()) { + rankings.add(new GuildRanking( + rs.getString("name"), + rs.getInt("points"), + rs.getShort("mark"), + rs.getByte("mark_color"), + rs.getShort("mark_bg"), + rs.getByte("mark_bg_color") + )); + } + } + return rankings; + } + + /** + * Retrieves a guild from the database by its ID. + * + * Executes a query to fetch the guild record corresponding to the provided guild ID. + * If a matching guild is found, it is loaded into a Guild object using GuildDao.loadGuild. + * + * @param conn the active SQL connection to use for the query + * @param guildId the ID of the guild to retrieve + * @return an Optional containing the guild if found, or Optional.empty() if no guild exists with the given ID + * @throws SQLException if a database access error occurs + */ + public static Optional getGuildById(Connection conn, int guildId) throws SQLException { + String sql = "SELECT * FROM guild.guilds WHERE id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, guildId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(loadGuild(conn, rs)); + } + } + } + return Optional.empty(); + } } \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java index f4760582..57d7310d 100644 --- a/src/main/java/kinoko/database/postgresql/type/InventoryDao.java +++ b/src/main/java/kinoko/database/postgresql/type/InventoryDao.java @@ -129,6 +129,22 @@ private static void deleteUnusedItems(Connection conn, int charId, Collection 0; + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java b/src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java new file mode 100644 index 00000000..d7cf370d --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java @@ -0,0 +1,48 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.user.data.WildHunterInfo; + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; + +public final class WildHunterInfoDao { + /** + * Loads WildHunterInfo for the specified character. + * + * Retrieves riding type and up to 5 captured mobs for the character. + * + * @param characterId the ID of the character + * @return WildHunterInfo populated with riding type and captured mobs + * @throws SQLException if a database access error occurs + */ + public static WildHunterInfo loadWildHunterInfo(Connection conn, int characterId) throws SQLException { + WildHunterInfo wh = new WildHunterInfo(); + + String sqlRiding = "SELECT riding_type FROM player.wild_hunter WHERE character_id = ?"; + String sqlMobs = "SELECT mob_id FROM player.wild_hunter_mob WHERE character_id = ?"; + + try (PreparedStatement stmt = conn.prepareStatement(sqlRiding)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + wh.setRidingType(rs.getInt("riding_type")); + } + } + } + + // Load captured mobs + try (PreparedStatement stmt = conn.prepareStatement(sqlMobs)) { + stmt.setInt(1, characterId); + try (ResultSet rs = stmt.executeQuery()) { + while (rs.next()) { + wh.getCapturedMobs().add(rs.getInt("mob_id")); + if (wh.getCapturedMobs().size() >= 5) break; // enforce max 5 + } + } + } + + return wh; + } +} diff --git a/src/main/java/kinoko/database/types/CharacterRankData.java b/src/main/java/kinoko/database/types/CharacterRankData.java new file mode 100644 index 00000000..cacd4a14 --- /dev/null +++ b/src/main/java/kinoko/database/types/CharacterRankData.java @@ -0,0 +1,11 @@ +package kinoko.database.types; + +import java.time.Instant; + +public record CharacterRankData(int characterId, int jobCategory, long cumulativeExp, Instant maxLevelTime) { + + @Override + public Instant maxLevelTime() { + return maxLevelTime != null ? maxLevelTime : Instant.MAX; + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/handler/stage/CashShopHandler.java b/src/main/java/kinoko/handler/stage/CashShopHandler.java index f8ca7bab..1a96c47f 100644 --- a/src/main/java/kinoko/handler/stage/CashShopHandler.java +++ b/src/main/java/kinoko/handler/stage/CashShopHandler.java @@ -93,7 +93,7 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { } // Generate an item SN for the locker item - (Relational DBs) - Safe for NoSQL DBs. - if (!DatabaseManager.idAccessor().generateItemId(cashItemInfo.getItem())){ + if (!DatabaseManager.idAccessor().generateItemSn(cashItemInfo.getItem())){ user.write(CashShopPacket.fail(CashItemResultType.Buy_Failed, CashItemFailReason.Unknown)); // Due to an unknown error, the request for Cash Shop has failed. log.error("Could not generate SN for Item ID: {}", cashItemInfo.getItem().getItemId()); return; @@ -594,7 +594,7 @@ public static void handleCashItemRequest(User user, InPacket inPacket) { selfItemSn = cashItemInfo.getItem().getItemSn(); Item pairItem = new Item(cashItemInfo.getItem()); pairItem.resetSN(false); - if (!DatabaseManager.idAccessor().generateItemId(pairItem)){ + if (!DatabaseManager.idAccessor().generateItemSn(pairItem)){ user.write(CashShopPacket.fail(CashItemResultType.Couple_Failed, CashItemFailReason.Unknown)); // Due to an unknown error, the request for Cash Shop has failed. log.error("Could not generate SN for Item ID: {}", cashItemInfo.getItem().getItemId()); return; diff --git a/src/main/java/kinoko/server/cashshop/Commodity.java b/src/main/java/kinoko/server/cashshop/Commodity.java index 14d42e48..196fd5cd 100644 --- a/src/main/java/kinoko/server/cashshop/Commodity.java +++ b/src/main/java/kinoko/server/cashshop/Commodity.java @@ -84,7 +84,7 @@ public Optional createCashItemInfo(long itemSn, int accountId, int } // Generate an item SN for the cash item - (Relational DBs) - Safe for NoSQL DBs. - DatabaseManager.idAccessor().generateItemId(item); + DatabaseManager.idAccessor().generateItemSn(item); final CashItemInfo cashItemInfo = new CashItemInfo( item, From c5f698fe3350f35e0773de447764ce2a519a7f1a Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 14 Oct 2025 17:07:10 -0400 Subject: [PATCH 49/83] Cleanup --- .../postgresql/PostgresAccountAccessor.java | 157 ++++++++---------- .../database/postgresql/type/AccountDao.java | 128 ++++++++++++++ 2 files changed, 193 insertions(+), 92 deletions(-) diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java index a351229f..19bc22d8 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccountAccessor.java @@ -2,12 +2,8 @@ import com.zaxxer.hikari.HikariDataSource; import kinoko.database.AccountAccessor; -import kinoko.database.DatabaseManager; import kinoko.database.postgresql.type.AccountDao; -import kinoko.database.postgresql.type.LockerDao; -import kinoko.server.ServerConfig; import kinoko.world.user.Account; -import org.mindrot.jbcrypt.BCrypt; import java.sql.*; @@ -19,124 +15,101 @@ public PostgresAccountAccessor(HikariDataSource dataSource) { super(dataSource); } - private String lowerUsername(String username) { - return username.toLowerCase(); - } - - private String hashPassword(String password) { - return BCrypt.hashpw(password, BCrypt.gensalt()); - } - - private boolean checkHashedPassword(String password, String hashedPassword) { - return BCrypt.checkpw(password, hashedPassword); - } - + /** + * Retrieves an account by its unique ID. + * Opens a database connection and delegates the loading logic to AccountDao. + * + * @param accountId the ID of the account to retrieve + * @return an Optional containing the Account if found, otherwise empty + */ @Override public Optional getAccountById(int accountId) { - String sql = "SELECT * FROM account.accounts WHERE id = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - - stmt.setInt(1, accountId); - - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - return Optional.of(AccountDao.load(conn, rs)); - } - } - + try (Connection conn = getConnection()) { + return AccountDao.getAccountById(conn, accountId); } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } + /** + * Retrieves an account by its username. + * The username is normalized and passed to AccountDao for lookup. + * + * @param username the username to look up + * @return an Optional containing the Account if found, otherwise empty + */ @Override public Optional getAccountByUsername(String username) { - String sql = "SELECT * FROM account.accounts WHERE username = ?"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - - stmt.setString(1, lowerUsername(username)); - - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - return Optional.of(AccountDao.load(conn, rs)); - } - } - + try (Connection conn = getConnection()) { + return AccountDao.getAccountByUsername(conn, username); } catch (SQLException e) { e.printStackTrace(); + return Optional.empty(); } - return Optional.empty(); } + /** + * Checks whether the provided password matches the stored hash for the account. + * Uses AccountDao to retrieve the hashed password and perform verification. + * + * @param account the account being authenticated + * @param password the plaintext password to verify + * @param secondary true if verifying the secondary password, false for the primary + * @return true if the password matches, false otherwise + */ @Override public boolean checkPassword(Account account, String password, boolean secondary) { - try (Connection conn = getConnection()) { + return withTransaction(conn -> { String hashed = AccountDao.getHashedPassword(conn, account, secondary); - return hashed != null && checkHashedPassword(password, hashed); - } catch (SQLException e) { - e.printStackTrace(); - return false; - } + return hashed != null && AccountDao.checkHashedPassword(password, hashed); + }); } + /** + * Updates the account's password if the provided old password matches the stored hash. + * The method handles both primary and secondary password updates through AccountDao. + * + * @param account the account whose password is being updated + * @param oldPassword the current plaintext password + * @param newPassword the new plaintext password to set + * @param secondary true if updating the secondary password, false for the primary + * @return true if the password was successfully updated, false otherwise + */ @Override public boolean savePassword(Account account, String oldPassword, String newPassword, boolean secondary) { - String sqlUpdate = "UPDATE account.accounts SET " + - (secondary ? "secondary_password" : "password") + - " = ? WHERE id = ?"; - try (Connection conn = getConnection()) { + return withTransaction(conn -> { String hashedOld = AccountDao.getHashedPassword(conn, account, secondary); - if (hashedOld == null || checkHashedPassword(oldPassword, hashedOld)) { - try (PreparedStatement updateStmt = conn.prepareStatement(sqlUpdate)) { - updateStmt.setString(1, hashPassword(newPassword)); - updateStmt.setInt(2, account.getId()); - return updateStmt.executeUpdate() > 0; - } + if (hashedOld == null || AccountDao.checkHashedPassword(oldPassword, hashedOld)) { + return AccountDao.updatePassword(conn, account, newPassword, secondary); } - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + return false; + }); } + /** + * Creates a new account with the provided username and password. + * Executes all SQL operations inside a transaction for safety. + * + * @param username the desired username for the new account + * @param password the plaintext password for the new account + * @return true if the account was successfully created, false otherwise + */ @Override public synchronized boolean newAccount(String username, String password) { - Optional accountIdOpt = DatabaseManager.idAccessor().nextAccountId(); // should be -1 - if (accountIdOpt.isEmpty() || getAccountByUsername(username).isPresent()) { - return false; - } - int accountId; - - String sql = "INSERT INTO account.accounts (username, password, character_slots, nx_credit, nx_prepaid, maple_point, trunk_size, trunk_money) " + - "VALUES (?, ?, ?, ?, ?, ?, ?, ?)" + - "RETURNING ID"; - try (Connection conn = getConnection(); - PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setString(1, lowerUsername(username)); - stmt.setString(2, hashPassword(password)); - stmt.setInt(3, ServerConfig.CHARACTER_BASE_SLOTS); - stmt.setInt(4, 0); - stmt.setInt(5, 0); - stmt.setInt(6, 0); - stmt.setInt(7, ServerConfig.TRUNK_BASE_SLOTS); - stmt.setInt(8, 0); - try (ResultSet rs = stmt.executeQuery()) { - if (rs.next()) { - accountId = rs.getInt("id"); - } else { - throw new SQLException("Failed to retrieve account ID after insert."); + return withTransaction(conn -> { + return AccountDao.createAccount(conn, username, password); } - } - return true; - } catch (SQLException e) { - e.printStackTrace(); - } - return false; + ); } + /** + * Saves all changes made to the given account, including its related data. + * Uses a transaction to ensure atomic updates across all tables. + * + * @param account the account containing updated data to persist + * @return true if the account was successfully saved, false otherwise + */ @Override public boolean saveAccount(Account account) { try { diff --git a/src/main/java/kinoko/database/postgresql/type/AccountDao.java b/src/main/java/kinoko/database/postgresql/type/AccountDao.java index f358c7fe..f99083ef 100644 --- a/src/main/java/kinoko/database/postgresql/type/AccountDao.java +++ b/src/main/java/kinoko/database/postgresql/type/AccountDao.java @@ -1,6 +1,8 @@ package kinoko.database.postgresql.type; +import kinoko.database.DatabaseManager; import kinoko.world.user.Account; +import org.mindrot.jbcrypt.BCrypt; import java.sql.Connection; import java.sql.PreparedStatement; @@ -116,4 +118,130 @@ public static Optional getAccountIdByCharacterId(Connection conn, int c } return Optional.empty(); } + + /** + * Retrieves an account by its unique ID from the database. + * Executes a query against the accounts table and constructs the Account object using load(). + * + * @param conn the active database connection + * @param accountId the ID of the account to retrieve + * @return an Optional containing the Account if found, otherwise empty + * @throws SQLException if a database access error occurs + */ + public static Optional getAccountById(Connection conn, int accountId) throws SQLException { + String sql = "SELECT * FROM account.accounts WHERE id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(load(conn, rs)); + } + } + } + return Optional.empty(); + } + + /** + * Retrieves an account by its username from the database. + * The username is converted to lowercase before lookup for consistency. + * + * @param conn the active database connection + * @param username the username of the account to retrieve + * @return an Optional containing the Account if found, otherwise empty + * @throws SQLException if a database access error occurs + */ + public static Optional getAccountByUsername(Connection conn, String username) throws SQLException { + String sql = "SELECT * FROM account.accounts WHERE username = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, username.toLowerCase()); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return Optional.of(load(conn, rs)); + } + } + } + return Optional.empty(); + } + + /** + * Updates the account's password or secondary password in the database. + * Automatically hashes the provided password before saving. + * + * @param conn the active database connection + * @param account the account whose password is being updated + * @param newPassword the new plaintext password + * @param secondary true to update the secondary password, false to update the primary + * @return true if the update succeeded, false otherwise + * @throws SQLException if a database access error occurs + */ + public static boolean updatePassword(Connection conn, Account account, String newPassword, boolean secondary) throws SQLException { + String column = secondary ? "secondary_password" : "password"; + String sql = "UPDATE account.accounts SET " + column + " = ? WHERE id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, hashPassword(newPassword)); + stmt.setInt(2, account.getId()); + return stmt.executeUpdate() > 0; + } + } + + /** + * Creates a new account in the database with default values for character slots and currencies. + * The password is automatically hashed before being stored. + * + * @param conn the active database connection + * @param username the desired username for the new account + * @param password the plaintext password for the new account + * @return true if the account was successfully created, false otherwise + * @throws SQLException if a database access error occurs + */ + public static boolean createAccount(Connection conn, String username, String password) throws SQLException { + String sql = """ + INSERT INTO account.accounts + (username, password, character_slots, nx_credit, nx_prepaid, maple_point, trunk_size, trunk_money) + VALUES (?, ?, ?, ?, ?, ?, ?, ?) + RETURNING id + """; + Optional accountIdOpt = DatabaseManager.idAccessor().nextAccountId(); // should be -1 + if (accountIdOpt.isEmpty() || getAccountByUsername(conn, username).isPresent()) { + return false; + } + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setString(1, username.toLowerCase()); + stmt.setString(2, hashPassword(password)); + stmt.setInt(3, kinoko.server.ServerConfig.CHARACTER_BASE_SLOTS); + stmt.setInt(4, 0); + stmt.setInt(5, 0); + stmt.setInt(6, 0); + stmt.setInt(7, kinoko.server.ServerConfig.TRUNK_BASE_SLOTS); + stmt.setInt(8, 0); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + return true; + } + } + } + return false; + } + + /** + * Generates a BCrypt hash for the provided plaintext password. + * + * @param password the plaintext password to hash + * @return the hashed password string + */ + public static String hashPassword(String password) { + return BCrypt.hashpw(password, BCrypt.gensalt()); + } + + /** + * Verifies whether a plaintext password matches the provided hashed password. + * + * @param password the plaintext password to verify + * @param hashedPassword the stored hashed password to compare against + * @return true if the passwords match, false otherwise + */ + public static boolean checkHashedPassword(String password, String hashedPassword) { + return BCrypt.checkpw(password, hashedPassword); + } } From 7c91f166d0753f95713c01ba6cbad53934e986fe Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 14 Oct 2025 17:54:00 -0400 Subject: [PATCH 50/83] Save Objects that weren't getting saved --- .../database/postgresql/PostgresAccessor.java | 26 +-- .../kinoko/database/postgresql/setup/init.sql | 6 +- .../postgresql/type/CharacterDataDao.java | 206 ++---------------- .../postgresql/type/ConfigManagerDao.java | 66 ++++++ .../database/postgresql/type/ExtendSpDao.java | 19 +- .../database/postgresql/type/ItemDao.java | 19 +- .../postgresql/type/MapTransferInfoDao.java | 61 +++++- .../postgresql/type/MiniGameRecordDao.java | 47 ++++ .../postgresql/type/PopularityRecordDao.java | 34 +++ .../postgresql/type/QuestManagerDao.java | 34 +++ .../postgresql/type/SkillManagerDao.java | 51 +++++ .../postgresql/type/WildHunterInfoDao.java | 49 +++++ 12 files changed, 389 insertions(+), 229 deletions(-) diff --git a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java index 2f7786c7..529ea662 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresAccessor.java @@ -24,13 +24,6 @@ protected final Connection getConnection() throws SQLException { return dataSource.getConnection(); } - /** - * Helper to lowercase strings (like usernames) - */ - protected final String lowerName(String name) { - return name.toLowerCase(); - } - /** * Executes the given action within a database transaction using a connection from the instance's data source. * Sets auto-commit to false, runs the action, commits the transaction if successful, and rolls back if a SQLException occurs. @@ -109,20 +102,21 @@ public static boolean withTransaction(Connection conn, try { conn.setAutoCommit(true); conn.close(); - } catch (SQLException ignored) { - ignored.printStackTrace(); + } catch (SQLException e) { + e.printStackTrace(); } } } } /** - * Executes the given action within a database transaction using a connection from the instance's data source. - * Sets auto-commit to false, runs the action, commits the transaction if successful, and rolls back if a SQLException occurs. - * Finally, restores auto-commit to true and closes the connection. + * Executes a database operation within a managed transaction using a connection from the data source. + * Auto-commit is disabled before execution and restored afterward. + * The provided action determines logical success; if it returns false or throws an exception, the transaction is rolled back. + * If successful, the transaction is committed before closing the connection. * - * @param action The action to execute inside the transaction. Can throw SQLException. - * @return true if the transaction committed successfully; false if an exception occurred and rollback was performed. + * @param action the action to execute inside the transaction; may throw SQLException + * @return true if the transaction was committed successfully, false if an exception occurred or rollback was performed */ public boolean withTransaction(SQLBooleanAction action) { Connection conn = null; @@ -157,8 +151,8 @@ public boolean withTransaction(SQLBooleanAction action) { conn.setAutoCommit(true); conn.close(); } catch - (SQLException ignored) { - ignored.printStackTrace(); + (SQLException e) { + e.printStackTrace(); } } } diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index 660a4991..8e20b7b7 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -319,9 +319,9 @@ CREATE TABLE IF NOT EXISTS player.minigame ( ); CREATE TABLE IF NOT EXISTS player.map_transfer ( - character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE , - map_id INT NOT NULL, - old_map_id INT NOT NULL + character_id INT PRIMARY KEY REFERENCES player.characters(id) ON DELETE CASCADE ON UPDATE CASCADE, + map_ids INT[] NOT NULL, + old_map_ids INT[] NOT NULL ); CREATE TABLE IF NOT EXISTS player.wild_hunter ( diff --git a/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java b/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java index fa88c4b3..8aef82f6 100644 --- a/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java +++ b/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java @@ -1,19 +1,13 @@ package kinoko.database.postgresql.type; -import kinoko.world.GameConstants; import kinoko.world.item.InventoryManager; -import kinoko.world.quest.QuestRecord; import kinoko.world.skill.SkillManager; import kinoko.world.quest.QuestManager; -import kinoko.world.skill.SkillRecord; import kinoko.world.user.CharacterData; import kinoko.world.user.data.*; import kinoko.world.user.stat.CharacterStat; import java.sql.*; -import java.time.Instant; -import java.util.Arrays; -import java.util.List; import java.util.Optional; import java.util.concurrent.atomic.AtomicInteger; @@ -218,10 +212,13 @@ public static boolean newCharacter(Connection conn, CharacterData characterData) // Save all dependent data using the same connection saveCharacterStats(conn, characterData); InventoryDao.saveCharacter(conn, characterData); - saveCharacterSkills(conn, characterData); - saveCharacterQuests(conn, characterData); - saveCharacterConfig(conn, characterData); - saveCharacterPopularity(conn, characterData); + SkillManagerDao.saveCharacterSkills(conn, characterData); + QuestManagerDao.saveCharacterQuests(conn, characterData); + ConfigManagerDao.saveCharacterConfig(conn, characterData); + PopularityRecordDao.saveCharacterPopularity(conn, characterData); + MapTransferInfoDao.saveMapTransferInfo(conn, characterData); + MiniGameRecordDao.saveMiniGameRecord(conn, characterData); + WildHunterInfoDao.saveWildHunterInfo(conn, characterData); ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); return true; @@ -325,184 +322,6 @@ ON CONFLICT (character_id) DO UPDATE SET } } - /** - * Saves or updates a character’s configuration data, including pet settings, key mappings, - * quickslot layout, and skill macros. - * Uses UPSERT logic to maintain up-to-date configuration for the given character. - * - * @param conn the active database connection - * @param characterData the character whose configuration should be saved - * @throws SQLException if a database access error occurs - */ - private static void saveCharacterConfig(Connection conn, CharacterData characterData) throws SQLException { - ConfigManager config = characterData.getConfigManager(); - - String sql = """ - INSERT INTO player.config - (character_id, pet_consume_item, pet_consume_mp_item, pet_exception_list, func_key_types, func_key_ids, quickslot_key_map) - VALUES (?, ?, ?, ?, ?, ?, ?) - ON CONFLICT (character_id) DO UPDATE - SET pet_consume_item = EXCLUDED.pet_consume_item, - pet_consume_mp_item = EXCLUDED.pet_consume_mp_item, - pet_exception_list = EXCLUDED.pet_exception_list, - func_key_types = EXCLUDED.func_key_types, - func_key_ids = EXCLUDED.func_key_ids, - quickslot_key_map = EXCLUDED.quickslot_key_map - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, config.getPetConsumeItem()); - stmt.setInt(3, config.getPetConsumeMpItem()); - - // pet_exception_list -> List -> Integer[] - List exceptionList = config.getPetExceptionList(); - Integer[] exceptionArray = exceptionList != null ? exceptionList.toArray(new Integer[0]) : new Integer[0]; - stmt.setArray(4, conn.createArrayOf("integer", exceptionArray)); - - // func_key_types & func_key_ids from FuncKeyMapped[] - FuncKeyMapped[] funcKeyMap = config.getFuncKeyMap(); - if (funcKeyMap == null) { - funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); - } - - Short[] funcTypes = Arrays.stream(funcKeyMap) - .map(f -> (short) f.getType().getValue()) - .toArray(Short[]::new); - Integer[] funcIds = Arrays.stream(funcKeyMap) - .map(FuncKeyMapped::getId) - .toArray(Integer[]::new); - - stmt.setArray(5, conn.createArrayOf("smallint", funcTypes)); // func_key_types - stmt.setArray(6, conn.createArrayOf("integer", funcIds)); // func_key_ids - - // quickslot_key_map -> int[] -> Integer[] - int[] quickslot = config.getQuickslotKeyMap(); - Integer[] quickslotKeys = quickslot != null - ? Arrays.stream(quickslot).boxed().toArray(Integer[]::new) - : new Integer[0]; - stmt.setArray(7, conn.createArrayOf("integer", quickslotKeys)); - - stmt.executeUpdate(); - } - // save skill macros - SkillMacrosDao.upsertMacros(conn, characterData.getCharacterId(), config.getMacroSysData()); - } - - /** - * Saves the character’s popularity (fame) relationships to other characters. - * Each entry represents a character that has received or given popularity points. - * Uses UPSERT logic to ensure timestamps are updated for existing records. - * - * @param conn the active database connection - * @param characterData the character whose popularity data should be saved - * @throws SQLException if a database access error occurs - */ - private static void saveCharacterPopularity(Connection conn, CharacterData characterData) throws SQLException { - String sql = """ - INSERT INTO player.popularity (character_id, other_character_id, timestamp) - VALUES (?, ?, ?) - ON CONFLICT (character_id, other_character_id) - DO UPDATE SET timestamp = EXCLUDED.timestamp - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - PopularityRecord pr = characterData.getPopularityRecord(); - int charId = characterData.getCharacterId(); - - for (var entry : pr.getRecords().entrySet()) { - stmt.setInt(1, charId); - stmt.setInt(2, entry.getKey()); - stmt.setTimestamp(3, Timestamp.from(entry.getValue())); - stmt.addBatch(); - } - - stmt.executeBatch(); - } - } - - - /** - * Saves or updates all skill-related data for the given character, including: - * - Skill levels and master levels - * - Active skill cooldowns - * Uses UPSERT logic to maintain consistency between client and server skill data. - * - * @param conn the active database connection - * @param characterData the character whose skills should be saved - * @throws SQLException if a database access error occurs - */ - private static void saveCharacterSkills(Connection conn, CharacterData characterData) throws SQLException { - String skillRecordSql = """ - INSERT INTO player.skill_record (character_id, skill_id, level, master_level) - VALUES (?, ?, ?, ?) - ON CONFLICT (character_id, skill_id) - DO UPDATE SET level = EXCLUDED.level, master_level = EXCLUDED.master_level - """; - - try (PreparedStatement stmt = conn.prepareStatement(skillRecordSql)) { - for (SkillRecord sr : characterData.getSkillManager().getSkillRecords()) { - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, sr.getSkillId()); - stmt.setInt(3, sr.getSkillLevel()); - stmt.setInt(4, sr.getMasterLevel()); - stmt.addBatch(); - } - stmt.executeBatch(); - } - - // Save skill cooltimes - String skillCooltimeSql = """ - INSERT INTO player.skill_cooltime (character_id, skill_id, cooldown_end) - VALUES (?, ?, ?) - ON CONFLICT (character_id, skill_id) - DO UPDATE SET cooldown_end = EXCLUDED.cooldown_end - """; - try (PreparedStatement stmt = conn.prepareStatement(skillCooltimeSql)) { - for (var entry : characterData.getSkillManager().getSkillCooltimes().entrySet()) { - int skillId = entry.getKey(); - Instant endTime = entry.getValue(); - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, skillId); - stmt.setTimestamp(3, Timestamp.from(endTime)); - stmt.addBatch(); - } - stmt.executeBatch(); - } - } - - /** - * Saves or updates the character’s quest progress records. - * Each entry includes the quest ID, its current state, progress string, and completion timestamp. - * Uses UPSERT logic to handle both new and existing quest records efficiently. - * - * @param conn the active database connection - * @param characterData the character whose quest data should be saved - * @throws SQLException if a database access error occurs - */ - private static void saveCharacterQuests(Connection conn, CharacterData characterData) throws SQLException { - String sql = """ - INSERT INTO player.quest_record (character_id, quest_id, status, progress, completed_time) - VALUES (?, ?, ?, ?, ?) - ON CONFLICT (character_id, quest_id) - DO UPDATE SET status = EXCLUDED.status, - progress = EXCLUDED.progress, - completed_time = EXCLUDED.completed_time - """; - - try (PreparedStatement stmt = conn.prepareStatement(sql)) { - for (QuestRecord qr : characterData.getQuestManager().getQuestRecords()) { - stmt.setInt(1, characterData.getCharacterId()); - stmt.setInt(2, qr.getQuestId()); - stmt.setInt(3, qr.getState().getValue()); - stmt.setString(4, qr.getValue()); - stmt.setTimestamp(5, qr.getCompletedTime() != null ? Timestamp.from(qr.getCompletedTime()) : null); - stmt.addBatch(); - } - stmt.executeBatch(); - } - } - /** * Saves/updates a CharacterData object to the database. * @@ -543,10 +362,13 @@ public static boolean saveCharacter(Connection conn, CharacterData characterData // Save dependent tables using the same connection saveCharacterStats(conn, characterData); InventoryDao.saveCharacter(conn, characterData); - saveCharacterSkills(conn, characterData); - saveCharacterQuests(conn, characterData); - saveCharacterConfig(conn, characterData); - saveCharacterPopularity(conn, characterData); + SkillManagerDao.saveCharacterSkills(conn, characterData); + QuestManagerDao.saveCharacterQuests(conn, characterData); + ConfigManagerDao.saveCharacterConfig(conn, characterData); + PopularityRecordDao.saveCharacterPopularity(conn, characterData); + MapTransferInfoDao.saveMapTransferInfo(conn, characterData); + MiniGameRecordDao.saveMiniGameRecord(conn, characterData); + WildHunterInfoDao.saveWildHunterInfo(conn, characterData); ExtendSpDao.upsertExtendSp(conn, characterData.getCharacterId(), characterData.getCharacterStat().getSp()); return true; diff --git a/src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java b/src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java index 1cb378e8..45b9e2e1 100644 --- a/src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ConfigManagerDao.java @@ -2,6 +2,7 @@ import kinoko.world.GameConstants; +import kinoko.world.user.CharacterData; import kinoko.world.user.data.ConfigManager; import kinoko.world.user.data.FuncKeyMapped; import kinoko.world.user.data.FuncKeyType; @@ -89,4 +90,69 @@ public static ConfigManager loadConfig(Connection conn, int characterId) throws } } } + + + /** + * Saves or updates a character’s configuration data, including pet settings, key mappings, + * quickslot layout, and skill macros. + * Uses UPSERT logic to maintain up-to-date configuration for the given character. + * + * @param conn the active database connection + * @param characterData the character whose configuration should be saved + * @throws SQLException if a database access error occurs + */ + public static void saveCharacterConfig(Connection conn, CharacterData characterData) throws SQLException { + ConfigManager config = characterData.getConfigManager(); + + String sql = """ + INSERT INTO player.config + (character_id, pet_consume_item, pet_consume_mp_item, pet_exception_list, func_key_types, func_key_ids, quickslot_key_map) + VALUES (?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (character_id) DO UPDATE + SET pet_consume_item = EXCLUDED.pet_consume_item, + pet_consume_mp_item = EXCLUDED.pet_consume_mp_item, + pet_exception_list = EXCLUDED.pet_exception_list, + func_key_types = EXCLUDED.func_key_types, + func_key_ids = EXCLUDED.func_key_ids, + quickslot_key_map = EXCLUDED.quickslot_key_map + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, config.getPetConsumeItem()); + stmt.setInt(3, config.getPetConsumeMpItem()); + + // pet_exception_list -> List -> Integer[] + List exceptionList = config.getPetExceptionList(); + Integer[] exceptionArray = exceptionList != null ? exceptionList.toArray(new Integer[0]) : new Integer[0]; + stmt.setArray(4, conn.createArrayOf("integer", exceptionArray)); + + // func_key_types & func_key_ids from FuncKeyMapped[] + FuncKeyMapped[] funcKeyMap = config.getFuncKeyMap(); + if (funcKeyMap == null) { + funcKeyMap = Arrays.copyOf(GameConstants.DEFAULT_FUNC_KEY_MAP, GameConstants.FUNC_KEY_MAP_SIZE); + } + + Short[] funcTypes = Arrays.stream(funcKeyMap) + .map(f -> (short) f.getType().getValue()) + .toArray(Short[]::new); + Integer[] funcIds = Arrays.stream(funcKeyMap) + .map(FuncKeyMapped::getId) + .toArray(Integer[]::new); + + stmt.setArray(5, conn.createArrayOf("smallint", funcTypes)); // func_key_types + stmt.setArray(6, conn.createArrayOf("integer", funcIds)); // func_key_ids + + // quickslot_key_map -> int[] -> Integer[] + int[] quickslot = config.getQuickslotKeyMap(); + Integer[] quickslotKeys = quickslot != null + ? Arrays.stream(quickslot).boxed().toArray(Integer[]::new) + : new Integer[0]; + stmt.setArray(7, conn.createArrayOf("integer", quickslotKeys)); + + stmt.executeUpdate(); + } + // save skill macros + SkillMacrosDao.upsertMacros(conn, characterData.getCharacterId(), config.getMacroSysData()); + } } diff --git a/src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java b/src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java index 2ec92ba7..9f1b6675 100644 --- a/src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ExtendSpDao.java @@ -9,7 +9,15 @@ public final class ExtendSpDao { /** - * Upserts all entries from ExtendSp into player.extend_sp. + * Inserts or updates extended SP data for a character in the database. + * If an entry with the same character ID and job level already exists, the SP value is updated. + * Otherwise, a new record is inserted. + * Skips execution if the provided ExtendSp object is null or empty. + * + * @param conn the active database connection + * @param characterId the ID of the character whose SP data is being stored + * @param extendSp the ExtendSp object containing job-level-to-SP mappings + * @throws SQLException if a database access error occurs */ public static void upsertExtendSp(Connection conn, int characterId, ExtendSp extendSp) throws SQLException { if (extendSp == null || extendSp.getMap().isEmpty()) { @@ -35,7 +43,14 @@ ON CONFLICT (character_id, job_level) } /** - * Loads ExtendSp for a given character. + * Loads a character's extended SP data from the database. + * Retrieves all job level–SP pairs associated with the given character ID and constructs an ExtendSp object from them. + * If no records are found, an empty ExtendSp instance is returned. + * + * @param conn the active database connection + * @param characterId the ID of the character whose extended SP data is being loaded + * @return an ExtendSp object containing the character's job-level-to-SP mappings, or empty if none exist + * @throws SQLException if a database access error occurs */ public static ExtendSp loadExtendSp(Connection conn, int characterId) throws SQLException { String sql = """ diff --git a/src/main/java/kinoko/database/postgresql/type/ItemDao.java b/src/main/java/kinoko/database/postgresql/type/ItemDao.java index ff43c05c..f596c452 100644 --- a/src/main/java/kinoko/database/postgresql/type/ItemDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ItemDao.java @@ -15,7 +15,7 @@ public class ItemDao { /** * Inserts a new item into the `item.items` table and returns its generated item_sn. - *

+ * * If the insertion is successful, the auto-generated item_sn is also set in the provided Item object. * This method is useful when creating a new item that does not yet have an item_sn. * @@ -56,11 +56,11 @@ public static long createNewItem(Connection conn, Item item) throws SQLException /** * Saves a collection of items to the database in batch. - *

+ * * For each item, this method checks if it already has an item_sn: * - If the item_sn is missing (<=0), a new one is generated and the item is inserted. * - If the item_sn exists, the item is updated with the latest quantity, attributes, title, and expiration date. - *

+ * * This approach ensures efficient batch inserts for new items while keeping existing items up to date. * * @param conn the active database connection @@ -127,6 +127,15 @@ public static void saveItemsBatch(Connection conn, Collection items) throw } } + /** + * Constructs an Item instance from the provided ResultSet. + * Extracts all relevant item data, including equipment, pet, and ring information if applicable. + * Converts nullable SQL fields (e.g., timestamps or optional data groups) into corresponding Java objects. + * + * @param rs the ResultSet containing item data retrieved from the database + * @return a fully populated Item object built from the ResultSet data + * @throws SQLException if a database access error occurs or a column value cannot be retrieved + */ public static Item from(ResultSet rs) throws SQLException { long itemSn = rs.getLong("item_sn"); int itemId = rs.getInt("item_id"); @@ -208,12 +217,12 @@ public static Item from(ResultSet rs) throws SQLException { /** * Cleans up invalid items from the database that no longer have valid references. - *

+ * * In the PostgreSQL implementation, all items are stored in {@code item.Items}, regardless of * whether they are currently held by a player (inventory, trunk, locker, wishlist, gifted) * or not. This can lead to orphaned item records when items are dropped, since dropped * items are not tracked by the database. - *

+ * * This method queries and removes items that are not referenced anywhere else, ensuring * synchronization between the in-game state and the persistent database state. This function * typically called during server initialization, when no dropped items exist. diff --git a/src/main/java/kinoko/database/postgresql/type/MapTransferInfoDao.java b/src/main/java/kinoko/database/postgresql/type/MapTransferInfoDao.java index 3d30a660..f0329e14 100644 --- a/src/main/java/kinoko/database/postgresql/type/MapTransferInfoDao.java +++ b/src/main/java/kinoko/database/postgresql/type/MapTransferInfoDao.java @@ -1,41 +1,80 @@ package kinoko.database.postgresql.type; +import kinoko.world.user.CharacterData; import kinoko.world.user.data.MapTransferInfo; -import java.sql.Connection; -import java.sql.PreparedStatement; -import java.sql.ResultSet; -import java.sql.SQLException; +import java.sql.*; +import java.util.Arrays; public final class MapTransferInfoDao { /** * Loads MapTransferInfo for the specified character. * - * Retrieves the main map ID and legacy/old map ID for the character. + * Retrieves the map_ids and old_map_ids arrays from the database and populates + * the corresponding lists in a MapTransferInfo object. * * @param conn the database connection to use * @param characterId the ID of the character - * @return MapTransferInfo populated with map transfer data + * @return MapTransferInfo populated with the character's map transfer lists * @throws SQLException if a database access error occurs */ public static MapTransferInfo loadMapTransferInfo(Connection conn, int characterId) throws SQLException { MapTransferInfo mti = new MapTransferInfo(); - String sql = "SELECT map_id, old_map_id FROM player.map_transfer WHERE character_id = ?"; + String sql = "SELECT map_ids, old_map_ids FROM player.map_transfer WHERE character_id = ?"; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setInt(1, characterId); try (ResultSet rs = stmt.executeQuery()) { if (rs.next()) { - int mapId = rs.getInt("map_id"); - int oldMapId = rs.getInt("old_map_id"); + Array mapArray = rs.getArray("map_ids"); + Array oldMapArray = rs.getArray("old_map_ids"); - mti.getMapTransfer().add(mapId); // main list - mti.getMapTransferEx().add(oldMapId); // legacy/old map + if (mapArray != null) { + Integer[] mapIds = (Integer[]) mapArray.getArray(); + mti.getMapTransfer().addAll(Arrays.asList(mapIds)); + } + if (oldMapArray != null) { + Integer[] oldMapIds = (Integer[]) oldMapArray.getArray(); + mti.getMapTransferEx().addAll(Arrays.asList(oldMapIds)); + } } } } return mti; } + + /** + * Saves MapTransferInfo for the specified character. + * + * Replaces or inserts the character’s map transfer data in the database. + * Converts the map transfer lists into SQL integer arrays and performs an UPSERT. + * + * @param conn the database connection to use + * @param characterData CharacterData instance. + * @throws SQLException if a database access error occurs + */ + public static void saveMapTransferInfo(Connection conn, CharacterData characterData) throws SQLException { + MapTransferInfo mapTransferInfo = characterData.getMapTransferInfo(); + int characterId = characterData.getCharacterId(); + + String sql = """ + INSERT INTO player.map_transfer (character_id, map_ids, old_map_ids) + VALUES (?, ?, ?) + ON CONFLICT (character_id) + DO UPDATE SET map_ids = EXCLUDED.map_ids, old_map_ids = EXCLUDED.old_map_ids + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + // Convert Java lists to SQL array + Array mapArray = conn.createArrayOf("INTEGER", mapTransferInfo.getMapTransfer().toArray()); + Array oldMapArray = conn.createArrayOf("INTEGER", mapTransferInfo.getMapTransferEx().toArray()); + + stmt.setInt(1, characterId); + stmt.setArray(2, mapArray); + stmt.setArray(3, oldMapArray); + stmt.executeUpdate(); + } + } } diff --git a/src/main/java/kinoko/database/postgresql/type/MiniGameRecordDao.java b/src/main/java/kinoko/database/postgresql/type/MiniGameRecordDao.java index 46552689..723c3208 100644 --- a/src/main/java/kinoko/database/postgresql/type/MiniGameRecordDao.java +++ b/src/main/java/kinoko/database/postgresql/type/MiniGameRecordDao.java @@ -1,5 +1,6 @@ package kinoko.database.postgresql.type; +import kinoko.world.user.CharacterData; import kinoko.world.user.data.MiniGameRecord; import java.sql.Connection; @@ -48,4 +49,50 @@ public static MiniGameRecord loadMiniGameRecord(Connection conn, int characterId return record; } + + /** + * Saves MiniGameRecord for the specified character. + * + * Updates the Omok and Memory game statistics in the database. + * If a record for the character does not exist, it inserts a new one. + * + * @param conn the database connection to use + * @param characterData CharacterData object + * @throws SQLException if a database access error occurs + */ + public static void saveMiniGameRecord(Connection conn, CharacterData characterData) throws SQLException { + int characterId = characterData.getCharacterId(); + MiniGameRecord record = characterData.getMiniGameRecord(); + + String sql = """ + INSERT INTO player.minigame + (character_id, omok_wins, omok_ties, omok_losses, omok_score, + memory_wins, memory_ties, memory_losses, memory_score) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + ON CONFLICT (character_id) + DO UPDATE SET + omok_wins = EXCLUDED.omok_wins, + omok_ties = EXCLUDED.omok_ties, + omok_losses = EXCLUDED.omok_losses, + omok_score = EXCLUDED.omok_score, + memory_wins = EXCLUDED.memory_wins, + memory_ties = EXCLUDED.memory_ties, + memory_losses = EXCLUDED.memory_losses, + memory_score = EXCLUDED.memory_score + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, characterId); + stmt.setInt(2, record.getOmokGameWins()); + stmt.setInt(3, record.getOmokGameTies()); + stmt.setInt(4, record.getOmokGameLosses()); + stmt.setDouble(5, record.getOmokGameScore()); + stmt.setInt(6, record.getMemoryGameWins()); + stmt.setInt(7, record.getMemoryGameTies()); + stmt.setInt(8, record.getMemoryGameLosses()); + stmt.setDouble(9, record.getMemoryGameScore()); + + stmt.executeUpdate(); + } + } } diff --git a/src/main/java/kinoko/database/postgresql/type/PopularityRecordDao.java b/src/main/java/kinoko/database/postgresql/type/PopularityRecordDao.java index 7d8240a1..3de8d394 100644 --- a/src/main/java/kinoko/database/postgresql/type/PopularityRecordDao.java +++ b/src/main/java/kinoko/database/postgresql/type/PopularityRecordDao.java @@ -1,5 +1,6 @@ package kinoko.database.postgresql.type; +import kinoko.world.user.CharacterData; import kinoko.world.user.data.PopularityRecord; import java.sql.Connection; @@ -41,4 +42,37 @@ public static PopularityRecord loadPopularityRecord(Connection conn, int charact return pr; } + + /** + * Saves the character’s popularity (fame) relationships to other characters. + * Each entry represents a character that has received or given popularity points. + * Uses UPSERT logic to ensure timestamps are updated for existing records. + * + * @param conn the active database connection + * @param characterData the character whose popularity data should be saved + * @throws SQLException if a database access error occurs + */ + public static void saveCharacterPopularity(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.popularity (character_id, other_character_id, timestamp) + VALUES (?, ?, ?) + ON CONFLICT (character_id, other_character_id) + DO UPDATE SET timestamp = EXCLUDED.timestamp + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + PopularityRecord pr = characterData.getPopularityRecord(); + int charId = characterData.getCharacterId(); + + for (var entry : pr.getRecords().entrySet()) { + stmt.setInt(1, charId); + stmt.setInt(2, entry.getKey()); + stmt.setTimestamp(3, Timestamp.from(entry.getValue())); + stmt.addBatch(); + } + + stmt.executeBatch(); + } + } + } diff --git a/src/main/java/kinoko/database/postgresql/type/QuestManagerDao.java b/src/main/java/kinoko/database/postgresql/type/QuestManagerDao.java index 474a5a51..faf6ec63 100644 --- a/src/main/java/kinoko/database/postgresql/type/QuestManagerDao.java +++ b/src/main/java/kinoko/database/postgresql/type/QuestManagerDao.java @@ -3,6 +3,7 @@ import kinoko.world.quest.QuestManager; import kinoko.world.quest.QuestRecord; import kinoko.world.quest.QuestState; +import kinoko.world.user.CharacterData; import java.sql.*; import java.time.Instant; @@ -51,4 +52,37 @@ public static QuestManager loadQuestRecords(Connection conn, int characterId) th return qm; } + + + /** + * Saves or updates the character’s quest progress records. + * Each entry includes the quest ID, its current state, progress string, and completion timestamp. + * Uses UPSERT logic to handle both new and existing quest records efficiently. + * + * @param conn the active database connection + * @param characterData the character whose quest data should be saved + * @throws SQLException if a database access error occurs + */ + public static void saveCharacterQuests(Connection conn, CharacterData characterData) throws SQLException { + String sql = """ + INSERT INTO player.quest_record (character_id, quest_id, status, progress, completed_time) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (character_id, quest_id) + DO UPDATE SET status = EXCLUDED.status, + progress = EXCLUDED.progress, + completed_time = EXCLUDED.completed_time + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + for (QuestRecord qr : characterData.getQuestManager().getQuestRecords()) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, qr.getQuestId()); + stmt.setInt(3, qr.getState().getValue()); + stmt.setString(4, qr.getValue()); + stmt.setTimestamp(5, qr.getCompletedTime() != null ? Timestamp.from(qr.getCompletedTime()) : null); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } } diff --git a/src/main/java/kinoko/database/postgresql/type/SkillManagerDao.java b/src/main/java/kinoko/database/postgresql/type/SkillManagerDao.java index 0bf51c2d..83b8c0c7 100644 --- a/src/main/java/kinoko/database/postgresql/type/SkillManagerDao.java +++ b/src/main/java/kinoko/database/postgresql/type/SkillManagerDao.java @@ -3,8 +3,10 @@ import kinoko.world.skill.SkillManager; import kinoko.world.skill.SkillRecord; +import kinoko.world.user.CharacterData; import java.sql.*; +import java.time.Instant; public final class SkillManagerDao { @@ -54,4 +56,53 @@ public static SkillManager loadSkillCooltimesAndRecords(Connection conn, int cha return sm; } + + /** + * Saves or updates all skill-related data for the given character, including: + * - Skill levels and master levels + * - Active skill cooldowns + * Uses UPSERT logic to maintain consistency between client and server skill data. + * + * @param conn the active database connection + * @param characterData the character whose skills should be saved + * @throws SQLException if a database access error occurs + */ + public static void saveCharacterSkills(Connection conn, CharacterData characterData) throws SQLException { + String skillRecordSql = """ + INSERT INTO player.skill_record (character_id, skill_id, level, master_level) + VALUES (?, ?, ?, ?) + ON CONFLICT (character_id, skill_id) + DO UPDATE SET level = EXCLUDED.level, master_level = EXCLUDED.master_level + """; + + try (PreparedStatement stmt = conn.prepareStatement(skillRecordSql)) { + for (SkillRecord sr : characterData.getSkillManager().getSkillRecords()) { + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, sr.getSkillId()); + stmt.setInt(3, sr.getSkillLevel()); + stmt.setInt(4, sr.getMasterLevel()); + stmt.addBatch(); + } + stmt.executeBatch(); + } + + // Save skill cooltimes + String skillCooltimeSql = """ + INSERT INTO player.skill_cooltime (character_id, skill_id, cooldown_end) + VALUES (?, ?, ?) + ON CONFLICT (character_id, skill_id) + DO UPDATE SET cooldown_end = EXCLUDED.cooldown_end + """; + try (PreparedStatement stmt = conn.prepareStatement(skillCooltimeSql)) { + for (var entry : characterData.getSkillManager().getSkillCooltimes().entrySet()) { + int skillId = entry.getKey(); + Instant endTime = entry.getValue(); + stmt.setInt(1, characterData.getCharacterId()); + stmt.setInt(2, skillId); + stmt.setTimestamp(3, Timestamp.from(endTime)); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } } diff --git a/src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java b/src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java index d7cf370d..bf98e3da 100644 --- a/src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java +++ b/src/main/java/kinoko/database/postgresql/type/WildHunterInfoDao.java @@ -1,5 +1,6 @@ package kinoko.database.postgresql.type; +import kinoko.world.user.CharacterData; import kinoko.world.user.data.WildHunterInfo; import java.sql.Connection; @@ -45,4 +46,52 @@ public static WildHunterInfo loadWildHunterInfo(Connection conn, int characterId return wh; } + + /** + * Saves WildHunterInfo for the specified character. + * + * Updates the riding type and captured mobs for the character. + * Existing captured mobs are cleared and replaced with the provided list (up to 5 mobs). + * + * @param conn the database connection to use + * @param characterData The CharacterData object + * @throws SQLException if a database access error occurs + */ + public static void saveWildHunterInfo(Connection conn, CharacterData characterData) throws SQLException { + int characterId = characterData.getCharacterId(); + WildHunterInfo wh = characterData.getWildHunterInfo(); + + // Save riding type + String sqlRiding = """ + INSERT INTO player.wild_hunter (character_id, riding_type) + VALUES (?, ?) + ON CONFLICT (character_id) + DO UPDATE SET riding_type = EXCLUDED.riding_type + """; + try (PreparedStatement stmt = conn.prepareStatement(sqlRiding)) { + stmt.setInt(1, characterId); + stmt.setInt(2, wh.getRidingType()); + stmt.executeUpdate(); + } + + // Clear existing captured mobs + String sqlDeleteMobs = "DELETE FROM player.wild_hunter_mob WHERE character_id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sqlDeleteMobs)) { + stmt.setInt(1, characterId); + stmt.executeUpdate(); + } + + // Insert captured mobs (up to 5) + String sqlInsertMob = "INSERT INTO player.wild_hunter_mob (character_id, mob_id) VALUES (?, ?)"; + try (PreparedStatement stmt = conn.prepareStatement(sqlInsertMob)) { + int count = 0; + for (Integer mobId : wh.getCapturedMobs()) { + if (count++ >= 5) break; + stmt.setInt(1, characterId); + stmt.setInt(2, mobId); + stmt.addBatch(); + } + stmt.executeBatch(); + } + } } From ae69f194cfccfd2dbd3316dec3ccd3cde9d60ac7 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 21 Oct 2025 22:43:27 -0400 Subject: [PATCH 51/83] Added new Command system --- .../cassandra/codec/CharacterStatCodec.java | 5 +- .../cassandra/type/CharacterStatUDT.java | 1 + .../kinoko/database/postgresql/setup/init.sql | 9 +- .../postgresql/type/AvatarDataDao.java | 7 +- .../postgresql/type/CharacterDataDao.java | 18 +- .../kinoko/handler/stage/LoginHandler.java | 5 + .../java/kinoko/handler/user/UserHandler.java | 40 +- .../kinoko/server/command/AdminCommands.java | 1018 ----------------- .../java/kinoko/server/command/Command.java | 1 + .../server/command/CommandProcessor.java | 158 ++- .../command/admin/BattleshipCommand.java | 18 + .../server/command/admin/ComboCommand.java | 29 + .../server/command/admin/JaguarCommand.java | 25 + .../server/command/admin/MobSkillCommand.java | 56 + .../server/command/admin/RideCommand.java | 48 + .../server/command/admin/TestCommand.java | 26 + .../server/command/gm/KillMobsCommand.java | 21 + .../kinoko/server/command/gm/MobCommand.java | 49 + .../kinoko/server/command/jrgm/HpCommand.java | 24 + .../server/command/jrgm/JobCommand.java | 83 ++ .../server/command/jrgm/LevelCommand.java | 37 + .../server/command/jrgm/LevelUpCommand.java | 31 + .../kinoko/server/command/jrgm/MpCommand.java | 24 + .../server/command/jrgm/StatCommand.java | 87 ++ .../server/command/manager/AvatarCommand.java | 53 + .../manager/ReloadCashShopCommand.java | 26 + .../command/manager/ReloadDropsCommand.java | 26 + .../command/manager/ReloadShopsCommand.java | 26 + .../command/manager/SetGmLevelCommand.java | 60 + .../server/command/manager/SkillCommand.java | 48 + .../server/command/player/DisposeCommand.java | 17 + .../server/command/player/HelpCommand.java | 63 + .../command/supergm/ClearLockerCommand.java | 17 + .../command/supergm/ClearQuestCommand.java | 38 + .../command/supergm/CompleteQuestCommand.java | 27 + .../server/command/supergm/ItemCommand.java | 53 + .../server/command/supergm/MesoCommand.java | 34 + .../server/command/supergm/MorphCommand.java | 42 + .../server/command/supergm/NpcCommand.java | 43 + .../server/command/supergm/NxCommand.java | 24 + .../command/supergm/QuestExCommand.java | 38 + .../command/supergm/ReactorCommand.java | 62 + .../command/supergm/StartQuestCommand.java | 37 + .../command/supergm/ToggleMobCommand.java | 30 + .../command/tester/ClearInventoryCommand.java | 53 + .../command/tester/CooldownCommand.java | 22 + .../server/command/tester/FindCommand.java | 306 +++++ .../server/command/tester/InfoCommand.java | 66 ++ .../server/command/tester/MapCommand.java | 56 + .../server/command/tester/MaxCommand.java | 93 ++ .../kinoko/server/node/ChannelServerNode.java | 4 + .../kinoko/server/node/ClientStorage.java | 13 + src/main/java/kinoko/util/ClassScanner.java | 29 + src/main/java/kinoko/world/user/User.java | 4 + .../kinoko/world/user/stat/AdminLevel.java | 54 + .../kinoko/world/user/stat/CharacterStat.java | 13 +- utility/change_db.bat | 65 ++ 57 files changed, 2313 insertions(+), 1049 deletions(-) delete mode 100644 src/main/java/kinoko/server/command/AdminCommands.java create mode 100644 src/main/java/kinoko/server/command/admin/BattleshipCommand.java create mode 100644 src/main/java/kinoko/server/command/admin/ComboCommand.java create mode 100644 src/main/java/kinoko/server/command/admin/JaguarCommand.java create mode 100644 src/main/java/kinoko/server/command/admin/MobSkillCommand.java create mode 100644 src/main/java/kinoko/server/command/admin/RideCommand.java create mode 100644 src/main/java/kinoko/server/command/admin/TestCommand.java create mode 100644 src/main/java/kinoko/server/command/gm/KillMobsCommand.java create mode 100644 src/main/java/kinoko/server/command/gm/MobCommand.java create mode 100644 src/main/java/kinoko/server/command/jrgm/HpCommand.java create mode 100644 src/main/java/kinoko/server/command/jrgm/JobCommand.java create mode 100644 src/main/java/kinoko/server/command/jrgm/LevelCommand.java create mode 100644 src/main/java/kinoko/server/command/jrgm/LevelUpCommand.java create mode 100644 src/main/java/kinoko/server/command/jrgm/MpCommand.java create mode 100644 src/main/java/kinoko/server/command/jrgm/StatCommand.java create mode 100644 src/main/java/kinoko/server/command/manager/AvatarCommand.java create mode 100644 src/main/java/kinoko/server/command/manager/ReloadCashShopCommand.java create mode 100644 src/main/java/kinoko/server/command/manager/ReloadDropsCommand.java create mode 100644 src/main/java/kinoko/server/command/manager/ReloadShopsCommand.java create mode 100644 src/main/java/kinoko/server/command/manager/SetGmLevelCommand.java create mode 100644 src/main/java/kinoko/server/command/manager/SkillCommand.java create mode 100644 src/main/java/kinoko/server/command/player/DisposeCommand.java create mode 100644 src/main/java/kinoko/server/command/player/HelpCommand.java create mode 100644 src/main/java/kinoko/server/command/supergm/ClearLockerCommand.java create mode 100644 src/main/java/kinoko/server/command/supergm/ClearQuestCommand.java create mode 100644 src/main/java/kinoko/server/command/supergm/CompleteQuestCommand.java create mode 100644 src/main/java/kinoko/server/command/supergm/ItemCommand.java create mode 100644 src/main/java/kinoko/server/command/supergm/MesoCommand.java create mode 100644 src/main/java/kinoko/server/command/supergm/MorphCommand.java create mode 100644 src/main/java/kinoko/server/command/supergm/NpcCommand.java create mode 100644 src/main/java/kinoko/server/command/supergm/NxCommand.java create mode 100644 src/main/java/kinoko/server/command/supergm/QuestExCommand.java create mode 100644 src/main/java/kinoko/server/command/supergm/ReactorCommand.java create mode 100644 src/main/java/kinoko/server/command/supergm/StartQuestCommand.java create mode 100644 src/main/java/kinoko/server/command/supergm/ToggleMobCommand.java create mode 100644 src/main/java/kinoko/server/command/tester/ClearInventoryCommand.java create mode 100644 src/main/java/kinoko/server/command/tester/CooldownCommand.java create mode 100644 src/main/java/kinoko/server/command/tester/FindCommand.java create mode 100644 src/main/java/kinoko/server/command/tester/InfoCommand.java create mode 100644 src/main/java/kinoko/server/command/tester/MapCommand.java create mode 100644 src/main/java/kinoko/server/command/tester/MaxCommand.java create mode 100644 src/main/java/kinoko/util/ClassScanner.java create mode 100644 src/main/java/kinoko/world/user/stat/AdminLevel.java create mode 100644 utility/change_db.bat diff --git a/src/main/java/kinoko/database/cassandra/codec/CharacterStatCodec.java b/src/main/java/kinoko/database/cassandra/codec/CharacterStatCodec.java index 5634a756..425ec478 100644 --- a/src/main/java/kinoko/database/cassandra/codec/CharacterStatCodec.java +++ b/src/main/java/kinoko/database/cassandra/codec/CharacterStatCodec.java @@ -6,6 +6,7 @@ import com.datastax.oss.driver.api.core.type.codec.TypeCodec; import com.datastax.oss.driver.api.core.type.reflect.GenericType; import kinoko.database.cassandra.type.CharacterStatUDT; +import kinoko.world.user.stat.AdminLevel; import kinoko.world.user.stat.CharacterStat; import kinoko.world.user.stat.ExtendSp; @@ -49,6 +50,7 @@ protected CharacterStat innerToOuter(UdtValue value) { cs.setPetSn1(value.getLong(CharacterStatUDT.PET_1)); cs.setPetSn2(value.getLong(CharacterStatUDT.PET_2)); cs.setPetSn3(value.getLong(CharacterStatUDT.PET_3)); + cs.setAdminLevel(AdminLevel.fromValue(value.getShort(CharacterStatUDT.ADMIN_LEVEL))); return cs; } @@ -81,6 +83,7 @@ protected UdtValue outerToInner(CharacterStat cs) { .setByte(CharacterStatUDT.PORTAL, cs.getPortal()) .setLong(CharacterStatUDT.PET_1, cs.getPetSn1()) .setLong(CharacterStatUDT.PET_2, cs.getPetSn2()) - .setLong(CharacterStatUDT.PET_3, cs.getPetSn3()); + .setLong(CharacterStatUDT.PET_3, cs.getPetSn3()) + .setShort(CharacterStatUDT.ADMIN_LEVEL, cs.getAdminLevel().getValue()); } } diff --git a/src/main/java/kinoko/database/cassandra/type/CharacterStatUDT.java b/src/main/java/kinoko/database/cassandra/type/CharacterStatUDT.java index 2ffe85c5..a3b38ec3 100644 --- a/src/main/java/kinoko/database/cassandra/type/CharacterStatUDT.java +++ b/src/main/java/kinoko/database/cassandra/type/CharacterStatUDT.java @@ -29,6 +29,7 @@ public final class CharacterStatUDT { public static final String PET_1 = "pet_1"; public static final String PET_2 = "pet_2"; public static final String PET_3 = "pet_3"; + public static final String ADMIN_LEVEL = "admin_level"; private static final String typeName = "character_stat_type"; diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index 8e20b7b7..631fd3ae 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -205,7 +205,8 @@ CREATE TABLE IF NOT EXISTS player.stats ( portal SMALLINT NOT NULL DEFAULT 0, pet_1 BIGINT, pet_2 BIGINT, - pet_3 BIGINT + pet_3 BIGINT, + admin_level SMALLINT ); CREATE TABLE IF NOT EXISTS player.extend_sp ( @@ -498,9 +499,9 @@ VALUES ( '$2a$10$LGtpvyti5yVVdWxN8L5sH.UiioiRweGw84mFaWJlfasSFDJ8.QPaW', -- bcrypt hash of "admin" NULL, 3, - 0, - 0, - 0, + 999999, + 999999, + 999999, 24, 0 ); diff --git a/src/main/java/kinoko/database/postgresql/type/AvatarDataDao.java b/src/main/java/kinoko/database/postgresql/type/AvatarDataDao.java index b5de50b7..703343c9 100644 --- a/src/main/java/kinoko/database/postgresql/type/AvatarDataDao.java +++ b/src/main/java/kinoko/database/postgresql/type/AvatarDataDao.java @@ -2,6 +2,7 @@ import kinoko.world.item.Inventory; import kinoko.world.user.AvatarData; +import kinoko.world.user.stat.AdminLevel; import kinoko.world.user.stat.CharacterStat; import java.sql.*; @@ -48,7 +49,8 @@ public static List getAvatarDataByAccountId(Connection conn, int acc s.portal, s.pet_1, s.pet_2, - s.pet_3 + s.pet_3, + s.admin_level FROM player.characters c JOIN player.stats s ON c.id = s.character_id WHERE c.account_id = ? @@ -83,7 +85,8 @@ public static List getAvatarDataByAccountId(Connection conn, int acc rs.getByte("portal"), rs.getLong("pet_1"), rs.getLong("pet_2"), - rs.getLong("pet_3") + rs.getLong("pet_3"), + AdminLevel.fromValue(rs.getShort("admin_level")) ); Inventory equipped = InventoryDao.loadEquippedInventory(conn, cs.getId()); diff --git a/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java b/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java index 8aef82f6..0782f029 100644 --- a/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java +++ b/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java @@ -5,6 +5,7 @@ import kinoko.world.quest.QuestManager; import kinoko.world.user.CharacterData; import kinoko.world.user.data.*; +import kinoko.world.user.stat.AdminLevel; import kinoko.world.user.stat.CharacterStat; import java.sql.*; @@ -57,7 +58,8 @@ public static CharacterData loadCharacterData(Connection conn, ResultSet rs) thr rs.getByte("portal"), rs.getLong("pet_1"), rs.getLong("pet_2"), - rs.getLong("pet_3") + rs.getLong("pet_3"), + AdminLevel.fromValue(rs.getShort("admin_level")) ); cd.setCharacterStat(cs); @@ -153,7 +155,13 @@ public static Optional getCharacterById(Connection conn, int char * @throws SQLException if a database access error occurs */ public static Optional getCharacterByName(Connection conn, String name) throws SQLException { - String sql = "SELECT * FROM player.characters WHERE name ILIKE ?"; + String sql = """ + SELECT c.*, s.*, m.guild_id, m.grade + FROM player.characters c + LEFT JOIN player.stats s ON c.id = s.character_id + LEFT JOIN guild.member m ON m.character_id = c.id + WHERE c.name ILIKE ? + """; try (PreparedStatement stmt = conn.prepareStatement(sql)) { stmt.setString(1, name.toLowerCase()); @@ -261,9 +269,9 @@ private static void saveCharacterStats(Connection conn, CharacterData characterD INSERT INTO player.stats ( character_id, gender, skin, face, hair, level, job, sub_job, base_str, base_dex, base_int, base_luk, hp, max_hp, mp, max_mp, - ap, exp, pop, pos_map, portal, pet_1, pet_2, pet_3 + ap, exp, pop, pos_map, portal, pet_1, pet_2, pet_3, admin_level ) VALUES ( - ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? + ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? ) ON CONFLICT (character_id) DO UPDATE SET gender = EXCLUDED.gender, @@ -289,6 +297,7 @@ ON CONFLICT (character_id) DO UPDATE SET pet_1 = EXCLUDED.pet_1, pet_2 = EXCLUDED.pet_2, pet_3 = EXCLUDED.pet_3 + admin_level = EXCLUDED.admin_level """; try (PreparedStatement stmt = conn.prepareStatement(sql)) { @@ -317,6 +326,7 @@ ON CONFLICT (character_id) DO UPDATE SET stmt.setLong(22, cs.getPetSn1()); stmt.setLong(23, cs.getPetSn2()); stmt.setLong(24, cs.getPetSn3()); + stmt.setShort(25, cs.getAdminLevel().getValue()); stmt.executeUpdate(); } diff --git a/src/main/java/kinoko/handler/stage/LoginHandler.java b/src/main/java/kinoko/handler/stage/LoginHandler.java index 327c4187..92453764 100644 --- a/src/main/java/kinoko/handler/stage/LoginHandler.java +++ b/src/main/java/kinoko/handler/stage/LoginHandler.java @@ -28,6 +28,7 @@ import kinoko.world.user.AvatarData; import kinoko.world.user.CharacterData; import kinoko.world.user.data.*; +import kinoko.world.user.stat.AdminLevel; import kinoko.world.user.stat.CharacterStat; import kinoko.world.user.stat.ExtendSp; import kinoko.world.user.stat.StatConstants; @@ -259,6 +260,10 @@ public static void handleCreateNewCharacter(Client c, InPacket inPacket) { cs.setPop((short) 0); cs.setPosMap(GameConstants.getStartingMap(job, selectedSubJob)); cs.setPortal((byte) 0); + if (ServerConfig.TESPIA) { + cs.setAdminLevel(AdminLevel.ADMIN); + } + characterData.setCharacterStat(cs); // Initialize inventory and add starting equips diff --git a/src/main/java/kinoko/handler/user/UserHandler.java b/src/main/java/kinoko/handler/user/UserHandler.java index 0416d4d0..bfa7419a 100644 --- a/src/main/java/kinoko/handler/user/UserHandler.java +++ b/src/main/java/kinoko/handler/user/UserHandler.java @@ -60,6 +60,7 @@ import kinoko.world.user.User; import kinoko.world.user.data.*; import kinoko.world.user.effect.Effect; +import kinoko.world.user.stat.AdminLevel; import kinoko.world.user.stat.CharacterStat; import kinoko.world.user.stat.Stat; import kinoko.world.user.stat.StatConstants; @@ -124,10 +125,28 @@ public static void handleUserChat(User user, InPacket inPacket) { inPacket.decodeInt(); // update_time final String text = inPacket.decodeString(); // sText final boolean onlyBalloon = inPacket.decodeBoolean(); // bOnlyBalloon - if (text.startsWith(ServerConfig.PLAYER_COMMAND_PREFIX) && text.length() > 1) { + + // if text starts with any valid command prefix for this user + boolean isCommand = false; + + if (text.length() > 1) { + // Player prefix is always allowed + if (text.startsWith(ServerConfig.PLAYER_COMMAND_PREFIX)) { + isCommand = true; + } + // Staff prefix is allowed if the user level >= TESTER + else if (user.getAdminLevel().isAtLeast(AdminLevel.TESTER) && + text.startsWith(ServerConfig.STAFF_COMMAND_PREFIX)) { + isCommand = true; + } + } + + if (isCommand) { CommandProcessor.tryProcessCommand(user, text); return; } + + user.getField().broadcastPacket(UserPacket.userChat(user, ChatType.NORMAL, text, onlyBalloon)); } @@ -1217,10 +1236,27 @@ public static void handleGroupMessage(User user, InPacket inPacket) { targetIds.add(inPacket.decodeInt()); } final String text = inPacket.decodeString(); // sText - if (text.startsWith(ServerConfig.PLAYER_COMMAND_PREFIX) && text.length() > 1) { + + // if the text starts with any valid command prefix for this user + boolean isCommand = false; + + if (text.length() > 1) { + // Player prefix is always allowed + if (text.startsWith(ServerConfig.PLAYER_COMMAND_PREFIX)) { + isCommand = true; + } + // Staff prefix is allowed if the user level >= TESTER + else if (user.getAdminLevel().isAtLeast(AdminLevel.TESTER) && + text.startsWith(ServerConfig.STAFF_COMMAND_PREFIX)) { + isCommand = true; + } + } + + if (isCommand) { CommandProcessor.tryProcessCommand(user, text); return; } + user.getConnectedServer().submitUserPacketBroadcast(targetIds, FieldPacket.groupMessage(groupType, user.getCharacterName(), text)); } diff --git a/src/main/java/kinoko/server/command/AdminCommands.java b/src/main/java/kinoko/server/command/AdminCommands.java deleted file mode 100644 index 2d11a793..00000000 --- a/src/main/java/kinoko/server/command/AdminCommands.java +++ /dev/null @@ -1,1018 +0,0 @@ -package kinoko.server.command; - -import kinoko.packet.user.DragonPacket; -import kinoko.packet.user.UserLocal; -import kinoko.packet.user.UserRemote; -import kinoko.packet.world.MessagePacket; -import kinoko.packet.world.WvsContext; -import kinoko.provider.*; -import kinoko.provider.item.ItemInfo; -import kinoko.provider.map.Foothold; -import kinoko.provider.map.MapInfo; -import kinoko.provider.map.PortalInfo; -import kinoko.provider.map.ReactorInfo; -import kinoko.provider.mob.MobSkillType; -import kinoko.provider.mob.MobTemplate; -import kinoko.provider.npc.NpcTemplate; -import kinoko.provider.quest.QuestInfo; -import kinoko.provider.reactor.ReactorTemplate; -import kinoko.provider.skill.SkillInfo; -import kinoko.provider.skill.SkillStat; -import kinoko.provider.skill.SkillStringInfo; -import kinoko.script.common.ScriptDispatcher; -import kinoko.server.ServerConfig; -import kinoko.server.cashshop.CashShop; -import kinoko.server.cashshop.Commodity; -import kinoko.util.BitFlag; -import kinoko.util.Rect; -import kinoko.util.Util; -import kinoko.world.GameConstants; -import kinoko.world.field.Field; -import kinoko.world.field.mob.Mob; -import kinoko.world.field.mob.MobLeaveType; -import kinoko.world.field.npc.Npc; -import kinoko.world.field.reactor.Reactor; -import kinoko.world.item.*; -import kinoko.world.job.Job; -import kinoko.world.job.JobConstants; -import kinoko.world.job.explorer.Beginner; -import kinoko.world.job.explorer.Pirate; -import kinoko.world.job.legend.Aran; -import kinoko.world.quest.QuestRecord; -import kinoko.world.quest.QuestState; -import kinoko.world.skill.SkillConstants; -import kinoko.world.skill.SkillManager; -import kinoko.world.skill.SkillRecord; -import kinoko.world.user.Dragon; -import kinoko.world.user.User; -import kinoko.world.user.effect.Effect; -import kinoko.world.user.stat.*; - -import java.lang.reflect.Method; -import java.util.*; - -public final class AdminCommands { - @Command("test") - public static void test(User user, String[] args) { - user.getConnectedServer().submitUserQueryRequestAll((queryResult) -> { - user.write(MessagePacket.system("Users in world: %d", queryResult.size())); - user.write(MessagePacket.system("Users in field : %d", user.getField().getUserPool().getCount())); - user.write(MessagePacket.system("Party ID : %d (%d)", user.getPartyId(), user.getCharacterData().getPartyId())); - user.setConsumeItemEffect(ItemProvider.getItemInfo(2022181).orElseThrow()); - user.dispose(); - }); - } - - @Command("dispose") - public static void dispose(User user, String[] args) { - user.closeDialog(); - user.dispose(); - user.write(MessagePacket.system("You have been disposed.")); - } - - @Command("info") - public static void info(User user, String[] args) { - // User stats - final Field field = user.getField(); - user.write(MessagePacket.system("HP : %d / %d, MP : %d / %d", user.getHp(), user.getMaxHp(), user.getMp(), user.getMaxMp())); - user.write(MessagePacket.system("STR : %d", user.getBasicStat().getStr())); - user.write(MessagePacket.system("DEX : %d", user.getBasicStat().getDex())); - user.write(MessagePacket.system("INT : %d", user.getBasicStat().getInt())); - user.write(MessagePacket.system("LUK : %d", user.getBasicStat().getLuk())); - user.write(MessagePacket.system("AP : %d", user.getCharacterStat().getAp())); - user.write(MessagePacket.system("SP : %s", user.getCharacterStat().getSp().getMap())); - user.write(MessagePacket.system("Damage : %d ~ %d", (int) CalcDamage.calcDamageMin(user), (int) CalcDamage.calcDamageMax(user))); - user.write(MessagePacket.system("Field ID : %d (%s)", field.getFieldId(), field.getFieldType())); - // Compute foothold below - final Optional footholdBelowResult = field.getFootholdBelow(user.getX(), user.getY()); - final String footholdBelow = footholdBelowResult.map(foothold -> String.valueOf(foothold.getSn())).orElse("unk"); - user.write(MessagePacket.system(" x : %d, y : %d, fh : %d (%s)", user.getX(), user.getY(), user.getFoothold(), footholdBelow)); - // Compute nearest portal - double nearestDistance = Double.MAX_VALUE; - PortalInfo nearestPortal = null; - for (PortalInfo pi : field.getMapInfo().getPortalInfos()) { - final double distance = Util.distance(user.getX(), user.getY(), pi.getX(), pi.getY()); - if (distance < nearestDistance) { - nearestDistance = distance; - nearestPortal = pi; - } - } - if (nearestPortal != null && nearestDistance < 200) { - user.write(MessagePacket.system("Portal name : %s (%d)", nearestPortal.getPortalName(), nearestPortal.getPortalId())); - user.write(MessagePacket.system(" x : %d, y : %d, script : %s", - nearestPortal.getX(), nearestPortal.getY(), nearestPortal.getScript())); - } - // Compute nearest mob - final Rect detectRect = Rect.of(-400, -400, 400, 400); - final Optional nearestMobResult = user.getNearestObject(field.getMobPool().getInsideRect(user.getRelativeRect(detectRect))); - if (nearestMobResult.isPresent()) { - final Mob mob = nearestMobResult.get(); - user.write(MessagePacket.system(mob.toString())); - user.write(MessagePacket.system(" Controller : %s", mob.getController().getCharacterName())); - } - // Compute nearest npc - final Optional nearestNpcResult = user.getNearestObject(field.getNpcPool().getInsideRect(user.getRelativeRect(detectRect))); - if (nearestNpcResult.isPresent()) { - final Npc npc = nearestNpcResult.get(); - user.write(MessagePacket.system(npc.toString())); - } - // Compute nearest reactor - final Optional nearestReactorResult = user.getNearestObject(field.getReactorPool().getInsideRect(user.getRelativeRect(detectRect))); - if (nearestReactorResult.isPresent()) { - final Reactor reactor = nearestReactorResult.get(); - user.write(MessagePacket.system(reactor.toString())); - } - } - - @Command({ "find", "lookup" }) - @Arguments({ "item/map/mob/npc/skill/quest/commodity", "id or query" }) - public static void find(User user, String[] args) { - final String type = args[1]; - final String query = String.join(" ", Arrays.copyOfRange(args, 2, args.length)); - final boolean isNumber = Util.isInteger(query); - if (type.equalsIgnoreCase("item")) { - int itemId = -1; - if (isNumber) { - itemId = Integer.parseInt(query); - } else { - final List> searchResult = StringProvider.getItemNames().entrySet().stream() - .filter((entry) -> entry.getValue().toLowerCase().contains(query.toLowerCase())) - .sorted(Comparator.comparingInt(Map.Entry::getKey)) - .toList(); - if (!searchResult.isEmpty()) { - if (searchResult.size() == 1) { - itemId = searchResult.get(0).getKey(); - } else { - user.write(MessagePacket.system("Results for item name : \"%s\"", query)); - for (var entry : searchResult) { - user.write(MessagePacket.system(" %d : %s", entry.getKey(), entry.getValue())); - } - return; - } - } - } - final Optional itemInfoResult = ItemProvider.getItemInfo(itemId); - if (itemInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not find item with %s : %s", isNumber ? "ID" : "name", query)); - return; - } - final ItemInfo ii = itemInfoResult.get(); - user.write(MessagePacket.system("Item : %s (%d)", StringProvider.getItemName(itemId), itemId)); - if (!ii.getItemInfos().isEmpty()) { - user.write(MessagePacket.system(" info")); - for (var entry : ii.getItemInfos().entrySet()) { - user.write(MessagePacket.system(" %s : %s", entry.getKey().name(), entry.getValue().toString())); - } - } - if (!ii.getItemSpecs().isEmpty()) { - user.write(MessagePacket.system(" spec")); - for (var entry : ii.getItemSpecs().entrySet()) { - user.write(MessagePacket.system(" %s : %s", entry.getKey().name(), entry.getValue().toString())); - } - } - } else if (type.equalsIgnoreCase("map")) { - final int mapId; - if (isNumber) { - mapId = Integer.parseInt(query); - } else { - final List> searchResult = StringProvider.getMapNames().entrySet().stream() - .filter((entry) -> entry.getValue().toLowerCase().contains(query.toLowerCase())) - .sorted(Comparator.comparingInt(Map.Entry::getKey)) - .toList(); - if (!searchResult.isEmpty()) { - if (searchResult.size() == 1) { - mapId = searchResult.get(0).getKey(); - } else { - mapId = -1; - user.write(MessagePacket.system("Results for map name : \"%s\"", query)); - for (var entry : searchResult) { - user.write(MessagePacket.system(" %d : %s", entry.getKey(), entry.getValue())); - } - return; - } - } else { - mapId = -1; - } - } - final Optional mapInfoResult = MapProvider.getMapInfo(mapId); - if (mapInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not find map with %s : %s", isNumber ? "ID" : "name", query)); - return; - } - final List connectedMaps = MapProvider.getMapInfos().stream() - .filter((mapInfo) -> mapInfo.getPortalInfos().stream().anyMatch((portalInfo) -> portalInfo.getDestinationFieldId() == mapId)) - .sorted(Comparator.comparingInt(MapInfo::getMapId)) - .toList(); - final MapInfo mapInfo = mapInfoResult.get(); - user.write(MessagePacket.system("Map : %s (%d)", StringProvider.getMapName(mapId), mapId)); - user.write(MessagePacket.system(" type : %s", mapInfo.getFieldType().name())); - user.write(MessagePacket.system(" returnMap : %d", mapInfo.getReturnMap())); - user.write(MessagePacket.system(" forcedReturn : %d", mapInfo.getForcedReturn())); - user.write(MessagePacket.system(" onFirstUserEnter : %s", mapInfo.getOnFirstUserEnter())); - user.write(MessagePacket.system(" onUserEnter : %s", mapInfo.getOnUserEnter())); - if (!mapInfo.getPortalInfos().isEmpty()) { - user.write(MessagePacket.system(" portals :")); - for (PortalInfo portalInfo : mapInfo.getPortalInfos().stream().sorted(Comparator.comparingInt(PortalInfo::getPortalId)).toList()) { - user.write(MessagePacket.system(" %s (%d, %d)", portalInfo.getPortalName(), portalInfo.getX(), portalInfo.getY())); - } - } - if (!connectedMaps.isEmpty()) { - user.write(MessagePacket.system(" connectedMap :")); - for (MapInfo connectedMapInfo : connectedMaps) { - user.write(MessagePacket.system(" %s (%d)", StringProvider.getMapName(connectedMapInfo.getMapId()), connectedMapInfo.getMapId())); - } - } - } else if (type.equalsIgnoreCase("mob")) { - int mobId = -1; - if (isNumber) { - mobId = Integer.parseInt(query); - } else { - final List> searchResult = StringProvider.getMobNames().entrySet().stream() - .filter((entry) -> entry.getValue().toLowerCase().contains(query.toLowerCase())) - .sorted(Comparator.comparingInt(Map.Entry::getKey)) - .toList(); - if (!searchResult.isEmpty()) { - if (searchResult.size() == 1) { - mobId = searchResult.get(0).getKey(); - } else { - user.write(MessagePacket.system("Results for mob name : \"%s\"", query)); - for (var entry : searchResult) { - user.write(MessagePacket.system(" %d : %s", entry.getKey(), entry.getValue())); - } - return; - } - } - } - final Optional mobTemplateResult = MobProvider.getMobTemplate(mobId); - if (mobTemplateResult.isEmpty()) { - user.write(MessagePacket.system("Could not find mob with %s : %s", isNumber ? "ID" : "name", query)); - return; - } - final MobTemplate mobTemplate = mobTemplateResult.get(); - user.write(MessagePacket.system("Mob : %s (%d)", StringProvider.getMobName(mobId), mobId)); - user.write(MessagePacket.system(" level : %d", mobTemplate.getLevel())); - } else if (type.equalsIgnoreCase("npc")) { - int npcId = -1; - if (isNumber) { - npcId = Integer.parseInt(query); - } else { - final List> searchResult = StringProvider.getNpcNames().entrySet().stream() - .filter((entry) -> entry.getValue().toLowerCase().contains(query.toLowerCase())) - .sorted(Comparator.comparingInt(Map.Entry::getKey)) - .toList(); - if (!searchResult.isEmpty()) { - if (searchResult.size() == 1) { - npcId = searchResult.get(0).getKey(); - } else { - user.write(MessagePacket.system("Results for npc name : \"%s\"", query)); - for (var entry : searchResult) { - user.write(MessagePacket.system(" %d : %s", entry.getKey(), entry.getValue())); - } - return; - } - } - } - final Optional npcTemplateResult = NpcProvider.getNpcTemplate(npcId); - if (npcTemplateResult.isEmpty()) { - user.write(MessagePacket.system("Could not find npc with %s : %s", isNumber ? "ID" : "name", query)); - return; - } - final NpcTemplate npcTemplate = npcTemplateResult.get(); - final List npcFields = MapProvider.getMapInfos().stream() - .filter((mapInfo) -> mapInfo.getLifeInfos().stream().anyMatch((lifeInfo) -> lifeInfo.getTemplateId() == npcTemplate.getId())) - .sorted(Comparator.comparingInt(MapInfo::getMapId)) - .toList(); - user.write(MessagePacket.system("Npc : %s (%d)", StringProvider.getNpcName(npcId), npcId)); - user.write(MessagePacket.system(" script : %s", npcTemplate.getScript())); - for (MapInfo mapInfo : npcFields) { - user.write(MessagePacket.system(" field : %s (%d)", StringProvider.getMapName(mapInfo.getMapId()), mapInfo.getMapId())); - } - } else if (type.equalsIgnoreCase("skill")) { - int skillId = -1; - if (isNumber) { - skillId = Integer.parseInt(query); - } else { - final List> searchResult = StringProvider.getSkillStrings().entrySet().stream() - .filter((entry) -> entry.getValue().getName().toLowerCase().contains(query.toLowerCase())) - .sorted(Comparator.comparingInt(Map.Entry::getKey)) - .toList(); - if (!searchResult.isEmpty()) { - if (searchResult.size() == 1) { - skillId = searchResult.getFirst().getKey(); - } else { - user.write(MessagePacket.system("Results for skill name : \"%s\"", query)); - for (var entry : searchResult) { - user.write(MessagePacket.system(" %d : %s", entry.getKey(), entry.getValue().getName())); - } - return; - } - } - } - final Optional skillInfoResult = SkillProvider.getSkillInfoById(skillId); - if (skillInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not find skill with %s : %s", isNumber ? "ID" : "name", query)); - return; - } - final SkillInfo si = skillInfoResult.get(); - user.write(MessagePacket.system("Skill : %s (%d)", StringProvider.getSkillName(skillId), skillId)); - } else if (type.equalsIgnoreCase("quest")) { - int questId = -1; - if (isNumber) { - questId = Integer.parseInt(query); - } else { - final List searchResult = QuestProvider.getQuestInfos().stream() - .filter((questInfo) -> questInfo.getQuestName().toLowerCase().contains(query.toLowerCase()) || - questInfo.getQuestParent().toLowerCase().contains(query.toLowerCase())) - .sorted(Comparator.comparingInt(QuestInfo::getQuestId)) - .toList(); - if (!searchResult.isEmpty()) { - if (searchResult.size() == 1) { - questId = searchResult.getFirst().getQuestId(); - } else { - user.write(MessagePacket.system("Results for quest name : \"%s\"", query)); - for (QuestInfo questInfo : searchResult) { - user.write(MessagePacket.system(" %d : %s%s", questInfo.getQuestId(), - questInfo.getQuestParent().isEmpty() ? "" : String.format("%s : ", questInfo.getQuestParent()), - questInfo.getQuestName() - )); - } - return; - } - } - } - final Optional questInfoResult = QuestProvider.getQuestInfo(questId); - if (questInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not find quest with %s : %s", isNumber ? "ID" : "name", query)); - return; - } - final QuestInfo questInfo = questInfoResult.get(); - user.write(MessagePacket.system("Quest %d : %s%s", questInfo.getQuestId(), - questInfo.getQuestParent().isEmpty() ? "" : String.format("%s : ", questInfo.getQuestParent()), - questInfo.getQuestName() - )); - } else if (type.equalsIgnoreCase("commodity")) { - if (!isNumber) { - user.write(MessagePacket.system("Can only lookup commodity by ID")); - return; - } - final int commodityId = Integer.parseInt(query); - final Optional commodityResult = CashShop.getCommodity(commodityId); - if (commodityResult.isEmpty()) { - user.write(MessagePacket.system("Could not find commodity with ID : %d", commodityId)); - return; - } - final Commodity commodity = commodityResult.get(); - user.write(MessagePacket.system("Commodity : %d", commodityId)); - user.write(MessagePacket.system(" itemId : %d (%s)", commodity.getItemId(), StringProvider.getItemName(commodity.getItemId()))); - user.write(MessagePacket.system(" count : %d", commodity.getCount())); - user.write(MessagePacket.system(" price : %d", commodity.getPrice())); - user.write(MessagePacket.system(" period : %d", commodity.getPeriod())); - user.write(MessagePacket.system(" gender : %d", commodity.getGender())); - } else { - user.write(MessagePacket.system("Unknown type : %s", type)); - } - } - - @Command("npc") - @Arguments("npc template ID") - public static void npc(User user, String[] args) { - final int templateId = Integer.parseInt(args[1]); - final Optional npcTemplateResult = NpcProvider.getNpcTemplate(templateId); - if (npcTemplateResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve npc ID : %d", templateId)); - return; - } - final String scriptName = npcTemplateResult.get().getScript(); - if (scriptName == null || scriptName.isEmpty()) { - user.write(MessagePacket.system("Could not find script for npc ID : %d", templateId)); - return; - } - user.write(MessagePacket.system("Starting script for npc ID : %d, script : %s", templateId, scriptName)); - ScriptDispatcher.startNpcScript(user, user, scriptName, templateId); - } - - @Command({ "map", "warp" }) - @Arguments("field ID to warp to") - public static void map(User user, String[] args) { - final int fieldId = Integer.parseInt(args[1]); - final String portalName; - if (args.length > 2) { - portalName = args[2]; - } else { - portalName = GameConstants.DEFAULT_PORTAL_NAME; - } - final Optional fieldResult = user.getConnectedServer().getFieldById(fieldId); - if (fieldResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve field ID : %d", fieldId)); - return; - } - final Field targetField = fieldResult.get(); - final Optional portalResult = targetField.getPortalByName(portalName); - if (portalResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve portal %s for field ID : %d", portalName, fieldId)); - return; - } - user.warp(targetField, portalResult.get(), false, false); - } - - @Command("whereami") - public static void whereAmI(User user, String[] args) { - user.write(MessagePacket.system("You are in map: %d", user.getField().getFieldId())); - } - - @Command("reactor") - @Arguments("reactor template ID") - public static void reactor(User user, String[] args) { - final int templateId = Integer.parseInt(args[1]); - final Optional reactorTemplateResult = ReactorProvider.getReactorTemplate(templateId); - if (reactorTemplateResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve reactor template ID : %d", templateId)); - return; - } - final Field field = user.getField(); - final ReactorInfo reactorInfo = new ReactorInfo(templateId, "", user.getX(), user.getY(), false, -1); - field.getReactorPool().addReactor(Reactor.from(reactorTemplateResult.get(), reactorInfo)); - } - - @Command("hitreactor") - @Arguments("reactor template ID") - public static void hitReactor(User user, String[] args) { - final int templateId = Integer.parseInt(args[1]); - final Field field = user.getField(); - final Optional reactorResult = field.getReactorPool().getByTemplateId(templateId); - if (reactorResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve reactor with template ID : %d", templateId)); - return; - } - final Reactor reactor = reactorResult.get(); - reactor.setState(reactor.getState() + 1); - field.getReactorPool().hitReactor(user, reactor, 0); - } - - @Command({ "mob", "spawn" }) - @Arguments("mob template ID") - public static void mob(User user, String[] args) { - final int templateId = Integer.parseInt(args[1]); - final Optional mobTemplateResult = MobProvider.getMobTemplate(templateId); - if (mobTemplateResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve mob template ID : %d", templateId)); - return; - } - final int count; - if (args.length > 2) { - count = Integer.parseInt(args[2]); - } else { - count = 1; - } - final Field field = user.getField(); - final Optional footholdResult = field.getFootholdBelow(user.getX(), user.getY()); - for (int i = 0; i < count; i++) { - final Mob mob = new Mob( - mobTemplateResult.get(), - null, - user.getX(), - user.getY(), - footholdResult.map(Foothold::getSn).orElse(user.getFoothold()) - ); - field.getMobPool().addMob(mob); - } - } - - @Command("togglemob") - @Arguments("true/false") - public static void disableMob(User user, String[] args) { - if (args[1].equalsIgnoreCase("true")) { - user.getField().setMobSpawn(true); - user.write(MessagePacket.system("Enabled mob spawns")); - } else if (args[1].equalsIgnoreCase("false")) { - user.getField().setMobSpawn(false); - user.write(MessagePacket.system("Disabled mob spawns")); - } - } - - @Command("item") - @Arguments("item ID") - public static void item(User user, String[] args) { - final int itemId = Integer.parseInt(args[1]); - final int quantity; - if (args.length > 2) { - quantity = Integer.parseInt(args[2]); - } else { - quantity = 1; - } - final Optional itemInfoResult = ItemProvider.getItemInfo(itemId); - if (itemInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve item ID : %d", itemId)); - return; - } - final ItemInfo ii = itemInfoResult.get(); - final Item item = ii.createItem(user.getNextItemSn(), Math.min(quantity, ii.getSlotMax()), ItemVariationOption.NORMAL); - - // Add item - final InventoryManager im = user.getInventoryManager(); - final Optional> addItemResult = im.addItem(item); - if (addItemResult.isPresent()) { - user.write(WvsContext.inventoryOperation(addItemResult.get(), true)); - user.write(UserLocal.effect(Effect.gainItem(item))); - } else { - user.write(MessagePacket.system("Failed to add item ID %d (%d) to inventory", itemId, quantity)); - } - } - - @Command("clearinventory") - @Arguments("inventory type") - public static void clearInventory(User user, String[] args) { - final Optional inventoryTypeResult = Arrays.stream(InventoryType.values()) - .filter((type) -> type.name().equalsIgnoreCase(args[1])) - .findFirst(); - if (inventoryTypeResult.isEmpty()) { - user.write(MessagePacket.system("Please specify a valid inventory type : EQUIP | CONSUME | INSTALL | ETC | CASH")); - return; - } - final InventoryType inventoryType = inventoryTypeResult.get(); - final List removeOperations = new ArrayList<>(); - final var iter = user.getInventoryManager().getInventoryByType(inventoryType).getItems().entrySet().iterator(); - while (iter.hasNext()) { - final var tuple = iter.next(); - final int position = tuple.getKey(); - removeOperations.add(InventoryOperation.delItem(inventoryType, position)); - iter.remove(); - } - user.write(WvsContext.inventoryOperation(removeOperations, true)); - user.write(MessagePacket.system("%s inventory cleared!", inventoryType)); - } - - @Command("clearlocker") - public static void clearLocker(User user, String[] args) { - user.getAccount().getLocker().getCashItems().clear(); - user.write(MessagePacket.system("Locker inventory cleared!")); - } - - @Command({ "meso", "money" }) - @Arguments("amount") - public static void meso(User user, String[] args) { - final int money = Integer.parseInt(args[1]); - final InventoryManager im = user.getInventoryManager(); - im.setMoney(money); - user.write(WvsContext.statChanged(Stat.MONEY, im.getMoney(), true)); - } - - @Command("nx") - @Arguments("amount") - public static void nx(User user, String[] args) { - final int nx = Integer.parseInt(args[1]); - user.getAccount().setNxPrepaid(nx); - user.write(MessagePacket.system("Set NX prepaid to %d", nx)); - } - - @Command("hp") - @Arguments("new hp") - public static void hp(User user, String[] args) { - final int newHp = Integer.parseInt(args[1]); - user.setHp(newHp); - } - - @Command("mp") - @Arguments("new mp") - public static void mp(User user, String[] args) { - final int newMp = Integer.parseInt(args[1]); - user.setMp(newMp); - } - - @Command("stat") - @Arguments({ "hp/mp/str/dex/int/luk/ap/sp", "new value" }) - public static void stat(User user, String[] args) { - final String stat = args[1].toLowerCase(); - final int value = Integer.parseInt(args[2]); - final CharacterStat cs = user.getCharacterStat(); - final Map statMap = new EnumMap<>(Stat.class); - switch (stat) { - case "hp" -> { - cs.setMaxHp(value); - statMap.put(Stat.HP, cs.getMaxHp()); - } - case "mp" -> { - cs.setMaxMp(value); - statMap.put(Stat.MP, cs.getMaxMp()); - } - case "str" -> { - cs.setBaseStr((short) value); - statMap.put(Stat.STR, cs.getBaseStr()); - } - case "dex" -> { - cs.setBaseDex((short) value); - statMap.put(Stat.DEX, cs.getBaseDex()); - } - case "int" -> { - cs.setBaseInt((short) value); - statMap.put(Stat.INT, cs.getBaseInt()); - } - case "luk" -> { - cs.setBaseLuk((short) value); - statMap.put(Stat.LUK, cs.getBaseLuk()); - } - case "ap" -> { - cs.setAp((short) value); - statMap.put(Stat.AP, cs.getAp()); - } - case "sp" -> { - if (JobConstants.isExtendSpJob(cs.getJob())) { - cs.getSp().setSp(JobConstants.getJobLevel(cs.getJob()), value); - statMap.put(Stat.SP, cs.getSp()); - } else { - cs.getSp().setNonExtendSp(value); - statMap.put(Stat.SP, (short) cs.getSp().getNonExtendSp()); - } - } - default -> { - user.write(MessagePacket.system("Syntax : %sstat hp/mp/str/dex/int/luk/ap/sp ", ServerConfig.PLAYER_COMMAND_PREFIX)); - return; - } - } - user.validateStat(); - user.write(WvsContext.statChanged(statMap, true)); - user.write(MessagePacket.system("Set %s to %d", stat, value)); - } - - @Command("avatar") - @Arguments("new look") - public static void avatar(User user, String[] args) { - final int look = Integer.parseInt(args[1]); - if (look >= 0 && look <= GameConstants.SKIN_MAX) { - user.getCharacterStat().setSkin((byte) look); - user.write(WvsContext.statChanged(Stat.SKIN, user.getCharacterStat().getSkin(), false)); - user.getField().broadcastPacket(UserRemote.avatarModified(user), user); - } else if (look >= GameConstants.FACE_MIN && look <= GameConstants.FACE_MAX) { - if (StringProvider.getItemName(look) == null) { - user.write(MessagePacket.system("Tried to change face with invalid ID : %d", look)); - return; - } - user.getCharacterStat().setFace(look); - user.write(WvsContext.statChanged(Stat.FACE, user.getCharacterStat().getFace(), false)); - user.getField().broadcastPacket(UserRemote.avatarModified(user), user); - } else if (look >= GameConstants.HAIR_MIN && look <= GameConstants.HAIR_MAX) { - if (StringProvider.getItemName(look) == null) { - user.write(MessagePacket.system("Tried to change hair with invalid ID : %d", look)); - return; - } - user.getCharacterStat().setHair(look); - user.write(WvsContext.statChanged(Stat.HAIR, user.getCharacterStat().getHair(), false)); - user.getField().broadcastPacket(UserRemote.avatarModified(user), user); - } else { - user.write(MessagePacket.system("Tried to change avatar with invalid ID : %d", look)); - } - } - - @Command("level") - @Arguments("new level") - public static void level(User user, String[] args) { - final int level = Integer.parseInt(args[1]); - if (level < 1 || level > GameConstants.LEVEL_MAX) { - user.write(MessagePacket.system("Could not change level to : %d", level)); - return; - } - final CharacterStat cs = user.getCharacterStat(); - cs.setLevel((short) level); - user.validateStat(); - user.write(WvsContext.statChanged(Stat.LEVEL, (byte) cs.getLevel(), true)); - user.getConnectedServer().notifyUserUpdate(user); - } - - @Command("levelup") - @Arguments("new level") - public static void levelUp(User user, String[] args) { - final int level = Integer.parseInt(args[1]); - if (level <= user.getLevel() || level > GameConstants.LEVEL_MAX) { - user.write(MessagePacket.system("Could not level up to : %d", level)); - return; - } - while (user.getLevel() < level) { - user.addExp(GameConstants.getNextLevelExp(user.getLevel()) - user.getCharacterStat().getExp()); - } - } - - @Command("job") - @Arguments("job ID") - public static void job(User user, String[] args) { - final int jobId = Integer.parseInt(args[1]); - final Job job = Job.getById(jobId); - if (job == null) { - user.write(MessagePacket.system("Could not change to unknown job : %d", jobId)); - return; - } - // Set job - user.getCharacterStat().setJob(job.getJobId()); - user.write(WvsContext.statChanged(Stat.JOB, job.getJobId(), false)); - user.getField().broadcastPacket(UserRemote.effect(user, Effect.jobChanged()), user); - // Update skills - final SkillManager sm = user.getSkillManager(); - final List skillRecords = new ArrayList<>(); - for (int skillRoot : JobConstants.getSkillRootFromJob(jobId)) { - for (SkillInfo si : SkillProvider.getSkillsForJob(Job.getById(skillRoot))) { - if (sm.getSkill(si.getSkillId()).isPresent()) { - continue; - } - if (si.isInvisible()) { - continue; - } - final SkillRecord sr = new SkillRecord(si.getSkillId()); - sr.setSkillLevel(0); - sr.setMasterLevel(si.getMasterLevel()); - sm.addSkill(sr); - skillRecords.add(sr); - } - } - user.updatePassiveSkillData(); - user.validateStat(); - user.write(WvsContext.changeSkillRecordResult(skillRecords, true)); - // Additional handling - if (JobConstants.isDragonJob(jobId)) { - final Dragon dragon = new Dragon(user.getJob()); - user.setDragon(dragon); - user.getField().broadcastPacket(DragonPacket.dragonEnterField(user, dragon)); - } else { - user.setDragon(null); - } - if (JobConstants.isWildHunterJob(jobId)) { - user.write(WvsContext.wildHunterInfo(user.getWildHunterInfo())); - } - user.getConnectedServer().notifyUserUpdate(user); - } - - @Command("skill") - @Arguments({ "skill ID", "skill level" }) - public static void skill(User user, String[] args) { - final int skillId = Integer.parseInt(args[1]); - final int slv = Integer.parseInt(args[2]); - final Optional skillInfoResult = SkillProvider.getSkillInfoById(skillId); - if (skillInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not find skill : %d", skillId)); - return; - } - final SkillInfo si = skillInfoResult.get(); - final SkillRecord skillRecord = new SkillRecord(si.getSkillId()); - skillRecord.setSkillLevel(Math.min(slv, si.getMaxLevel())); - skillRecord.setMasterLevel(si.getMaxLevel()); - final SkillManager sm = user.getSkillManager(); - sm.addSkill(skillRecord); - user.updatePassiveSkillData(); - user.validateStat(); - user.write(WvsContext.changeSkillRecordResult(skillRecord, true)); - } - - @Command("morph") - @Arguments("morph ID") - public static void morph(User user, String[] args) { - final int morphId = Integer.parseInt(args[1]); - if (SkillProvider.getMorphInfoById(morphId).isEmpty()) { - user.write(MessagePacket.system("Could not resolve morph info for morph ID : %d", morphId)); - return; - } - final SecondaryStat ss = user.getSecondaryStat(); - final BitFlag flag = BitFlag.from(Set.of(CharacterTemporaryStat.Morph), CharacterTemporaryStat.FLAG_SIZE); - ss.getTemporaryStats().put(CharacterTemporaryStat.Morph, TemporaryStatOption.of(morphId, -5300000, 0)); - user.write(WvsContext.temporaryStatSet(ss, flag)); - user.getField().broadcastPacket(UserRemote.temporaryStatSet(user, ss, flag)); - } - - @Command("ride") - @Arguments("vehicle ID") - public static void ride(User user, String[] args) { - final int vehicleId = Integer.parseInt(args[1]); - if (ItemProvider.getItemInfo(vehicleId).isEmpty()) { - user.write(MessagePacket.system("Could not resolve item info for vehicle ID : %d", vehicleId)); - return; - } - final SecondaryStat ss = user.getSecondaryStat(); - final BitFlag flag = BitFlag.from(Set.of(CharacterTemporaryStat.RideVehicle), CharacterTemporaryStat.FLAG_SIZE); - ss.getTemporaryStats().put(CharacterTemporaryStat.RideVehicle, TwoStateTemporaryStat.ofTwoState(CharacterTemporaryStat.RideVehicle, vehicleId, Beginner.MONSTER_RIDER, 0)); - user.write(WvsContext.temporaryStatSet(ss, flag)); - user.getField().broadcastPacket(UserRemote.temporaryStatSet(user, ss, flag)); - } - - @Command("clearquest") - @Arguments("quest ID") - public static void clearQuest(User user, String[] args) { - final int questId = Integer.parseInt(args[1]); - final Optional questRecordResult = user.getQuestManager().getQuestRecord(questId); - if (questRecordResult.isEmpty()) { - user.write(MessagePacket.system("Could not find quest record : %d", questId)); - return; - } - final QuestRecord qr = questRecordResult.get(); - qr.setState(QuestState.NONE); - user.write(MessagePacket.questRecord(qr)); - user.validateStat(); - } - - @Command("startquest") - @Arguments("quest ID") - public static void startQuest(User user, String[] args) { - final int questId = Integer.parseInt(args[1]); - final Optional questInfoResult = QuestProvider.getQuestInfo(questId); - if (questInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not find quest : %d", questId)); - return; - } - final QuestRecord qr = user.getQuestManager().forceStartQuest(questId); - user.write(MessagePacket.questRecord(qr)); - user.validateStat(); - } - - @Command("completequest") - @Arguments("quest ID") - public static void completeQuest(User user, String[] args) { - final int questId = Integer.parseInt(args[1]); - final QuestRecord qr = user.getQuestManager().forceCompleteQuest(questId); - user.write(MessagePacket.questRecord(qr)); - user.validateStat(); - } - - @Command({ "questex", "qr" }) - @Arguments("quest ID") - public static void questex(User user, String[] args) { - final int questId = Integer.parseInt(args[1]); - final String newValue; - if (args.length > 2) { - newValue = args[2]; - } else { - newValue = null; - } - if (newValue == null) { - final Optional questRecordResult = user.getQuestManager().getQuestRecord(questId); - final String value = questRecordResult.map(QuestRecord::getValue).orElse(""); - user.write(MessagePacket.system("Get QR value for quest ID %d : %s", questId, value)); - } else { - final QuestRecord qr = user.getQuestManager().setQuestInfoEx(questId, newValue); - user.write(MessagePacket.questRecord(qr)); - user.validateStat(); - user.write(MessagePacket.system("Set QR value for quest ID %d : %s", questId, newValue)); - } - } - - @Command("killmobs") - public static void killMobs(User user, String[] args) { - user.getField().getMobPool().forEach((mob) -> { - if (mob.getHp() > 0) { - mob.damage(user, mob.getMaxHp(), 0, MobLeaveType.ETC); - } - }); - } - - @Command("mobskill") - @Arguments({ "skill ID", "skill level" }) - public static void mobskill(User user, String[] args) { - final int skillId = Integer.parseInt(args[1]); - final int slv = Integer.parseInt(args[2]); - final MobSkillType skillType = MobSkillType.getByValue(skillId); - if (skillType == null) { - user.write(MessagePacket.system("Could not resolve mob skill %d", skillId)); - return; - } - final CharacterTemporaryStat cts = skillType.getCharacterTemporaryStat(); - if (cts == null) { - user.write(MessagePacket.system("Could not resolve mob skill %s does not apply a CTS", skillType)); - return; - } - // Apply mob skill - final Optional skillInfoResult = SkillProvider.getMobSkillInfoById(skillId); - if (skillInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve mob skill info %d", skillId)); - return; - } - final SkillInfo si = skillInfoResult.get(); - user.setTemporaryStat(cts, TemporaryStatOption.ofMobSkill(Math.max(si.getValue(SkillStat.x, slv), 1), skillId, slv, si.getDuration(slv))); - } - - @Command("combo") - @Arguments("value") - public static void combo(User user, String[] args) { - final int combo = Integer.parseInt(args[1]); - user.setTemporaryStat(CharacterTemporaryStat.ComboAbilityBuff, TemporaryStatOption.of(combo, Aran.COMBO_ABILITY, 0)); - user.write(UserLocal.incCombo(combo)); - } - - @Command({ "battleship", "bship" }) - public static void battleship(User user, String[] args) { - user.write(MessagePacket.system("Battleship HP : %d", Pirate.getBattleshipDurability(user))); - } - - @Command("jaguar") - @Arguments("index") - public static void jaguar(User user, String[] args) { - final int index = Integer.parseInt(args[1]); - user.getWildHunterInfo().setRidingType(index); - user.write(WvsContext.wildHunterInfo(user.getWildHunterInfo())); - } - - @Command("cd") - public static void cd(User user, String[] args) { - final var iter = user.getSkillManager().getSkillCooltimes().keySet().iterator(); - while (iter.hasNext()) { - final int skillId = iter.next(); - user.write(UserLocal.skillCooltimeSet(skillId, 0)); - iter.remove(); - } - } - - @Command("max") - public static void max(User user, String[] args) { - // Set stats - final CharacterStat cs = user.getCharacterStat(); - cs.setLevel((short) 200); -// cs.setBaseStr((short) 10000); -// cs.setBaseDex((short) 10000); -// cs.setBaseInt((short) 10000); -// cs.setBaseLuk((short) 10000); - cs.setMaxHp(50000); - cs.setMaxMp(50000); - cs.setExp(0); - user.validateStat(); - user.write(WvsContext.statChanged(Map.of( - Stat.LEVEL, (byte) cs.getLevel(), - Stat.STR, cs.getBaseStr(), - Stat.DEX, cs.getBaseDex(), - Stat.INT, cs.getBaseInt(), - Stat.LUK, cs.getBaseLuk(), - Stat.MHP, cs.getMaxHp(), - Stat.MMP, cs.getMaxMp(), - Stat.EXP, cs.getExp() - ), true)); - - // Reset skills - final SkillManager sm = user.getSkillManager(); - final List removedRecords = new ArrayList<>(); - for (SkillRecord skillRecord : sm.getSkillRecords()) { - if (JobConstants.isBeginnerJob(SkillConstants.getSkillRoot(skillRecord.getSkillId()))) { - continue; - } - skillRecord.setSkillLevel(0); - skillRecord.setMasterLevel(0); - removedRecords.add(skillRecord); - sm.removeSkill(skillRecord.getSkillId()); - } - user.write(WvsContext.changeSkillRecordResult(removedRecords, true)); - - // Add skills - final List skillRecords = new ArrayList<>(); - for (int skillRoot : JobConstants.getSkillRootFromJob(user.getJob())) { - if (JobConstants.isBeginnerJob(skillRoot)) { - continue; - } - final Job job = Job.getById(skillRoot); - for (SkillInfo si : SkillProvider.getSkillsForJob(job)) { - final SkillRecord skillRecord = new SkillRecord(si.getSkillId()); - skillRecord.setSkillLevel(si.getMaxLevel()); - skillRecord.setMasterLevel(si.getMaxLevel()); - sm.addSkill(skillRecord); - skillRecords.add(skillRecord); - } - } - user.updatePassiveSkillData(); - user.validateStat(); - user.write(WvsContext.changeSkillRecordResult(skillRecords, true)); - - // Heal - user.setHp(user.getMaxHp()); - user.setMp(user.getMaxMp()); - } - - @Command("help") - public static void help(User user, String[] args) { - if (args.length == 1) { - for (Class clazz : new Class[]{ AdminCommands.class }) { - user.write(MessagePacket.system("Admin Commands :")); - for (Method method : clazz.getDeclaredMethods()) { - if (!method.isAnnotationPresent(Command.class)) { - continue; - } - user.write(MessagePacket.system("%s", CommandProcessor.getHelpString(method))); - } - } - } else { - final String commandName = args[1].toLowerCase(); - final Optional commandResult = CommandProcessor.getCommand(commandName); - if (commandResult.isEmpty()) { - user.write(MessagePacket.system("Unknown command : %s", commandName)); - return; - } - final Method method = commandResult.get(); - user.write(MessagePacket.system("Syntax : %s", CommandProcessor.getHelpString(method))); - } - } - - @Command("reloaddrops") - public static void reloadDrops(User user, String[] args) { - RewardProvider.initialize(); - } - - @Command("reloadshops") - public static void reloadShops(User user, String[] args) { - ShopProvider.initialize(); - } - - @Command({ "reloadcashshop", "reloadcs" }) - public static void reloadCashShop(User user, String[] args) { - CashShop.initialize(); - } -} diff --git a/src/main/java/kinoko/server/command/Command.java b/src/main/java/kinoko/server/command/Command.java index 0c805453..4f50c02c 100644 --- a/src/main/java/kinoko/server/command/Command.java +++ b/src/main/java/kinoko/server/command/Command.java @@ -1,5 +1,6 @@ package kinoko.server.command; + import java.lang.annotation.*; /** diff --git a/src/main/java/kinoko/server/command/CommandProcessor.java b/src/main/java/kinoko/server/command/CommandProcessor.java index 95c8d19b..16dc392d 100644 --- a/src/main/java/kinoko/server/command/CommandProcessor.java +++ b/src/main/java/kinoko/server/command/CommandProcessor.java @@ -2,7 +2,9 @@ import kinoko.packet.world.MessagePacket; import kinoko.server.ServerConfig; +import kinoko.util.ClassScanner; import kinoko.world.user.User; +import kinoko.world.user.stat.AdminLevel; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -13,28 +15,99 @@ public final class CommandProcessor { private static final Logger log = LogManager.getLogger(CommandProcessor.class); private static final Map commandMap = new HashMap<>(); + private static final Map methodLevelMap = new HashMap<>(); + + /** + * Returns an unmodifiable map of all registered command aliases to their corresponding methods. + * This allows iterating over all commands without modifying the internal command registry. + * + * @return an unmodifiable view of the command map + */ + public static Map getCommandMap() { + return Collections.unmodifiableMap(commandMap); + } + + /** + * Returns the required admin level for the given command method. + * If the method is not registered or has no level explicitly set, it defaults to PLAYER. + * + * @param method the command method to query + * @return the admin level required to execute the command + */ + public static AdminLevel getRequiredLevel(Method method) { + return methodLevelMap.getOrDefault(method, AdminLevel.PLAYER); + } + public static void initialize() { - for (Class clazz : new Class[]{ AdminCommands.class }) { - for (Method method : clazz.getDeclaredMethods()) { - if (!method.isAnnotationPresent(Command.class)) { - continue; - } - if (method.getParameterCount() != 2 || method.getParameterTypes()[0] != User.class || method.getParameterTypes()[1] != String[].class) { - throw new RuntimeException(String.format("Incorrect parameters for command method \"%s\"", method.getName())); - } - final Command annotation = method.getAnnotation(Command.class); - for (String value : annotation.value()) { - final String alias = value.toLowerCase(); - if (commandMap.containsKey(alias)) { - throw new RuntimeException(String.format("Multiple methods found for Command alias \"%s\"", alias)); + // List of packages to scan for command classes + String[] commandPackages = new String[]{ + "kinoko.server.command.admin", + "kinoko.server.command.manager", + "kinoko.server.command.supergm", + "kinoko.server.command.gm", + "kinoko.server.command.jrgm", + "kinoko.server.command.tester", + "kinoko.server.command.player" + }; + + Map packageLevels = Map.of( + "admin", AdminLevel.ADMIN, + "manager", AdminLevel.MANAGER, + "supergm", AdminLevel.SUPER_GM, + "gm", AdminLevel.GM, + "jrgm", AdminLevel.JR_GM, + "tester", AdminLevel.TESTER, + "player", AdminLevel.PLAYER + ); + + for (String pkg : commandPackages) { + // Get all classes in the package + Set> classes = ClassScanner.getClasses(pkg); + for (Class clazz : classes) { + for (Method method : clazz.getDeclaredMethods()) { + if (!method.isAnnotationPresent(Command.class)) { + continue; + } + + // Validate method signature + if (method.getParameterCount() != 2 + || method.getParameterTypes()[0] != User.class + || method.getParameterTypes()[1] != String[].class) { + throw new RuntimeException( + String.format("Incorrect parameters for command method \"%s\"", method.getName())); + } + + // Determine enforced level from package + String pkgName = clazz.getPackageName().toLowerCase(); + AdminLevel enforcedLevel = packageLevels.entrySet().stream() + .filter(e -> pkgName.endsWith(e.getKey())) + .map(Map.Entry::getValue) + .findFirst() + .orElse(AdminLevel.PLAYER); + + // store the command's enforced admin level. + methodLevelMap.put(method, enforcedLevel); + + + // Register aliases + final Command annotation = method.getAnnotation(Command.class); + for (String value : annotation.value()) { + final String alias = value.toLowerCase(); + if (commandMap.containsKey(alias)) { + throw new RuntimeException( + String.format("Multiple methods found for Command alias \"%s\"", alias)); + } + commandMap.put(alias, method); } - commandMap.put(alias, method); } } } + + log.info("CommandProcessor initialized with {} commands.", commandMap.size()); } + public static Optional getCommand(String commandName) { return Optional.ofNullable(commandMap.get(commandName.toLowerCase())); } @@ -42,20 +115,59 @@ public static Optional getCommand(String commandName) { public static String getHelpString(Method method) { final Command command = method.getAnnotation(Command.class); final Arguments arguments = method.getAnnotation(Arguments.class); + + // Determine the required admin level for this command + AdminLevel requiredLevel = methodLevelMap.getOrDefault(method, AdminLevel.PLAYER); + + // Choose prefix based on the required admin level + String prefix = (requiredLevel.isAtLeast(AdminLevel.TESTER)) + ? ServerConfig.STAFF_COMMAND_PREFIX + : ServerConfig.PLAYER_COMMAND_PREFIX; + final String commandString = String.join("|", command.value()); - final List argumentString = Arrays.stream(arguments != null ? arguments.value() : new String[]{}).map((value) -> String.format("<%s>", value)).toList(); - return String.format("%s%s %s", ServerConfig.PLAYER_COMMAND_PREFIX, commandString, String.join(" ", argumentString)); + final List argumentString = Arrays.stream(arguments != null ? arguments.value() : new String[]{}) + .map(value -> String.format("<%s>", value)) + .toList(); + + return String.format("%s%s %s", prefix, commandString, String.join(" ", argumentString)); } public static void tryProcessCommand(User user, String text) { - final String[] arguments = text.replaceFirst(ServerConfig.PLAYER_COMMAND_PREFIX, "").split(" "); + AdminLevel userLevel = user.getAdminLevel(); + + // allowed command prefixes + List allowedPrefixes = new ArrayList<>(); + allowedPrefixes.add(ServerConfig.PLAYER_COMMAND_PREFIX); // everyone can use player commands + if (userLevel.isAtLeast(AdminLevel.TESTER)) { + allowedPrefixes.add(ServerConfig.STAFF_COMMAND_PREFIX); // Testers and higher can use staff commands + } + + String usedPrefix = allowedPrefixes.stream() + .filter(text::startsWith) + .findFirst() + .orElse(null); + + + // no registered prefix found. + if (usedPrefix == null) { + return; + } + + + final String[] arguments = text.substring(usedPrefix.length()).trim().split(" "); final String commandName = arguments[0].toLowerCase(); final Optional commandResult = getCommand(commandName); + + // no registered command found. if (commandResult.isEmpty()) { user.write(MessagePacket.system("Unknown command : %s", text)); return; } + final Method method = commandResult.get(); + final Command commandAnnotation = method.getAnnotation(Command.class); + + // Check args if (method.isAnnotationPresent(Arguments.class)) { final Arguments annotation = method.getAnnotation(Arguments.class); if (arguments.length < annotation.value().length + 1) { @@ -63,6 +175,18 @@ public static void tryProcessCommand(User user, String text) { return; } } + + // Check admin level + AdminLevel requiredLevel = methodLevelMap.getOrDefault(method, AdminLevel.PLAYER); + if (!user.getAdminLevel().isAtLeast(requiredLevel)) { + if (ServerConfig.TESPIA) { + user.write(MessagePacket.system("You do not have permission to use this command.")); // We don't want to show this message to a normal player. + } + return; + } + + + // invoke command try { method.invoke(null, user, arguments); } catch (IllegalAccessException | InvocationTargetException e) { diff --git a/src/main/java/kinoko/server/command/admin/BattleshipCommand.java b/src/main/java/kinoko/server/command/admin/BattleshipCommand.java new file mode 100644 index 00000000..7a17a2ce --- /dev/null +++ b/src/main/java/kinoko/server/command/admin/BattleshipCommand.java @@ -0,0 +1,18 @@ +package kinoko.server.command.admin; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.world.job.explorer.Pirate; +import kinoko.world.user.User; + +/** + * Shows the Battleship HP for the executing user. + * Admin-level command. + */ +public final class BattleshipCommand { + + @Command({ "battleship", "bship" }) + public static void battleship(User user, String[] args) { + user.write(MessagePacket.system("Battleship HP : %d", Pirate.getBattleshipDurability(user))); + } +} diff --git a/src/main/java/kinoko/server/command/admin/ComboCommand.java b/src/main/java/kinoko/server/command/admin/ComboCommand.java new file mode 100644 index 00000000..954c7e61 --- /dev/null +++ b/src/main/java/kinoko/server/command/admin/ComboCommand.java @@ -0,0 +1,29 @@ +package kinoko.server.command.admin; + +import kinoko.packet.user.UserLocal; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.job.legend.Aran; +import kinoko.world.user.User; +import kinoko.world.user.stat.CharacterTemporaryStat; +import kinoko.world.user.stat.TemporaryStatOption; + +/** + * Sets the Combo Ability Buff temporary stat for the user. + * Admin-level command. + */ +public final class ComboCommand { + + @Command("combo") + @Arguments("value") + public static void combo(User user, String[] args) { + try { + int combo = Integer.parseInt(args[1]); + user.setTemporaryStat(CharacterTemporaryStat.ComboAbilityBuff, + TemporaryStatOption.of(combo, Aran.COMBO_ABILITY, 0)); + user.write(UserLocal.incCombo(combo)); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(kinoko.packet.world.MessagePacket.system("Usage: !combo ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/admin/JaguarCommand.java b/src/main/java/kinoko/server/command/admin/JaguarCommand.java new file mode 100644 index 00000000..f9933d8f --- /dev/null +++ b/src/main/java/kinoko/server/command/admin/JaguarCommand.java @@ -0,0 +1,25 @@ +package kinoko.server.command.admin; + +import kinoko.packet.world.WvsContext; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.user.User; + +/** + * Sets the Wild Hunter mount (riding type) for the user. + * Admin-level command. + */ +public final class JaguarCommand { + + @Command("jaguar") + @Arguments("index") + public static void jaguar(User user, String[] args) { + try { + int index = Integer.parseInt(args[1]); + user.getWildHunterInfo().setRidingType(index); + user.write(WvsContext.wildHunterInfo(user.getWildHunterInfo())); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(kinoko.packet.world.MessagePacket.system("Usage: !jaguar ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/admin/MobSkillCommand.java b/src/main/java/kinoko/server/command/admin/MobSkillCommand.java new file mode 100644 index 00000000..73aa486b --- /dev/null +++ b/src/main/java/kinoko/server/command/admin/MobSkillCommand.java @@ -0,0 +1,56 @@ +package kinoko.server.command.admin; + +import kinoko.packet.world.MessagePacket; +import kinoko.provider.SkillProvider; +import kinoko.provider.mob.MobSkillType; +import kinoko.provider.skill.SkillInfo; +import kinoko.provider.skill.SkillStat; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.user.User; +import kinoko.world.user.stat.CharacterTemporaryStat; +import kinoko.world.user.stat.TemporaryStatOption; + +import java.util.Optional; + +/** + * Applies a mob skill as a temporary stat to the user. + * Admin-level command. + */ +public final class MobSkillCommand { + + @Command("mobskill") + @Arguments({ "skill ID", "skill level" }) + public static void mobskill(User user, String[] args) { + try { + int skillId = Integer.parseInt(args[1]); + int slv = Integer.parseInt(args[2]); + + MobSkillType skillType = MobSkillType.getByValue(skillId); + if (skillType == null) { + user.write(MessagePacket.system("Could not resolve mob skill %d", skillId)); + return; + } + + CharacterTemporaryStat cts = skillType.getCharacterTemporaryStat(); + if (cts == null) { + user.write(MessagePacket.system("Mob skill %s does not apply a CTS", skillType)); + return; + } + + Optional skillInfoResult = SkillProvider.getMobSkillInfoById(skillId); + if (skillInfoResult.isEmpty()) { + user.write(MessagePacket.system("Could not resolve mob skill info %d", skillId)); + return; + } + + SkillInfo si = skillInfoResult.get(); + int value = Math.max(si.getValue(SkillStat.x, slv), 1); + int duration = si.getDuration(slv); + + user.setTemporaryStat(cts, TemporaryStatOption.ofMobSkill(value, skillId, slv, duration)); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !mobskill ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/admin/RideCommand.java b/src/main/java/kinoko/server/command/admin/RideCommand.java new file mode 100644 index 00000000..894e44f0 --- /dev/null +++ b/src/main/java/kinoko/server/command/admin/RideCommand.java @@ -0,0 +1,48 @@ +package kinoko.server.command.admin; + +import kinoko.packet.user.UserRemote; +import kinoko.packet.world.MessagePacket; +import kinoko.packet.world.WvsContext; +import kinoko.provider.ItemProvider; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.util.BitFlag; +import kinoko.world.job.explorer.Beginner; +import kinoko.world.user.User; +import kinoko.world.user.stat.CharacterTemporaryStat; +import kinoko.world.user.stat.SecondaryStat; +import kinoko.world.user.stat.TwoStateTemporaryStat; + +import java.util.Set; + +/** + * Sets the user to ride a specific vehicle. + * Admin-level command. + */ +public final class RideCommand { + + @Command("ride") + @Arguments("vehicle ID") + public static void ride(User user, String[] args) { + try { + int vehicleId = Integer.parseInt(args[1]); + if (ItemProvider.getItemInfo(vehicleId).isEmpty()) { + user.write(MessagePacket.system("Could not resolve item info for vehicle ID : %d", vehicleId)); + return; + } + + SecondaryStat ss = user.getSecondaryStat(); + BitFlag flag = BitFlag.from( + Set.of(CharacterTemporaryStat.RideVehicle), + CharacterTemporaryStat.FLAG_SIZE + ); + ss.getTemporaryStats().put(CharacterTemporaryStat.RideVehicle, + TwoStateTemporaryStat.ofTwoState(CharacterTemporaryStat.RideVehicle, vehicleId, Beginner.MONSTER_RIDER, 0) + ); + user.write(WvsContext.temporaryStatSet(ss, flag)); + user.getField().broadcastPacket(UserRemote.temporaryStatSet(user, ss, flag)); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !ride ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/admin/TestCommand.java b/src/main/java/kinoko/server/command/admin/TestCommand.java new file mode 100644 index 00000000..a903ddee --- /dev/null +++ b/src/main/java/kinoko/server/command/admin/TestCommand.java @@ -0,0 +1,26 @@ +package kinoko.server.command.admin; + +import kinoko.packet.world.MessagePacket; +import kinoko.provider.ItemProvider; +import kinoko.server.command.Command; +import kinoko.world.user.User; + +public class TestCommand { + /** + * Admin command to test server queries and dispose the player after applying an effect. + */ + @Command("test") + public static void test(User user, String[] args) { + user.getConnectedServer().submitUserQueryRequestAll(queryResult -> { + user.write(MessagePacket.system("Users in world: %d", queryResult.size())); + user.write(MessagePacket.system("Users in field : %d", user.getField().getUserPool().getCount())); + user.write(MessagePacket.system("Party ID : %d (%d)", user.getPartyId(), user.getCharacterData().getPartyId())); + + // Apply item effect (throws if item not found) + user.setConsumeItemEffect(ItemProvider.getItemInfo(2022181).orElseThrow()); + + // Dispose player + user.dispose(); + }); + } +} diff --git a/src/main/java/kinoko/server/command/gm/KillMobsCommand.java b/src/main/java/kinoko/server/command/gm/KillMobsCommand.java new file mode 100644 index 00000000..b7a240ed --- /dev/null +++ b/src/main/java/kinoko/server/command/gm/KillMobsCommand.java @@ -0,0 +1,21 @@ +package kinoko.server.command.gm; + +import kinoko.server.command.Command; +import kinoko.world.field.mob.MobLeaveType; +import kinoko.world.user.User; + +/** + * Instantly kills all mobs in the user's field. + * GM-level command. + */ +public final class KillMobsCommand { + + @Command("killmobs") + public static void killMobs(User user, String[] args) { + user.getField().getMobPool().forEach(mob -> { + if (mob.getHp() > 0) { + mob.damage(user, mob.getMaxHp(), 0, MobLeaveType.ETC); + } + }); + } +} diff --git a/src/main/java/kinoko/server/command/gm/MobCommand.java b/src/main/java/kinoko/server/command/gm/MobCommand.java new file mode 100644 index 00000000..3ff31794 --- /dev/null +++ b/src/main/java/kinoko/server/command/gm/MobCommand.java @@ -0,0 +1,49 @@ +package kinoko.server.command.gm; + +import kinoko.packet.world.MessagePacket; +import kinoko.provider.MobProvider; +import kinoko.provider.map.Foothold; +import kinoko.provider.mob.MobTemplate; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.field.Field; +import kinoko.world.field.mob.Mob; +import kinoko.world.user.User; + +import java.util.Optional; + +/** + * GM command to spawn mobs at the user's location. + */ +public final class MobCommand { + + @Command({ "mob", "spawn" }) + @Arguments("mob template ID") + public static void mob(User user, String[] args) { + try { + final int templateId = Integer.parseInt(args[1]); + final Optional mobTemplateResult = MobProvider.getMobTemplate(templateId); + if (mobTemplateResult.isEmpty()) { + user.write(MessagePacket.system("Could not resolve mob template ID: %d", templateId)); + return; + } + + final int count = args.length > 2 ? Integer.parseInt(args[2]) : 1; + final Field field = user.getField(); + final Optional footholdResult = field.getFootholdBelow(user.getX(), user.getY()); + + for (int i = 0; i < count; i++) { + final Mob mob = new Mob( + mobTemplateResult.get(), + null, + user.getX(), + user.getY(), + footholdResult.map(Foothold::getSn).orElse(user.getFoothold()) + ); + field.getMobPool().addMob(mob); + } + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !mob [count]")); + } + } +} diff --git a/src/main/java/kinoko/server/command/jrgm/HpCommand.java b/src/main/java/kinoko/server/command/jrgm/HpCommand.java new file mode 100644 index 00000000..ccb5adeb --- /dev/null +++ b/src/main/java/kinoko/server/command/jrgm/HpCommand.java @@ -0,0 +1,24 @@ +package kinoko.server.command.jrgm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.user.User; + +/** + * JrGM command to set HP. + */ +public final class HpCommand { + + @Command("hp") + @Arguments("new hp") + public static void hp(User user, String[] args) { + try { + int newHp = Integer.parseInt(args[1]); + user.setHp(newHp); + user.write(MessagePacket.system("HP set to %d", newHp)); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !hp ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/jrgm/JobCommand.java b/src/main/java/kinoko/server/command/jrgm/JobCommand.java new file mode 100644 index 00000000..1275fd9a --- /dev/null +++ b/src/main/java/kinoko/server/command/jrgm/JobCommand.java @@ -0,0 +1,83 @@ +package kinoko.server.command.jrgm; + +import kinoko.packet.user.DragonPacket; +import kinoko.packet.user.UserRemote; +import kinoko.packet.world.MessagePacket; +import kinoko.packet.world.WvsContext; +import kinoko.provider.SkillProvider; +import kinoko.provider.skill.SkillInfo; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.job.Job; +import kinoko.world.job.JobConstants; +import kinoko.world.skill.SkillRecord; +import kinoko.world.skill.SkillManager; +import kinoko.world.user.Dragon; +import kinoko.world.user.User; +import kinoko.world.user.effect.Effect; +import kinoko.world.user.stat.Stat; + +import java.util.ArrayList; +import java.util.List; + +/** + * Changes the user's job and initializes job skills. + * JrGM-level command. + */ +public final class JobCommand { + + @Command("job") + @Arguments("job ID") + public static void job(User user, String[] args) { + try { + int jobId = Integer.parseInt(args[1]); + Job job = Job.getById(jobId); + if (job == null) { + user.write(MessagePacket.system("Could not change to unknown job : %d", jobId)); + return; + } + + // Update job + user.getCharacterStat().setJob(job.getJobId()); + user.write(WvsContext.statChanged(Stat.JOB, job.getJobId(), false)); + user.getField().broadcastPacket(UserRemote.effect(user, Effect.jobChanged()), user); + + // Update skills + SkillManager sm = user.getSkillManager(); + List skillRecords = new ArrayList<>(); + for (int skillRoot : JobConstants.getSkillRootFromJob(jobId)) { + for (SkillInfo si : SkillProvider.getSkillsForJob(Job.getById(skillRoot))) { + if (sm.getSkill(si.getSkillId()).isPresent()) continue; + if (si.isInvisible()) continue; + + SkillRecord sr = new SkillRecord(si.getSkillId()); + sr.setSkillLevel(0); + sr.setMasterLevel(si.getMasterLevel()); + sm.addSkill(sr); + skillRecords.add(sr); + } + } + + user.updatePassiveSkillData(); + user.validateStat(); + user.write(WvsContext.changeSkillRecordResult(skillRecords, true)); + + // Additional handling for Dragon and WildHunter jobs + if (JobConstants.isDragonJob(jobId)) { + Dragon dragon = new Dragon(user.getJob()); + user.setDragon(dragon); + user.getField().broadcastPacket(DragonPacket.dragonEnterField(user, dragon)); + } else { + user.setDragon(null); + } + + if (JobConstants.isWildHunterJob(jobId)) { + user.write(WvsContext.wildHunterInfo(user.getWildHunterInfo())); + } + + user.getConnectedServer().notifyUserUpdate(user); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !job ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/jrgm/LevelCommand.java b/src/main/java/kinoko/server/command/jrgm/LevelCommand.java new file mode 100644 index 00000000..358026fd --- /dev/null +++ b/src/main/java/kinoko/server/command/jrgm/LevelCommand.java @@ -0,0 +1,37 @@ +package kinoko.server.command.jrgm; + +import kinoko.packet.world.MessagePacket; +import kinoko.packet.world.WvsContext; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.GameConstants; +import kinoko.world.user.User; +import kinoko.world.user.stat.CharacterStat; +import kinoko.world.user.stat.Stat; + +/** + * Sets the user's level directly. + * JrGM-level command. + */ +public final class LevelCommand { + + @Command("level") + @Arguments("new level") + public static void level(User user, String[] args) { + try { + int level = Integer.parseInt(args[1]); + if (level < 1 || level > GameConstants.LEVEL_MAX) { + user.write(MessagePacket.system("Could not change level to : %d", level)); + return; + } + + CharacterStat cs = user.getCharacterStat(); + cs.setLevel((short) level); + user.validateStat(); + user.write(WvsContext.statChanged(Stat.LEVEL, (byte) cs.getLevel(), true)); + user.getConnectedServer().notifyUserUpdate(user); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !level ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/jrgm/LevelUpCommand.java b/src/main/java/kinoko/server/command/jrgm/LevelUpCommand.java new file mode 100644 index 00000000..07bea323 --- /dev/null +++ b/src/main/java/kinoko/server/command/jrgm/LevelUpCommand.java @@ -0,0 +1,31 @@ +package kinoko.server.command.jrgm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.GameConstants; +import kinoko.world.user.User; + +/** + * Levels up the user to a specific level. + * JrGM-level command. + */ +public final class LevelUpCommand { + + @Command("levelup") + @Arguments("new level") + public static void levelUp(User user, String[] args) { + try { + int level = Integer.parseInt(args[1]); + if (level <= user.getLevel() || level > GameConstants.LEVEL_MAX) { + user.write(MessagePacket.system("Could not level up to : %d", level)); + return; + } + while (user.getLevel() < level) { + user.addExp(GameConstants.getNextLevelExp(user.getLevel()) - user.getCharacterStat().getExp()); + } + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !levelup ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/jrgm/MpCommand.java b/src/main/java/kinoko/server/command/jrgm/MpCommand.java new file mode 100644 index 00000000..79d4fa26 --- /dev/null +++ b/src/main/java/kinoko/server/command/jrgm/MpCommand.java @@ -0,0 +1,24 @@ +package kinoko.server.command.jrgm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.user.User; + +/** + * JrGM command to set MP. + */ +public final class MpCommand { + + @Command("mp") + @Arguments("new mp") + public static void mp(User user, String[] args) { + try { + int newMp = Integer.parseInt(args[1]); + user.setMp(newMp); + user.write(MessagePacket.system("MP set to %d", newMp)); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !mp ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/jrgm/StatCommand.java b/src/main/java/kinoko/server/command/jrgm/StatCommand.java new file mode 100644 index 00000000..d19aca7d --- /dev/null +++ b/src/main/java/kinoko/server/command/jrgm/StatCommand.java @@ -0,0 +1,87 @@ +package kinoko.server.command.jrgm; + +import kinoko.packet.world.MessagePacket; +import kinoko.packet.world.WvsContext; +import kinoko.server.ServerConfig; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.job.JobConstants; +import kinoko.world.user.User; +import kinoko.world.user.stat.CharacterStat; +import kinoko.world.user.stat.Stat; + +import java.util.EnumMap; +import java.util.Map; + +/** + * JrGM command to set individual stats: HP, MP, STR, DEX, INT, LUK, AP, SP. + */ +public final class StatCommand { + + @Command("stat") + @Arguments({ "hp/mp/str/dex/int/luk/ap/sp", "new value" }) + public static void stat(User user, String[] args) { + try { + String stat = args[1].toLowerCase(); + int value = Integer.parseInt(args[2]); + + CharacterStat cs = user.getCharacterStat(); + Map statMap = new EnumMap<>(Stat.class); + + switch (stat) { + case "hp" -> { + cs.setMaxHp(value); + statMap.put(Stat.HP, cs.getMaxHp()); + } + case "mp" -> { + cs.setMaxMp(value); + statMap.put(Stat.MP, cs.getMaxMp()); + } + case "str" -> { + cs.setBaseStr((short) value); + statMap.put(Stat.STR, cs.getBaseStr()); + } + case "dex" -> { + cs.setBaseDex((short) value); + statMap.put(Stat.DEX, cs.getBaseDex()); + } + case "int" -> { + cs.setBaseInt((short) value); + statMap.put(Stat.INT, cs.getBaseInt()); + } + case "luk" -> { + cs.setBaseLuk((short) value); + statMap.put(Stat.LUK, cs.getBaseLuk()); + } + case "ap" -> { + cs.setAp((short) value); + statMap.put(Stat.AP, cs.getAp()); + } + case "sp" -> { + if (JobConstants.isExtendSpJob(cs.getJob())) { + cs.getSp().setSp(JobConstants.getJobLevel(cs.getJob()), value); + statMap.put(Stat.SP, cs.getSp()); + } else { + cs.getSp().setNonExtendSp(value); + statMap.put(Stat.SP, (short) cs.getSp().getNonExtendSp()); + } + } + default -> { + user.write(MessagePacket.system( + "Syntax: %sstat hp/mp/str/dex/int/luk/ap/sp ", + ServerConfig.PLAYER_COMMAND_PREFIX)); + return; + } + } + + user.validateStat(); + user.write(WvsContext.statChanged(statMap, true)); + user.write(MessagePacket.system("Set %s to %d", stat, value)); + + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system( + "Syntax: %sstat hp/mp/str/dex/int/luk/ap/sp ", + ServerConfig.PLAYER_COMMAND_PREFIX)); + } + } +} diff --git a/src/main/java/kinoko/server/command/manager/AvatarCommand.java b/src/main/java/kinoko/server/command/manager/AvatarCommand.java new file mode 100644 index 00000000..1d5e6924 --- /dev/null +++ b/src/main/java/kinoko/server/command/manager/AvatarCommand.java @@ -0,0 +1,53 @@ +package kinoko.server.command.manager; + +import kinoko.packet.user.UserRemote; +import kinoko.packet.world.MessagePacket; +import kinoko.packet.world.WvsContext; +import kinoko.provider.StringProvider; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.GameConstants; +import kinoko.world.user.User; +import kinoko.world.user.stat.Stat; + +/** + * Changes the user's avatar (hair, face, or skin). + * SuperGM-level command. + */ +public final class AvatarCommand { + + @Command("avatar") + @Arguments("new look") + public static void avatar(User user, String[] args) { + try { + int look = Integer.parseInt(args[1]); + + if (look >= 0 && look <= GameConstants.SKIN_MAX) { + user.getCharacterStat().setSkin((byte) look); + user.write(WvsContext.statChanged(Stat.SKIN, user.getCharacterStat().getSkin(), false)); + user.getField().broadcastPacket(UserRemote.avatarModified(user), user); + } else if (look >= GameConstants.FACE_MIN && look <= GameConstants.FACE_MAX) { + if (StringProvider.getItemName(look) == null) { + user.write(MessagePacket.system("Tried to change face with invalid ID : %d", look)); + return; + } + user.getCharacterStat().setFace(look); + user.write(WvsContext.statChanged(Stat.FACE, user.getCharacterStat().getFace(), false)); + user.getField().broadcastPacket(UserRemote.avatarModified(user), user); + } else if (look >= GameConstants.HAIR_MIN && look <= GameConstants.HAIR_MAX) { + if (StringProvider.getItemName(look) == null) { + user.write(MessagePacket.system("Tried to change hair with invalid ID : %d", look)); + return; + } + user.getCharacterStat().setHair(look); + user.write(WvsContext.statChanged(Stat.HAIR, user.getCharacterStat().getHair(), false)); + user.getField().broadcastPacket(UserRemote.avatarModified(user), user); + } else { + user.write(MessagePacket.system("Tried to change avatar with invalid ID : %d", look)); + } + + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !avatar ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/manager/ReloadCashShopCommand.java b/src/main/java/kinoko/server/command/manager/ReloadCashShopCommand.java new file mode 100644 index 00000000..4d4d8b13 --- /dev/null +++ b/src/main/java/kinoko/server/command/manager/ReloadCashShopCommand.java @@ -0,0 +1,26 @@ +package kinoko.server.command.manager; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.cashshop.CashShop; +import kinoko.server.command.Command; +import kinoko.world.user.User; + +public final class ReloadCashShopCommand { + + /** + * Reloads the cash shop. + * Manager command. + * + * @param user the user executing the command + * @param args command arguments (none expected) + */ + @Command({ "reloadcashshop", "reloadcs" }) + public static void reloadCashShop(User user, String[] args) { + try { + CashShop.initialize(); + user.write(MessagePacket.system("Cash shop reloaded successfully.")); + } catch (Exception e) { + user.write(MessagePacket.system("Failed to reload cash shop: %s", e.getMessage())); + } + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/server/command/manager/ReloadDropsCommand.java b/src/main/java/kinoko/server/command/manager/ReloadDropsCommand.java new file mode 100644 index 00000000..601c2595 --- /dev/null +++ b/src/main/java/kinoko/server/command/manager/ReloadDropsCommand.java @@ -0,0 +1,26 @@ +package kinoko.server.command.manager; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.world.user.User; +import kinoko.provider.RewardProvider; + +public final class ReloadDropsCommand { + + /** + * Reloads all drop data. + * Manager command. + * + * @param user the user executing the command + * @param args command arguments (none expected) + */ + @Command("reloaddrops") + public static void reloadDrops(User user, String[] args) { + try { + RewardProvider.initialize(); + user.write(MessagePacket.system("Drops reloaded successfully.")); + } catch (Exception e) { + user.write(MessagePacket.system("Failed to reload drops: %s", e.getMessage())); + } + } +} diff --git a/src/main/java/kinoko/server/command/manager/ReloadShopsCommand.java b/src/main/java/kinoko/server/command/manager/ReloadShopsCommand.java new file mode 100644 index 00000000..bbb3790b --- /dev/null +++ b/src/main/java/kinoko/server/command/manager/ReloadShopsCommand.java @@ -0,0 +1,26 @@ +package kinoko.server.command.manager; + +import kinoko.packet.world.MessagePacket; +import kinoko.provider.ShopProvider; +import kinoko.server.command.Command; +import kinoko.world.user.User; + +public final class ReloadShopsCommand { + + /** + * Reloads all shop data. + * Manager command. + * + * @param user the user executing the command + * @param args command arguments (none expected) + */ + @Command("reloadshops") + public static void reloadShops(User user, String[] args) { + try { + ShopProvider.initialize(); + user.write(MessagePacket.system("Shops reloaded successfully.")); + } catch (Exception e) { + user.write(MessagePacket.system("Failed to reload shops: %s", e.getMessage())); + } + } +} diff --git a/src/main/java/kinoko/server/command/manager/SetGmLevelCommand.java b/src/main/java/kinoko/server/command/manager/SetGmLevelCommand.java new file mode 100644 index 00000000..0c50ea76 --- /dev/null +++ b/src/main/java/kinoko/server/command/manager/SetGmLevelCommand.java @@ -0,0 +1,60 @@ +package kinoko.server.command.manager; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Arguments; +import kinoko.server.command.Command; +import kinoko.world.user.User; +import kinoko.world.user.stat.AdminLevel; + +import java.util.Optional; + +public class SetGmLevelCommand { + /** + * Sets the admin level of a target user. + * Usage: !setadmin + * Level 0 = Admin, Level 6 = Player. + * Only users currently connected can be targeted. + * Notifies both the issuer and the target of the change. + * + * @param user The user issuing the command. + * @param args Command arguments: target username and desired admin level. + */ + @Command({"setgmlevel", "setadminlevel", "setadmin"}) + @Arguments({ "Character Name", "Admin Level" }) + public static void setGMLevel(User user, String[] args) { + if (args.length < 3) { + user.write(MessagePacket.system("Usage: !setadmin ")); + return; + } + + final String targetName = args[1]; + final String levelStr = args[2]; + + int level; + try { + level = Integer.parseInt(levelStr); + } catch (NumberFormatException e) { + user.write(MessagePacket.system("Invalid level: %s", levelStr)); + return; + } + + if (level < 0 || level > 6) { + user.write(MessagePacket.system("Admin level must be between 0 (Admin) and 6 (Player).")); + return; + } + + Optional target = user.getConnectedServer().getUserByCharacterName(targetName); + if (target.isEmpty()) { + user.write(MessagePacket.system("User not found: %s", targetName)); + return; + } + + User targetUser = target.get(); + + AdminLevel adminLevel = AdminLevel.fromValue((short) level); + targetUser.getCharacterStat().setAdminLevel(adminLevel); + + user.write(MessagePacket.system("Set admin level of %s to %s (%d)", targetName, adminLevel.name(), level)); + targetUser.write(MessagePacket.system("Your admin level has been set to %s by %s", adminLevel.name(), user.getCharacterName())); + } +} diff --git a/src/main/java/kinoko/server/command/manager/SkillCommand.java b/src/main/java/kinoko/server/command/manager/SkillCommand.java new file mode 100644 index 00000000..88c8c016 --- /dev/null +++ b/src/main/java/kinoko/server/command/manager/SkillCommand.java @@ -0,0 +1,48 @@ +package kinoko.server.command.manager; + +import kinoko.packet.world.MessagePacket; +import kinoko.packet.world.WvsContext; +import kinoko.provider.SkillProvider; +import kinoko.provider.skill.SkillInfo; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.skill.SkillRecord; +import kinoko.world.skill.SkillManager; +import kinoko.world.user.User; + +import java.util.Optional; + +/** + * Adds or sets a skill for a user. + * Manager-level command. + */ +public final class SkillCommand { + + @Command("skill") + @Arguments({ "skill ID", "skill level" }) + public static void skill(User user, String[] args) { + try { + int skillId = Integer.parseInt(args[1]); + int slv = Integer.parseInt(args[2]); + + Optional skillInfoResult = SkillProvider.getSkillInfoById(skillId); + if (skillInfoResult.isEmpty()) { + user.write(MessagePacket.system("Could not find skill : %d", skillId)); + return; + } + + SkillInfo si = skillInfoResult.get(); + SkillRecord skillRecord = new SkillRecord(si.getSkillId()); + skillRecord.setSkillLevel(Math.min(slv, si.getMaxLevel())); + skillRecord.setMasterLevel(si.getMaxLevel()); + + SkillManager sm = user.getSkillManager(); + sm.addSkill(skillRecord); + user.updatePassiveSkillData(); + user.validateStat(); + user.write(WvsContext.changeSkillRecordResult(skillRecord, true)); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !skill ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/player/DisposeCommand.java b/src/main/java/kinoko/server/command/player/DisposeCommand.java new file mode 100644 index 00000000..06e7eb24 --- /dev/null +++ b/src/main/java/kinoko/server/command/player/DisposeCommand.java @@ -0,0 +1,17 @@ +package kinoko.server.command.player; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.world.user.User; + +public class DisposeCommand { + /** + * Player command to dispose themselves. + */ + @Command("dispose") + public static void dispose(User user, String[] args) { + user.closeDialog(); + user.dispose(); + user.write(MessagePacket.system("You have been disposed.")); + } +} diff --git a/src/main/java/kinoko/server/command/player/HelpCommand.java b/src/main/java/kinoko/server/command/player/HelpCommand.java new file mode 100644 index 00000000..8f1e5e8d --- /dev/null +++ b/src/main/java/kinoko/server/command/player/HelpCommand.java @@ -0,0 +1,63 @@ +package kinoko.server.command.player; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.world.user.User; +import kinoko.server.command.CommandProcessor; +import kinoko.world.user.stat.AdminLevel; + +import java.lang.reflect.Method; +import java.util.HashSet; +import java.util.Optional; +import java.util.Set; + +public final class HelpCommand { + + /** + * Displays a list of all commands the user can access, or syntax for a specific command. + * + * If called with no arguments (e.g., !help), it lists all commands available to the user. + * If called with a command name (e.g., !help meso), it shows the syntax for that specific command. + * + * @param user the user executing the command + * @param args command arguments + */ + @Command("help") + public static void help(User user, String[] args) { + AdminLevel userLevel = user.getAdminLevel(); + + // If just "!help" or "@help" -> list all accessible commands + if (args.length == 1) { + user.write(MessagePacket.system("Available Commands:")); + + // Use a set to avoid duplicate methods caused by multiple aliases + Set uniqueMethods = new HashSet<>(CommandProcessor.getCommandMap().values()); + + for (Method method : uniqueMethods) { + AdminLevel requiredLevel = CommandProcessor.getRequiredLevel(method); + if (!userLevel.isAtLeast(requiredLevel)) continue; + + user.write(MessagePacket.system("%s", CommandProcessor.getHelpString(method))); + } + } + // "!help " -> show syntax only + else { + String commandName = args[1].toLowerCase(); + Optional commandResult = CommandProcessor.getCommand(commandName); + + if (commandResult.isEmpty()) { + user.write(MessagePacket.system("Unknown command: %s", commandName)); + return; + } + + Method method = commandResult.get(); + AdminLevel requiredLevel = CommandProcessor.getRequiredLevel(method); + if (!userLevel.isAtLeast(requiredLevel)) { + user.write(MessagePacket.system("Unknown command: %s", commandName)); + return; + } + + user.write(MessagePacket.system("Syntax: %s", CommandProcessor.getHelpString(method))); + } + } +} diff --git a/src/main/java/kinoko/server/command/supergm/ClearLockerCommand.java b/src/main/java/kinoko/server/command/supergm/ClearLockerCommand.java new file mode 100644 index 00000000..7c464475 --- /dev/null +++ b/src/main/java/kinoko/server/command/supergm/ClearLockerCommand.java @@ -0,0 +1,17 @@ +package kinoko.server.command.supergm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.world.user.User; + +/** + * SuperGM command to clear a user's locker inventory. + */ +public final class ClearLockerCommand { + + @Command("clearlocker") + public static void clearLocker(User user, String[] args) { + user.getAccount().getLocker().getCashItems().clear(); + user.write(MessagePacket.system("Locker inventory cleared!")); + } +} diff --git a/src/main/java/kinoko/server/command/supergm/ClearQuestCommand.java b/src/main/java/kinoko/server/command/supergm/ClearQuestCommand.java new file mode 100644 index 00000000..4a1e9ee5 --- /dev/null +++ b/src/main/java/kinoko/server/command/supergm/ClearQuestCommand.java @@ -0,0 +1,38 @@ +package kinoko.server.command.supergm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.quest.QuestRecord; +import kinoko.world.quest.QuestState; +import kinoko.world.user.User; + +import java.util.Optional; + +/** + * Clears a quest (sets its state to NONE) for the user. + * SuperGM-level command. + */ +public final class ClearQuestCommand { + + @Command("clearquest") + @Arguments("quest ID") + public static void clearQuest(User user, String[] args) { + try { + int questId = Integer.parseInt(args[1]); + Optional questRecordResult = user.getQuestManager().getQuestRecord(questId); + + if (questRecordResult.isEmpty()) { + user.write(MessagePacket.system("Could not find quest record : %d", questId)); + return; + } + + QuestRecord qr = questRecordResult.get(); + qr.setState(QuestState.NONE); + user.write(MessagePacket.questRecord(qr)); + user.validateStat(); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !clearquest ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/supergm/CompleteQuestCommand.java b/src/main/java/kinoko/server/command/supergm/CompleteQuestCommand.java new file mode 100644 index 00000000..a25e029d --- /dev/null +++ b/src/main/java/kinoko/server/command/supergm/CompleteQuestCommand.java @@ -0,0 +1,27 @@ +package kinoko.server.command.supergm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.quest.QuestRecord; +import kinoko.world.user.User; + +/** + * Force-completes a quest for the user. + * SuperGM-level command. + */ +public final class CompleteQuestCommand { + + @Command("completequest") + @Arguments("quest ID") + public static void completeQuest(User user, String[] args) { + try { + int questId = Integer.parseInt(args[1]); + QuestRecord qr = user.getQuestManager().forceCompleteQuest(questId); + user.write(MessagePacket.questRecord(qr)); + user.validateStat(); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !completequest ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/supergm/ItemCommand.java b/src/main/java/kinoko/server/command/supergm/ItemCommand.java new file mode 100644 index 00000000..0ab71539 --- /dev/null +++ b/src/main/java/kinoko/server/command/supergm/ItemCommand.java @@ -0,0 +1,53 @@ +package kinoko.server.command.supergm; + +import kinoko.packet.user.UserLocal; +import kinoko.packet.world.MessagePacket; +import kinoko.packet.world.WvsContext; +import kinoko.provider.ItemProvider; +import kinoko.provider.item.ItemInfo; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.item.*; +import kinoko.world.user.User; +import kinoko.world.user.effect.Effect; + +import java.util.List; +import java.util.Optional; + +/** + * SuperGM command to give items to a user. + */ +public final class ItemCommand { + + @Command("item") + @Arguments("item ID") + public static void item(User user, String[] args) { + try { + final int itemId = Integer.parseInt(args[1]); + final int quantity = args.length > 2 ? Integer.parseInt(args[2]) : 1; + + final Optional itemInfoResult = ItemProvider.getItemInfo(itemId); + if (itemInfoResult.isEmpty()) { + user.write(MessagePacket.system("Could not resolve item ID: %d", itemId)); + return; + } + + final ItemInfo ii = itemInfoResult.get(); + final Item item = ii.createItem(user.getNextItemSn(), Math.min(quantity, ii.getSlotMax()), ItemVariationOption.NORMAL); + + // Add item to inventory + final InventoryManager im = user.getInventoryManager(); + final Optional> addItemResult = im.addItem(item); + + if (addItemResult.isPresent()) { + user.write(WvsContext.inventoryOperation(addItemResult.get(), true)); + user.write(UserLocal.effect(Effect.gainItem(item))); + } else { + user.write(MessagePacket.system("Failed to add item ID %d (%d) to inventory", itemId, quantity)); + } + + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !item [quantity]")); + } + } +} diff --git a/src/main/java/kinoko/server/command/supergm/MesoCommand.java b/src/main/java/kinoko/server/command/supergm/MesoCommand.java new file mode 100644 index 00000000..e3bebe1f --- /dev/null +++ b/src/main/java/kinoko/server/command/supergm/MesoCommand.java @@ -0,0 +1,34 @@ +package kinoko.server.command.supergm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.item.InventoryManager; +import kinoko.world.user.User; +import kinoko.packet.world.WvsContext; +import kinoko.world.user.stat.Stat; + + +public final class MesoCommand { + /** + * Sets the specified amount of mesos for the given user. + * + * This command is intended for SuperGM (or GM+) use only. + * The user must provide a single numeric argument representing the meso amount. + * + * @param user the target user whose mesos will be set + * @param args the command arguments, where args[1] should be the amount of mesos + */ + @Command(value = {"meso", "money"}) + @Arguments("amount") + public static void meso(User user, String[] args) { + try { + final int money = Integer.parseInt(args[1]); + final InventoryManager im = user.getInventoryManager(); + im.setMoney(money); + user.write(WvsContext.statChanged(Stat.MONEY, im.getMoney(), true)); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !meso ")); + } + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/server/command/supergm/MorphCommand.java b/src/main/java/kinoko/server/command/supergm/MorphCommand.java new file mode 100644 index 00000000..932ddae9 --- /dev/null +++ b/src/main/java/kinoko/server/command/supergm/MorphCommand.java @@ -0,0 +1,42 @@ +package kinoko.server.command.supergm; + +import kinoko.packet.user.UserRemote; +import kinoko.packet.world.MessagePacket; +import kinoko.packet.world.WvsContext; +import kinoko.provider.SkillProvider; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.util.BitFlag; +import kinoko.world.user.User; +import kinoko.world.user.stat.CharacterTemporaryStat; +import kinoko.world.user.stat.SecondaryStat; +import kinoko.world.user.stat.TemporaryStatOption; + +import java.util.Set; + +/** + * Morphs the user into a specified morph ID. + * SuperGM-level command. + */ +public final class MorphCommand { + + @Command("morph") + @Arguments("morph ID") + public static void morph(User user, String[] args) { + try { + int morphId = Integer.parseInt(args[1]); + if (SkillProvider.getMorphInfoById(morphId).isEmpty()) { + user.write(MessagePacket.system("Could not resolve morph info for morph ID : %d", morphId)); + return; + } + + SecondaryStat ss = user.getSecondaryStat(); + BitFlag flag = BitFlag.from(Set.of(CharacterTemporaryStat.Morph), CharacterTemporaryStat.FLAG_SIZE); + ss.getTemporaryStats().put(CharacterTemporaryStat.Morph, TemporaryStatOption.of(morphId, -5300000, 0)); + user.write(WvsContext.temporaryStatSet(ss, flag)); + user.getField().broadcastPacket(UserRemote.temporaryStatSet(user, ss, flag)); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !morph ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/supergm/NpcCommand.java b/src/main/java/kinoko/server/command/supergm/NpcCommand.java new file mode 100644 index 00000000..92bb31c9 --- /dev/null +++ b/src/main/java/kinoko/server/command/supergm/NpcCommand.java @@ -0,0 +1,43 @@ +package kinoko.server.command.supergm; + +import kinoko.packet.world.MessagePacket; +import kinoko.provider.NpcProvider; +import kinoko.provider.npc.NpcTemplate; +import kinoko.script.common.ScriptDispatcher; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.user.User; + +import java.util.Optional; + +/** + * SuperGM command to start NPC scripts. + */ +public final class NpcCommand { + + @Command("npc") + @Arguments("npc template ID") + public static void npc(User user, String[] args) { + try { + final int templateId = Integer.parseInt(args[1]); + final Optional npcTemplateResult = NpcProvider.getNpcTemplate(templateId); + + if (npcTemplateResult.isEmpty()) { + user.write(MessagePacket.system("Could not resolve npc ID: %d", templateId)); + return; + } + + final String scriptName = npcTemplateResult.get().getScript(); + if (scriptName == null || scriptName.isEmpty()) { + user.write(MessagePacket.system("Could not find script for npc ID: %d", templateId)); + return; + } + + user.write(MessagePacket.system("Starting script for npc ID: %d, script: %s", templateId, scriptName)); + ScriptDispatcher.startNpcScript(user, user, scriptName, templateId); + + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !npc ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/supergm/NxCommand.java b/src/main/java/kinoko/server/command/supergm/NxCommand.java new file mode 100644 index 00000000..46fcf142 --- /dev/null +++ b/src/main/java/kinoko/server/command/supergm/NxCommand.java @@ -0,0 +1,24 @@ +package kinoko.server.command.supergm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.user.User; + +/** + * SuperGM command to set a user's NX prepaid balance. + */ +public final class NxCommand { + + @Command("nx") + @Arguments("amount") + public static void nx(User user, String[] args) { + try { + int nx = Integer.parseInt(args[1]); + user.getAccount().setNxPrepaid(nx); + user.write(MessagePacket.system("Set NX prepaid to %d", nx)); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !nx ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/supergm/QuestExCommand.java b/src/main/java/kinoko/server/command/supergm/QuestExCommand.java new file mode 100644 index 00000000..44a19be8 --- /dev/null +++ b/src/main/java/kinoko/server/command/supergm/QuestExCommand.java @@ -0,0 +1,38 @@ +package kinoko.server.command.supergm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.quest.QuestRecord; +import kinoko.world.user.User; + +import java.util.Optional; + +/** + * Gets or sets a quest record (QR) value for a specified quest ID. + * SuperGM-level command. + */ +public final class QuestExCommand { + + @Command({ "questex", "qr" }) + @Arguments("quest ID") + public static void questex(User user, String[] args) { + try { + int questId = Integer.parseInt(args[1]); + String newValue = args.length > 2 ? args[2] : null; + + if (newValue == null) { + Optional questRecordResult = user.getQuestManager().getQuestRecord(questId); + String value = questRecordResult.map(QuestRecord::getValue).orElse(""); + user.write(MessagePacket.system("Get QR value for quest ID %d : %s", questId, value)); + } else { + QuestRecord qr = user.getQuestManager().setQuestInfoEx(questId, newValue); + user.write(MessagePacket.questRecord(qr)); + user.validateStat(); + user.write(MessagePacket.system("Set QR value for quest ID %d : %s", questId, newValue)); + } + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !questex [value]")); + } + } +} diff --git a/src/main/java/kinoko/server/command/supergm/ReactorCommand.java b/src/main/java/kinoko/server/command/supergm/ReactorCommand.java new file mode 100644 index 00000000..f9e18868 --- /dev/null +++ b/src/main/java/kinoko/server/command/supergm/ReactorCommand.java @@ -0,0 +1,62 @@ +package kinoko.server.command.supergm; + +import kinoko.packet.world.MessagePacket; +import kinoko.provider.ReactorProvider; +import kinoko.provider.map.ReactorInfo; +import kinoko.provider.reactor.ReactorTemplate; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.field.Field; +import kinoko.world.field.reactor.Reactor; +import kinoko.world.user.User; + +import java.util.Optional; + +/** + * SuperGM commands to spawn and interact with reactors. + */ +public final class ReactorCommand { + + @Command("reactor") + @Arguments("reactor template ID") + public static void reactor(User user, String[] args) { + try { + final int templateId = Integer.parseInt(args[1]); + final Optional reactorTemplateResult = ReactorProvider.getReactorTemplate(templateId); + + if (reactorTemplateResult.isEmpty()) { + user.write(MessagePacket.system("Could not resolve reactor template ID: %d", templateId)); + return; + } + + final Field field = user.getField(); + final ReactorInfo reactorInfo = new ReactorInfo(templateId, "", user.getX(), user.getY(), false, -1); + field.getReactorPool().addReactor(Reactor.from(reactorTemplateResult.get(), reactorInfo)); + + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !reactor ")); + } + } + + @Command("hitreactor") + @Arguments("reactor template ID") + public static void hitReactor(User user, String[] args) { + try { + final int templateId = Integer.parseInt(args[1]); + final Field field = user.getField(); + final Optional reactorResult = field.getReactorPool().getByTemplateId(templateId); + + if (reactorResult.isEmpty()) { + user.write(MessagePacket.system("Could not resolve reactor with template ID: %d", templateId)); + return; + } + + final Reactor reactor = reactorResult.get(); + reactor.setState(reactor.getState() + 1); + field.getReactorPool().hitReactor(user, reactor, 0); + + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !hitreactor ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/supergm/StartQuestCommand.java b/src/main/java/kinoko/server/command/supergm/StartQuestCommand.java new file mode 100644 index 00000000..0a99311f --- /dev/null +++ b/src/main/java/kinoko/server/command/supergm/StartQuestCommand.java @@ -0,0 +1,37 @@ +package kinoko.server.command.supergm; + +import kinoko.packet.world.MessagePacket; +import kinoko.provider.QuestProvider; +import kinoko.provider.quest.QuestInfo; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.quest.QuestRecord; +import kinoko.world.user.User; + +import java.util.Optional; + +/** + * Force-starts a quest for the user. + * SuperGM-level command. + */ +public final class StartQuestCommand { + + @Command("startquest") + @Arguments("quest ID") + public static void startQuest(User user, String[] args) { + try { + int questId = Integer.parseInt(args[1]); + Optional questInfoResult = QuestProvider.getQuestInfo(questId); + if (questInfoResult.isEmpty()) { + user.write(MessagePacket.system("Could not find quest : %d", questId)); + return; + } + + QuestRecord qr = user.getQuestManager().forceStartQuest(questId); + user.write(MessagePacket.questRecord(qr)); + user.validateStat(); + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !startquest ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/supergm/ToggleMobCommand.java b/src/main/java/kinoko/server/command/supergm/ToggleMobCommand.java new file mode 100644 index 00000000..75228671 --- /dev/null +++ b/src/main/java/kinoko/server/command/supergm/ToggleMobCommand.java @@ -0,0 +1,30 @@ +package kinoko.server.command.supergm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.user.User; + +/** + * SuperGM command to enable or disable mob spawns in the current field. + */ +public final class ToggleMobCommand { + + @Command("togglemob") + @Arguments("true/false") + public static void disableMob(User user, String[] args) { + try { + if (args[1].equalsIgnoreCase("true")) { + user.getField().setMobSpawn(true); + user.write(MessagePacket.system("Enabled mob spawns")); + } else if (args[1].equalsIgnoreCase("false")) { + user.getField().setMobSpawn(false); + user.write(MessagePacket.system("Disabled mob spawns")); + } else { + user.write(MessagePacket.system("Usage: !togglemob ")); + } + } catch (ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !togglemob ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/tester/ClearInventoryCommand.java b/src/main/java/kinoko/server/command/tester/ClearInventoryCommand.java new file mode 100644 index 00000000..0bf02f22 --- /dev/null +++ b/src/main/java/kinoko/server/command/tester/ClearInventoryCommand.java @@ -0,0 +1,53 @@ +package kinoko.server.command.tester; + +import kinoko.packet.world.MessagePacket; +import kinoko.packet.world.WvsContext; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.item.InventoryOperation; +import kinoko.world.item.InventoryType; +import kinoko.world.user.User; + +import java.util.ArrayList; +import java.util.Arrays; +import java.util.List; +import java.util.Optional; + +/** + * Tester command to clear a specific inventory of a user. + */ +public final class ClearInventoryCommand { + + @Command({"clearinventory", "clearinv"}) + @Arguments("inventory type") + public static void clearInventory(User user, String[] args) { + try { + Optional inventoryTypeResult = Arrays.stream(InventoryType.values()) + .filter(type -> type.name().equalsIgnoreCase(args[1])) + .findFirst(); + + if (inventoryTypeResult.isEmpty()) { + user.write(MessagePacket.system( + "Please specify a valid inventory type: EQUIP | CONSUME | INSTALL | ETC | CASH")); + return; + } + + InventoryType inventoryType = inventoryTypeResult.get(); + List removeOperations = new ArrayList<>(); + + var iter = user.getInventoryManager().getInventoryByType(inventoryType).getItems().entrySet().iterator(); + while (iter.hasNext()) { + var tuple = iter.next(); + int position = tuple.getKey(); + removeOperations.add(InventoryOperation.delItem(inventoryType, position)); + iter.remove(); + } + + user.write(WvsContext.inventoryOperation(removeOperations, true)); + user.write(MessagePacket.system("%s inventory cleared!", inventoryType)); + + } catch (ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !clearinventory ")); + } + } +} diff --git a/src/main/java/kinoko/server/command/tester/CooldownCommand.java b/src/main/java/kinoko/server/command/tester/CooldownCommand.java new file mode 100644 index 00000000..30ca27e7 --- /dev/null +++ b/src/main/java/kinoko/server/command/tester/CooldownCommand.java @@ -0,0 +1,22 @@ +package kinoko.server.command.tester; + +import kinoko.packet.user.UserLocal; +import kinoko.server.command.Command; +import kinoko.world.user.User; + +/** + * Clears all skill cooldowns for the executing user. + * Tester-level command. + */ +public final class CooldownCommand { + + @Command({"cd", "cooldown", "cooldowns"}) + public static void cd(User user, String[] args) { + var iter = user.getSkillManager().getSkillCooltimes().keySet().iterator(); + while (iter.hasNext()) { + int skillId = iter.next(); + user.write(UserLocal.skillCooltimeSet(skillId, 0)); // remove visual cooldown + iter.remove(); // remove from internal map + } + } +} diff --git a/src/main/java/kinoko/server/command/tester/FindCommand.java b/src/main/java/kinoko/server/command/tester/FindCommand.java new file mode 100644 index 00000000..3414ffb0 --- /dev/null +++ b/src/main/java/kinoko/server/command/tester/FindCommand.java @@ -0,0 +1,306 @@ +package kinoko.server.command.tester; + +import kinoko.packet.world.MessagePacket; +import kinoko.provider.*; +import kinoko.provider.item.ItemInfo; +import kinoko.provider.map.MapInfo; +import kinoko.provider.mob.MobTemplate; +import kinoko.provider.npc.NpcTemplate; +import kinoko.provider.quest.QuestInfo; +import kinoko.provider.skill.SkillInfo; +import kinoko.server.cashshop.CashShop; +import kinoko.server.cashshop.Commodity; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.util.Util; +import kinoko.world.user.User; +import kinoko.provider.map.PortalInfo; + +import java.util.Arrays; +import java.util.Comparator; +import java.util.List; +import java.util.Map; +import java.util.Optional; +import kinoko.provider.skill.SkillStringInfo; + + +/** + * Fully detailed FindCommand with helpers for item/map/mob/npc/skill/quest/commodity. + */ +public final class FindCommand { + + @Command({ "find", "lookup" }) + @Arguments({ "item/map/mob/npc/skill/quest/commodity", "id or query" }) + public static void find(User user, String[] args) { + if (args.length < 2) { + user.write(MessagePacket.system("Usage: !find ")); + return; + } + + final String type = args[1].toLowerCase(); + final String query = String.join(" ", Arrays.copyOfRange(args, 2, args.length)); + final boolean isNumber = Util.isInteger(query); + + try { + switch (type) { + case "item" -> findItem(user, query, isNumber); + case "map" -> findMap(user, query, isNumber); + case "mob" -> findMob(user, query, isNumber); + case "npc" -> findNpc(user, query, isNumber); + case "skill" -> findSkill(user, query, isNumber); + case "quest" -> findQuest(user, query, isNumber); + case "commodity" -> findCommodity(user, query, isNumber); + default -> user.write(MessagePacket.system("Unknown type: %s", type)); + } + } catch (NumberFormatException e) { + user.write(MessagePacket.system("Invalid number: %s", query)); + } + } + + private static void findItem(User user, String query, boolean isNumber) { + int itemId = -1; + if (isNumber) { + itemId = Integer.parseInt(query); + } else { + List> results = StringProvider.getItemNames().entrySet().stream() + .filter(entry -> entry.getValue().toLowerCase().contains(query.toLowerCase())) + .sorted(Map.Entry.comparingByKey()) + .toList(); + if (results.isEmpty()) { + user.write(MessagePacket.system("No item found for name: %s", query)); + return; + } else if (results.size() == 1) { + itemId = results.get(0).getKey(); + } else { + user.write(MessagePacket.system("Results for item name: \"%s\"", query)); + results.forEach(entry -> user.write(MessagePacket.system(" %d : %s", entry.getKey(), entry.getValue()))); + return; + } + } + + Optional itemInfoResult = ItemProvider.getItemInfo(itemId); + if (itemInfoResult.isEmpty()) { + user.write(MessagePacket.system("Could not find item with %s: %s", isNumber ? "ID" : "name", query)); + return; + } + + ItemInfo ii = itemInfoResult.get(); + user.write(MessagePacket.system("Item: %s (%d)", StringProvider.getItemName(itemId), itemId)); + if (!ii.getItemInfos().isEmpty()) { + user.write(MessagePacket.system(" info:")); + ii.getItemInfos().forEach((key, value) -> user.write(MessagePacket.system(" %s : %s", key.name(), value))); + } + if (!ii.getItemSpecs().isEmpty()) { + user.write(MessagePacket.system(" spec:")); + ii.getItemSpecs().forEach((key, value) -> user.write(MessagePacket.system(" %s : %s", key.name(), value))); + } + } + + private static void findMap(User user, String query, boolean isNumber) { + int mapId = -1; + if (isNumber) { + mapId = Integer.parseInt(query); + } else { + List> results = StringProvider.getMapNames().entrySet().stream() + .filter(entry -> entry.getValue().toLowerCase().contains(query.toLowerCase())) + .sorted(Map.Entry.comparingByKey()) + .toList(); + if (results.isEmpty()) { + user.write(MessagePacket.system("No map found for name: %s", query)); + return; + } else if (results.size() == 1) { + mapId = results.get(0).getKey(); + } else { + user.write(MessagePacket.system("Results for map name: \"%s\"", query)); + results.forEach(entry -> user.write(MessagePacket.system(" %d : %s", entry.getKey(), entry.getValue()))); + return; + } + } + + final int mapIdFinal = mapId; // make a final copy for lambdas + + Optional mapInfoResult = MapProvider.getMapInfo(mapIdFinal); + if (mapInfoResult.isEmpty()) { + user.write(MessagePacket.system("Could not find map with %s: %s", isNumber ? "ID" : "name", query)); + return; + } + + MapInfo mapInfo = mapInfoResult.get(); + + List connectedMaps = MapProvider.getMapInfos().stream() + .filter(m -> m.getPortalInfos().stream().anyMatch(p -> p.getDestinationFieldId() == mapIdFinal)) + .sorted(Comparator.comparingInt(MapInfo::getMapId)) + .toList(); + + user.write(MessagePacket.system("Map: %s (%d)", StringProvider.getMapName(mapIdFinal), mapIdFinal)); + user.write(MessagePacket.system(" type: %s", mapInfo.getFieldType())); + user.write(MessagePacket.system(" returnMap: %d", mapInfo.getReturnMap())); + user.write(MessagePacket.system(" forcedReturn: %d", mapInfo.getForcedReturn())); + user.write(MessagePacket.system(" onFirstUserEnter: %s", mapInfo.getOnFirstUserEnter())); + user.write(MessagePacket.system(" onUserEnter: %s", mapInfo.getOnUserEnter())); + + if (!mapInfo.getPortalInfos().isEmpty()) { + user.write(MessagePacket.system(" portals:")); + mapInfo.getPortalInfos().stream() + .sorted(Comparator.comparingInt(PortalInfo::getPortalId)) + .forEach(p -> user.write(MessagePacket.system(" %s (%d, %d)", p.getPortalName(), p.getX(), p.getY()))); + } + + if (!connectedMaps.isEmpty()) { + user.write(MessagePacket.system(" connectedMaps:")); + connectedMaps.forEach(m -> user.write(MessagePacket.system(" %s (%d)", StringProvider.getMapName(m.getMapId()), m.getMapId()))); + } + } + + + private static void findMob(User user, String query, boolean isNumber) { + int mobId = isNumber ? Integer.parseInt(query) : -1; + if (!isNumber) { + List> results = StringProvider.getMobNames().entrySet().stream() + .filter(entry -> entry.getValue().toLowerCase().contains(query.toLowerCase())) + .sorted(Map.Entry.comparingByKey()) + .toList(); + if (results.isEmpty()) { + user.write(MessagePacket.system("No mob found for name: %s", query)); + return; + } else if (results.size() == 1) { + mobId = results.get(0).getKey(); + } else { + user.write(MessagePacket.system("Results for mob name: \"%s\"", query)); + results.forEach(entry -> user.write(MessagePacket.system(" %d : %s", entry.getKey(), entry.getValue()))); + return; + } + } + + Optional mobTemplateResult = MobProvider.getMobTemplate(mobId); + if (mobTemplateResult.isEmpty()) { + user.write(MessagePacket.system("Could not find mob with %s: %s", isNumber ? "ID" : "name", query)); + return; + } + + MobTemplate mob = mobTemplateResult.get(); + user.write(MessagePacket.system("Mob: %s (%d)", StringProvider.getMobName(mobId), mobId)); + user.write(MessagePacket.system(" level: %d", mob.getLevel())); + } + + private static void findNpc(User user, String query, boolean isNumber) { + int npcId = isNumber ? Integer.parseInt(query) : -1; + if (!isNumber) { + List> results = StringProvider.getNpcNames().entrySet().stream() + .filter(entry -> entry.getValue().toLowerCase().contains(query.toLowerCase())) + .sorted(Map.Entry.comparingByKey()) + .toList(); + if (results.isEmpty()) { + user.write(MessagePacket.system("No npc found for name: %s", query)); + return; + } else if (results.size() == 1) { + npcId = results.get(0).getKey(); + } else { + user.write(MessagePacket.system("Results for npc name: \"%s\"", query)); + results.forEach(entry -> user.write(MessagePacket.system(" %d : %s", entry.getKey(), entry.getValue()))); + return; + } + } + + Optional npcTemplateResult = NpcProvider.getNpcTemplate(npcId); + if (npcTemplateResult.isEmpty()) { + user.write(MessagePacket.system("Could not find npc with %s: %s", isNumber ? "ID" : "name", query)); + return; + } + + NpcTemplate npc = npcTemplateResult.get(); + List npcFields = MapProvider.getMapInfos().stream() + .filter(m -> m.getLifeInfos().stream().anyMatch(l -> l.getTemplateId() == npc.getId())) + .sorted(Comparator.comparingInt(MapInfo::getMapId)) + .toList(); + + user.write(MessagePacket.system("Npc: %s (%d)", StringProvider.getNpcName(npcId), npcId)); + user.write(MessagePacket.system(" script: %s", npc.getScript())); + npcFields.forEach(f -> user.write(MessagePacket.system(" field: %s (%d)", StringProvider.getMapName(f.getMapId()), f.getMapId()))); + } + + private static void findSkill(User user, String query, boolean isNumber) { + int skillId = isNumber ? Integer.parseInt(query) : -1; + if (!isNumber) { + List> results = StringProvider.getSkillStrings().entrySet().stream() + .filter(e -> e.getValue().getName().toLowerCase().contains(query.toLowerCase())) + .sorted(Map.Entry.comparingByKey()) + .toList(); + if (results.isEmpty()) { + user.write(MessagePacket.system("No skill found for name: %s", query)); + return; + } else if (results.size() == 1) { + skillId = results.get(0).getKey(); + } else { + user.write(MessagePacket.system("Results for skill name: \"%s\"", query)); + results.forEach(e -> user.write(MessagePacket.system(" %d : %s", e.getKey(), e.getValue().getName()))); + return; + } + } + + Optional skillInfoResult = SkillProvider.getSkillInfoById(skillId); + if (skillInfoResult.isEmpty()) { + user.write(MessagePacket.system("Could not find skill with %s: %s", isNumber ? "ID" : "name", query)); + return; + } + + user.write(MessagePacket.system("Skill: %s (%d)", StringProvider.getSkillName(skillId), skillId)); + } + + private static void findQuest(User user, String query, boolean isNumber) { + int questId = isNumber ? Integer.parseInt(query) : -1; + if (!isNumber) { + List results = QuestProvider.getQuestInfos().stream() + .filter(q -> q.getQuestName().toLowerCase().contains(query.toLowerCase()) || + q.getQuestParent().toLowerCase().contains(query.toLowerCase())) + .sorted(Comparator.comparingInt(QuestInfo::getQuestId)) + .toList(); + if (results.isEmpty()) { + user.write(MessagePacket.system("No quest found for name: %s", query)); + return; + } else if (results.size() == 1) { + questId = results.get(0).getQuestId(); + } else { + user.write(MessagePacket.system("Results for quest name: \"%s\"", query)); + results.forEach(q -> user.write(MessagePacket.system(" %d : %s%s", + q.getQuestId(), + q.getQuestParent().isEmpty() ? "" : q.getQuestParent() + " : ", + q.getQuestName()))); + return; + } + } + + Optional questInfoResult = QuestProvider.getQuestInfo(questId); + if (questInfoResult.isEmpty()) { + user.write(MessagePacket.system("Could not find quest with %s: %s", isNumber ? "ID" : "name", query)); + return; + } + + QuestInfo quest = questInfoResult.get(); + user.write(MessagePacket.system("Quest %d : %s%s", quest.getQuestId(), + quest.getQuestParent().isEmpty() ? "" : quest.getQuestParent() + " : ", + quest.getQuestName())); + } + + private static void findCommodity(User user, String query, boolean isNumber) { + if (!isNumber) { + user.write(MessagePacket.system("Can only lookup commodity by ID")); + return; + } + + int commodityId = Integer.parseInt(query); + Optional commodityResult = CashShop.getCommodity(commodityId); + if (commodityResult.isEmpty()) { + user.write(MessagePacket.system("Could not find commodity with ID: %d", commodityId)); + return; + } + + Commodity commodity = commodityResult.get(); + user.write(MessagePacket.system("Commodity: %d", commodityId)); + user.write(MessagePacket.system(" itemId : %d (%s)", commodity.getItemId(), StringProvider.getItemName(commodity.getItemId()))); + user.write(MessagePacket.system(" count : %d", commodity.getCount())); + user.write(MessagePacket.system(" price : %d", commodity.getPrice())); + user.write(MessagePacket.system(" period : %d", commodity.getPeriod())); + user.write(MessagePacket.system(" gender : %d", commodity.getGender())); + } +} diff --git a/src/main/java/kinoko/server/command/tester/InfoCommand.java b/src/main/java/kinoko/server/command/tester/InfoCommand.java new file mode 100644 index 00000000..27bf8083 --- /dev/null +++ b/src/main/java/kinoko/server/command/tester/InfoCommand.java @@ -0,0 +1,66 @@ +package kinoko.server.command.tester; + +import kinoko.packet.world.MessagePacket; +import kinoko.provider.map.PortalInfo; +import kinoko.server.command.Command; +import kinoko.util.Rect; +import kinoko.util.Util; +import kinoko.world.field.Field; +import kinoko.world.user.User; +import kinoko.world.user.stat.CalcDamage; + +/** + * Tester command to display detailed user info. + */ +public final class InfoCommand { + + @Command("info") + public static void info(User user, String[] args) { + final Field field = user.getField(); + final var stats = user.getBasicStat(); + final var charStats = user.getCharacterStat(); + + // Basic stats + user.write(MessagePacket.system("HP : %d / %d, MP : %d / %d", user.getHp(), user.getMaxHp(), user.getMp(), user.getMaxMp())); + user.write(MessagePacket.system("STR : %d, DEX : %d, INT : %d, LUK : %d", stats.getStr(), stats.getDex(), stats.getInt(), stats.getLuk())); + user.write(MessagePacket.system("AP : %d", charStats.getAp())); + user.write(MessagePacket.system("SP : %s", charStats.getSp().getMap())); + user.write(MessagePacket.system("Damage : %d ~ %d", (int) CalcDamage.calcDamageMin(user), (int) CalcDamage.calcDamageMax(user))); + user.write(MessagePacket.system("Field ID : %d (%s)", field.getFieldId(), field.getFieldType())); + + // Foothold below + final String footholdBelow = field.getFootholdBelow(user.getX(), user.getY()) + .map(fh -> String.valueOf(fh.getSn())) + .orElse("unk"); + user.write(MessagePacket.system(" x : %d, y : %d, fh : %d (%s)", user.getX(), user.getY(), user.getFoothold(), footholdBelow)); + + // Nearest portal + final PortalInfo nearestPortal = field.getMapInfo().getPortalInfos().stream() + .min((a, b) -> Double.compare(Util.distance(user.getX(), user.getY(), a.getX(), a.getY()), + Util.distance(user.getX(), user.getY(), b.getX(), b.getY()))) + .orElse(null); + + if (nearestPortal != null && Util.distance(user.getX(), user.getY(), nearestPortal.getX(), nearestPortal.getY()) < 200) { + user.write(MessagePacket.system("Portal name : %s (%d)", nearestPortal.getPortalName(), nearestPortal.getPortalId())); + user.write(MessagePacket.system(" x : %d, y : %d, script : %s", nearestPortal.getX(), nearestPortal.getY(), nearestPortal.getScript())); + } + + // Detection rectangle for nearby objects + final Rect detectRect = Rect.of(-400, -400, 400, 400); + + // Nearest mob + user.getNearestObject(field.getMobPool().getInsideRect(user.getRelativeRect(detectRect))) + .ifPresent(mob -> { + user.write(MessagePacket.system(mob.toString())); + user.write(MessagePacket.system(" Controller : %s", mob.getController().getCharacterName())); + }); + + // Nearest NPC + user.getNearestObject(field.getNpcPool().getInsideRect(user.getRelativeRect(detectRect))) + .ifPresent(npc -> user.write(MessagePacket.system(npc.toString()))); + + // Nearest Reactor + user.getNearestObject(field.getReactorPool().getInsideRect(user.getRelativeRect(detectRect))) + .ifPresent(reactor -> user.write(MessagePacket.system(reactor.toString()))); + } +} diff --git a/src/main/java/kinoko/server/command/tester/MapCommand.java b/src/main/java/kinoko/server/command/tester/MapCommand.java new file mode 100644 index 00000000..e4ab0c63 --- /dev/null +++ b/src/main/java/kinoko/server/command/tester/MapCommand.java @@ -0,0 +1,56 @@ +package kinoko.server.command.tester; + +import kinoko.packet.world.MessagePacket; +import kinoko.provider.map.PortalInfo; +import kinoko.server.command.Command; +import kinoko.server.command.Arguments; +import kinoko.world.GameConstants; +import kinoko.world.field.Field; +import kinoko.world.user.User; + +import java.util.Optional; + +/** + * Tester commands to warp users to specific maps or get their current location. + */ +public final class MapCommand { + + @Command("whereami") + public static void whereAmI(User user, String[] args) { + user.write(MessagePacket.system("You are in map: %d", user.getField().getFieldId())); + } + + @Command({ "map", "warp" }) + @Arguments("field ID to warp to") + public static void map(User user, String[] args) { + try { + // Parse the field ID + final int fieldId = Integer.parseInt(args[1]); + + // Use supplied portal name or fallback to server default + final String portalName = args.length > 2 ? args[2] : GameConstants.DEFAULT_PORTAL_NAME; + + // Get the field + final Optional fieldResult = user.getConnectedServer().getFieldById(fieldId); + if (fieldResult.isEmpty()) { + user.write(MessagePacket.system("Could not resolve field ID: %d", fieldId)); + return; + } + final Field targetField = fieldResult.get(); + + // Get the portal by name + final Optional portalResult = targetField.getPortalByName(portalName); + if (portalResult.isEmpty()) { + user.write(MessagePacket.system("Could not resolve portal '%s' for field ID: %d", portalName, fieldId)); + return; + } + + // Warp the user + user.warp(targetField, portalResult.get(), false, false); + + } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { + user.write(MessagePacket.system("Usage: !map [portal name]")); + } + } + +} diff --git a/src/main/java/kinoko/server/command/tester/MaxCommand.java b/src/main/java/kinoko/server/command/tester/MaxCommand.java new file mode 100644 index 00000000..6a9d59e3 --- /dev/null +++ b/src/main/java/kinoko/server/command/tester/MaxCommand.java @@ -0,0 +1,93 @@ +package kinoko.server.command.tester; + +import kinoko.packet.world.WvsContext; +import kinoko.packet.world.MessagePacket; +import kinoko.provider.SkillProvider; +import kinoko.provider.skill.SkillInfo; +import kinoko.server.command.Command; +import kinoko.world.user.User; +import kinoko.world.user.stat.CharacterStat; +import kinoko.world.user.stat.Stat; +import kinoko.world.skill.SkillManager; +import kinoko.world.skill.SkillRecord; +import kinoko.world.job.Job; +import kinoko.world.job.JobConstants; +import kinoko.world.skill.SkillConstants; + +import java.util.ArrayList; +import java.util.List; +import java.util.Map; + +public final class MaxCommand { + + /** + * Fully maxes the character stats, resets and re-adds skills, and restores HP/MP. + * Tester-level command. + * + * @param user the user executing the command + * @param args command arguments (none expected) + */ + @Command("max") + public static void max(User user, String[] args) { + try { + // Set stats + CharacterStat cs = user.getCharacterStat(); + cs.setLevel((short) 200); + cs.setMaxHp(50000); + cs.setMaxMp(50000); + cs.setExp(0); + user.validateStat(); + user.write(WvsContext.statChanged(Map.of( + Stat.LEVEL, (byte) cs.getLevel(), + Stat.STR, cs.getBaseStr(), + Stat.DEX, cs.getBaseDex(), + Stat.INT, cs.getBaseInt(), + Stat.LUK, cs.getBaseLuk(), + Stat.MHP, cs.getMaxHp(), + Stat.MMP, cs.getMaxMp(), + Stat.EXP, cs.getExp() + ), true)); + + // Reset skills + SkillManager sm = user.getSkillManager(); + List removedRecords = new ArrayList<>(); + for (SkillRecord skillRecord : sm.getSkillRecords()) { + if (JobConstants.isBeginnerJob(SkillConstants.getSkillRoot(skillRecord.getSkillId()))) { + continue; + } + skillRecord.setSkillLevel(0); + skillRecord.setMasterLevel(0); + removedRecords.add(skillRecord); + sm.removeSkill(skillRecord.getSkillId()); + } + user.write(WvsContext.changeSkillRecordResult(removedRecords, true)); + + // Add skills + List skillRecords = new ArrayList<>(); + for (int skillRoot : JobConstants.getSkillRootFromJob(user.getJob())) { + if (JobConstants.isBeginnerJob(skillRoot)) { + continue; + } + Job job = Job.getById(skillRoot); + for (SkillInfo si : SkillProvider.getSkillsForJob(job)) { + SkillRecord skillRecord = new SkillRecord(si.getSkillId()); + skillRecord.setSkillLevel(si.getMaxLevel()); + skillRecord.setMasterLevel(si.getMaxLevel()); + sm.addSkill(skillRecord); + skillRecords.add(skillRecord); + } + } + user.updatePassiveSkillData(); + user.validateStat(); + user.write(WvsContext.changeSkillRecordResult(skillRecords, true)); + + // Heal + user.setHp(user.getMaxHp()); + user.setMp(user.getMaxMp()); + + } catch (Exception e) { + user.write(MessagePacket.system("Failed to max your character: %s", e.getMessage())); + e.printStackTrace(); + } + } +} diff --git a/src/main/java/kinoko/server/node/ChannelServerNode.java b/src/main/java/kinoko/server/node/ChannelServerNode.java index 5078042b..c120f1b8 100644 --- a/src/main/java/kinoko/server/node/ChannelServerNode.java +++ b/src/main/java/kinoko/server/node/ChannelServerNode.java @@ -117,6 +117,10 @@ public Optional getUserByCharacterId(int characterId) { return clientStorage.getUserByCharacterId(characterId); } + public Optional getUserByCharacterName(String characterName) { + return clientStorage.getUserByCharacterName(characterName); + } + public void notifyUserConnect(User user) { centralClientFuture.channel().writeAndFlush(CentralPacket.userConnect(RemoteUser.from(user))); } diff --git a/src/main/java/kinoko/server/node/ClientStorage.java b/src/main/java/kinoko/server/node/ClientStorage.java index d66ff726..e34d77e4 100644 --- a/src/main/java/kinoko/server/node/ClientStorage.java +++ b/src/main/java/kinoko/server/node/ClientStorage.java @@ -43,6 +43,19 @@ public Optional getUserByCharacterId(int characterId) { } } + public Optional getUserByCharacterName(String characterName) { + lock.lock(); + try { + return mapByCharacterId.values().stream() + .map(Client::getUser) + .filter(Objects::nonNull) + .filter(user -> user.getCharacterName().equalsIgnoreCase(characterName)) + .findFirst(); + } finally { + lock.unlock(); + } + } + public void addClient(Client client) { lock.lock(); try { diff --git a/src/main/java/kinoko/util/ClassScanner.java b/src/main/java/kinoko/util/ClassScanner.java new file mode 100644 index 00000000..e1f05730 --- /dev/null +++ b/src/main/java/kinoko/util/ClassScanner.java @@ -0,0 +1,29 @@ +package kinoko.util; + +import java.io.File; +import java.net.URL; +import java.util.HashSet; +import java.util.Objects; +import java.util.Set; + +public final class ClassScanner { + public static Set> getClasses(String packageName) { + Set> classes = new HashSet<>(); + String path = packageName.replace('.', '/'); + try { + URL resource = Thread.currentThread().getContextClassLoader().getResource(path); + if (resource == null) return classes; + File dir = new File(resource.toURI()); + if (!dir.exists()) return classes; + for (File file : Objects.requireNonNull(dir.listFiles())) { + if (file.isFile() && file.getName().endsWith(".class")) { + String className = packageName + "." + file.getName().replace(".class", ""); + classes.add(Class.forName(className)); + } + } + } catch (Exception e) { + e.printStackTrace(); + } + return classes; + } +} diff --git a/src/main/java/kinoko/world/user/User.java b/src/main/java/kinoko/world/user/User.java index bdda96d1..0ed084d3 100644 --- a/src/main/java/kinoko/world/user/User.java +++ b/src/main/java/kinoko/world/user/User.java @@ -114,6 +114,10 @@ public int getCharacterId() { return characterData.getCharacterId(); } + public AdminLevel getAdminLevel(){ + return characterData.getCharacterStat().getAdminLevel(); + } + public String getCharacterName() { return characterData.getCharacterName(); } diff --git a/src/main/java/kinoko/world/user/stat/AdminLevel.java b/src/main/java/kinoko/world/user/stat/AdminLevel.java new file mode 100644 index 00000000..bd17cf1f --- /dev/null +++ b/src/main/java/kinoko/world/user/stat/AdminLevel.java @@ -0,0 +1,54 @@ +package kinoko.world.user.stat; + +public enum AdminLevel { + ADMIN((short) 0), // ADMIN_LEVEL_1 + MANAGER((short) 1), // ADMIN_LEVEL_2 + SUPER_GM((short) 2), // ADMIN_LEVEL_3 + GM((short) 3), // ADMIN_LEVEL_4 + JR_GM((short) 4), // ADMIN_LEVEL_5 + TESTER((short) 5), // ADMIN_LEVEL_10 + PLAYER((short) 6); // No Corresponding Client Ver for nSubGradeCode. + + private final short value; + + /** + * Creates a new admin level with the given numeric value. + * @param value The short value representing this level. + */ + AdminLevel(short value) { + this.value = value; + } + + /** + * Gets the numeric value representing this admin level. + * @return The short value of the admin level. + */ + public short getValue() { + return value; + } + + /** + * Returns the corresponding AdminLevel for the given numeric value. + * If the value does not match any level, defaults to PLAYER. + * @param value The numeric value to look up. + * @return The corresponding AdminLevel, or PLAYER if not found. + */ + public static AdminLevel fromValue(short value) { + for (AdminLevel level : values()) { + if (level.value == value) { + return level; + } + } + return PLAYER; + } + + /** + * Checks if this level has at least the same or higher authority as another level. + * Lower numeric values indicate higher privileges. + * @param other The other AdminLevel to compare against. + * @return True if this level has equal or higher authority; false otherwise. + */ + public boolean isAtLeast(AdminLevel other) { + return this.value <= other.value; + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/world/user/stat/CharacterStat.java b/src/main/java/kinoko/world/user/stat/CharacterStat.java index fc5c6f7c..4b45d8fb 100644 --- a/src/main/java/kinoko/world/user/stat/CharacterStat.java +++ b/src/main/java/kinoko/world/user/stat/CharacterStat.java @@ -40,6 +40,7 @@ public final class CharacterStat implements Encodable { private long petSn1; private long petSn2; private long petSn3; + private AdminLevel adminLevel = AdminLevel.PLAYER; public CharacterStat(){ @@ -50,7 +51,7 @@ public CharacterStat(int id, String name, byte gender, byte skin, int face, int short baseStr, short baseDex, short baseInt, short baseLuk, int hp, int maxHp, int mp, int maxMp, short ap, int exp, short pop, int posMap, byte portal, - long petSn1, long petSn2, long petSn3) { + long petSn1, long petSn2, long petSn3, AdminLevel adminLevel) { this.id = id; this.name = name; this.gender = gender; @@ -77,12 +78,22 @@ public CharacterStat(int id, String name, byte gender, byte skin, int face, int this.petSn2 = petSn2; this.petSn3 = petSn3; this.sp = ExtendSp.from(new HashMap<>()); // empty on init. + + this.adminLevel = adminLevel == null ? AdminLevel.PLAYER : adminLevel; } public int getId() { return id; } + public AdminLevel getAdminLevel() { + return adminLevel; + } + + public void setAdminLevel(AdminLevel adminLevel) { + this.adminLevel = adminLevel; + } + public void setId(int id) { this.id = id; } diff --git a/utility/change_db.bat b/utility/change_db.bat new file mode 100644 index 00000000..d01f4264 --- /dev/null +++ b/utility/change_db.bat @@ -0,0 +1,65 @@ +@echo off +setlocal + +:: ========================================================================== +:: This script sets up your environment file (.env) for the project. +:: +:: How it works: +:: - Prompts you to choose between Cassandra ("c") or Postgres ("p"). +:: - Copies the corresponding environment file from the root folder to +:: the working directory as ".env". +:: +:: Required files: +:: - c.env : Cassandra environment variables +:: - p.env : Postgres environment variables +:: Both files must exist in the parent folder of this script. +:: +:: File naming: +:: - c.env -> used when you type "c" +:: - p.env -> used when you type "p" +:: +:: Benefits: +:: - Ensures you always have the correct environment configuration +:: for the database you are using. +:: - Reduces the chance of errors from manually renaming or copying +:: environment files. +:: - Makes it easier to switch between databases during development. +:: +:: Example usage: +:: Run the script and enter "c" or "p" when prompted. +:: ========================================================================== + + +:: Ask user for input +:ask +set /p choice=Enter "c" for Cassandra or "p" for Postgres: + +:: Convert to lowercase just in case +set choice=%choice:~0,1% +set choice=%choice:"=% + +:: Determine which file to copy +if /i "%choice%"=="c" ( + if exist "..\c.env" ( + copy /y "..\c.env" "..\.env" + echo Copied ..\c.env to ..\.env + ) else ( + echo ..\c.env not found! + ) + goto end +) + +if /i "%choice%"=="p" ( + if exist "..\p.env" ( + copy /y "..\p.env" "..\.env" + echo Copied ..\p.env to ..\.env + ) else ( + echo ..\p.env not found! + ) + goto end +) + +echo Invalid input. Please enter "c" or "p". +goto ask + +:end From 0dd0789a7282b1950b9d4c62f5d20b05fe9a3ed9 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 21 Oct 2025 22:50:15 -0400 Subject: [PATCH 52/83] remove mistake from merging --- .../kinoko/world/user/stat/CharacterStat.java | 38 ------------------- 1 file changed, 38 deletions(-) diff --git a/src/main/java/kinoko/world/user/stat/CharacterStat.java b/src/main/java/kinoko/world/user/stat/CharacterStat.java index 79434fc1..4b45d8fb 100644 --- a/src/main/java/kinoko/world/user/stat/CharacterStat.java +++ b/src/main/java/kinoko/world/user/stat/CharacterStat.java @@ -82,44 +82,6 @@ public CharacterStat(int id, String name, byte gender, byte skin, int face, int this.adminLevel = adminLevel == null ? AdminLevel.PLAYER : adminLevel; } - public CharacterStat(){ - - } - - public CharacterStat(int id, String name, byte gender, byte skin, int face, int hair, - short level, short job, short subJob, - short baseStr, short baseDex, short baseInt, short baseLuk, - int hp, int maxHp, int mp, int maxMp, short ap, - int exp, short pop, int posMap, byte portal, - long petSn1, long petSn2, long petSn3) { - this.id = id; - this.name = name; - this.gender = gender; - this.skin = skin; - this.face = face; - this.hair = hair; - this.level = level; - this.job = job; - this.subJob = subJob; - this.baseStr = baseStr; - this.baseDex = baseDex; - this.baseInt = baseInt; - this.baseLuk = baseLuk; - this.hp = hp; - this.maxHp = maxHp; - this.mp = mp; - this.maxMp = maxMp; - this.ap = ap; - this.exp = exp; - this.pop = pop; - this.posMap = posMap; - this.portal = portal; - this.petSn1 = petSn1; - this.petSn2 = petSn2; - this.petSn3 = petSn3; - this.sp = ExtendSp.from(new HashMap<>()); // empty on init. - } - public int getId() { return id; } From 1de5ad7c2673b648978c5f63491ece80ef627c0d Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 28 Oct 2025 18:30:28 -0400 Subject: [PATCH 53/83] Fixed query syntax issue --- .../kinoko/database/postgresql/type/CharacterDataDao.java | 2 +- src/main/java/kinoko/handler/stage/LoginHandler.java | 5 ++--- 2 files changed, 3 insertions(+), 4 deletions(-) diff --git a/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java b/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java index 0782f029..421ab8be 100644 --- a/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java +++ b/src/main/java/kinoko/database/postgresql/type/CharacterDataDao.java @@ -296,7 +296,7 @@ ON CONFLICT (character_id) DO UPDATE SET portal = EXCLUDED.portal, pet_1 = EXCLUDED.pet_1, pet_2 = EXCLUDED.pet_2, - pet_3 = EXCLUDED.pet_3 + pet_3 = EXCLUDED.pet_3, admin_level = EXCLUDED.admin_level """; diff --git a/src/main/java/kinoko/handler/stage/LoginHandler.java b/src/main/java/kinoko/handler/stage/LoginHandler.java index 92453764..cdd2d3bb 100644 --- a/src/main/java/kinoko/handler/stage/LoginHandler.java +++ b/src/main/java/kinoko/handler/stage/LoginHandler.java @@ -260,9 +260,8 @@ public static void handleCreateNewCharacter(Client c, InPacket inPacket) { cs.setPop((short) 0); cs.setPosMap(GameConstants.getStartingMap(job, selectedSubJob)); cs.setPortal((byte) 0); - if (ServerConfig.TESPIA) { - cs.setAdminLevel(AdminLevel.ADMIN); - } + cs.setAdminLevel(ServerConfig.TESPIA ? AdminLevel.ADMIN : AdminLevel.PLAYER); + characterData.setCharacterStat(cs); From f057757b84b5e8791d85ea3dd1746d3c20d01586 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 28 Oct 2025 18:56:23 -0400 Subject: [PATCH 54/83] Added search as an alias to find --- src/main/java/kinoko/server/command/tester/FindCommand.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/kinoko/server/command/tester/FindCommand.java b/src/main/java/kinoko/server/command/tester/FindCommand.java index 3414ffb0..64cfdd45 100644 --- a/src/main/java/kinoko/server/command/tester/FindCommand.java +++ b/src/main/java/kinoko/server/command/tester/FindCommand.java @@ -29,7 +29,7 @@ */ public final class FindCommand { - @Command({ "find", "lookup" }) + @Command({ "find", "lookup", "search"}) @Arguments({ "item/map/mob/npc/skill/quest/commodity", "id or query" }) public static void find(User user, String[] args) { if (args.length < 2) { From 90cc634fa9fd24cbdb0dee97b3f894f99ceadf62 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 28 Oct 2025 19:59:36 -0400 Subject: [PATCH 55/83] Lynx Scripts --- src/main/java/kinoko/script/Consume.java | 24 + .../java/kinoko/script/HarvestReactor.java | 39 + src/main/java/kinoko/script/UniversalNPC.java | 73 + .../java/kinoko/script/boss/CapLatanica.java | 35 + .../java/kinoko/script/boss/Scarlion.java | 45 + src/main/java/kinoko/script/boss/Zakum.java | 456 ++ .../kinoko/script/common/ScriptManager.java | 34 +- .../script/common/ScriptManagerImpl.java | 100 +- .../kinoko/script/continent/AquaRoad.java | 55 + .../kinoko/script/continent/ContiMove.java | 39 + .../kinoko/script/continent/Edelstein.java | 151 + .../java/kinoko/script/continent/ElNath.java | 370 ++ .../kinoko/script/continent/ElNathMts.java | 339 ++ .../kinoko/script/continent/FlorinaBeach.java | 63 + .../java/kinoko/script/continent/LHC.java | 54 + .../kinoko/script/continent/Ludibrium.java | 82 + .../kinoko/script/continent/LudusLake.java | 11 +- .../kinoko/script/continent/MinarForest.java | 719 +++ .../kinoko/script/continent/Nautilus.java | 118 + .../kinoko/script/continent/NihalDesert.java | 111 + .../java/kinoko/script/continent/Orbis.java | 94 + .../script/continent/PhantomForest.java | 313 ++ .../script/continent/VictoriaIsland.java | 193 + .../java/kinoko/script/continent/Zipangu.java | 61 + .../java/kinoko/script/event/Gachapon.java | 108 + .../java/kinoko/script/party/AmoriaPQ.java | 146 + .../java/kinoko/script/party/BalrogPQ.java | 101 + .../kinoko/script/party/CrimsonwoodPQ.java | 242 + .../java/kinoko/script/party/GuildPQ.java | 239 + .../java/kinoko/script/party/HenesysPQ.java | 8 +- .../java/kinoko/script/party/KerningPQ.java | 50 +- src/main/java/kinoko/script/party/LudiPQ.java | 271 + .../script/party/MonsterCarnivalPQ.java | 116 + .../java/kinoko/script/party/MuLungDojo.java | 649 +++ .../java/kinoko/script/party/OrbisPQ.java | 273 + .../kinoko/script/quest/AndroidQuest.java | 30 + .../java/kinoko/script/quest/AranQuest.java | 1756 ++++++- .../kinoko/script/quest/AranTutorial.java | 54 + .../java/kinoko/script/quest/CygnusQuest.java | 1919 ++++++- .../kinoko/script/quest/CygnusTutorial.java | 13 + .../java/kinoko/script/quest/EvanQuest.java | 2756 +++++++++- .../kinoko/script/quest/EvanTutorial.java | 161 + .../java/kinoko/script/quest/EventQuest.java | 4476 +++++++++++++++++ .../kinoko/script/quest/EventScripts.java | 187 + .../kinoko/script/quest/Explorer4thJob.java | 773 +++ .../kinoko/script/quest/ExplorerQuest.java | 1673 +++++- .../kinoko/script/quest/HighLevelQuest.java | 1369 +++++ .../kinoko/script/quest/LudibriumQuest.java | 1435 ++++++ .../java/kinoko/script/quest/MiscQuest.java | 1240 +++++ .../java/kinoko/script/quest/NeoCity.java | 1178 +++++ .../java/kinoko/script/quest/NihalQuest.java | 1912 +++++++ .../java/kinoko/script/quest/PirateQuest.java | 138 + .../kinoko/script/quest/ResistanceQuest.java | 206 +- .../kinoko/script/quest/ResistanceQuest2.java | 119 + .../kinoko/script/quest/SpecialQuest.java | 1346 +++++ .../java/kinoko/script/quest/TitleQuest.java | 615 +++ .../kinoko/script/quest/TrainingQuest.java | 428 ++ .../kinoko/script/quest/TutorialQuest.java | 143 + 58 files changed, 29632 insertions(+), 77 deletions(-) create mode 100644 src/main/java/kinoko/script/HarvestReactor.java create mode 100644 src/main/java/kinoko/script/UniversalNPC.java create mode 100644 src/main/java/kinoko/script/boss/CapLatanica.java create mode 100644 src/main/java/kinoko/script/boss/Scarlion.java create mode 100644 src/main/java/kinoko/script/boss/Zakum.java create mode 100644 src/main/java/kinoko/script/continent/AquaRoad.java create mode 100644 src/main/java/kinoko/script/continent/ElNath.java create mode 100644 src/main/java/kinoko/script/continent/FlorinaBeach.java create mode 100644 src/main/java/kinoko/script/continent/LHC.java create mode 100644 src/main/java/kinoko/script/continent/Ludibrium.java create mode 100644 src/main/java/kinoko/script/continent/Nautilus.java create mode 100644 src/main/java/kinoko/script/continent/Orbis.java create mode 100644 src/main/java/kinoko/script/continent/PhantomForest.java create mode 100644 src/main/java/kinoko/script/continent/Zipangu.java create mode 100644 src/main/java/kinoko/script/event/Gachapon.java create mode 100644 src/main/java/kinoko/script/party/AmoriaPQ.java create mode 100644 src/main/java/kinoko/script/party/BalrogPQ.java create mode 100644 src/main/java/kinoko/script/party/CrimsonwoodPQ.java create mode 100644 src/main/java/kinoko/script/party/GuildPQ.java create mode 100644 src/main/java/kinoko/script/party/LudiPQ.java create mode 100644 src/main/java/kinoko/script/party/MonsterCarnivalPQ.java create mode 100644 src/main/java/kinoko/script/party/MuLungDojo.java create mode 100644 src/main/java/kinoko/script/party/OrbisPQ.java create mode 100644 src/main/java/kinoko/script/quest/AndroidQuest.java create mode 100644 src/main/java/kinoko/script/quest/EventQuest.java create mode 100644 src/main/java/kinoko/script/quest/EventScripts.java create mode 100644 src/main/java/kinoko/script/quest/Explorer4thJob.java create mode 100644 src/main/java/kinoko/script/quest/HighLevelQuest.java create mode 100644 src/main/java/kinoko/script/quest/LudibriumQuest.java create mode 100644 src/main/java/kinoko/script/quest/MiscQuest.java create mode 100644 src/main/java/kinoko/script/quest/NihalQuest.java create mode 100644 src/main/java/kinoko/script/quest/PirateQuest.java create mode 100644 src/main/java/kinoko/script/quest/ResistanceQuest2.java create mode 100644 src/main/java/kinoko/script/quest/SpecialQuest.java create mode 100644 src/main/java/kinoko/script/quest/TrainingQuest.java create mode 100644 src/main/java/kinoko/script/quest/TutorialQuest.java diff --git a/src/main/java/kinoko/script/Consume.java b/src/main/java/kinoko/script/Consume.java index bb16a5b1..345528ee 100644 --- a/src/main/java/kinoko/script/Consume.java +++ b/src/main/java/kinoko/script/Consume.java @@ -30,4 +30,28 @@ public static void blackBag(ScriptManager sm) { sm.spawnMob(9300388, MobAppearType.REGEN, sm.getUser().getX(), sm.getUser().getY(), false); sm.removeItem(2430032); } + + @Script("consume_2430071") + public static void consume_2430071(ScriptManager sm) { + // Opalescent Glass Marble (2430071) + // Dual Blade Quest 2363 "Time for the Awakening" + // Gives Mirror of Insight (4032616) when consumed + + if (!sm.hasQuestStarted(2363)) { + sm.message("You don't have the quest to use this item."); + return; + } + + if (sm.hasItem(4032616, 1)) { + sm.message("You already have the Mirror of Insight."); + return; + } + + if (sm.canAddItem(4032616, 1) && sm.removeItem(2430071, 1)) { + sm.addItem(4032616, 1); // Mirror of Insight + sm.message("You obtained the Mirror of Insight!"); + } else { + sm.message("Please check if your inventory is full."); + } + } } diff --git a/src/main/java/kinoko/script/HarvestReactor.java b/src/main/java/kinoko/script/HarvestReactor.java new file mode 100644 index 00000000..07ba1fcc --- /dev/null +++ b/src/main/java/kinoko/script/HarvestReactor.java @@ -0,0 +1,39 @@ +package kinoko.script; + +import kinoko.provider.reward.Reward; +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +import java.util.List; + +/** + * Harvest Reactor Scripts + * - Coconut trees, herbs, ore veins, etc. + */ +public final class HarvestReactor extends ScriptHandler { + + // COCONUT TREES (Florina Beach) =============================================================================== + + @Script("coconut0") + public static void coconut0(ScriptManager sm) { + // Coconut Tree Reactors (1102000, 1102001, 1102002) + // Maps: Florina Beach (110000000+) + // Drop: 4000136 (Coconut) - Only for Quest 22573 (Tropical Fruit Punch) + sm.dropRewards(List.of( + Reward.item(4000136, 1, 1, 1.0, 22573) // Coconut - 100% drop when quest 22573 is active + )); + } + + // DUAL BLADE REACTORS ============================================================================= + + @Script("dual_ball00") + public static void dual_ball00(ScriptManager sm) { + // Opalescent Marble Reactor - Dual Blade Quest 2363 "Time for the Awakening" + // Map: Marble Room (910350000) + // Drop: 2430071 (Opalescent Glass Marble) - Use this item to get Mirror of Insight + sm.dropRewards(List.of( + Reward.item(2430071, 1, 1, 1.0, 2363) // Opalescent Glass Marble - 100% drop when quest 2363 is active + )); + } +} diff --git a/src/main/java/kinoko/script/UniversalNPC.java b/src/main/java/kinoko/script/UniversalNPC.java new file mode 100644 index 00000000..3cdaeda5 --- /dev/null +++ b/src/main/java/kinoko/script/UniversalNPC.java @@ -0,0 +1,73 @@ +package kinoko.script; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +import java.util.Map; + +/** + * Universal NPCs that appear across multiple towns + * Includes event NPCs, seasonal NPCs, and special functionality NPCs + */ +public final class UniversalNPC extends ScriptHandler { + + @Script("cny") + public static void cny(ScriptManager sm) { + // Chinese New Year Event NPC + // Appears in various towns during CNY event + sm.sayNext("Happy Chinese New Year! I'm here to celebrate with you!"); + sm.sayBoth("Unfortunately, the Chinese New Year event is not currently active."); + sm.sayBoth("Please check back during the event period for special rewards and festivities!"); + } + + @Script("Event00") + public static void Event00(ScriptManager sm) { + // Generic Event NPC (9010038) + // Used for various seasonal events + final int answer = sm.askMenu("Hello! I'm an event coordinator. What would you like to know about?", Map.of( + 0, "What events are currently active?", + 1, "Tell me about past events", + 2, "Never mind" + )); + + if (answer == 0) { + sm.sayNext("Currently, there are no special events running."); + sm.sayBoth("Please check back later for new events and special activities!"); + } else if (answer == 1) { + sm.sayNext("We've had many exciting events in the past!"); + sm.sayBoth("From holiday celebrations to special boss battles, there's always something fun happening in MapleStory."); + sm.sayBoth("Keep an eye out for announcements about upcoming events!"); + } else { + sm.sayOk("Feel free to come back anytime if you have questions about events!"); + } + } + + @Script("GachaponEvent") + public static void GachaponEvent(ScriptManager sm) { + // Gachapon Event NPC (9010000) + // Special gachapon with event-exclusive rewards + sm.sayNext("Welcome to the special Gachapon event!"); + + final int answer = sm.askMenu("What would you like to know?", Map.of( + 0, "What is Gachapon?", + 1, "Are there any special Gachapon events?", + 2, "Where can I find Gachapon machines?" + )); + + if (answer == 0) { + sm.sayNext("#bGachapon#k is a special machine where you can insert a #bGachapon Ticket#k and receive a random item!"); + sm.sayBoth("The items you can get range from common equipment to rare scrolls and special prizes."); + sm.sayBoth("Each town's Gachapon has different items, so try them all!"); + } else if (answer == 1) { + sm.sayNext("Currently, there are no special Gachapon events running."); + sm.sayBoth("During events, Gachapon machines may have increased rates for rare items or exclusive event prizes!"); + sm.sayBoth("Check back during special occasions for limited-time rewards."); + } else if (answer == 2) { + sm.sayNext("Gachapon machines can be found in most major towns!"); + sm.sayBoth("Look for the colorful Gachapon machines in:"); + sm.sayBoth("#b- Henesys\r\n- Ellinia\r\n- Perion\r\n- Kerning City\r\n- Sleepywood\r\n- Orbis\r\n- El Nath\r\n- Ludibrium\r\n- Aquarium\r\n- Leafre\r\n- Mu Lung\r\n- Herb Town\r\n- Omega Sector\r\n- Korean Folk Town\r\n- Shrine\r\n- Showa#k"); + sm.sayBoth("Each location has different prize pools, so explore them all!"); + } + } +} diff --git a/src/main/java/kinoko/script/boss/CapLatanica.java b/src/main/java/kinoko/script/boss/CapLatanica.java new file mode 100644 index 00000000..c2c78f94 --- /dev/null +++ b/src/main/java/kinoko/script/boss/CapLatanica.java @@ -0,0 +1,35 @@ +package kinoko.script.boss; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.world.field.mob.MobAppearType; + +public class CapLatanica extends ScriptHandler { + @Script("captinsg00") + public static void captinsg00(ScriptManager sm) { + // Singapore : Ghost Ship 7 (541010060) + // in00 (-97, 332) + sm.playPortalSE(); + sm.warp(541010100, "sp"); + } + + @Script("sgboss0") + public static void sgboss0(ScriptManager sm) { + // sgboss0 (5411000) + // Singapore : The Engine Room (541010100) + sm.broadcastSoundEffect("Bgm09/TimeAttack"); + sm.spawnMob(9420513, MobAppearType.NORMAL, -148, 225, false); + sm.broadcastMessage("As you wish. Here comes Capt. Latanica!"); + } + + @Script("captinsg01") + public static void captinsg01(ScriptManager sm) { + // Bob : Ghost Ship Keeper (9270033) + // Singapore : The Engine Room (541010100) + if(sm.askYesNo("I can help you escape his wrath... do you want to leave?")) { + sm.setReactorState(5411000, 0); + sm.warp(541010110); + } + } +} diff --git a/src/main/java/kinoko/script/boss/Scarlion.java b/src/main/java/kinoko/script/boss/Scarlion.java new file mode 100644 index 00000000..8c2468df --- /dev/null +++ b/src/main/java/kinoko/script/boss/Scarlion.java @@ -0,0 +1,45 @@ +package kinoko.script.boss; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.world.field.mob.MobAppearType; + +public class Scarlion extends ScriptHandler { + final static int TARGA_BOSS = 9420542; + final static int SCARLION_BOSS = 9420546; + @Script("MalaysiaBoss_GL") + public static void MalaysiaBossGL(ScriptManager sm) { + // Aldol (9270047) + // Malaysia : Entrance to the Spooky World (551030100) + if(sm.askYesNo("Do you want to go to the Spooky World Entrance?")) { + sm.partyWarpInstance(551030200, "sp", 551030100, 60 * 60); + } + } + + @Script("myboss0") + public static void myboss0(ScriptManager sm) { + // myboss0 (5511000) + // Malaysia : Spooky World (551030200) + sm.broadcastMessage("Beware! The furious Targa has shown himself!"); + sm.spawnMob(TARGA_BOSS, MobAppearType.NORMAL, -527, 637, true); + } + + @Script("myboss1") + public static void myboss1(ScriptManager sm) { + // myboss1 (5511001) + // Malaysia : Spooky World (551030200) + sm.broadcastMessage("Beware! The furious Scarlion has shown himself!"); + sm.spawnMob(SCARLION_BOSS, MobAppearType.NORMAL, -238, 636, true); + } + + @Script("Malay_Warp") + public static void malayWarp(ScriptManager sm) { + // Aldol (9201134) + // Malaysia : Spooky World (551030200) + if(sm.askYesNo("Do you want to go out?")) { + sm.getField().reset(); + sm.partyWarp(551030100, "sp"); + } + } +} diff --git a/src/main/java/kinoko/script/boss/Zakum.java b/src/main/java/kinoko/script/boss/Zakum.java new file mode 100644 index 00000000..b203af56 --- /dev/null +++ b/src/main/java/kinoko/script/boss/Zakum.java @@ -0,0 +1,456 @@ +/* Adobis + * + * El Nath: The Door to Zakum (211042300) + * + * Zakum Quest NPC + + * Custom Quest 100200 = whether you can do Zakum + * Custom Quest 100201 = Collecting Gold Teeth <- indicates it's been started + * Custom Quest 100203 = Collecting Gold Teeth <- indicates it's finished + * Quest 7000 - Indicates if you've cleared first stage / fail + * 4031061 = Piece of Fire Ore - stage 1 reward + * 4031062 = Breath of Fire - stage 2 reward + * 4001017 = Eye of Fire - stage 3 reward + * 4000082 = Zombie's Gold Tooth (stage 3 req) + */ + +package kinoko.script.boss; + +import kinoko.provider.reward.Reward; +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.world.field.mob.MobAppearType; +import kinoko.world.field.mob.MobType; +import kinoko.world.quest.QuestRecordType; +import kinoko.world.user.User; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.atomic.AtomicBoolean; + +public class Zakum extends ScriptHandler { + final static int FIRST_STAGE_MAP = 280010000; + final static int ZAKUM_BOSS_MAP = 280030000; + @Script("Zakum00") + public static void zakum00(ScriptManager sm) { + // Adobis (2030008) + // El Nath : The Door to Zakum (211042300) + // Dead Mine : The Door to Chaos Zakum (211042301) + AtomicBoolean shouldStop = new AtomicBoolean(false); + if (sm.getLevel() < 50) { + sm.sayOk("Please come back to me when you've become stronger. I've seen a few adventurers in my day, and you're far too weak to complete my tasks."); + return; + } + + // Initial dialog + sm.sayOk("Shhhh ... be quiet. Deep in the dungeon rests a powerful foe. Complete the quests in order of the level, and you'll be able to meet the boss of the Zakum Dungeon. It won't be easy, at all ... but try your best."); + + // Ask the player which quest they want to complete + int choice = sm.askMenu("Well ... alright. You seem more than qualified for this. Which of these tasks do want to tackle on?#b", Map.of( + 0, "Explore the Dead Mine. (Level 1)", + 1, "Observe the Zakum Dungeon. (Level 2)", + 2, "Request for a refinery. (Level 3)", + 3, "Get briefed for the quest.", + 4, "Skip all quests (15,000,000 mesos)") + ); + + switch (choice) { + case 0: + if (sm.getQRValue(QuestRecordType.ZakumPreqStageOne).equals("3")) { + sm.sayOk("You have already looked through the Cave at the Dead Mine three times today and therefore I cannot let you in once more. Please come back tomorrow."); + return; + } + + // Check if at a party of at least 1 with level 50 + if (!sm.checkParty(1, 50)) { + sm.sayOk("You are not currently in a party right now. You may only tackle this assignment as a party."); + return; + } + + if(!sm.getUser().getPartyInfo().isBoss()) { + sm.sayNext("This journey will be a never ending maze of quests you won't be able to solve by yourself. But if you're willing to take on the challenge, then talk to the chief of your occupation at the Chief's Residence in El Nath to receive the quest."); + sm.sayBoth("After receiving the quest, either join a party or form one yourself, and have the leader of the party speak to me to start the quest. Once you are ready, have the leader of the party come up and talk to me."); + return; + } + + sm.removeItem(4001015); + sm.removeItem(4001016); + sm.removeItem(4001018); + sm.forceStartQuest(100200); + for (var member : sm.getField().getUserPool().getPartyMembers(sm.getUser().getPartyId())) { + if (!member.getQuestManager().hasQuestStarted(7000000)) { + sm.message("There's a member of your party that hasn't received the quest from the chief of the occupation at El Nath."); + shouldStop.set(true); + break; + } + } + + if (shouldStop.get()) { + return; + } + + sm.getField().getUserPool().forEachPartyMember(sm.getUser(), (member) -> { + member.getInventoryManager().removeItem(4001015, member.getInventoryManager().getItemCount(4001015)); + member.getInventoryManager().removeItem(4001016, member.getInventoryManager().getItemCount(4001016)); + member.getInventoryManager().removeItem(4031061, member.getInventoryManager().getItemCount(4031061)); + member.getQuestManager().forceStartQuest(100200); + }); + + sm.setQRValue(QuestRecordType.ZakumPreqStageOne, sm.getUser().getCharacterName()); + sm.playPortalSE(); + sm.partyWarpInstance(280010000, "st00", 211042300, 30 * 60); + break; + + case 1: + // Check if at a party of at least 1 with level 50 + if (!sm.checkParty(1, 50)) { + sm.sayOk("You are not currently in a party right now. You may only tackle this assignment as a party."); + return; + } + + // Check if Quest 1 is completed + sm.getField().getUserPool().forEachPartyMember(sm.getUser(), (member) -> { + if (!member.getQuestManager().hasQuestCompleted(100200)) { + shouldStop.set(true); + } + }); + + if (!sm.hasQuestCompleted(100200) || shouldStop.get()) { + sm.sayOk("It doesn't look like you or someone from your party have cleared the previous stage yet. Please beat the previous stage before moving onto the next level."); + return; + } + + // Check if still in the middle of Quest 1 + sm.getField().getUserPool().forEachPartyMember(sm.getUser(), (member) -> { + if (member.getQuestManager().hasQuestStarted(100200)) { + shouldStop.set(true); + } + }); + + if(sm.hasQuestStarted(100200) || shouldStop.get()) { + sm.sayOk("It seems like you or someone from your party in the middle of the 1st stage. You must first clear this one before moving on to Level 2. Please clear the 1st stage first."); + return; + } + + sm.getField().getUserPool().forEachPartyMember(sm.getUser(), (member) -> { + if (member.getQuestManager().hasQuestStarted(100201)) { + shouldStop.set(true); + } + }); + + if (sm.hasQuestStarted(100201) || shouldStop.get()) { + if(!sm.askYesNo("Hmmm ... you or someone from your party must have tried this quest before and gave up midway through. What do you think? Do you want to retry this level?")) { + sm.sayOk("I see ... but if you ever decide to change your mind, then talk to me."); + return; + } + } else if (sm.hasQuestCompleted(100201) || shouldStop.get()) { + if(!sm.askYesNo("Hmmm ... You or someone from your party have already cleared this level before. For you to be rewarded again, you need to restart the quest from Level 1. Otherwise, you will still be able to do the quest but will not be rewarded. Do you still want to retry this level?")) { + sm.sayOk("I see ... but if you ever decide to change your mind, then talk to me."); + return; + } + } + + if (!sm.askYesNo("You have safely cleared the 1st stage. There's still a long way to go before meeting the boss of Zakum Dungeon, however. So, what do you think? Are you ready to move on to the next stage?")) { + sm.sayOk("I see ... but if you ever decide to change your mind, then talk to me."); + return; + } + + sm.sayNext("Alright! From here on out, you'll be transported to the map where obstacles will be aplenty. There will be a person standing at the deepest part of the map, and if you talk to her, you'll find an item that will be used as a material to create an item that summons the boss of Zakum Dungeon. Please get me that item. Good luck!"); + sm.forceStartQuest(100201); + sm.getField().getUserPool().forEachPartyMember(sm.getUser(), (member) -> { + member.getQuestManager().forceStartQuest(100201); + }); + + sm.partyWarpInstance(280020000, "sp", 211042300, 30 * 60); + + break; + + case 2: + // Check if at a party of at least 1 with level 50 + if (!sm.checkParty(1, 50)) { + sm.sayOk("You are not currently in a party right now. You may only tackle this assignment as a party."); + return; + } + + // Same logic as Quest 2 for Quest 3 + if (!sm.hasQuestCompleted(100201)) { + sm.sayOk("Hmmm ... I don't think you have cleared the previous stage, yet. Please beat the previous stage before moving onto the next level."); + return; + } + + if (sm.hasQuestStarted(100202)) { + if (!sm.hasItem(4000082, 30)) { + sm.sayOk("I don't think you have #b30 Zombie's Lost Gold Teeth#k yet. Gather them all up and I may be able to refine them and make a special item for you ..."); + return; + } + sm.sayNext("Ha ha ha, don't worry, I'll make it in a heartbeat!"); + if(!sm.hasItem(4000082, 30) || !sm.hasItem(4001018, 1) || !sm.hasItem(4031062, 1) || !sm.canAddItem(4001017, 1)) { + sm.sayOk("Hmmm... are you sure you have all the items required to make #rEye of Fire#k with you? If so, then please check and see if your etc. inventory is full or not."); + return; + } + + sm.addItem(4001017, 5); // Eye of Fire + sm.forceCompleteQuest(100202); + sm.sayOk("Here it is. You will now be able to enter the alter of the Zakum Dungeon when the door on the left is open.. You'll need\\r\\n#b#t4001017##k with you in order to go through the door and enter the stage. Now, let's see how many can enter this place ...?"); + } + else if (sm.hasQuestCompleted(3)) { + if (!sm.askYesNo("Hmmm ... aren't you the one who refined #b#t4001017##k before? Then what can I do for you? Are you interested in mixing #b#t4031061##k with #b#t4031062##k again to create #b#t4001017##k?")) { + sm.sayOk("I see ... but please be aware that you won't be able to see the boss of Zakum Dungeon without the #b#t4001017##k."); + return; + } + sm.sayOk("Hmmm, by mixing #b#t4031061##k with #b#t4031062##k, I can make an the item that will be used as a sacrifice to summon the boss, called #b#t4001017##k. The problem is ... (cough cough) as you can see, I am not feeling terribly well these days, so it's difficult for me to move around and gather up items. Well ... will it be ok for you to gather up #b30 Zombie's Lost Gold Teeth#k for me? Don't ask me where I'll be using it, though ..."); + sm.forceStartQuest(100202); + } + else { + sm.sayOk("Hmmm, by mixing #b#t4031061##k with #b#t4031062##k, I can make an the item that will be used as a sacrifice to summon the boss, called #b#t4001017##k. The problem is ... (cough cough) as you can see, I am not feeling terribly well these days, so it's difficult for me to move around and gather up items. Well ... will it be ok for you to gather up #b30 Zombie's Lost Gold Teeth#k for me? Don't ask me where I'll be using it, though ..."); + sm.forceStartQuest(100202); + } + break; + + case 3: + sm.sayNext("Not sure where to start? In order to do this quest, you'll have to receive the approval from the chief of your occupation. I do not want to be scolded later on for letting someone in without going through the proper procedure. The only ones that I can let in are the party full of members that have received the approval."); + sm.sayBoth("Complete the quests in order of the level, and you'll be able to meet the boss of the Zakum Dungeon. Gather up the items I'll request from you, and I'll make them into a sacrificial item. Place the sacrificial item at the altar, and you'll get to see what you've come to see. To do that, first look through the Dead Mine and bring back #b#t4001018##k."); + sm.sayBoth("There, other than #b#t4001018##k, you'll also find Paper documents. Give that to #b#p2032002##k, and you may get something helpful in return along with Piece of Fire ore. Next, go across the lava area and find #b#t4031062##k. It'll be a treacherous road to take, but ... it's a must item, in terms of making a sacrificical item."); + sm.sayBoth("Once you have gotten #b#t4031062##k, you'll need to refine the #bPieces of Fire ore#k and #b#t4031062#s#k that you have acquired at level 1 and 2. Don't worry about it, though; I can refine them for you. Once you've completed them all, all you'll have left to do is to meet the boss of Zakum Dungeon. It won't be easy, at all ... but try your best."); + break; + + case 4: + // Skip all quests with 15M mesos + if (sm.hasQuestCompleted(100202)) { + sm.sayOk("You've already completed all the quests. You don't need to skip anything!"); + return; + } + + if (!sm.askYesNo("So, you wish to skip all the tedious quest work? I can give you #b5 Eyes of Fire#k right now if you pay me #e15,000,000 mesos#n. This will save you all the trouble of going through the three stages. Do you accept?")) { + sm.sayOk("Very well. Come back if you change your mind."); + return; + } + + if (!sm.addMoney(-15000000)) { + sm.sayOk("You don't have enough mesos. I need exactly #e15,000,000 mesos#n to give you the Eyes of Fire."); + return; + } + + if (!sm.canAddItem(4001017, 5)) { + sm.addMoney(15000000); // Refund + sm.sayOk("Your ETC inventory is full. Please make room and come back."); + return; + } + + sm.addItem(4001017, 5); // Give 5x Eye of Fire + sm.forceCompleteQuest(100200); // Complete stage 1 + sm.forceCompleteQuest(100201); // Complete stage 2 + sm.forceCompleteQuest(100202); // Complete stage 3 + sm.sayOk("Here are your #b5 Eyes of Fire#k. You may now enter the Zakum altar through the portal on the left. Good luck!"); + break; + + default: + return; + } + } + + @Script("Zakum01") + public static void zakum01(ScriptManager sm) { + // Aura (2032002) + // Adobis's Mission I : Unknown Dead Mine (280010000) + sm.sayNext("You are the one who wanted to investigate the Dead Mine. You need to gather up the necessary items to reach the point of your final goal: meeting the boss of the Zakum Dungeon. To obtain that item, you'll first need to acquire the materials for that item, right? You can get one of the materials, #b#t4031061##k, right here. It won't be easy, though ..."); + sm.sayNext("Here, there is an entrance that leads to numerous caves. Once inside the cave, you'll see some boxes. Destroy them all, and collect #b7 of #t4001016#s#k. The box cannot be destroyed using attack skills; only the regular, basic attack works. Afterwards, gather up the 7 keys, move into the innermost room, where the treasure chest is. Drop the keys there to obtain #b#t4031061##k. It'll take some time after dropping the keys to obtain it, so be patient."); + sm.sayNext("Of course, not every box contains #t4001016#. You'll all run into some very unexpected circumstances, so please be aware of that. Every once in a while, in the middle of going through the boxes, #t4001015# will pop out. Gather those up, too, and something good will definitely happen. You need to collect at least 30 #t4001015#s. This is all I can tell you, for now."); + final int answer = sm.askMenu("Anything do you want to ask?", Map.of( + 0, "I brought #t4031061#.", + 1, "Forget the quest, I'm out of here." + )); + + if(answer == 0) { + if (!sm.getQRValue(QuestRecordType.ZakumPreqStageOne).equals(sm.getUser().getCharacterName())) { + sm.sayOk("Once you have obtained #b#t4031061##k by dropping 7 #b#t4001016#s#k at the huge chest in the cave, please hand the item over to the party leader. Once the leader of the party has #b#t4031061##k in possession and talks to me, that'll signal that you have cleared Level 1."); + return; + } + + if(!sm.hasItem(4031061, 1)) { + sm.sayOk("I guess you haven't gotten #b#t4031061##k yet. Please go through the various treasure chests in here within the time limit, collect #b7 of #t4001016#s#k, and drop them all at the treasure chest in the innermost part of the cave to collect #b#t4031061##k. Once you have obtained the item, please hand it to me."); + return; + } + + if(!sm.hasItem(4001015)) { + if(!sm.askYesNo("You brought back #b1 #t4031061##k safely, but it doesn't look like you have brought #b#t4001015# back. Is this all your party has gathered up?")) { + sm.sayOk("All the items collected from the cave by the party members should be given to the party leader, who'll give them all to me. Please double-check."); + return; + } + } else { + if (!sm.askYesNo("You have brought back #b1 #t4031061##k and #b" + sm.getItemCount(4001015) + " #t4001015#s#k. Is this all the items your party members have gathered up?")) { + sm.sayOk("All the items collected from the cave by the party members should be given to the party leader, who'll give them all to me. Please double-check."); + return; + } + } + + if(!sm.removeItem(4031061, 1)) { + sm.sayOk("Please check and see if you have #b1 #t4031061##k with you."); + return; + } + + sm.sayOk("Alright. Using the portal that's been made down there, you can return to the map where Adobis is. While using the portal, I'll be handing out #b#t4001018##k made out of #b#t4031061##k you've all given me to each and every member of the party. Congratulations on clearing Level 1. See you around ..."); + + sm.forceCompleteQuest(100200); + sm.getField().getUserPool().forEachPartyMember(sm.getUser(), (member) -> { + member.getQuestManager().forceCompleteQuest(100200); + }); + } else if(answer == 1) { + if (sm.askYesNo("If you quit in the middle of a mission, you'll have to start all over again ... not only that, but since it's a party quest, even if one player decides to leave, it may be difficult to clear the level. Are you SURE you want to leave?")) { + sm.sayOk("Alright, I'll send you to the Exit Map. #b#p2030011##k will be there standing. Go talk to him; He'll let you out. So long..."); + sm.partyWarp(280090000, "st00"); + } + } + } + + @Script("go280010000") + public static void go280010000(ScriptManager sm) { + // go280010000 (2110000) + // Adobis's Mission I : Area 1-2 (280010011) + // Adobis's Mission I : Area 3-2 (280010031) + // Adobis's Mission I : Area 4-2 (280010041) + // Adobis's Mission I : Area 7-2 (280010071) + // Adobis's Mission I : Area 8-2 (280010081) + // Adobis's Mission I : Area 9-2 (280010091) + // Adobis's Mission I : Area 11-1 (280010110) + // Adobis's Mission I : Area 14-1 (280010140) + // Adobis's Mission I : Area 16 (280011000) + // Adobis's Mission I : Area 16-1 (280011001) + // Adobis's Mission I : Area 16-2 (280011002) + // Adobis's Mission I : Area 16-3 (280011003) + // Adobis's Mission I : Area 16-4 (280011004) + // Adobis's Mission I : Area 16-5 (280011005) + // Adobis's Mission I : Area 16-6 (280011006) + sm.warp(280010000); + } + + @Script("boxBItem0") + public static void boxbitem0(ScriptManager sm) { + // boxBItem0 (2112014) + // Adobis's Mission I : Area 16-5 (280011005) + sm.dropRewards(List.of( + Reward.item(4031061, 1, 1, 1) + )); + } + + @Script("Zakum03") + public static void zakum03(ScriptManager sm) { + // Adobis's Mission I : Unknown Dead Mine (280010000) + // ps01 (440, 193) + if(sm.hasQuestCompleted(100200)) { + if(!sm.canAddItem(4001018, 1)) { + sm.sayOk("Please make room for the #b#t4001018##k."); + return; + } + + sm.addItem(4001018, 1); + sm.warp(211042300, "sp"); + } else { + sm.message("Currently, this portal doesn't work."); + } + } + + @Script("Zakum04") + public static void zakum04(ScriptManager sm) { + // Ali (2030011) + // Adobis's Mission I : The Room of Tragedy (280090000) + if(sm.hasItem(4031061, 1)) { + sm.sayOk("Great job clearing level 1! Alright ... I'll send you off to where #b#p2030008##k is. Before that!! Please be aware that the various, special items you have acquired here will not be carried out of here. I'll be taking away those items from your item inventory, so remember that. See ya!"); + } else { + sm.sayOk("Must have quit midway through. Alright, I'll send you off right now. Before that!! Please be aware that the various, special items you have acquired here will not be carried out of here. I'll be taking away those items from your item inventory, so remember that. See ya!"); + } + sm.removeItem(4001015); + sm.removeItem(4001016); + sm.removeItem(4031061); + sm.warp(211042300); + } + + @Script("boxKey0") + public static void boxKey0(ScriptManager sm) { + // boxKey0 (2112004) + // Adobis's Mission I : Area 9-2 (280010091) + // Adobis's Mission I : Area 11-1 (280010110) + // Adobis's Mission I : Area 14-1 (280010140) + // Adobis's Mission I : Area 16-2 (280011002) + // Adobis's Mission I : Area 16-3 (280011003) + // boxKey0 (2112011) + // Adobis's Mission I : Area 4-2 (280010041) + // Adobis's Mission I : Area 16-5 (280011005) + sm.dropRewards(List.of( + Reward.item(4001016, 1, 1, 1) + )); + } + + @Script("Zakum02") + public static void zakum02(ScriptManager sm) { + // Lira (2032003) + // Adobis's Mission I : Breath of Lava (280020001) + sm.sayNext("How did you go through such treacherous road to get here?? Incredible! #b#t4031062##k is here. Please give this to my brother. You'll finally be meeting up with the one you've been looking for, very soon."); + if(!sm.canAddItem(4031062, 1)) { + sm.sayOk("Your ETC inventory seems to be full. Please make room in order to receive the item."); + return; + } + + sm.addItem(4031062, 1); + sm.forceCompleteQuest(100201); + sm.addExp(15000); + sm.warp(211042300); + } + + @Script("Zakum06") + public static void zakum06(ScriptManager sm) { + // Amon (2030010) + // Adobis's Mission I : Breath of Lava (280020000) + // Adobis's Mission I : Breath of Lava (280020001) + // Last Mission : akum's Altar (280030000) + // Last Mission : Chaos Zakum's Altar (280030001) + if (sm.getFieldId() == 280030000) { + boolean exit = false; + if(sm.getQRValue(QuestRecordType.Zakum).equals("1")) { + exit = sm.askYesNo("Are you sure you want to leave this place? You are entitled to enter the Zakum Altar up to twice a day, and by leaving right now, you may only re-enter this shrine once more for the rest of the day."); + } else if(sm.getQRValue(QuestRecordType.Zakum).equals("2")) { + exit = sm.askYesNo("Are you sure you want to leave this place? You are entitled to enter the Zakum Altar up to twice a day, and since you have been here twice already, you will be denied entrance to this shrine for the rest of the day by leaving right now."); + } else { + sm.sayOk("How did you??? This is bonkers. Get out of here..."); + exit = true; + } + + if (exit) { + sm.partyWarp(211042300, "sp"); + } + } else { + if (sm.askYesNo("Are you sure you want to quit and leave this place? Next time you come back in, you'll have to start all over again.")) { + sm.partyWarp(211042300, "sp"); + } + } + } + + @Script("Zakum05") + public static void zakum05(ScriptManager sm) { + // El Nath : The Door to Zakum (211042300) + // ps00 (-722, -217) + // Dead Mine : The Door to Chaos Zakum (211042301) + // ps00 (-722, -217) + if(!sm.hasQuestCompleted(100202)) { + sm.sayOk("You may only enter this place after clearing level 3. You'll also need to have the Eye of Fire in possession."); + return; + } + + sm.setQRValue(QuestRecordType.Zakum, "1"); + sm.playPortalSE(); + sm.partyWarpInstance(ZAKUM_BOSS_MAP, "st00", 211042301, 60 * 60); + } + + @Script("boss") + public static void boss(ScriptManager sm) { + // boss (2111001) + // Last Mission : Zakum's Altar (280030000) + sm.soundEffect("Bgm06/FinalFight"); + sm.broadcastMessage("Zakum is summoned by the force of eye of fire."); + sm.spawnMob(8800000, MobAppearType.SUSPENDED, -11, -215, false, MobType.PARENT_MOB); + for (int i = 0; i < 8; i++) { + sm.spawnMob(8800003 + i, MobAppearType.REGEN, -11, -215, false, MobType.SUB_MOB); + } + } +} diff --git a/src/main/java/kinoko/script/common/ScriptManager.java b/src/main/java/kinoko/script/common/ScriptManager.java index 7f949a10..e7258853 100644 --- a/src/main/java/kinoko/script/common/ScriptManager.java +++ b/src/main/java/kinoko/script/common/ScriptManager.java @@ -8,11 +8,13 @@ import kinoko.world.field.Field; import kinoko.world.field.FieldObject; import kinoko.world.field.mob.MobAppearType; +import kinoko.world.field.mob.MobType; import kinoko.world.item.BodyPart; import kinoko.world.item.InventoryType; import kinoko.world.job.Job; import kinoko.world.quest.QuestRecordType; import kinoko.world.user.User; +import kinoko.server.event.EventType; import java.util.List; import java.util.Map; @@ -110,6 +112,14 @@ default boolean hasItem(int itemId) { } boolean hasItem(int itemId, int quantity); + default boolean hasItems(List> items) { + for (var item : items) { + if (!hasItem(item.getLeft(), item.getRight())) { + return false; + } + } + return true; + } int getItemCount(int itemId); @@ -188,11 +198,27 @@ default void partyWarpInstance(List mapIds, String portalName, int retu FieldObject getSource(); + default void spawnMob(int templateId, MobAppearType appearType, int x, int y, boolean isLeft, MobType mobType) { + spawnMob(templateId, appearType.getValue(), x, y, isLeft, mobType.getValue()); + } + default void spawnMob(int templateId, MobAppearType appearType, int x, int y, boolean isLeft) { - spawnMob(templateId, appearType.getValue(), x, y, isLeft); + spawnMob(templateId, appearType.getValue(), x, y, isLeft, false); } - void spawnMob(int templateId, int summonType, int x, int y, boolean isLeft); + default void spawnMob(int templateId, int summonType, int x, int y, boolean isLeft) { + spawnMob(templateId, summonType, x, y, isLeft, false); + } + + void spawnMob(int templateId, int summonType, int x, int y, boolean isLeft, int mobType); + + void spawnMob(int templateId, int summonType, int x, int y, boolean isLeft, boolean originalField); + void killMob(int templateId); + void addCooldownTimeForParty(EventType eventType, long time); + + String getTimeUntilEventReset(EventType eventType); + + boolean partyHasCoolDown(EventType eventType, int runsPerDay); void spawnNpc(int templateId, int x, int y, boolean isFlip, boolean originalField); @@ -283,4 +309,8 @@ default boolean checkParty(int memberCount, int levelMin) { String askText(String text, String textDefault, int textLengthMin, int textLengthMax, ScriptMessageParam... overrides); String askBoxText(String text, String textDefault, int textBoxColumns, int textBoxLines, ScriptMessageParam... overrides); + void openShopNPC(int templateId); + + int getRandomIntBelow(int number); + } \ No newline at end of file diff --git a/src/main/java/kinoko/script/common/ScriptManagerImpl.java b/src/main/java/kinoko/script/common/ScriptManagerImpl.java index 256ba63a..9a4f8607 100644 --- a/src/main/java/kinoko/script/common/ScriptManagerImpl.java +++ b/src/main/java/kinoko/script/common/ScriptManagerImpl.java @@ -19,6 +19,7 @@ import kinoko.provider.reward.Reward; import kinoko.provider.skill.SkillInfo; import kinoko.server.dialog.ScriptDialog; +import kinoko.server.dialog.shop.ShopDialog; import kinoko.server.event.EventState; import kinoko.server.event.EventType; import kinoko.server.field.Instance; @@ -52,6 +53,7 @@ import java.util.*; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.TimeUnit; import java.util.function.Predicate; import java.util.stream.Collectors; @@ -738,12 +740,64 @@ public FieldObject getSource() { } @Override - public void spawnMob(int templateId, int summonType, int x, int y, boolean isLeft) { + public void killMob(int mobTemplateId) { + final Optional mobResult = user.getField().getMobPool().getByTemplateId(mobTemplateId); + if (mobResult.isEmpty()) { + throw new ScriptError("Could not resolve mob template ID : %d", mobTemplateId); + } + + Mob mob = mobResult.get(); + user.getField().getMobPool().forEach((field_mob) -> { + if (field_mob == mob) { + message("Setting HP to 0 -- mob id " + mob.getTemplateId()); + mob.setHp(0); + } + }); + } + @Override + public void addCooldownTimeForParty(EventType eventType, long time) { + final List members = field.getUserPool().getPartyMembers(user.getPartyId()); + for (User member : members) { + member.addCoolDown(eventType, time); + } + } + + private long getMillisecondsUntilEventReset(EventType eventType) { + long remainingTime = user.getEventAmountDone(eventType) == 0 ? 0 : user.getCoolDownByType(eventType).getNextResetTime() - System.currentTimeMillis(); + return remainingTime < 0 ? 0 : remainingTime; + } + + @Override + public String getTimeUntilEventReset(EventType eventType) { + long msTillReset = getMillisecondsUntilEventReset(eventType); + long days = TimeUnit.MILLISECONDS.toDays(msTillReset); + long hours = TimeUnit.MILLISECONDS.toHours(msTillReset) % TimeUnit.DAYS.toHours(1); + long minutes = TimeUnit.MILLISECONDS.toMinutes(msTillReset) % TimeUnit.HOURS.toMinutes(1); + return (days > 0 ? days + " day(s) " : "") + (hours > 0 ? hours + " hour(s) " : "") + (minutes > 0 ? minutes + " minute(s) " : ""); + } + + @Override + public boolean partyHasCoolDown(EventType eventType, int runsPerDay) { + final List members = field.getUserPool().getPartyMembers(user.getPartyId()); + for (User member : members) { + if (member.getAccount().isGM()) { + return false; + } + if (member.getEventAmountDone(eventType) >= runsPerDay) { + return true; + } + } + return false; + } + + @Override + public void spawnMob(int templateId, int summonType, int x, int y, boolean isLeft, int mobType) { final Optional mobTemplateResult = MobProvider.getMobTemplate(templateId); if (mobTemplateResult.isEmpty()) { throw new ScriptError("Could not resolve mob template ID : %d", templateId); } - final Optional footholdResult = field.getFootholdBelow(x, y - GameConstants.REACTOR_SPAWN_HEIGHT); + final Field targetField = user.getField(); + final Optional footholdResult = targetField.getFootholdBelow(x, y - GameConstants.REACTOR_SPAWN_HEIGHT); final Mob mob = new Mob( mobTemplateResult.get(), null, @@ -753,7 +807,28 @@ public void spawnMob(int templateId, int summonType, int x, int y, boolean isLef ); mob.setLeft(isLeft); mob.setSummonType(summonType); - field.getMobPool().addMob(mob); + mob.setMobType(mobType); + targetField.getMobPool().addMob(mob); + } + + @Override + public void spawnMob(int templateId, int summonType, int x, int y, boolean isLeft, boolean originalField) { + final Optional mobTemplateResult = MobProvider.getMobTemplate(templateId); + if (mobTemplateResult.isEmpty()) { + throw new ScriptError("Could not resolve mob template ID : %d", templateId); + } + final Field targetField = originalField ? field : user.getField(); + final Optional footholdResult = targetField.getFootholdBelow(x, y - GameConstants.REACTOR_SPAWN_HEIGHT); + final Mob mob = new Mob( + mobTemplateResult.get(), + null, + x, + y, + footholdResult.map(Foothold::getSn).orElse(0) + ); + mob.setLeft(isLeft); + mob.setSummonType(summonType); + targetField.getMobPool().addMob(mob); } @Override @@ -1152,4 +1227,23 @@ private int getMessageParam(ScriptMessageParam... overrides) { .reduce(0, (a, b) -> a | b); } } + @Override + public void openShopNPC(int templateId) { + final Optional npcResult = field.getNpcPool().getByTemplateId(templateId); + if (npcResult.isEmpty()) { + throw new ScriptError("Could not find npc with template ID : %d", templateId); + } + final Npc npc = npcResult.get(); + if (ShopProvider.isShop(npc.getTemplateId())) { + final ShopDialog shopDialog = ShopDialog.from(npc.getTemplate()); + user.setDialog(shopDialog); + user.write(FieldPacket.openShopDlg(user, shopDialog)); + } + } + + @Override + public int getRandomIntBelow(int number) { + return new Random().nextInt(number); + } + } diff --git a/src/main/java/kinoko/script/continent/AquaRoad.java b/src/main/java/kinoko/script/continent/AquaRoad.java new file mode 100644 index 00000000..6522e853 --- /dev/null +++ b/src/main/java/kinoko/script/continent/AquaRoad.java @@ -0,0 +1,55 @@ +package kinoko.script.continent; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.world.user.User; + +import java.util.concurrent.atomic.AtomicBoolean; + +public class AquaRoad extends ScriptHandler { + @Script("s4common2") + public static void s4common2(ScriptManager sm) { + // TODO: Make more GMS-like + AtomicBoolean allEligible = new AtomicBoolean(true); + if (!sm.hasQuestStarted(6301)) { + sm.sayOk("Cracked Dimension? Where did you hear that?"); + return; + } + + if (!sm.getUser().getPartyInfo().isBoss()) { + sm.sayOk("Only party leader can apply to enter. Please get your representative to talk to me."); + return; + } + + if (sm.getItemCount(4031472) > 40) { + sm.sayOk("If you have 40 " + blue(itemName(4031472)) + ", you need no more."); + return; + } + + if (!sm.hasItem(4000175, 1)) { + sm.sayOk("Without " + blue(itemName(4000175)) + ", you can't enter Cracked Dimension."); + return; + } + + if (!sm.checkParty(1, 10)) { + sm.sayOk("You don't have a party. You can challenge with party."); + return; + } + + sm.getField().getUserPool().forEachPartyMember(sm.getUser(), (member) -> { + if (!member.is4thJob()) { + allEligible.set(false); + } + }); + + if (!sm.getUser().is4thJob() || !allEligible.get()) { + sm.sayOk("You can't enter if anyone in your party hasn't make 4th job advancement."); + return; + } + + sm.removeItem(4000175, 1); + sm.partyWarpInstance(923000000, "sp", 230040001, 60 * 5); + } + +} diff --git a/src/main/java/kinoko/script/continent/ContiMove.java b/src/main/java/kinoko/script/continent/ContiMove.java index d5f7947e..69c5e94e 100644 --- a/src/main/java/kinoko/script/continent/ContiMove.java +++ b/src/main/java/kinoko/script/continent/ContiMove.java @@ -7,9 +7,11 @@ import kinoko.script.common.ScriptManager; import kinoko.server.event.*; import kinoko.util.Util; +import kinoko.world.job.JobConstants; import java.util.List; import java.util.Map; +import java.util.stream.Stream; public final class ContiMove extends ScriptHandler { @Script("sell_ticket") @@ -693,4 +695,41 @@ public static void rankRoom(ScriptManager sm) { } } } + + @Script("ossyria_taxi") + public static void ossyria_taxi(ScriptManager sm) { + // Ossyria Taxi (2012001) + // Orbis : Orbis (200000000) + // El Nath : El Nath (211000000) + // Ludibrium : Ludibrium (220000000) + // Leafre : Leafre (240000000) + // Mu Lung : Mu Lung (250000000) + // Herb Town : Herb Town (251000000) + // Ariant : Ariant (260000000) + // Magatia : Magatia (261000000) + final boolean isBeginner = JobConstants.isBeginnerJob(sm.getJob()); + final int price = isBeginner ? 100 : 1000; + final List towns = Stream.of( + 200000000, // Orbis : Orbis + 211000000, // El Nath : El Nath + 220000000, // Ludibrium : Ludibrium + 240000000, // Leafre : Leafre + 250000000, // Mu Lung : Mu Lung + 251000000, // Herb Town : Herb Town + 260000000, // Ariant : Ariant + 261000000 // Magatia : Magatia + ).filter(mapId -> sm.getFieldId() != mapId).toList(); + final Map options = createOptions(towns, (mapId) -> String.format("#m%d# (%d Mesos)", mapId, price)); + sm.sayNext("Hello, I'm the Ossyria continent taxi driver. I can take you to any major town in Ossyria quickly and safely. Where would you like to go?" + (isBeginner ? "\r\nWe have a special 90% discount for beginners." : "")); + final int answer = sm.askMenu("Please select your destination.", options); + if (sm.askYesNo(String.format("You want to go to #b#m%d##k? It'll cost you #b%d#k mesos.", towns.get(answer), price))) { + if (sm.addMoney(-price)) { + sm.warp(towns.get(answer)); + } else { + sm.sayOk("You don't have enough mesos. Sorry, but you can't ride without paying the fare."); + } + } else { + sm.sayOk("There's plenty to explore here too. Come back when you need a ride!"); + } + } } diff --git a/src/main/java/kinoko/script/continent/Edelstein.java b/src/main/java/kinoko/script/continent/Edelstein.java index 275c2b61..16fabcbf 100644 --- a/src/main/java/kinoko/script/continent/Edelstein.java +++ b/src/main/java/kinoko/script/continent/Edelstein.java @@ -135,6 +135,127 @@ public static void q23905e(ScriptManager sm) { sm.forceCompleteQuest(23905); } + @Script("talk2152012") + public static void talk2152012(ScriptManager sm) { + // Checky (2152012) + // Black Wing Territory : Edelstein (310000000) + // Trade Pet Food for Recyclable Rue Battery (quest 23914) + if (sm.hasItem(4032750)) { + sm.sayOk("Hello. Please line up in the back if you want a balloon from Checky. What? You want a Recyclable Rue Battery? Why are you asking for another one when you already have one?"); + return; + } + if (!sm.hasQuestStarted(23914)) { + sm.sayOk("Please line up in the back if you want a balloon from Checky."); + return; + } + sm.sayNext("Hello. Stand in line to get a balloon from Checky. Hmm, you seem a little old to still want a balloon, but if you do a good job standing in a straight line, maybe Checky will give you a balloon, too."); + if (!sm.askYesNo("You don't want a balloon? So what do you need? A used Rue Battery? Oh I know, you want to recycle them! Okay, but I can't give them to you for free. I'll give them to you in exchange for Pet Foods.")) { + sm.sayNext("Huh? So you don't need batteries for recycling? You are foolish to ignore the importance of recycling."); + return; + } + if (!sm.hasItem(2120000) || !sm.canAddItem(4032750, 1)) { + sm.sayOk("Do you have the Pet Foods? How about emptying some slots in your Etc tab?"); + return; + } + sm.removeItem(2120000, 1); + sm.addItem(4032750, 1); + sm.sayOk("Now go reduce, reuse, and recycle."); + } + + @Script("talk2152013") + public static void talk2152013(ScriptManager sm) { + // Cutie (2152013) + // Black Wing Territory : Edelstein (310000000) + // Trade Morning Glory for Recyclable Rue Battery (quest 23914) + if (sm.hasItem(4032750)) { + sm.sayOk("Please make good use of the Recyclable Rue Battery!"); + return; + } + if (!sm.hasQuestStarted(23914)) { + sm.sayOk("Just looking at a balloon makes me happy. l feel like l could float into the air, too! Hehe."); + return; + } + sm.sayNext("I'm so happy I got a balloon! If I get another one, I'll be sure to give it to you. Huh? You don't want a balloon? So what do you want?"); + if (!sm.askYesNo("Oh. You're collecting Recyclable Batteries. I have one... Okay, how about this! I will give you the battery if you bring me one Morning Glory. What do you think?")) { + sm.sayNext("I guess you have a better way of obtaining a Recyclable Rue Battery."); + return; + } + if (!sm.hasItem(4000596) || !sm.canAddItem(4032750, 1)) { + sm.sayOk("Talk to me again with one Morning Glory in your possession and at least one slot available in the Etc tab of your inventory."); + return; + } + sm.removeItem(4000596, 1); + sm.addItem(4032750, 1); + sm.sayOk("Doesn't recycling make you feel good?"); + } + + @Script("talk2152014") + public static void talk2152014(ScriptManager sm) { + // Mystery (2152014) + // Black Wing Territory : Edelstein (310000000) + // Trade Cork Stopper for Recyclable Rue Battery (quest 23914) + if (sm.hasItem(4032750)) { + sm.sayOk("I see that you already have a Recyclable Rue Battery..."); + return; + } + if (!sm.hasQuestStarted(23914)) { + sm.sayOk("I'm so hot, I'm probably blinding you. l also like balloons. And now, I won't share my balloons with you, so don't even ask."); + return; + } + sm.sayNext("I'm so hot. I'm probably blinding you. I also like balloons. And now, l won't share my balloons with you, so don't even ask. What? You don't want any balloons? So what do you need then?"); + if (!sm.askYesNo("You need Recyclable Batteries? Hmm, I do have one. l can give it to you, but of course you have to give me something in return. I'll give you my battery if you bring me one Cork Stopper.")) { + sm.sayNext("You know nothing about the art of negotiation. Hmph."); + return; + } + if (!sm.hasItem(4000597) || !sm.canAddItem(4032750, 1)) { + sm.sayOk("I will give you a battery if you bring me a Cork Stopper. And don't be a moron. Make sure to have at least one free Etc slot in your inventory."); + return; + } + sm.removeItem(4000597, 1); + sm.addItem(4032750, 1); + sm.sayOk("Ah, I can see you understand, yes, TRULY understand, the art of recycling. Just like l do..."); + } + + @Script("talk2152015") + public static void talk2152015(ScriptManager sm) { + // Fatty (2152015) + // Black Wing Territory : Edelstein (310000000) + sm.sayOk("Chomp, chomp... Please don't bother me, I'm busy eating. Gulp!"); + } + + @Script("talk2153002") + public static void talk2153002(ScriptManager sm) { + // Bunny : Large Black Wing Gatekeeper (2153002) + // Dry Road : Mine Entrance (310040200) + final Item equippedCap = sm.getUser().getInventoryManager().getEquipped().getItem(BodyPart.CAP.getValue()); + final boolean hasBlackWingsHat = equippedCap != null && equippedCap.getItemId() == 1003134; + + if (hasBlackWingsHat) { + sm.sayOk("Member of the Black Wing indeed. Take the portal to the right to enter."); + } else { + sm.sayOk("The Verne Mine is currently being used by the Black Wings. You can't enter unless you are a member of the Black Wings. Bring something to prove that you are a member. Something with the Black Wings logo on it... Something sort of like my hat, for instance."); + } + } + + @Script("talk2154003") + public static void talk2154003(ScriptManager sm) { + // Android (2154003) + // Verne Mine : Power Plant Security (310050100) + // Quest 23949 - Crime Prevention System Inspection + if (sm.hasQuestCompleted(23949)) { + sm.sayOk("Crime Prevention Systems inspection complete. All systems functioning within normal parameters."); + return; + } + + + if (!sm.hasQuestStarted(23949)) { + sm.sayOk("System status: Normal. All Crime Prevention Systems operational. Please proceed to your designated area."); + return; + } + + sm.sayOk("Crime Prevention System inspection in progress. Proceed to #bIntruder Search Warrant 3#k and complete your assigned task."); + } + @Script("edelItem0") public static void reactorTree(ScriptManager sm) { // edelItem0 (3102000) @@ -159,6 +280,36 @@ public static void reactorDahlia(ScriptManager sm) { )); } + @Script("edelItem2") + public static void reactorCream(ScriptManager sm) { + // edelItem2 (3102002) + // Edelstein : Ulrica's Base (931010020) + // Drops: 4032760 - Fluffy Fresh Cream (at reactor state 4) + sm.dropRewards(List.of( + Reward.item(4032760, 1, 1, 1.0) + )); + } + + @Script("edelItem3") + public static void reactorOre(ScriptManager sm) { + // edelItem3 (3102003) + // Hidden Street : Hidden Laboratory (931020031) + // Drops: 4032775 - Small Ore Edo (at reactor state 12) + sm.dropRewards(List.of( + Reward.item(4032775, 1, 1, 1.0) + )); + } + + @Script("edelItem4") + public static void reactorSurlWater(ScriptManager sm) { + // edelItem4 (3109000) + // Edelstein : Surl's Water Cellar (931000410) + // Triggers quest 23130 when reactor reaches state 10 + if (sm.hasQuestStarted(23120)) { + sm.forceStartQuest(23130); + } + } + @Script("enterResiTR") public static void enterResiTR(ScriptManager sm) { // Resistance Headquarters : Training Room Entrance (310010010) diff --git a/src/main/java/kinoko/script/continent/ElNath.java b/src/main/java/kinoko/script/continent/ElNath.java new file mode 100644 index 00000000..a9ecc65c --- /dev/null +++ b/src/main/java/kinoko/script/continent/ElNath.java @@ -0,0 +1,370 @@ +package kinoko.script.continent; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.util.Tuple; + +import java.util.List; +import java.util.Map; + +public class ElNath extends ScriptHandler { + @Script("refine_elnath") + public static void refineElNath(ScriptManager sm) { + // Vogen + if (sm.askYesNo("Looks like you have quite a bit of ores and jewels with you. For a small service fee, I can refine them into the materials needed to create shields or weapons. I've been doing this for 50 years, so it's a piece of cake! What do you think? You want me to do it?")) { + final int answer = sm.askMenu("Good decision! Give me the ores and the service fee, and I can refine them so that they'll be of some use. Before doing so, don't forget to check your etc. inventory to make sure that you have enough free space for the new items. Let's see, what would you like me to do?", Map.of( + 0, "Refine the ore of a mineral", + 1, "Refine the ore of a jewel", + 2, "Refine a rare gem", + 3, "Refine a crystal", + 4, "Create materials", + 5, "Create arrows" + )); + + switch (answer) { + case 0: + final int answer0 = sm.askMenu("Which of these minerals would you like to make?", Map.of( + 0, "#t4011000#", + 1, "#t4011001#", + 2, "#t4011002#", + 3, "#t4011003#", + 4, "#t4011004#", + 5, "#t4011005#", + 6, "#t4011006#" + )); + + switch (answer0) { + case 0: + handleRefineOres(sm, 1, "#t4011000#", "#v4010000#", "#t4010000#s", 300); + break; + case 1: + handleRefineOres(sm, 2, "#t4011001#", "#v4010001#", "#t4010001#s", 300); + break; + case 2: + handleRefineOres(sm, 3, "#t4011002#", "#v4010002#", "#t4010002#s", 300); + break; + case 3: + handleRefineOres(sm, 4, "#t4011003#", "#v4010003#", "#t4010003#s", 500); + break; + case 4: + handleRefineOres(sm, 5, "#t4011004#", "#v4010004#", "#t4010004#s", 500); + break; + case 5: + handleRefineOres(sm, 6, "#t4011005#", "#v4010005#", "#t4010005#s", 500); + break; + case 6: + handleRefineOres(sm, 7, "#t4011006#", "#v4010006#", "#t4010006#s", 800); + break; + } + break; + case 1: + final int answer1 = sm.askMenu("Which jewel would you like to refine?", Map.of( + 0, "#t4021000#", + 1, "#t4021001#", + 2, "#t4021002#", + 3, "#t4021003#", + 4, "#t4021004#", + 5, "#t4021005#", + 6, "#t4021006#", + 7, "#t4021007#", + 8, "#t4021008#" + )); + + switch(answer1) { + case 0: + handleRefineOres(sm, 100, "#t4021000#", "#v4020000#", "#t4020000#s", 500); + break; + case 1: + handleRefineOres(sm, 101, "#t4021001#", "#v4020001#", "#t4020001#s", 500); + break; + case 2: + handleRefineOres(sm, 102, "#t4021002#", "#v4020002#", "#t4020002#s", 500); + break; + case 3: + handleRefineOres(sm, 103, "#t4021003#", "#v4020003#", "#t4020003#s", 500); + break; + case 4: + handleRefineOres(sm, 104, "#t4021004#", "#v4020004#", "#t4020004#s", 500); + break; + case 5: + handleRefineOres(sm, 105, "#t4021005#", "#v4020005#", "#t4020005#s", 500); + break; + case 6: + handleRefineOres(sm, 106, "#t4021006#", "#v4020006#", "#t4020006#s", 500); + break; + case 7: + handleRefineOres(sm, 107, "#t4021007#", "#v4020007#", "#t4020007#s", 1000); + break; + case 8: + handleRefineOres(sm, 108, "#t4021008#", "#v4020008#", "#t4020008#s", 3000); + break; + } + break; + case 2: + final int answer2 = sm.askMenu("Yes, I can refine even rare gems. I may need a lot of material to do this, but it is possible. Which gem would you like to refine?", Map.of( + 0, "#t4011007#", + 1, "#t4021009#" + )); + + switch(answer2) { + case 0: + handleRefineRareGem(sm, 200, "#t4011007#", "refined #t4011000#, #t4011001#, #t4011002#, #t4011003#, #t4011004#, #t4011005#, #t4011006#", 10000); + break; + case 1: + handleRefineRareGem(sm, 201, "#t4021009#", "refined #t4021000#, #t4021001#, #t4021002#, #t4021003#, #t4021004#, #t4021005#, #t4021006#, #t4021007#, #t4021008#", 15000); + break; + } + break; + case 3: + final int answer3 = sm.askMenu("Hmmm... Do you really have a crystal? I haven't seen one of them in a while, so I don't really believe you, but if you really have one I can refine it and turn it into something useful. So, which crystal would you like to refine?", Map.of( + 0, "#t4005000#", + 1, "#t4005001#", + 2, "#t4005002#", + 3, "#t4005003#", + 4, "#t4005004#" + )); + + switch (answer3) { + case 0: + handleRefineOres(sm, 300, "#t4005000#", "#v4004000#", "#t4004000#", 5000); + break; + case 1: + handleRefineOres(sm, 301, "#t4005001#", "#v4004001#", "#t4004001#", 5000); + break; + case 2: + handleRefineOres(sm, 302, "#t4005002#", "#v4004002#", "#t4004002#", 5000); + break; + case 3: + handleRefineOres(sm, 303, "#t4005003#", "#v4004003#", "#t4004003#", 5000); + break; + case 4: + handleRefineOres(sm, 304, "#t4005004#", "#v4004004#", "#t4004004#", 100000); + break; + } + break; + case 4: + final int answer4 = sm.askMenu("So, you want to create some materials! Let's see, what type of material would you like to make?", Map.of( + 0, "#bCreate #t4003001# with #t4000003#es#k", + 1, "#bCreate #t4003001# with #t4000018#s#k", + 2, "#bCreate #t4003000#s#k" + )); + + switch (answer4) { + case 0: + handleCreateMaterials(sm, 1, "#t4003001#", "#t4000003#", 10, 1); + break; + case 1: + handleCreateMaterials(sm, 2, "#t4003001#", "#t4000018#s", 5, 1); + break; + case 2: + handleCreateMaterials(sm, 3, "#t4003000#s", "#t4011001#(s) and #t4011000#(s) each", 1, 15); + break; + } + break; + case 5: + final int answer5 = sm.askMenu("So, you want to create arrows! With a strong arrow, you will have a better advantage in battle. Let's see, what kind of arrow would you like me to create?", Map.of( + 0, "#b#t2060000##k", + 1, "#b#t2061000##k", + 2, "#b#t2060001##k", + 3, "#b#t2061001##k", + 4, "#b#t2060002##k", + 5, "#b#t2061002##k" + )); + + sm.message("Creating arrows is not implemented yet."); + } + } else { + sm.sayOk("I understand. Is the service fee too high for you? But understand that I'll be in this town for a long time, so if you ever want to refine anything just bring it to me."); + } + } + + public static void handleRefineOres(ScriptManager sm, int index, String makeItem, String needItemIcon, String needItemString, int unitPrice) { + if (index == 200 || index == 201) { + final int numOfItems = sm.askNumber("Very good, very good ... how many #b" + makeItem + "s#k would you like to make?", 1, 1, 100); + final int nPrice = unitPrice * numOfItems; + if(!sm.askYesNo("Alright, you wanna create #b" + numOfItems + " " + makeItem + "#k(s)?? For that you will need #r" + nPrice + "mesos and " + needItemIcon + " " + numOfItems + " " + needItemString + "#k each. What do you think? Do you really want to do it?")) { + sm.sayOk("I understand. Is the service fee too high for you? But understand that I'll be in this town for a long time, so if you ever want to refine anything just bring it to me."); + return; + } + + // a rare jewel + if (index == 200) { + List> removeItems = List.of( + Tuple.of(4011000, numOfItems), + Tuple.of(4011001, numOfItems), + Tuple.of(4011002, numOfItems), + Tuple.of(4011003, numOfItems), + Tuple.of(4011004, numOfItems), + Tuple.of(4011005, numOfItems), + Tuple.of(4011006, numOfItems) + ); + if(!handleTransaction(sm, numOfItems, nPrice, removeItems, 4011007)) return; + } else { + List> removeItems = List.of( + Tuple.of(4021000, numOfItems), + Tuple.of(4021001, numOfItems), + Tuple.of(4021002, numOfItems), + Tuple.of(4021003, numOfItems), + Tuple.of(4021004, numOfItems), + Tuple.of(4021005, numOfItems), + Tuple.of(4021006, numOfItems), + Tuple.of(4021007, numOfItems), + Tuple.of(4021008, numOfItems) + ); + if(!handleTransaction(sm, numOfItems, nPrice, removeItems, 4021009)) return; + } + + sm.sayOk("Here! Take #b" + numOfItems + " " + makeItem + "#k(s). It's been 50 years, but I still have my skills. If you need my help in the near future, feel free to drop by.\""); + } else { + final int numOfItems = sm.askNumber("To make a " + makeItem + ", I will need the following materials. How many would you like to make?\r\n\r\n#b" + needItemIcon + " 10 " + needItemString + "\r\n" + unitPrice + " mesos#k", 1, 1, 100); + final int nPrice = unitPrice * numOfItems; + final int nAllNum = numOfItems * 10; + if (!sm.askYesNo("You want to make #b" + numOfItems + " " + makeItem + "(s)#k?? Then you will need #r" + nPrice + " mesos and " + needItemIcon + " " + nAllNum + " " + needItemString + "#k(s). What do you think? You wanna do it?")) { + sm.sayOk("I understand... Is the service fee too high for you? Know that I will be in this town for a long time, so if you ever want to refine anything just bring it to me."); + return; + } + + // mineral + if (index >= 1 && index <= 7) { + List> removeItems = List.of( + Tuple.of(4010000 + (index - 1), nAllNum) + ); + if(!handleTransaction(sm, numOfItems, nPrice, removeItems, 4011000 + (index - 1))) return; + } + + // jewel + if (index >= 100 && index <= 108) { + List> removeItems = List.of( + Tuple.of(4020000 + (index - 100), nAllNum) + ); + if(!handleTransaction(sm, numOfItems, nPrice, removeItems, 4021000 + (index - 100))) return; + } + + // crystal + if (index >= 300 && index <= 304) { + List> removeItems = List.of( + Tuple.of(4004000 + (index - 300), nAllNum) + ); + if(!handleTransaction(sm, numOfItems, nPrice, removeItems, 4005000 + (index - 300))) return; + } + + sm.sayOk("Here! Take #b" + numOfItems + " " + makeItem + "(s)#k. It's been 50 years, but I still have my skills. If you need my help in the near future, feel free to drop by."); + } + } + + public static void handleRefineRareGem(ScriptManager sm, int index, String makeItem, String needItem, int unitPrice) { + if (index == 200 || index == 201) { + final int numOfItems = sm.askNumber("Very good, very good ... how many #b" + makeItem + "s#k would you like to make?", 1, 1, 100); + final int nPrice = unitPrice * numOfItems; + if(!sm.askYesNo("Alright, you wanna create #b" + numOfItems + " " + makeItem + "#k(s)?? For that you will need #r" + nPrice + " mesos and " + numOfItems + " " + needItem + "#k each. What do you think? Do you really want to do it?")) { + sm.sayOk("I understand. Is the service fee too high for you? But understand that I'll be in this town for a long time, so if you ever want to refine anything just bring it to me."); + return; + } + + if (index == 200) { + List> removeItems = List.of( + Tuple.of(4011000, numOfItems), + Tuple.of(4011001, numOfItems), + Tuple.of(4011002, numOfItems), + Tuple.of(4011003, numOfItems), + Tuple.of(4011004, numOfItems), + Tuple.of(4011005, numOfItems), + Tuple.of(4011006, numOfItems) + ); + if(!handleTransaction(sm, numOfItems, 10000 * numOfItems, removeItems, 4011007)) return; + } else { + List> removeItems = List.of( + Tuple.of(4021000, numOfItems), + Tuple.of(4021001, numOfItems), + Tuple.of(4021002, numOfItems), + Tuple.of(4021003, numOfItems), + Tuple.of(4021004, numOfItems), + Tuple.of(4021005, numOfItems), + Tuple.of(4021006, numOfItems), + Tuple.of(4021007, numOfItems), + Tuple.of(4021008, numOfItems) + ); + if(!handleTransaction(sm, numOfItems, 15000 * numOfItems, removeItems, 4021009)) return; + } + } + } + + private static void handleCreateMaterials(ScriptManager sm, int index, String makeItem, String needItem, int needNumber, int itemNumber) { + var numOfItems = sm.askNumber("I can make #b" + itemNumber + " " + makeItem + "(s) with " + needNumber + " " + needItem + "#k. This one is free, as long as you have the necessary materials, then you're good to go. What do you think? How many would you like to make?", 1, 1, 100); + var nNeedNum = numOfItems * needNumber; + var nAllNum = numOfItems * itemNumber; + if(!sm.askYesNo("Alright, you want to make #b" + makeItem + "#k " + numOfItems + " times? I'm going to need #r" + nNeedNum + " " + needItem + "#k to do it. Do you still want me to make them?")) { + sm.sayOk("Don't have the materials? You can get something by eliminating the monsters in this area, so work hard on this task..."); + return; + } + + List> removeItems; + switch (index) { + case 1: + removeItems = List.of( + Tuple.of(4000003, numOfItems) + ); + if(!handleTransaction(sm, nAllNum, 0, removeItems, 4003001)) return; + break; + case 2: + removeItems = List.of( + Tuple.of(4000018, numOfItems) + ); + if(!handleTransaction(sm, nAllNum, 0, removeItems, 4003001)) return; + break; + case 3: + removeItems = List.of( + Tuple.of(4011001, numOfItems), + Tuple.of(4011000, numOfItems) + ); + if(!handleTransaction(sm, nAllNum, 0, removeItems, 4003000)) return; + break; + } + } + + private static boolean handleTransaction(ScriptManager sm, int numOfItems, int mesos, List> removeItems, int itemToGive) { + if(!sm.hasItems(removeItems) || !sm.canAddMoney(-mesos) || !sm.canAddItem(itemToGive, numOfItems)) { + sm.sayOk("Hmm... Please make sure you have all the necessary materials, and that you have some free space in your inventory..."); + return false; + } + + sm.addMoney(-mesos); + for (var item : removeItems) { + sm.removeItem(item.getLeft(), item.getRight()); + } + sm.addItem(itemToGive, numOfItems); + return true; + } + + @Script("goDungeon") + public static void goDungeoun(ScriptManager sm) { + sm.sayNext("Hey, looks like you want to go a lot further from here. There, however, you'll find monsters on all sides, aggressive, dangerous, and even if you think you're ready, be careful. A long time ago, some brave heroes from our town went to eliminate those who threatened it, but they never returned..."); + if (sm.getLevel() >= 50) { + if (sm.askYesNo("If you're thinking of entering, I suggest you change your mind. But if you really want to enter... Only those strong enough to stay alive inside will be allowed. I don't want to see anyone else die. Let's see... Hmm...! You look quite strong. Okay, do you wish to enter?")) { + sm.warp(211040300, "under00"); + } else { + sm.sayOk("Even though your level is high, it's difficult to get in there. But if you change your mind, talk to me. After all, my duty is to protect this place."); + } + } else { + sm.sayOk("\"If you're thinking of entering, I suggest you change your mind. But if you really want to enter... Only those strong enough to stay alive inside will be allowed. I don't want to see anyone else die. Let's see... Hmmm... you haven't reached level 50 yet. I can't let you in, forget it."); + } + } + + @Script("Zakumgo") + public static void zakumgo(ScriptManager sm) { + sm.playPortalSE(); + sm.warp(211042300); + } + + + @Script("enterRider") + public static void enterRider(ScriptManager sm) { + if (sm.hasQuestStarted(21610) && sm.hasItem(4001193, 1)) { + sm.playPortalSE(); + sm.warpInstance(921110000, "sp", 211050000, 3 * 60); + } else { + sm.message("Only attendants of the 2nd Wolf Riding quest may enter this field."); + } + } +} diff --git a/src/main/java/kinoko/script/continent/ElNathMts.java b/src/main/java/kinoko/script/continent/ElNathMts.java index 0fe6c258..7759ec1a 100644 --- a/src/main/java/kinoko/script/continent/ElNathMts.java +++ b/src/main/java/kinoko/script/continent/ElNathMts.java @@ -5,6 +5,7 @@ import kinoko.script.common.ScriptHandler; import kinoko.script.common.ScriptManager; import kinoko.util.Tuple; +import kinoko.world.job.Job; import kinoko.world.quest.QuestRecordType; import java.util.List; @@ -126,6 +127,39 @@ public static void Pianus(ScriptManager sm) { sm.warp(230040420, "out00"); // Aqua Road : The Cave of Pianus } + @Script("aquaItem0") + public static void aquaItem0(ScriptManager sm) { + // aquaItem0 (2302000) + // Aqua Road : Ocean I.C (230010000) + // Mushking Empire quest item drops + sm.dropRewards(List.of( + Reward.item(4031274, 1, 1, 0.2), // Paper A + Reward.item(4031275, 1, 1, 0.2), // Paper B + Reward.item(4031276, 1, 1, 0.2), // Paper C + Reward.item(4031277, 1, 1, 0.2), // Paper D + Reward.item(4031278, 1, 1, 0.2) // Paper E + )); + } + + @Script("aquaItem1") + public static void aquaItem1(ScriptManager sm) { + // aquaItem1 (2302001) + // Aqua Road : Deep Sea Canyon 2 (230040100) + sm.dropRewards(List.of( + Reward.item(2022040, 1, 1, 0.3), // Bubble + Reward.item(4031251, 1, 1, 0.2) // Deep Sea Dust + )); + } + + @Script("aquaItem2") + public static void aquaItem2(ScriptManager sm) { + // aquaItem2 (2302002) + // Hidden Street : Fish Resting Place (230030001) + sm.dropRewards(List.of( + Reward.item(2022040, 1, 1, 0.3) // Bubble + )); + } + @Script("aquaItem3") public static void aquaItem3(ScriptManager sm) { // aquaItem3 (2302006) @@ -139,4 +173,309 @@ public static void aquaItem3(ScriptManager sm) { Reward.item(4032476, 1, 1, 0.2, 22407) // Captain Alpha's Buckle )); } + + @Script("aquaItem4") + public static void aquaItem4(ScriptManager sm) { + // aquaItem4 (2302003) + // Hidden Street : Cold Cave (923000100) + sm.dropRewards(List.of( + Reward.item(4001108, 1, 1, 0.2), // Cold Fire + Reward.item(4001107, 1, 1, 0.2), // Black Book + Reward.item(4161017, 1, 1, 0.1) // Calen's Notebook + )); + } + + @Script("aquaItem5") + public static void aquaItem5(ScriptManager sm) { + // aquaItem5 (2302005) + // Hidden Street : Boar Breeding Room (923010000) + sm.dropRewards(List.of( + Reward.item(4031508, 1, 1, 0.2) // Research Report + )); + } + + @Script("mistSeaReactor") + public static void mistSeaReactor(ScriptManager sm) { + // mistSeaReactor (2309000) + // Mist Sea : 5th Operation Room (923020114) + // Triggers jump reactor in same map when activated + sm.getField().getReactorPool().getBy(reactor -> "jump".equals(reactor.getName())).ifPresent(reactor -> { + sm.getField().getReactorPool().hitReactor(sm.getUser(), reactor, 0); + }); + } + + // THIEF 3RD JOB ADVANCEMENT NPC --------------------------------------------------------------------------- + + @Script("thief3") + public static void thief3(ScriptManager sm) { + // Arec : Shadow Instructor (2020011) + // El Nath : Chief's Residence (211000001) + + // Check for 3rd job advancement (Level 70, job 410/420/432) + if (sm.getLevel() >= 70 && (sm.getJob() == 410 || sm.getJob() == 420 || sm.getJob() == 432)) { + sm.sayNext("You're a strong and determined Thief. You have trained yourself well and are now ready to take the next step in your journey."); + + if (sm.askYesNo("You are ready to become a #b3rd job Thief#k. Would you like to make the job advancement now?")) { + if (sm.getJob() == 410) { + // Assassin → Hermit + sm.setJob(Job.getById(411)); + sm.sayNext("Congratulations! You are now a #bHermit#k. Continue your training and seek out the masters when you reach Level 120 for your final advancement."); + } else if (sm.getJob() == 420) { + // Bandit → Chief Bandit + sm.setJob(Job.getById(421)); + sm.sayNext("Congratulations! You are now a #bChief Bandit#k. Continue your training and seek out the masters when you reach Level 120 for your final advancement."); + } else if (sm.getJob() == 432) { + // Blade Specialist → Blade Lord (Dual Blade 3rd job) + sm.setJob(Job.BLADE_LORD); + sm.sayNext("You passed the test. The fight with the master Thief has proven your own worth as a Thief. You are now a #bBlade Lord#k, and bring your might to a new level!"); + sm.sayOk("Continue your training. When you reach Level 120, seek out #b#p2081400##k in Leafre for your final advancement."); + } + } else { + sm.sayOk("Come back when you are ready to make the job advancement."); + } + return; + } + + // Check for 4th job quest (Quest 6930, Level 120, job 411/421) + if (sm.getLevel() >= 120 && (sm.getJob() == 411 || sm.getJob() == 421)) { + // 4th job path - handled by q6930s quest script + if (!sm.hasQuestStarted(6930) && !sm.hasQuestCompleted(6930)) { + // Trigger quest 6930 start dialog + sm.sayNext("Long time no see. I heard about you. You seem to have had a hard time. Did you find darkness within you? Then you must have a reason for being here. What can I do for you?"); + + final int answer = sm.askMenu("I knew that you would return to see me someday. But I have no power to make your wish come true. Go to #bMinar Forest#k. If you find #b#p2081400##k, she may be able help you. Would you like to meet her?", + java.util.Map.of(0, "I want the 4th job advancement.")); + + if (answer == 0) { + if (sm.askYesNo("I'll write a recommendation letter for you. Hope you get a new power.")) { + sm.forceStartQuest(6930); + sm.addItem(4031516, 1); // Letter of Introduction + sm.sayNext("Take this letter to #b#p2081400##k in Leafre. She will guide you on the path to ultimate power."); + } else { + sm.sayOk("Aren't you here for the 4th job advancement? If you don't want to that's fine."); + } + } + return; + } + } + + // Default message + sm.sayOk("Continue your training. When you are strong enough, return to me for your next job advancement."); + } + + @Script("warrior3") + public static void warrior3(ScriptManager sm) { + // Tylus : Warrior Job Instructor (2020008) + // El Nath : Chief's Residence (211000001) + + // Check for 3rd job advancement (Level 70, job 110/120/130) + if (sm.getLevel() >= 70 && (sm.getJob() == 110 || sm.getJob() == 120 || sm.getJob() == 130)) { + sm.sayNext("You have proven yourself as a strong and dedicated Warrior. You are now ready to take the next step on your path."); + + if (sm.askYesNo("You are ready to become a #b3rd job Warrior#k. Would you like to make the job advancement now?")) { + if (sm.getJob() == 110) { + // Fighter → Crusader + sm.setJob(Job.getById(111)); + sm.sayNext("Congratulations! You are now a #bCrusader#k. Continue your training and seek out the masters when you reach Level 120 for your final advancement."); + } else if (sm.getJob() == 120) { + // Page → White Knight + sm.setJob(Job.getById(121)); + sm.sayNext("Congratulations! You are now a #bWhite Knight#k. Continue your training and seek out the masters when you reach Level 120 for your final advancement."); + } else if (sm.getJob() == 130) { + // Spearman → Dragon Knight + sm.setJob(Job.getById(131)); + sm.sayNext("Congratulations! You are now a #bDragon Knight#k. Continue your training and seek out the masters when you reach Level 120 for your final advancement."); + } + } else { + sm.sayOk("Come back when you are ready to make the job advancement."); + } + return; + } + + // Check for 4th job quest (Quest 6900, Level 120, job 111/121/131) + if (sm.getLevel() >= 120 && (sm.getJob() == 111 || sm.getJob() == 121 || sm.getJob() == 131)) { + // 4th job path - handled by q6900s quest script + if (!sm.hasQuestStarted(6900) && !sm.hasQuestCompleted(6900)) { + // Trigger quest 6900 start dialog + sm.sayNext("It's been a quite a while since I've last seen you. I'm happy to see you improved so much. Do you realize the hidden strength within you? You must have some reason to see me. What can I do for you?"); + + final int answer = sm.askMenu("I knew that you would return to see me someday. But I have no power to make your wish come true. Go to #bMinar Forest#k. If you find #b#p2081100##k, she may be able help you. Would you like to meet her?", + java.util.Map.of(0, "I want the 4th job advancement.")); + + if (answer == 0) { + if (sm.askYesNo("I'll recommend you to him. Hope you get stronger!")) { + sm.forceStartQuest(6900); + sm.addItem(4031342, 1); // Letter of Introduction + sm.sayNext("Remember. Bishop of Minar forest, #b#p2081100##k. Please see him."); + } else { + sm.sayOk("Aren't you here to do the 4th job advancement? If not, that's fine."); + } + } + return; + } + } + + // Default message + sm.sayOk("Continue your training. When you are strong enough, return to me for your next job advancement."); + } + + @Script("wizard3") + public static void wizard3(ScriptManager sm) { + // Robeira : Magician Job Instructor (2020009) + // El Nath : Chief's Residence (211000001) + + // Check for 3rd job advancement (Level 70, job 210/220/230) + if (sm.getLevel() >= 70 && (sm.getJob() == 210 || sm.getJob() == 220 || sm.getJob() == 230)) { + sm.sayNext("Your magical prowess has grown significantly. You have mastered the fundamentals and are ready to unlock greater power."); + + if (sm.askYesNo("You are ready to become a #b3rd job Magician#k. Would you like to make the job advancement now?")) { + if (sm.getJob() == 210) { + // F/P Wizard → F/P Mage + sm.setJob(Job.getById(211)); + sm.sayNext("Congratulations! You are now a #bFire/Poison Mage#k. Continue your training and seek out the masters when you reach Level 120 for your final advancement."); + } else if (sm.getJob() == 220) { + // I/L Wizard → I/L Mage + sm.setJob(Job.getById(221)); + sm.sayNext("Congratulations! You are now an #bIce/Lightning Mage#k. Continue your training and seek out the masters when you reach Level 120 for your final advancement."); + } else if (sm.getJob() == 230) { + // Cleric → Priest + sm.setJob(Job.getById(231)); + sm.sayNext("Congratulations! You are now a #bPriest#k. Continue your training and seek out the masters when you reach Level 120 for your final advancement."); + } + } else { + sm.sayOk("Come back when you are ready to make the job advancement."); + } + return; + } + + // Check for 4th job quest (Quest 6910, Level 120, job 211/221/231) + if (sm.getLevel() >= 120 && (sm.getJob() == 211 || sm.getJob() == 221 || sm.getJob() == 231)) { + // 4th job path - handled by q6910s quest script + if (!sm.hasQuestStarted(6910) && !sm.hasQuestCompleted(6910)) { + // Trigger quest 6910 start dialog + sm.sayNext("Long time no see. I'm happy to see you improved. Did you find the truth in your mind? Then you must have some reason to be here. What can I do for you?"); + + final int answer = sm.askMenu("Yes. I was expecting you. But I don't have enough power to help you. Go to #bMinar Forest#k. #b#p2081200##k will help make your dream come true. Do you want to see him?", + java.util.Map.of(0, "I want the 4th job advancement.")); + + if (answer == 0) { + if (sm.askYesNo("Then I'll recommend you to him. Don't be rude to him. May you find the power you seek!")) { + sm.forceStartQuest(6910); + sm.addItem(4031510, 1); // Letter of Introduction + sm.sayNext("Remember. The bishop of Minar Forest, #b#p2081200##k. Please see him."); + } else { + sm.sayOk("Aren't you here for the 4th job advancement? If you don't want it, that's fine."); + } + } + return; + } + } + + // Default message + sm.sayOk("Continue your training. When you are strong enough, return to me for your next job advancement."); + } + + @Script("bowman3") + public static void bowman3(ScriptManager sm) { + // Rene : Bowman Job Instructor (2020010) + // El Nath : Chief's Residence (211000001) + + // Check for 3rd job advancement (Level 70, job 310/320) + if (sm.getLevel() >= 70 && (sm.getJob() == 310 || sm.getJob() == 320)) { + sm.sayNext("Your archery skills have reached an impressive level. You are now ready to advance to the next stage of your journey."); + + if (sm.askYesNo("You are ready to become a #b3rd job Bowman#k. Would you like to make the job advancement now?")) { + if (sm.getJob() == 310) { + // Hunter → Ranger + sm.setJob(Job.getById(311)); + sm.sayNext("Congratulations! You are now a #bRanger#k. Continue your training and seek out the masters when you reach Level 120 for your final advancement."); + } else if (sm.getJob() == 320) { + // Crossbowman → Sniper + sm.setJob(Job.getById(321)); + sm.sayNext("Congratulations! You are now a #bSniper#k. Continue your training and seek out the masters when you reach Level 120 for your final advancement."); + } + } else { + sm.sayOk("Come back when you are ready to make the job advancement."); + } + return; + } + + // Check for 4th job quest (Quest 6920, Level 120, job 311/321) + if (sm.getLevel() >= 120 && (sm.getJob() == 311 || sm.getJob() == 321)) { + // 4th job path - handled by q6920s quest script + if (!sm.hasQuestStarted(6920) && !sm.hasQuestCompleted(6920)) { + // Trigger quest 6920 start dialog + sm.sayNext("Long time no see. You remind me of the time when you came to me for the third advancement. Did you find the truth in your mind? You must have some reason to come to see me. What can I do for you?"); + + final int answer = sm.askMenu("I knew that you would return to see me someday. But I have no power to make your wish come true. Go to #bMinar Forest#k. If you find #b#p2081300##k, he may be able help you. Would you like to meet him?", + java.util.Map.of(0, "I want the 4th job advancement.")); + + if (answer == 0) { + if (sm.askYesNo("I'll write a recommendation letter for you. Hope you get a new power.")) { + sm.forceStartQuest(6920); + sm.addItem(4031513, 1); // Letter of Introduction + sm.sayNext("Remember. The bishop of Minar Forest, #b#p2081200##k. Please see him."); + } else { + sm.sayOk("Aren't you here for the 4th job advancement? If you don't want it, that's fine."); + } + } + return; + } + } + + // Default message + sm.sayOk("Continue your training. When you are strong enough, return to me for your next job advancement."); + } + + @Script("pirate3") + public static void pirate3(ScriptManager sm) { + // Pedro : Pirate Job Instructor (2020013) + // El Nath : Chief's Residence (211000001) + + // Check for 3rd job advancement (Level 70, job 510/520) + if (sm.getLevel() >= 70 && (sm.getJob() == 510 || sm.getJob() == 520)) { + sm.sayNext("You have honed your skills as a Pirate and proven yourself worthy of greater power. The time has come for you to advance."); + + if (sm.askYesNo("You are ready to become a #b3rd job Pirate#k. Would you like to make the job advancement now?")) { + if (sm.getJob() == 510) { + // Brawler → Marauder + sm.setJob(Job.getById(511)); + sm.sayNext("Congratulations! You are now a #bMarauder#k. Continue your training and seek out the masters when you reach Level 120 for your final advancement."); + } else if (sm.getJob() == 520) { + // Gunslinger → Outlaw + sm.setJob(Job.getById(521)); + sm.sayNext("Congratulations! You are now an #bOutlaw#k. Continue your training and seek out the masters when you reach Level 120 for your final advancement."); + } + } else { + sm.sayOk("Come back when you are ready to make the job advancement."); + } + return; + } + + // Check for 4th job quest (Quest 6940, Level 120, job 511/521) + if (sm.getLevel() >= 120 && (sm.getJob() == 511 || sm.getJob() == 521)) { + // 4th job path - handled by q6940s quest script + if (!sm.hasQuestStarted(6940) && !sm.hasQuestCompleted(6940)) { + // Trigger quest 6940 start dialog + sm.sayNext("It has been a long time. I have kept tabs on your steady progression. Seeing you standing before me, healthy and strong, I can sense that a lot has happened since our last encounter. Have you finally uncovered the freedom within you all this time? If so, then there must be a reason why you came all the way to see me. What is it?"); + + final int answer = sm.askMenu("I see... I have known that this day will someday come. Unfortunately, I do not have the powers to fulfill your wish. In order for you to complete this process, you'll have to head over to the #bMinar Forst#k and meet #b#p2081500##k, who should be meditating as you walk in. He may be enough to fulfill your wish. Would you like to pay a visit?", + java.util.Map.of(0, "I'd like to make the 4th job advancement.")); + + if (answer == 0) { + if (sm.askYesNo("I will write up a recommendation letter for you right now. I hope you come out of this with a wealth of new power at your disposal.")) { + sm.forceStartQuest(6940); + sm.addItem(4031859, 1); // Letter of Introduction + sm.sayNext("Remember the name. The priest of Minar Forest, #b#p2081500##k. Visit him."); + } else { + sm.sayOk("Aren't you here to see me to make the 4th job advancement? If not, then don't mind me."); + } + } + return; + } + } + + // Default message + sm.sayOk("Continue your training. When you are strong enough, return to me for your next job advancement."); + } } diff --git a/src/main/java/kinoko/script/continent/FlorinaBeach.java b/src/main/java/kinoko/script/continent/FlorinaBeach.java new file mode 100644 index 00000000..f2aea6bb --- /dev/null +++ b/src/main/java/kinoko/script/continent/FlorinaBeach.java @@ -0,0 +1,63 @@ +package kinoko.script.continent; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +import java.util.Map; + +public class FlorinaBeach extends ScriptHandler { + @Script("florina1") + public static void florina1(ScriptManager sm) { + // Pison : Tour Guide (1081001) + // Florina Beach : A Look-Out Shed Around the Beach (120030000) + sm.sayNext("So you want to leave #b#m120030000##k? If you want, I can take you back to #b#m120020400##k."); + if (!sm.askYesNo("Are you sure you want to return to #b#m120020400##k? Alright, we'll have to get going fast. Do you want to head back to #m120020400# now?")) { + sm.sayOk("You must have some business to take care of here. It's not a bad idea to take some rest at #m120020400# Look at me; I love it here so much that I wound up living here. Hahaha anyway, talk to me when you feel like going back."); + return; + } + + sm.warp(120020400, "sp"); + } + + @Script("florina2") + public static void florina2(ScriptManager sm) { + // Pason : Tour Guide (1002002) + // Beach : White Wave Harbor (120020400) + // Shuri : Tour Guide (2010005) + // Nara : Tour Guide (2040048) + final int answer = sm.askMenu("Have you heard of the beach with a spectacular view of the ocean called #bFlorina Beach#k, located near Lith Harbor? I can take you there right now for either #b1500 mesos#k, or if you have a #bVIP Ticket to Florina Beach#k with you, in which case you'll be there for free.", Map.of( + 0, "#bI'll pay 1500 mesos.", + 1, "I have a VIP Ticket to Florina Beach.", + 2, "What is a VIP Ticket to Florina Beach#k" + )); + + switch (answer) { + case 0: + if(!sm.canAddMoney(-1500)) { + sm.sayOk("I think you're lacking mesos. There are many ways to gather up some money, you know, like... selling your armor... defeating monsters... doing quests... you know what I'm talking about."); + } else { + sm.addMoney(-1500); + sm.warp(120030000, "st00"); + } + break; + case 1: + if (!sm.askYesNo("So you have a #bVIP Ticket to Florina Beach#k? You can always head over to Florina Beach with that. Alright then, but just be aware that you may be running into some monsters there too. Okay, would you like to head over to Florina Beach right now?")) { + sm.sayOk("You must have some business to take care of here. You must be tired from all that traveling and hunting. Go take some rest, and if you feel like changing your mind, then come talk to me."); + return; + } + + if(!sm.hasItem(4031134, 1)) { + sm.sayOk("Hmmm, so where exactly is your #bVIP Ticket to Florina Beach#k? Are you sure you have one? Please double-check."); + return; + } + + sm.warp(120030000, "st00"); + break; + case 2: + sm.sayNext("You must be curious about a #bVIP Ticket to Florina Beach#k. Haha, that's very understandable. A VIP Ticket to Florina Beach is an item where as long as you have in possession, you may make your way to Florina Beach for free. It's such a rare item that even we had to buy those, but unfortunately I lost mine a few weeks ago during my precious summer break."); + sm.sayPrev("I came back without it, and it just feels awful not having it. Hopefully someone picked it up and put it somewhere safe. Anyway, this is my story and who knows, you may be able to pick it up and put it to good use. If you have any questions, feel free to ask."); + break; + } + } +} diff --git a/src/main/java/kinoko/script/continent/LHC.java b/src/main/java/kinoko/script/continent/LHC.java new file mode 100644 index 00000000..7ffe27ed --- /dev/null +++ b/src/main/java/kinoko/script/continent/LHC.java @@ -0,0 +1,54 @@ +package kinoko.script.continent; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +public class LHC extends ScriptHandler { + @Script("lionCastle_enter") + public static void lionCastle_enter(ScriptManager sm) { + sm.playPortalSE(); + sm.warp(211060010, "west00"); + } + + @Script("gotoNext1") + public static void gotoNext1(ScriptManager sm) { + sm.playPortalSE(); + sm.warp(211060300, "west00"); + } + + @Script("gotoNext2_1") + public static void gotoNext2_1(ScriptManager sm) { + sm.playPortalSE(); + sm.warp(211060500, "west00"); + } + + @Script("gotoNext2_2") + public static void gotoNext2_2(ScriptManager sm) { + sm.playPortalSE(); + sm.warp(211060410, "in00"); + } + + @Script("2ndTowerTop") + public static void secondTowerTop(ScriptManager sm) { + sm.playPortalSE(); + sm.warp(211060401); + } + + @Script("3rdTowerTop") + public static void thirdTowerTop(ScriptManager sm) { + sm.playPortalSE(); + sm.warp(211060601); + } + + @Script("vanleonItem0") + public static void vanleonItem0(ScriptManager sm) { + sm.message("Not implemented yet."); + } + + @Script("q3162s") + public static void q3162s(ScriptManager sm) { + sm.sayNext("Royal Guard Ani comes out every hour, but right now he's not feeling like fighting."); + sm.forceStartQuest(3162); + } +} diff --git a/src/main/java/kinoko/script/continent/Ludibrium.java b/src/main/java/kinoko/script/continent/Ludibrium.java new file mode 100644 index 00000000..49b46b6a --- /dev/null +++ b/src/main/java/kinoko/script/continent/Ludibrium.java @@ -0,0 +1,82 @@ +package kinoko.script.continent; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +/** + * Ludibrium continent portal and NPC scripts + * Includes Ellin Forest access, pet training, and parkour functionality + */ +public final class Ludibrium extends ScriptHandler { + public static final int PET_TRAINER_LETTER = 4031128; + + // PORTAL SCRIPTS + + @Script("move_elin") + public static void move_elin(ScriptManager sm) { + // Portal from Ludibrium Helios Tower (222020400) to Ellin Forest + // Based on old script: move_elin.js + sm.playPortalSE(); + sm.warp(300000100, "out00"); // Ellin Forest + } + + // NPC SCRIPTS + + @Script("ludi028") + public static void ludi028(ScriptManager sm) { + // Pet Trainer Yuppy (2040032) + // Ludibrium : Ludibrium Pet Walkway (220000006) + // Pet training parkour course - Start NPC + + if (sm.hasItem(PET_TRAINER_LETTER)) { + sm.sayNext("Get that letter, jump over obstacles with your pet, and take that letter to my brother #p2040033#. Get him the letter and something good is going to happen to your pet."); + return; + } + + if (!sm.askYesNo("This is the road where you can go take a walk with your pet. You can just walk around with it, or you can train your pet to go through the obstacles here. If you aren't too close with your pet yet, that may present a problem and he will not follow your command as much... so, what do you think? Wanna train your pet?")) { + sm.sayNext("Hmmm... too busy to do it right now? If you feel like doing it, though, come back and find me."); + return; + } + + if (!sm.canAddItem(PET_TRAINER_LETTER, 1)) { + sm.sayNext("Your etc. inventory is full! I can't give you the letter unless there's room on ur inventory. Make an empty slot and then talk to me."); + return; + } + + sm.addItem(PET_TRAINER_LETTER, 1); + sm.sayOk("Ok, here's the letter. He wouldn't know I sent you if you just went there straight, so go through the obstacles with your pet, go to the very top, and then talk to #p2040033# to give him the letter. It won't be hard if you pay attention to your pet while going through obstacles. Good luck!"); + } + + @Script("ludi029") + public static void ludi029(ScriptManager sm) { + // Pet Trainer Neil (2040033) + // Ludibrium : Ludibrium Pet Walkway (220000006) + // Pet training parkour course - End NPC (rewards pet closeness) + + // Check if player is at the top of the parkour (y position check) + if (sm.getUser().getY() > -1038) { + // Player hasn't reached the top yet + return; + } + + if (!sm.hasItem(PET_TRAINER_LETTER)) { + sm.sayOk("My brother told me to take care of the pet obstacle course, but... since I'm so far away from him, I can't help but wanting to goof around ...hehe, since I don't see him in sight, might as well just chill for a few minutes."); + return; + } + + sm.sayNext("Eh, that's my brother's letter! Probably scolding me for thinking I'm not working and stuff... Eh? Ahhh... you followed my brother's advice and trained your pet and got up here, huh? Nice!! Since you worked hard to get here, I'll boost your intimacy level with your pet."); + + if (!sm.hasItem(PET_TRAINER_LETTER) || sm.getUser().getPet(0) == null) { + sm.sayBoth("Hmmm... did you really get here with your pet? These obstacles are for pets. What are you here for without it?? Get outta here!"); + return; + } + + sm.removeItem(PET_TRAINER_LETTER, 1); + // TODO: Implement pet closeness system + // Random closeness gain between 1-9 + // final int closenessGain = (int) (Math.random() * 9) + 1; + // sm.getUser().getPet(0).addCloseness(closenessGain); + sm.sayOk("What do you think? Don't you think you have gotten much closer with your pet? If you have time, train your pet again on this obstacle course... of course, with my brother's permission."); + } +} diff --git a/src/main/java/kinoko/script/continent/LudusLake.java b/src/main/java/kinoko/script/continent/LudusLake.java index fce5cf4e..913ae5ad 100644 --- a/src/main/java/kinoko/script/continent/LudusLake.java +++ b/src/main/java/kinoko/script/continent/LudusLake.java @@ -1,6 +1,15 @@ package kinoko.script.continent; +import kinoko.script.common.Script; import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +import java.util.List; +import java.util.Map; +import java.util.stream.Stream; + +import kinoko.world.job.JobConstants; public final class LudusLake extends ScriptHandler { -} \ No newline at end of file + // Placeholder for Ludus Lake continent scripts +} diff --git a/src/main/java/kinoko/script/continent/MinarForest.java b/src/main/java/kinoko/script/continent/MinarForest.java index 983d59a1..4a416cdf 100644 --- a/src/main/java/kinoko/script/continent/MinarForest.java +++ b/src/main/java/kinoko/script/continent/MinarForest.java @@ -4,6 +4,7 @@ import kinoko.script.common.ScriptHandler; import kinoko.script.common.ScriptManager; import kinoko.util.Tuple; +import kinoko.world.job.Job; import kinoko.world.quest.QuestRecordType; import java.util.List; @@ -333,4 +334,722 @@ public static void reundodraco(ScriptManager sm) { // Time Lane : Temple of Time (270000100) sm.resetConsumeItemEffect(MINI_DRACO_TRANSFORMATION); } + + + // WARRIOR 4TH JOB ADVANCEMENT NPC --------------------------------------------------------------------------- + + @Script("warrior4") + public static void warrior4(ScriptManager sm) { + // Harmonia : The Warrior Instructor (2081100) + // Leafre : Forest of the Priest (240010501) + + // Check job and level + if (!(sm.getJob() == 111 || sm.getJob() == 121 || sm.getJob() == 131)) { + sm.sayOk("Why do you want to see me? There is nothing you want to ask me."); + return; + } + + if (sm.getLevel() < 120) { + sm.sayOk("You're still weak to go to warrior extreme road. If you get stronger, come back to me."); + return; + } + + // Check if ready for 4th job change (Quest 6904 completed) + if (sm.hasQuestCompleted(6904)) { + // Ready for job advancement + if (sm.getJob() == 111) { + sm.sayNext("You're qualified to be a true warrior. Do you want job advancement?"); + if (sm.askYesNo("I want to advance to Hero.")) { + sm.setJob(Job.getById(112)); // Hero + sm.sayNext("You became the best warrior #bHero#k."); + sm.sayOk("Don't forget that it all depends on how much you train."); + } + } else if (sm.getJob() == 121) { + sm.sayNext("You're qualified to be a true warrior. Do you want job advancement?"); + if (sm.askYesNo("I want to advance to Paladin.")) { + sm.setJob(Job.getById(122)); // Paladin + sm.sayNext("You became the best warrior #bPaladin#k."); + sm.sayOk("Don't forget that it all depends on how much you train."); + } + } else if (sm.getJob() == 131) { + sm.sayNext("You're qualified to be a true warrior. Do you want job advancement?"); + if (sm.askYesNo("I want to advance to Dark Knight.")) { + sm.setJob(Job.getById(132)); // Dark Knight + sm.sayNext("You became the best warrior #bDark Knight#k."); + sm.sayOk("Don't forget that it all depends on how much you train."); + } + } + return; + } + + // Quest 6900 end - Receive letter from Tylus + if (sm.hasQuestStarted(6900) && sm.hasItem(4031342, 1)) { + sm.sayNext("Why do you want to see me, young warrior.."); + sm.sayBoth("#b#p2020008##k?... Is he the one in El Nath? Then I can trust you."); + + if (sm.askYesNo("A young warrior who wants increase their power. I have to tell you something. Talk to me only if you're ready to hear the truth. Many secrets will be revealed...")) { + sm.removeItem(4031342, 1); + sm.forceCompleteQuest(6900); + sm.addExp(20000); + sm.sayNext("Good. Talk to me when you're ready to hear the first story."); + } else { + sm.sayOk("Come back when you're ready."); + } + return; + } + + // Quest 6901 - First Story (Zakum) + if (sm.hasQuestCompleted(6900) && !sm.hasQuestStarted(6901) && !sm.hasQuestCompleted(6901)) { + sm.sayNext("The first story is about the town vanished into lava volcano. Would you like to listen?"); + if (sm.askYesNo("Good. You should listen to the story. Are you ready?")) { + sm.forceStartQuest(6901); + sm.sayNext("Have you ever been to the deep lava volcano in the El Nath mountains? There used to be a town there."); + sm.sayBoth("People in the town worshipped a human shaped stone statue and the volcano. They built an altar and stone statue under the tree at the basin of the deepest volcano and worshiped the altar to prove their faith."); + sm.sayBoth("Then disaster struck. It was the wicked Zakum's tree. Zakum's spirit didn't have a body but instead possessed the stone statue that people built. His evil rapidly spread through the town..."); + sm.sayBoth("After that, the town disappeared. You've heard about the fearsome power called #bZakum#k sleeping under the lava volcano, as you've traveled the Maple World for a long time. Now you know how he came to be. You can guess what happened to the townspeople..."); + sm.forceCompleteQuest(6901); + sm.sayOk("That's the end of the first story. Talk to me when you're ready to listen to the second story."); + } else { + sm.sayOk("Are you afraid? You've come this far..."); + } + return; + } + + // Quest 6902 - Second Story (Holychoras) + if (sm.hasQuestCompleted(6901) && !sm.hasQuestStarted(6902) && !sm.hasQuestCompleted(6902)) { + sm.sayNext("The second story is about a growing stone and the Aquarium. Do you want to hear about it?"); + if (sm.askYesNo("Good. You deserve to listen to the story. Are you ready?")) { + sm.forceStartQuest(6902); + sm.sayNext("Have you been to the Aquarium under the sea? It is a mysterious place, floating over the valley of the deep sea. Haven't you wondered how it floats?"); + sm.sayBoth("There is a stone called the #bHolychoras#k underneath the valley of the deep sea. Nobody knows how came to be there, but it has been there for ages."); + sm.sayBoth("#bHolychoras#k has a strange power that purifies the sea. That's how Aqua Road is purified and the Aquarium can float over the valley under the sea. If #bHolychoras#k disappears, the sea will grow dark..."); + sm.forceCompleteQuest(6902); + sm.sayOk("This is the end of the second story. Talk to me when you're ready to listen to the third story."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged."); + } + return; + } + + // Quest 6903 - Third Story (Ludibrium) + if (sm.hasQuestCompleted(6902) && !sm.hasQuestStarted(6903) && !sm.hasQuestCompleted(6903)) { + sm.sayNext("The third is the story about the lake where time stopped. Do you wanna listen?"); + if (sm.askYesNo("Yes, you do have the right to listen to this story. Are you ready?")) { + sm.forceStartQuest(6903); + sm.sayNext("When you go to the north, there's a big lake. Bigger than a forest! People call this lake Ludus or the lake where time stopped."); + sm.sayBoth("Near the Lake lies Ludibrium Castle, supported by huge towers. The Holy Power of the clock tower in the middle of Ludibrium Castle protects the castle by stopping the time there."); + sm.sayBoth("But the dimensional crack in Ludibrium castle is getting wider and wider. A wicked power has invaded through the crack and changed the castle. You could probably feel the power of beings from the other dimension. They are called Alishar and Papulatus, and both are getting stronger."); + sm.sayBoth("You know why I'm telling you this story. The 4th job advancement requires more responsibility. You must know what you'll be up against."); + sm.sayBoth("You have to understand the Maple World more and behave as a true hero. Now I'll give you the last task for the 4th job advancement."); + sm.forceCompleteQuest(6903); + sm.sayOk("Talk to me when you're ready."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged..."); + } + return; + } + + // Quest 6904 - Final Trial + if (sm.hasQuestCompleted(6903) && !sm.hasQuestStarted(6904)) { + sm.sayNext("Now I'll give you the last task to do the 4th job advancement."); + if (sm.askYesNo("Get me two things: #b#t4031343##k and #b#t4031344##k. Are you ready?")) { + sm.forceStartQuest(6904); + sm.sayNext("Go and get #b#t4031343##k and #b#t4031344##k."); + sm.sayOk("It's up to you how you get it. If you want to use your power and courage, you can catch #bManon and Griffey#k. If you wanna use wisdom, you can get them through #b#p2081000##k in Leafre."); + } else { + sm.sayOk("What are you afraid of? Great power awaits you.."); + } + return; + } + + // Quest 6904 - Turn in items + if (sm.hasQuestStarted(6904) && !sm.hasQuestCompleted(6904)) { + if (sm.hasItem(4031343, 1) && sm.hasItem(4031344, 1)) { + sm.removeItem(4031343, 1); + sm.removeItem(4031344, 1); + sm.forceCompleteQuest(6904); + sm.addExp(50000); + sm.sayNext("You proved your quality as a hero."); + sm.sayOk("Now, what lies before you is the Way of a #bWarrior#k. Talk to me again if you are ready for the 4th job Advancement."); + } else { + sm.sayOk("You haven't gathered #b#t4031343##k and #b#t4031344##k. That's will prove your quality."); + } + return; + } + + // Default message + sm.sayOk("You're not ready to make 4th job advancement. When you're ready, talk to me."); + } + + // THIEF 4TH JOB ADVANCEMENT NPC --------------------------------------------------------------------------- + + @Script("thief4") + public static void thief4(ScriptManager sm) { + // Hellin : The Assassin Instructor (2081400) + // Leafre : Forest of the Priest (240010501) + + // Check job and level + if (!(sm.getJob() == 411 || sm.getJob() == 421 || sm.getJob() == 433)) { + sm.sayOk("Why do you want to see me? There is nothing you want to ask me."); + return; + } + + if (sm.getLevel() < 120) { + sm.sayOk("You're still weak to go to thief extreme road. If you get stronger, come back to me."); + return; + } + + // Check if ready for 4th job change (Quest 6934 completed OR Dual Blade with Secret Scroll) + final boolean hasDualBladeSecretScroll = (sm.getJob() == 433 && sm.hasItem(4031348, 1)); + if (sm.hasQuestCompleted(6934) || hasDualBladeSecretScroll) { + // Ready for job advancement + if (sm.getJob() == 411) { + sm.sayNext("You're qualified to be a true thief. Do you want job advancement?"); + if (sm.askYesNo("I want to advance to Night Lord.")) { + sm.setJob(Job.getById(412)); // Night Lord + sm.sayNext("You became the best thief #bNight Lord#k."); + sm.sayOk("Don't forget that it all depends on how much you train."); + } + } else if (sm.getJob() == 421) { + sm.sayNext("You're qualified to be a true thief. Do you want job advancement?"); + if (sm.askYesNo("I want to advance to Shadower.")) { + sm.setJob(Job.getById(422)); // Shadower + sm.sayNext("You became the best thief #bShadower#k."); + sm.sayOk("Don't forget that it all depends on how much you train."); + } + } else if (sm.getJob() == 433) { + sm.sayNext("You're qualified to be a true thief. Do you want job advancement?"); + if (sm.askYesNo("I want to advance to Dual Master.")) { + // Remove Secret Scroll if quest wasn't completed + if (!sm.hasQuestCompleted(6934) && sm.hasItem(4031348, 1)) { + sm.removeItem(4031348, 1); + } + sm.setJob(Job.BLADE_MASTER); // Job 434 + sm.sayNext("You became the best thief #bDual Master#k."); + sm.sayOk("Don't forget that it all depends on how much you train."); + } + } + return; + } + + // Quest 6930 end - Receive letter from Arec + if (sm.hasQuestStarted(6930) && sm.hasItem(4031516, 1)) { + sm.sayNext("What are you doing here, young Thief?"); + sm.sayBoth("#b#p2020011##k?... The one in El Nath? Then I can trust you."); + + if (sm.askYesNo("A young Thief dreaming of being a Nightlord or Shadower. I have a few stories to tell you, my stealthy friend. Are you ready?")) { + sm.removeItem(4031516, 1); + sm.forceCompleteQuest(6930); + sm.sayNext("Good. Talk to me when you're ready to hear the first story."); + } else { + sm.sayOk("Come back when you're ready."); + } + return; + } + + // Quest 6931 - First Story (Zakum) + if (sm.hasQuestCompleted(6930) && !sm.hasQuestStarted(6931) && !sm.hasQuestCompleted(6931)) { + sm.sayNext("The first story is about the town vanished into lava volcano. Would you like to listen?"); + if (sm.askYesNo("Good. You have the right to listen to the story. Are you ready?")) { + sm.forceStartQuest(6931); + sm.sayNext("Have you ever been to the deep lava volcano in the El Nath mountains? There used to be a town there."); + sm.sayBoth("People in the town worshipped a human shaped stone statue and the volcano. They built an altar and stone statue under the tree at the basin of the deepest volcano and worshiped the altar to prove their faith."); + sm.sayBoth("Then disaster struck. It was the wicked Zakum's tree. Zakum's spirit didn't have a body but instead possessed the stone statue that people built. His evil rapidly spread through the town..."); + sm.sayBoth("After that, the town disappeared. You've heard about the fearsome power called #bZakum#k sleeping under the lava volcano, as you've traveled the Maple World for a long time. Now you know how he came to be. You can guess what happened to the townspeople.."); + sm.forceCompleteQuest(6931); + sm.sayOk("That's the end of the first story. Talk to me when you're ready to listen to the second story."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged."); + } + return; + } + + // Quest 6932 - Second Story (Holychoras) + if (sm.hasQuestCompleted(6931) && !sm.hasQuestStarted(6932) && !sm.hasQuestCompleted(6932)) { + sm.sayNext("The second story is about a growing stone and the Aquarium. Do you want to hear about it?"); + if (sm.askYesNo("Good. You deserve to listen to the story. Are you ready?")) { + sm.forceStartQuest(6932); + sm.sayNext("Have you been to the Aquarium under the sea? It is a mysterious place, floating over the valley of the deep sea. Haven't you wondered how it floats?"); + sm.sayBoth("There is a stone called the #bHolychoras#k underneath the valley of the deep sea. Nobody knows how came to be there, but it has been there for ages."); + sm.sayBoth("#bHolychoras#k has a strange power that purifies the sea. That's how Aqua Road is purified and the Aquarium can float over the valley under the sea. If #bHolychoras#k disappears, the sea will grow dark..."); + sm.forceCompleteQuest(6932); + sm.sayOk("This is the end of the second story. Talk to me when you're ready to listen to the third story."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged."); + } + return; + } + + // Quest 6933 - Third Story (Ludibrium) + if (sm.hasQuestCompleted(6932) && !sm.hasQuestStarted(6933) && !sm.hasQuestCompleted(6933)) { + sm.sayNext("The third is the story about the lake where time stopped. Do you wanna listen?"); + if (sm.askYesNo("Yes, you do have the right to listen to the stories. Are you ready?")) { + sm.forceStartQuest(6933); + sm.sayNext("When you go to the north, there's a big lake. Bigger than a forest! People call this lake Ludus or the lake where the time stopped."); + sm.sayBoth("Near the Lake lies Ludibrium Castle, supported by huge towers. The Holy Power of the clock tower in the middle of Ludibrium Castle protects the castle by stopping the time there."); + sm.sayBoth("But the dimensional crack in Ludibrium castle is getting wider and wider. A wicked power has invaded through the crack and changed the castle. You could probably feel the power of beings from the other dimension. They are called Alishar and Papulatus, and both are getting stronger."); + sm.sayBoth("You know why I'm telling you this story. The 4th job advancement requires more responsibility. You must know what you'll be up against."); + sm.sayBoth("You have to understand the Maple World more and behave as a true hero. Now I'll give you the last task for the 4th job advancement."); + sm.forceCompleteQuest(6933); + sm.sayOk("Talk to me when you're ready for the final trial."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged."); + } + return; + } + + // Quest 6934 - Final Trial + if (sm.hasQuestCompleted(6933) && !sm.hasQuestStarted(6934)) { + sm.sayNext("Now I'll give you the last task to do the 4th job advancement."); + if (sm.askYesNo("Get me two things: #b#t4031517##k and #b#t4031518##k. Are you ready?")) { + sm.forceStartQuest(6934); + sm.sayOk("It's up to you how you get it. If you wanna use your power and courage, you can catch #bManon and Griffey#k. If you wanna use wisdom and warm heart, you can get them through #b#p2081000##k in Leafre."); + } else { + sm.sayOk("What are you afraid of? Great power awaits you..."); + } + return; + } + + // Quest 6934 - Turn in items + if (sm.hasQuestStarted(6934) && !sm.hasQuestCompleted(6934)) { + if (sm.hasItem(4031517, 1) && sm.hasItem(4031518, 1)) { + sm.removeItem(4031517, 1); + sm.removeItem(4031518, 1); + sm.forceCompleteQuest(6934); + sm.sayNext("You proved your quality as a hero."); + sm.sayOk("Now you only have to go to the way of a Shadower or Nightlord. Talk to me if you're ready for the 4th job advancement."); + } else { + sm.sayOk("Haven't you got #b#t4031517##k and #b#t4031518##k?"); + } + return; + } + + // Default message + sm.sayOk("You're not ready to make 4th job advancement. When you're ready, talk to me."); + } + + // MAGICIAN 4TH JOB ADVANCEMENT NPC --------------------------------------------------------------------------- + + @Script("wizard4") + public static void wizard4(ScriptManager sm) { + // Bishop : The Magician Instructor (2081200) + // Leafre : Forest of the Priest (240010501) + + // Check job and level + if (!(sm.getJob() == 211 || sm.getJob() == 221 || sm.getJob() == 231)) { + sm.sayOk("Why do you want to see me? There is nothing you want to ask me."); + return; + } + + if (sm.getLevel() < 120) { + sm.sayOk("You're still weak to go to magician extreme road. If you get stronger, come back to me."); + return; + } + + // Check if ready for 4th job change (Quest 6914 completed) + if (sm.hasQuestCompleted(6914)) { + // Ready for job advancement + if (sm.getJob() == 211) { + sm.sayNext("You're qualified to be a true magician. Do you want job advancement?"); + if (sm.askYesNo("I want to advance to Arch Mage (Fire, Poison).")) { + sm.setJob(Job.getById(212)); // Arch Mage F/P + sm.sayNext("You became the best magician #bArch Mage (Fire, Poison)#k."); + sm.sayOk("Don't forget that it all depends on how much you train."); + } + } else if (sm.getJob() == 221) { + sm.sayNext("You're qualified to be a true magician. Do you want job advancement?"); + if (sm.askYesNo("I want to advance to Arch Mage (Ice, Lightning).")) { + sm.setJob(Job.getById(222)); // Arch Mage I/L + sm.sayNext("You became the best magician #bArch Mage (Ice, Lightning)#k."); + sm.sayOk("Don't forget that it all depends on how much you train."); + } + } else if (sm.getJob() == 231) { + sm.sayNext("You're qualified to be a true magician. Do you want job advancement?"); + if (sm.askYesNo("I want to advance to Bishop.")) { + sm.setJob(Job.getById(232)); // Bishop + sm.sayNext("You became the best magician #bBishop#k."); + sm.sayOk("Don't forget that it all depends on how much you train."); + } + } + return; + } + + // Quest 6910 end - Receive letter from Robeira + if (sm.hasQuestStarted(6910) && sm.hasItem(4031510, 1)) { + sm.sayNext("What are you doing here young magician?"); + sm.sayBoth("#b#p2020009##k?... That's the one who lives in El Nath. If she recommended you, I can trust you."); + + if (sm.askYesNo("A young magician dreaming of being an Arch Mage. I have to tell you a few stories. Talk to me when you're ready.")) { + sm.removeItem(4031510, 1); + sm.forceCompleteQuest(6910); + sm.addExp(20000); + sm.sayNext("Good. Talk to me when you're ready to hear the first story."); + } else { + sm.sayOk("Come back when you're ready."); + } + return; + } + + // Quest 6911-6913 story quests (same as Warrior/Thief pattern) + if (sm.hasQuestCompleted(6910) && !sm.hasQuestStarted(6911) && !sm.hasQuestCompleted(6911)) { + sm.sayNext("The first story is about the town vanished into lava volcano. Would you like to listen?"); + if (sm.askYesNo("Good. You should listen to the story. Are you ready?")) { + sm.forceStartQuest(6911); + sm.sayNext("Have you ever been to the deep lava volcano in the El Nath mountains? There used to be a town there."); + sm.sayBoth("People in the town worshipped a human shaped stone statue and the volcano. They built an altar and stone statue under the tree at the basin of the deepest volcano and worshiped the altar to prove their faith."); + sm.sayBoth("Then disaster struck. It was the wicked Zakum's tree. Zakum's spirit didn't have a body but instead possessed the stone statue that people built. His evil rapidly spread through the town..."); + sm.sayBoth("After that, the town disappeared. You've heard about the fearsome power called #bZakum#k sleeping under the lava volcano, as you've traveled the Maple World for a long time. Now you know how he came to be. You can guess what happened to the townspeople..."); + sm.forceCompleteQuest(6911); + sm.sayOk("That's the end of the first story. Talk to me when you're ready to listen to the second story."); + } else { + sm.sayOk("Are you afraid? You've come this far..."); + } + return; + } + + if (sm.hasQuestCompleted(6911) && !sm.hasQuestStarted(6912) && !sm.hasQuestCompleted(6912)) { + sm.sayNext("The second story is about a growing stone and the Aquarium. Do you want to hear about it?"); + if (sm.askYesNo("Good. You deserve to listen to the story. Are you ready?")) { + sm.forceStartQuest(6912); + sm.sayNext("Have you been to the Aquarium under the sea? It is a mysterious place, floating over the valley of the deep sea. Haven't you wondered how it floats?"); + sm.sayBoth("There is a stone called the #bHolychoras#k underneath the valley of the deep sea. Nobody knows how came to be there, but it has been there for ages."); + sm.sayBoth("#bHolychoras#k has a strange power that purifies the sea. That's how Aqua Road is purified and the Aquarium can float over the valley under the sea. If #bHolychoras#k disappears, the sea will grow dark..."); + sm.forceCompleteQuest(6912); + sm.sayOk("This is the end of the second story. Talk to me when you're ready to listen to the third story."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged."); + } + return; + } + + if (sm.hasQuestCompleted(6912) && !sm.hasQuestStarted(6913) && !sm.hasQuestCompleted(6913)) { + sm.sayNext("The third is the story about the lake where time stopped. Do you wanna listen?"); + if (sm.askYesNo("Yes, you do have the right to listen to this story. Are you ready?")) { + sm.forceStartQuest(6913); + sm.sayNext("When you go to the north, there's a big lake. Bigger than a forest! People call this lake Ludus or the lake where time stopped."); + sm.sayBoth("Near the Lake lies Ludibrium Castle, supported by huge towers. The Holy Power of the clock tower in the middle of Ludibrium Castle protects the castle by stopping the time there."); + sm.sayBoth("But the dimensional crack in Ludibrium castle is getting wider and wider. A wicked power has invaded through the crack and changed the castle. You could probably feel the power of beings from the other dimension. They are called Alishar and Papulatus, and both are getting stronger."); + sm.sayBoth("You know why I'm telling you this story. The 4th job advancement requires more responsibility. You must know what you'll be up against."); + sm.sayBoth("You have to understand the Maple World more and behave as a true hero. Now I'll give you the last task for the 4th job advancement."); + sm.forceCompleteQuest(6913); + sm.sayOk("Talk to me when you're ready."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged..."); + } + return; + } + + // Quest 6914 - Final Trial + if (sm.hasQuestCompleted(6913) && !sm.hasQuestStarted(6914)) { + sm.sayNext("Now I'll give you the last task to do the 4th job advancement."); + if (sm.askYesNo("Get me two things: #b#t4031511##k and #b#t4031512##k. Are you ready?")) { + sm.forceStartQuest(6914); + sm.sayNext("Get me #b#t4031511##k and #b#t4031512##k..."); + sm.sayOk("It's up to you how you get it. If you wanna use your power and courage, you can catch #bManon and Griffey#k. If you wanna use wisdom and warm heart, you can get them through #b#p2081000##k in Leafre."); + } else { + sm.sayOk("What are you afraid of? Great power awaits you..."); + } + return; + } + + // Quest 6914 - Turn in items + if (sm.hasQuestStarted(6914) && !sm.hasQuestCompleted(6914)) { + if (sm.hasItem(4031511, 1) && sm.hasItem(4031512, 1)) { + sm.removeItem(4031511, 1); + sm.removeItem(4031512, 1); + sm.forceCompleteQuest(6914); + sm.addExp(50000); + sm.sayNext("You proved your quality as a hero."); + sm.sayOk("Now you only have to go to the way of an Arch Mage. Talk to me if you're ready for the 4th job advancement."); + } else { + sm.sayOk("You haven't found #b#t4031511##k and #b#t4031512##k."); + } + return; + } + + // Default message + sm.sayOk("You're not ready to make 4th job advancement. When you're ready, talk to me."); + } + + // BOWMAN 4TH JOB ADVANCEMENT NPC --------------------------------------------------------------------------- + + @Script("bowman4") + public static void bowman4(ScriptManager sm) { + // Bishop : The Bowman Instructor (2081300) + // Leafre : Forest of the Priest (240010501) + + // Check job and level + if (!(sm.getJob() == 311 || sm.getJob() == 321)) { + sm.sayOk("Why do you want to see me? There is nothing you want to ask me."); + return; + } + + if (sm.getLevel() < 120) { + sm.sayOk("You're still weak to go to bowman extreme road. If you get stronger, come back to me."); + return; + } + + // Check if ready for 4th job change (Quest 6924 completed) + if (sm.hasQuestCompleted(6924)) { + // Ready for job advancement + if (sm.getJob() == 311) { + sm.sayNext("You're qualified to be a true bowman. Do you want job advancement?"); + if (sm.askYesNo("I want to advance to Bow Master.")) { + sm.setJob(Job.getById(312)); // Bow Master + sm.sayNext("You became the best bowman #bBow Master#k."); + sm.sayOk("Don't forget that it all depends on how much you train."); + } + } else if (sm.getJob() == 321) { + sm.sayNext("You're qualified to be a true bowman. Do you want job advancement?"); + if (sm.askYesNo("I want to advance to Marksman.")) { + sm.setJob(Job.getById(322)); // Marksman + sm.sayNext("You became the best bowman #bMarksman#k."); + sm.sayOk("Don't forget that it all depends on how much you train."); + } + } + return; + } + + // Quest 6920 end - Receive letter from Rene + if (sm.hasQuestStarted(6920) && sm.hasItem(4031513, 1)) { + sm.sayNext("What are you doing here, young Bowman?"); + sm.sayBoth("#b#p2020010##k?... The one in El Nath? Then I can trust you."); + + if (sm.askYesNo("A young Bowman dreaming of being a Bowmaster or Marksman. I have to tell you a few stories first. Talk to me when you're ready.")) { + sm.removeItem(4031513, 1); + sm.forceCompleteQuest(6920); + sm.addExp(20000); + sm.sayNext("Good. Talk to me when you're ready to hear the first story."); + } else { + sm.sayOk("Come back when you're ready."); + } + return; + } + + // Quest 6921-6923 story quests (same pattern) + if (sm.hasQuestCompleted(6920) && !sm.hasQuestStarted(6921) && !sm.hasQuestCompleted(6921)) { + sm.sayNext("The first story is about the town vanished into lava volcano. Would you like to listen?"); + if (sm.askYesNo("Good. You should listen to the story. Are you ready?")) { + sm.forceStartQuest(6921); + sm.sayNext("Have you ever been to the deep lava volcano in the El Nath mountains? There used to be a town there."); + sm.sayBoth("People in the town worshipped a human shaped stone statue and the volcano. They built an altar and stone statue under the tree at the basin of the deepest volcano and worshiped the altar to prove their faith."); + sm.sayBoth("Then disaster struck. It was the wicked Zakum's tree. Zakum's spirit didn't have a body but instead possessed the stone statue that people built. His evil rapidly spread through the town..."); + sm.sayBoth("After that, the town disappeared. You've heard about the fearsome power called #bZakum#k sleeping under the lava volcano, as you've traveled the Maple World for a long time. Now you know how he came to be. You can guess what happened to the townspeople..."); + sm.forceCompleteQuest(6921); + sm.sayOk("That's the end of the first story. Talk to me when you're ready to listen to the second story."); + } else { + sm.sayOk("Are you afraid? You've come this far..."); + } + return; + } + + if (sm.hasQuestCompleted(6921) && !sm.hasQuestStarted(6922) && !sm.hasQuestCompleted(6922)) { + sm.sayNext("The second story is about a growing stone and the Aquarium. Do you want to hear about it?"); + if (sm.askYesNo("Good. You deserve to listen to the story. Are you ready?")) { + sm.forceStartQuest(6922); + sm.sayNext("Have you been to the Aquarium under the sea? It is a mysterious place, floating over the valley of the deep sea. Haven't you wondered how it floats?"); + sm.sayBoth("There is a stone called the #bHolychoras#k underneath the valley of the deep sea. Nobody knows how came to be there, but it has been there for ages."); + sm.sayBoth("#bHolychoras#k has a strange power that purifies the sea. That's how Aqua Road is purified and the Aquarium can float over the valley under the sea. If #bHolychoras#k disappears, the sea will grow dark..."); + sm.forceCompleteQuest(6922); + sm.sayOk("This is the end of the second story. Talk to me when you're ready to listen to the third story."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged."); + } + return; + } + + if (sm.hasQuestCompleted(6922) && !sm.hasQuestStarted(6923) && !sm.hasQuestCompleted(6923)) { + sm.sayNext("The third is the story about the lake where time stopped. Do you wanna listen?"); + if (sm.askYesNo("Yes, you do have the right to listen to this story. Are you ready?")) { + sm.forceStartQuest(6923); + sm.sayNext("When you go to the north, there's a big lake. Bigger than a forest! People call this lake Ludus or the lake where time stopped."); + sm.sayBoth("Near the Lake lies Ludibrium Castle, supported by huge towers. The Holy Power of the clock tower in the middle of Ludibrium Castle protects the castle by stopping the time there."); + sm.sayBoth("But the dimensional crack in Ludibrium castle is getting wider and wider. A wicked power has invaded through the crack and changed the castle. You could probably feel the power of beings from the other dimension. They are called Alishar and Papulatus, and both are getting stronger."); + sm.sayBoth("You know why I'm telling you this story. The 4th job advancement requires more responsibility. You must know what you'll be up against."); + sm.sayBoth("You have to understand the Maple World more and behave as a true hero. Now I'll give you the last task for the 4th job advancement."); + sm.forceCompleteQuest(6923); + sm.sayOk("Talk to me when you're ready."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged..."); + } + return; + } + + // Quest 6924 - Final Trial + if (sm.hasQuestCompleted(6923) && !sm.hasQuestStarted(6924)) { + sm.sayNext("Now I'll give you the last task to do the 4th job advancement."); + if (sm.askYesNo("Get me two things. Nothing too hard. You have to bring me #b#t4031514##k and #b#t4031515##k.")) { + sm.forceStartQuest(6924); + sm.sayOk("It's up to you how you get it. If you wanna use your power and courage, you can catch #bManon and Griffey#k. If you wanna use wisdom and warm heart, you can get them through #b#p2081000##k in Leafre."); + } else { + sm.sayOk("What are you afraid of? Great power awaits you..."); + } + return; + } + + // Quest 6924 - Turn in items + if (sm.hasQuestStarted(6924) && !sm.hasQuestCompleted(6924)) { + if (sm.hasItem(4031514, 1) && sm.hasItem(4031515, 1)) { + sm.removeItem(4031514, 1); + sm.removeItem(4031515, 1); + sm.forceCompleteQuest(6924); + sm.addExp(50000); + sm.sayNext("You proved your quality as a hero."); + sm.sayOk("Now you only have to go to the way of a Bowmaster or Marksman. Talk to me if you're ready for the 4th job advancement."); + } else { + sm.sayOk("You haven't got #b#t4031514##k and #b#t4031515##k..."); + } + return; + } + + // Default message + sm.sayOk("You're not ready to make 4th job advancement. When you're ready, talk to me."); + } + + // PIRATE 4TH JOB ADVANCEMENT NPC --------------------------------------------------------------------------- + + @Script("pirate4") + public static void pirate4(ScriptManager sm) { + // Priest : The Pirate Instructor (2081500) + // Leafre : Forest of the Priest (240010501) + + // Check job and level + if (!(sm.getJob() == 511 || sm.getJob() == 521)) { + sm.sayOk("Why do you want to see me? There is nothing you want to ask me."); + return; + } + + if (sm.getLevel() < 120) { + sm.sayOk("You're still weak to go to pirate extreme road. If you get stronger, come back to me."); + return; + } + + // Check if ready for 4th job change (Quest 6944 completed) + if (sm.hasQuestCompleted(6944)) { + // Ready for job advancement + if (sm.getJob() == 511) { + sm.sayNext("You're qualified to be a true pirate. Do you want job advancement?"); + if (sm.askYesNo("I want to advance to Viper.")) { + sm.setJob(Job.getById(512)); // Viper (Buccaneer) + sm.sayNext("You became the best pirate #bViper#k."); + sm.sayOk("Don't forget that it all depends on how much you train."); + } + } else if (sm.getJob() == 521) { + sm.sayNext("You're qualified to be a true pirate. Do you want job advancement?"); + if (sm.askYesNo("I want to advance to Captain.")) { + sm.setJob(Job.getById(522)); // Captain (Corsair) + sm.sayNext("You became the best pirate #bCaptain#k."); + sm.sayOk("Don't forget that it all depends on how much you train."); + } + } + return; + } + + // Quest 6940 end - Receive letter from Pedro + if (sm.hasQuestStarted(6940) && sm.hasItem(4031859, 1)) { + sm.sayNext("What made you come all the way here to see me, young Pirate?"); + sm.sayBoth("#b#p2020013##k... Are you talking about the one in El Nath? If he's the one that recommended you, then you must be legit."); + + if (sm.askYesNo("Hello young Pirate, the one who strives to walk the path of the ultimate. I have a story I must share with you. When you are ready to see the truth, talk to me.")) { + sm.removeItem(4031859, 1); + sm.forceCompleteQuest(6940); + sm.addExp(20000); + sm.sayNext("Good. Talk to me when you're ready to hear the first story."); + } else { + sm.sayOk("Come back when you're ready."); + } + return; + } + + // Quest 6941-6943 story quests (same pattern) + if (sm.hasQuestCompleted(6940) && !sm.hasQuestStarted(6941) && !sm.hasQuestCompleted(6941)) { + sm.sayNext("The first story is about the town vanished into lava volcano. Would you like to listen?"); + if (sm.askYesNo("Good. You should listen to the story. Are you ready?")) { + sm.forceStartQuest(6941); + sm.sayNext("Have you ever been to the deep lava volcano in the El Nath mountains? There used to be a town there."); + sm.sayBoth("People in the town worshipped a human shaped stone statue and the volcano. They built an altar and stone statue under the tree at the basin of the deepest volcano and worshiped the altar to prove their faith."); + sm.sayBoth("Then disaster struck. It was the wicked Zakum's tree. Zakum's spirit didn't have a body but instead possessed the stone statue that people built. His evil rapidly spread through the town..."); + sm.sayBoth("After that, the town disappeared. You've heard about the fearsome power called #bZakum#k sleeping under the lava volcano, as you've traveled the Maple World for a long time. Now you know how he came to be. You can guess what happened to the townspeople..."); + sm.forceCompleteQuest(6941); + sm.sayOk("That's the end of the first story. Talk to me when you're ready to listen to the second story."); + } else { + sm.sayOk("Are you afraid? You've come this far..."); + } + return; + } + + if (sm.hasQuestCompleted(6941) && !sm.hasQuestStarted(6942) && !sm.hasQuestCompleted(6942)) { + sm.sayNext("The second story is about a growing stone and the Aquarium. Do you want to hear about it?"); + if (sm.askYesNo("Good. You deserve to listen to the story. Are you ready?")) { + sm.forceStartQuest(6942); + sm.sayNext("Have you been to the Aquarium under the sea? It is a mysterious place, floating over the valley of the deep sea. Haven't you wondered how it floats?"); + sm.sayBoth("There is a stone called the #bHolychoras#k underneath the valley of the deep sea. Nobody knows how came to be there, but it has been there for ages."); + sm.sayBoth("#bHolychoras#k has a strange power that purifies the sea. That's how Aqua Road is purified and the Aquarium can float over the valley under the sea. If #bHolychoras#k disappears, the sea will grow dark..."); + sm.forceCompleteQuest(6942); + sm.sayOk("This is the end of the second story. Talk to me when you're ready to listen to the third story."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged."); + } + return; + } + + if (sm.hasQuestCompleted(6942) && !sm.hasQuestStarted(6943) && !sm.hasQuestCompleted(6943)) { + sm.sayNext("The third is the story about the lake where time stopped. Do you wanna listen?"); + if (sm.askYesNo("Yes, you do have the right to listen to this story. Are you ready?")) { + sm.forceStartQuest(6943); + sm.sayNext("When you go to the north, there's a big lake. Bigger than a forest! People call this lake Ludus or the lake where time stopped."); + sm.sayBoth("Near the Lake lies Ludibrium Castle, supported by huge towers. The Holy Power of the clock tower in the middle of Ludibrium Castle protects the castle by stopping the time there."); + sm.sayBoth("But the dimensional crack in Ludibrium castle is getting wider and wider. A wicked power has invaded through the crack and changed the castle. You could probably feel the power of beings from the other dimension. They are called Alishar and Papulatus, and both are getting stronger."); + sm.sayBoth("You know why I'm telling you this story. The 4th job advancement requires more responsibility. You must know what you'll be up against."); + sm.sayBoth("You have to understand the Maple World more and behave as a true hero. Now I'll give you the last task for the 4th job advancement."); + sm.forceCompleteQuest(6943); + sm.sayOk("Talk to me when you're ready."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged..."); + } + return; + } + + // Quest 6944 - Final Trial + if (sm.hasQuestCompleted(6943) && !sm.hasQuestStarted(6944)) { + sm.sayNext("I will now give you the last task required to complete the 4th job advancement."); + if (sm.askYesNo("It is your mission to acquire two items that I assign to you. I want #b#t4031517##k and #b#t4031518##k.")) { + sm.forceStartQuest(6944); + sm.sayNext("How you will acquire these items, I'll leave that up to you. If you want to acquire by fully utilizing your courage and physical capabilities, then you should get them through #bManon and Griffey#k. If you want to acquire them using brains and wisdom, then head to Leafre and see #b#p2081000##k."); + } else { + sm.sayOk("What is there for you to fear? You are walking the path of Pirate greatness, and you don't wish to encounter hardship?"); + } + return; + } + + // Quest 6944 - Turn in items + if (sm.hasQuestStarted(6944) && !sm.hasQuestCompleted(6944)) { + if (sm.hasItem(4031860, 1) && sm.hasItem(4031861, 1)) { + sm.removeItem(4031860, 1); + sm.removeItem(4031861, 1); + sm.forceCompleteQuest(6944); + sm.addExp(50000); + sm.sayNext("You have proven your worth as a person that can be called a hero."); + sm.sayOk("What you'll need to do now is to keep walking the path of great Pirates. Talk to me when you are ready to make the job advancement."); + } else { + sm.sayOk("I don't think you have acquired #b#t4031517##k and #b#t4031518##k, yet."); + } + return; + } + + // Default message + sm.sayOk("You're not ready to make 4th job advancement. When you're ready, talk to me."); + } + + // ARCHER 4TH JOB WRAPPER (game uses "archer4" instead of "bowman4") + @Script("archer4") + public static void archer4(ScriptManager sm) { + bowman4(sm); + } + + // MAGICIAN 4TH JOB WRAPPER (game uses "magician4" instead of "wizard4") + @Script("magician4") + public static void magician4(ScriptManager sm) { + wizard4(sm); + } } diff --git a/src/main/java/kinoko/script/continent/Nautilus.java b/src/main/java/kinoko/script/continent/Nautilus.java new file mode 100644 index 00000000..4e132965 --- /dev/null +++ b/src/main/java/kinoko/script/continent/Nautilus.java @@ -0,0 +1,118 @@ +package kinoko.script.continent; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.script.quest.ExplorerQuest; +import kinoko.script.quest.PirateQuest; + +import java.util.Map; + +/** + * Nautilus (Pirate Town) Scripts + */ +public final class Nautilus extends ScriptHandler { + + @Script("kairinT") + public static void kairinT(ScriptManager sm) { + // Handle 1st, 2nd, and 3rd job advancement + if (sm.getJob() == 0) { + // 1st job advancement - Beginner -> Pirate + ExplorerQuest.pirate(sm); + } else if (sm.getJob() == 500) { + // 2nd job advancement - Pirate -> Brawler/Gunslinger + // Check if player has quest started and items ready to turn in + if (sm.hasQuestStarted(2191) && sm.hasItem(4031856, 15)) { + // Quest 2191 completion - Brawler + PirateQuest.q2191e(sm); + } else if (sm.hasQuestStarted(2192) && sm.hasItem(4031857, 15)) { + // Quest 2192 completion - Gunslinger + PirateQuest.q2192e(sm); + } else { + // Start new quest + pirate1(sm); + } + } else if (sm.getJob() == 510 || sm.getJob() == 520) { + // 3rd job advancement - direct to El Nath + sm.sayOk("You've become a formidable pirate! To advance to 3rd job, you must travel to #bEl Nath#k and seek out the pirate instructor there. They will help you become a #bMarauder#k or #bOutlaw#k!"); + } else if (sm.getJob() == 511 || sm.getJob() == 521) { + // 4th job advancement - direct to El Nath then Leafre + sm.sayOk("You have reached the peak of piracy! To achieve your 4th job advancement, you must travel to #bEl Nath#k and speak with the instructor there. They will guide you on your final trial."); + } else { + sm.sayOk("I can only help Pirates with their job advancement."); + } + } + + @Script("pirate1") + public static void pirate1(ScriptManager sm) { + // Kyrin : Pirate Job Instructor (1090000) + // Nautilus : Navigation Room (120000101) + + // Check if player is a Pirate beginner (job 500) + if (sm.getJob() != 500) { + sm.sayOk("You don't seem to be a Pirate beginner. I can only help those who wish to become Pirates."); + return; + } + + // Check level requirement + if (sm.getLevel() < 30) { + sm.sayOk("You need to be at least Level 30 to make your first job advancement. Train harder and come back when you're ready!"); + return; + } + + // Check if already has quest started + if (sm.hasQuestStarted(2191)) { + sm.sayOk("You're already on the path to becoming a Brawler. Complete the quest and return to me!"); + return; + } + + if (sm.hasQuestStarted(2192)) { + sm.sayOk("You're already on the path to becoming a Gunslinger. Complete the quest and return to me!"); + return; + } + + // Check if already completed a quest (already advanced) + if (sm.hasQuestCompleted(2191) || sm.hasQuestCompleted(2192)) { + sm.sayOk("You've already made your job advancement. Continue training and become stronger!"); + return; + } + + // Offer job choice + sm.sayNext("You've reached Level 30. Impressive! You're now ready to choose your path as a Pirate. There are two paths available to you:"); + sm.sayBoth("#bBrawlers#k use their fists and raw power to crush their enemies in close combat. They are fierce warriors who rely on strength and agility."); + sm.sayBoth("#bGunslingers#k use guns to attack from a distance. They are skilled marksmen who rely on precision and speed."); + + final int answer = sm.askMenu("Which path do you wish to take?", Map.of( + 0, "I want to become a Brawler.", + 1, "I want to become a Gunslinger." + )); + + if (answer == 0) { + // Brawler path - trigger quest 2191 + sm.sayNext("Ah, the path of the Brawler! A fine choice. Brawlers are powerful warriors who use their fists to dominate the battlefield."); + + if (sm.askYesNo("Are you ready to take the test to become a Brawler?")) { + sm.forceStartQuest(2191); + sm.sayNext("Excellent! I will now test your abilities."); + sm.sayBoth("You'll need to head to the test room and fight #rOctopirates#k. Your task is to bring back #b15 Potent Power Crystals#k."); + sm.sayOk("Remember: These Octopirates can only be attacked with #rFlash Fist#k. Other attacks will be fruitless. Good luck!"); + // TODO: Warp to test room when map is configured + } else { + sm.sayOk("Think carefully about your decision and return when you're ready."); + } + } else if (answer == 1) { + // Gunslinger path - trigger quest 2192 + sm.sayNext("Ah, the path of the Gunslinger! A fine choice. Gunslingers are skilled marksmen who can take down enemies from afar."); + + if (sm.askYesNo("Are you ready to take the test to become a Gunslinger?")) { + sm.forceStartQuest(2192); + sm.sayNext("Excellent! I will now test your abilities."); + sm.sayBoth("You'll need to head to the test room and fight #rOctopirates#k. Your task is to bring back #b15 Potent Wind Crystals#k."); + sm.sayOk("Remember: These Octopirates can only be attacked with #rDouble Shot#k. Other attacks will be fruitless. Good luck!"); + // TODO: Warp to test room when map is configured + } else { + sm.sayOk("Think carefully about your decision and return when you're ready."); + } + } + } +} diff --git a/src/main/java/kinoko/script/continent/NihalDesert.java b/src/main/java/kinoko/script/continent/NihalDesert.java index d233ff87..a97dc69c 100644 --- a/src/main/java/kinoko/script/continent/NihalDesert.java +++ b/src/main/java/kinoko/script/continent/NihalDesert.java @@ -1,6 +1,117 @@ package kinoko.script.continent; +import kinoko.packet.user.UserLocal; +import kinoko.script.common.Script; +import kinoko.script.common.ScriptDispatcher; import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.server.dialog.UIType; public final class NihalDesert extends ScriptHandler { + + // NPC 2100001 - Khan (Repair Service) + @Script("make_ariant1") + public static void make_ariant1(ScriptManager sm) { + if (!sm.askYesNo("I'll be on repair duty for a while. Do you have something you need fixed?")) { + sm.sayNext("Good items break easily. You should repair them once in a while."); + return; + } + sm.write(UserLocal.openUI(UIType.REPAIRDURABILITY)); + } + + // NPC 2101003 - Ardin (Sand Bandits hideout dialogue) + @Script("adin_enter") + public static void adin_enter(ScriptManager sm) { + sm.sayOk("Hey, hey! Don't start any trouble with anyone. I want nothing to do with you."); + } + + // NPC 2101011 - Sejan (General dialogue) + @Script("cejan") + public static void cejan(ScriptManager sm) { + sm.sayNext("..."); + } + + // Portal - Sand Bandits hideout entrance (requires quest 3936 completed) + @Script("ariant_Agit") + public static void ariant_Agit(ScriptManager sm) { + if (sm.hasQuestCompleted(3936)) { + sm.playPortalSE(); + sm.message("The lock opens from the inside of the door. The door slowly opens."); + sm.warp(260000201, "sp"); // Shabby House + } else { + sm.message("The door is locked."); + } + } + + // Portal - Ariant Palace entrance (requires Palace Entry Permit item 4031582) + @Script("ariant_castle") + public static void ariant_castle(ScriptManager sm) { + if (sm.hasItem(4031582)) { + sm.playPortalSE(); + sm.warp(260000301, "sp"); // Ariant Palace Garden + } else { + sm.message("Those who have not received the permit cannot enter the palace."); + } + } + + // Portal - Hidden Map (Rocky Hills 260010401) - Opens NPC dialogue instead of warping + @Script("thief_in1") + public static void thief_in1(ScriptManager sm) { + // Trigger NPC 2103008 dialogue using ScriptDispatcher + ScriptDispatcher.startNpcScript(sm.getUser(), sm.getUser(), "2103008", 2103008); + } + + // NPC 2103008 - Strange Voice (Hidden Map 260010401) + @Script("2103008") + public static void npc2103008(ScriptManager sm) { + final String magicWord = sm.askText("If you want to open the door, then yell out the magic word...", "", 0, 50); + + if (magicWord.equals("Open Sesame") || magicWord.isEmpty()) { + sm.warp(260010402, "sp"); + } + } + + // NPC 2111003 - Humanoid A (Magatia) - Snowfield Rose quest + @Script("jenu_homun") + public static void jenu_homun(ScriptManager sm) { + // Quest 3335 - Snowfield Rose blooming + if (!sm.hasQuestStarted(3335) || sm.hasItem(4031695)) { + sm.sayOk("I would want nothing more than to be a human being with a warm, beating heart... That way, I can finally hold her hand the way it's meant to be held. Unfortunately, I can't do that right now..."); + return; + } + + if (!sm.askAccept("You're back... Are you ready to initiate the full bloom of the Snowfield Rose? You're aware that only the May Mist will allow the rose to bloom, right?")) { + return; + } + + sm.sayNext("I will now take you to a place where the incubator for the Snowfield Rose awaits..."); + + // Warp to Snowfield Rose instance with 15 minute time limit + sm.warpInstance(926120300, "sp", 261000000, 900); + } + + // NPC - D.Roid (Magatia Alcadno) + @Script("sca_DitRoi") + public static void sca_DitRoi(ScriptManager sm) { + sm.sayOk("..."); + } + + // NPC 9300172 - Juliet (Romeo & Juliet PQ) + @Script("juliet_start") + public static void juliet_start(ScriptManager sm) { + sm.sayOk("Oh Romeo, Romeo, wherefore art thou Romeo?"); + } + + // NPC 9300171 - Romeo (Romeo & Juliet PQ) + @Script("romio_start") + public static void romio_start(ScriptManager sm) { + sm.sayOk("But soft! What light through yonder window breaks?"); + } + + // Portal - Magatia Dark Lab entrance (map 261020600) + @Script("magatia_dark0") + public static void magatia_dark0(ScriptManager sm) { + sm.playPortalSE(); + sm.warp(261020500, "sp"); + } } \ No newline at end of file diff --git a/src/main/java/kinoko/script/continent/Orbis.java b/src/main/java/kinoko/script/continent/Orbis.java new file mode 100644 index 00000000..0b43bf13 --- /dev/null +++ b/src/main/java/kinoko/script/continent/Orbis.java @@ -0,0 +1,94 @@ +package kinoko.script.continent; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.world.field.Field; +import kinoko.world.field.mob.MobAppearType; +import kinoko.world.quest.QuestRecordType; + +import java.util.List; +import java.util.Map; + +public class Orbis extends ScriptHandler { + @Script("oldBook1") + public static void oldBook1(ScriptManager sm) { + if (sm.hasQuestCompleted(QuestRecordType.AlcasterAndTheDarkCrystal.getQuestId())) { + final int answer = sm.askMenu("Thanks to you, #b#t4031056##k is safely sealed. As a result, I used up about half of the power I have accumulated over the last 800 years...but can now die in peace. Would you happen to be looking for rare items by any chance? As a sign of appreciation for your hard work, I'll sell some items in my possession to you and ONLY you. Pick out the one you want!", Map.of( + 0, "#t2050003# (Price : 600 mesos)", + 1, "#t2050004# (Price : 800 mesos)", + 2, "#t4006000# (Price : 5,500 mesos)", + 3, "#t4006001# (Price : 5,500 mesos)" + )); + + switch (answer) { + case 0: + sellItem(sm,2050003, 300, "The item that cures the state of being sealed and cursed."); + break; + case 1: + sellItem(sm, 2050004, 400, "The item that cures everything."); + break; + case 2: + sellItem(sm, 4006000, 5000, "The item of magical power used for high level skills."); + break; + case 3: + sellItem(sm, 4006001, 5000, "The item of summoning power used for high level skills."); + break; + } + } else if(sm.getLevel() > 54) { + sm.sayOk("If you decide to help me, in return I will put items up for sale."); + } else { + sm.sayOk("I am Alcaster the Sorcerer, resident of this city for over 300 years, where I have worked on many charms and spells."); + } + } + + private static void sellItem(ScriptManager sm, int itemId, int unitPrice, String description) { + final int nRetNum = sm.askNumber("So the item you need is #b#t" + itemId + "##k, right? That's " + description + " It's not an easy item to get, but for you, I'll sell it for cheap. It'll cost you #b" + unitPrice + " mesos #k per. How many would you like to buy?", 1, 1, 100); + final int nPrice = unitPrice * nRetNum; + if(!sm.askYesNo("Do you really want to buy #r" + nRetNum + " #t" + itemId + "#(s)#k? It'll cost you " + unitPrice + " mesos per #t" + itemId + "#, which is #r" + nPrice + "#k mesos in total.")) { + sm.sayOk("I understand. You see, I have many different items here. Take a look. I am selling these items just for you. So I won't rob you at all."); + return; + } + + if(!sm.canAddMoney(-nPrice) || !sm.canAddItem(itemId, nRetNum)) { + sm.sayOk("Are you sure you have enough mesos? Please check if your use or etc. inventory is full and that you have at least #r" + nPrice + "#k mesos."); + return; + } + + sm.addItem(itemId, nRetNum); + sm.sayOk("Thank you. If some other day you are in need of items, stop by. I may have gotten old with time, but I can still make magic items easily."); + } + + @Script("oldBook2") + public static void oldBook2(ScriptManager sm) { + // Lisa (2012012) + // Orbis : Orbis (200000000) + if (!sm.hasQuestStarted(QuestRecordType.WheresHella.getQuestId())) { + sm.sayOk("Are you looking for #bHella#k? Technically she lives here, but you won't be able to find her these days. A few months ago, she left town suddenly and never came back. It won't do much good to stop by her house, but at least the housekeeper should be there. How about talking to her?"); + } else if(!sm.hasQuestStarted(QuestRecordType.TheSmallGraveThatsHidden.getQuestId())) { + sm.sayOk("Where has #bHella#k gone... what? You know that she's alright? Hmmm... I don't know if I should trust a stranger's word, but if it's true, that's great. Of course you already warned Jade, right? Out of everyone, he is the most worried about her."); + } else { + sm.sayOk("Monsters have been a lot more evil and cruel lately. And what if they come here?? I hope that never happens, right? Right?"); + } + } + + + @Script("enterNepenthes") + public static void enterNepenthes(ScriptManager sm) { + if (sm.hasQuestStarted(21739)) { + sm.playPortalSE(); + sm.warpInstance(List.of(920030000, 920030001), "sp", 200060000, 60 * 15); + + } else { + sm.playPortalSE(); + sm.warp(200060001); + } + } + + @Script("sealGarden") + public static void sealGarden(ScriptManager sm) { + if (sm.hasQuestStarted(21739)) { + sm.spawnMob(9300348, MobAppearType.NORMAL, 591, -34, false); + } + } +} diff --git a/src/main/java/kinoko/script/continent/PhantomForest.java b/src/main/java/kinoko/script/continent/PhantomForest.java new file mode 100644 index 00000000..bbc44c23 --- /dev/null +++ b/src/main/java/kinoko/script/continent/PhantomForest.java @@ -0,0 +1,313 @@ +package kinoko.script.continent; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.util.Util; +import kinoko.util.Tuple; + +import java.util.Collections; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +import static java.util.Map.entry; + +public class PhantomForest extends ScriptHandler { + @Script("Fallen_Woods") + public static void Fallen_Woods(ScriptManager sm) { + sm.sayOk("I see you there. What do you want?"); + } + + + @Script("Badge_Bounty") + public static void Badge_Bounty(ScriptManager sm) { + if (!sm.hasQuestCompleted(8225)) { + sm.sayOk("Hey, I'm not a bandit, ok?"); + return; + } + + List itemList = List.of(4032007, 4032006, 4032009, 4032008, 4032007, 4032006, 4032009, 4032008); + + List>> prizes = List.of( + List.of( + Tuple.of(1002801, 1), + Tuple.of(1462052, 1), + Tuple.of(1462006, 1), + Tuple.of(1462009, 1), + Tuple.of(1452012, 1), + Tuple.of(1472031, 1), + Tuple.of(2044701, 1), + Tuple.of(2044501, 1), + Tuple.of(3010041, 1), + Tuple.of(0, 750000) + ), + List.of( + Tuple.of(1332077, 1), + Tuple.of(1322062, 1), + Tuple.of(1302068, 1), + Tuple.of(4032016, 1), + Tuple.of(2043001, 1), + Tuple.of(2043201, 1), + Tuple.of(2044401, 1), + Tuple.of(2044301, 1), + Tuple.of(3010041, 1), + Tuple.of(0, 1250000) + ), + List.of( + Tuple.of(1472072, 1), + Tuple.of(1332077, 1), + Tuple.of(1402048, 1), + Tuple.of(1302068, 1), + Tuple.of(4032017, 1), + Tuple.of(4032015, 1), + Tuple.of(2043023, 1), + Tuple.of(2043101, 1), + Tuple.of(2043301, 1), + Tuple.of(3010040, 1), + Tuple.of(0, 2500000) + ), + List.of( + Tuple.of(1002801, 1), + Tuple.of(1382008, 1), + Tuple.of(1382006, 1), + Tuple.of(4032016, 1), + Tuple.of(4032015, 1), + Tuple.of(2043701, 1), + Tuple.of(2043801, 1), + Tuple.of(3010040, 1), + Tuple.of(0, 1750000) + ), + List.of(Tuple.of(0, 3500000)), + List.of(Tuple.of(0, 3500000)), + List.of(Tuple.of(0, 3500000)), + List.of(Tuple.of(0, 3500000)) + ); + + if (sm.getUser().getInventoryManager().getEtcInventory().getRemaining() == 0 || sm.getUser().getInventoryManager().getConsumeInventory().getRemaining() == 0) { + sm.sayOk("Your use or etc. inventory seems to be full. You need the free spaces to trade with me! Make room, and then find me."); + return; + } + + int qnt = 0; + int requiredItem = 0; + int lastSelection = 0; + + sm.sayNext("Hey, got a little bit of time? Well, my job is to collect items here and sell them elsewhere, but these days the monsters have become much more hostile so it have been difficult to get good items... What do you think? Do you want to do some business with me?"); + if (!sm.askYesNo("The deal is simple. You get me something I need, I get you something you need. The problem is, I deal with a whole bunch of people, so the items I have to offer may change every time you see me. What do you think? Still want to do it?")) { + sm.sayOk("Hmmm...it shouldn't be a bad deal for you. Come see me at the right time and you may get a much better item to be offered. Anyway, let me know if you have a change of mind."); + return; + } + + final int selection = handleQuestSelection(sm, itemList); + lastSelection = selection; + requiredItem = itemList.get(lastSelection); + + if (selection < 4) { + qnt = 50; + } else { + qnt = 25; + } + + if (!sm.askYesNo("Let's see, you want to trade your " + blue(String.valueOf(qnt) + " " + itemName(requiredItem)) + " with my stuff, right? Before trading make sure you have an empty slot available on your use or etc. inventory. Now, do you want to trade with me?")) { + sm.sayOk("Hmmm...it shouldn't be a bad deal for you. Come see me at the right time and you may get a much better item to be offered. Anyway, let me know if you have a change of mind."); + return; + } + + if (!sm.hasItem(requiredItem, qnt)) { + sm.sayOk("Hmmm... are you sure you have " + blue(String.valueOf(qnt) + " " + itemName(requiredItem)) + "? If so, then please check and see if your item inventory is full or not."); + return; + } + + int randPrizeIndex = Util.getRandom(prizes.get(lastSelection).size()); + Tuple reward = prizes.get(lastSelection).get(randPrizeIndex); + int prizeItem = reward.getLeft(); + int prizeQty = reward.getRight(); + + if (prizeItem == 0) { + // Meso + sm.removeItem(requiredItem, qnt); + sm.addMoney(prizeQty); + sm.sayOk("For your " + blue(qnt + " " + itemName(requiredItem)) + ", here's " + blue(prizeQty + " mesos") + ". What do you think? Did you like the items I gave you in return? I plan on being here for awhile, so if you gather up more items, I'm always open for a trade..."); + return; + } + + if (!sm.addItem(prizeItem, prizeQty)) { + sm.sayOk("Your use and etc. inventory seems to be full. You need the free spaces to trade with me! Make room, and then find me."); + return; + } + + sm.removeItem(requiredItem, qnt); + + sm.sayOk("For your " + blue(qnt + " " + itemName(requiredItem)) + ", " + blue(prizeQty + " " + itemName(prizeItem)) + ". What do you think? Did you like the items I gave you in return? I plan on being here for awhile, so if you gather up more items, I'm always open for a trade..."); + } + + private static int handleQuestSelection(ScriptManager sm, List itemList) { + Map selections = new HashMap<>(); + List qnty = List.of(50, 25); + String prompt = "Ok! First you need to choose the item that you'll trade with. The better the item, the more likely the chance that I'll give you something much nicer in return.\r\n"; + + for (int i = 0; i < itemList.size(); i++) { + selections.put(i, itemImage(itemList.get(i)) + " " + blue(itemName(itemList.get(i))) + " " + qnty.get(i / 4)); + } + + return sm.askMenu(prompt, selections); + } + + @Script("MoStore") + public static void MoStore(ScriptManager sm) { + if (!sm.hasQuestCompleted(8225)) { + sm.sayOk("Hm, at who do you think you are looking at?"); + return; + } + + sm.openShopNPC(9201099); + } + + @Script("Gear_Upgrade") + public static void Gear_Upgrade(ScriptManager sm) { + if (!sm.hasQuestCompleted(8225)) { + sm.sayOk("Step aside, novice, we're doing business here."); + return; + } + + final int selection = sm.askMenu("Hey, partner! If you have the right goods, I can turn it into something very nice...", Map.of( + 0, "Weapon Forging", + 1, "Weapon Upgrading" + )); + + int item = -1; + List materials = Collections.emptyList(); + List materialsQty = Collections.emptyList(); + int cost = -1; + int qty = 1; + + if (selection == 0) { + // Weapon Forging + final int wfSelection = sm.askMenu("So, what kind of weapon would you like me to forge?", Map.of( + 0, itemName(2070018), + 1, itemName(1382060), + 2, itemName(1442068), + 3, itemName(1452060) + )); + + List itemSet = List.of(2070018, 1382060, 1442068, 1452060); + List> materialList = List.of( + List.of(4032015, 4032016, 4032017, 4021008, 4032005), + List.of(4032016, 4032017, 4032004, 4032005, 4032012, 4005001), + List.of(4032015, 4032017, 4032004, 4032005, 4032012, 4005000), + List.of(4032015, 4032016, 4032004, 4032005, 4032012, 4005002) + ); + List> materialQtyList = List.of( + List.of(1, 1, 1, 100, 30), + List.of(1, 1, 400, 10, 30, 4), + List.of(1, 1, 500, 40, 20, 4), + List.of(1, 1, 300, 75, 10, 4) + ); + + item = itemSet.get(wfSelection); + materials = materialList.get(wfSelection); + materialsQty = materialQtyList.get(wfSelection); + cost = 70000; + } else if (selection == 1) { + // Weapon Upgrading + final int wuSelection = sm.askMenu("An upgraded weapon? Of course, but note that upgrades won't carry over to the new item...", Map.ofEntries( + entry(0, itemName(1472074)), + entry(1, itemName(1472073)), + entry(2, itemName(1472075)), + entry(3, itemName(1332079)), + entry(4, itemName(1332078)), + entry(5, itemName(1332080)), + entry(6, itemName(1462054)), + entry(7, itemName(1462053)), + entry(8, itemName(1462055)), + entry(9, itemName(1402050)), + entry(10, itemName(1402049)), + entry(11, itemName(1402051)) + )); + + List itemSet = List.of(1472074, 1472073, 1472075, 1332079, 1332078, 1332080, 1462054, 1462053, 1462055, 1402050, 1402049, 1402051); + List> materialList = List.of( + List.of(4032017, 4005001, 4021008), + List.of(4032015, 4005002, 4021008), + List.of(4032016, 4005000, 4021008), + List.of(4032017, 4005001, 4021008), + List.of(4032015, 4005002, 4021008), + List.of(4032016, 4005000, 4021008), + List.of(4032017, 4005001, 4021008), + List.of(4032017, 4005001, 4021008), + List.of(4032016, 4005000, 4021008), + List.of(4032017, 4005001, 4021008), + List.of(4032015, 4005002, 4021008), + List.of(4032016, 4005000, 4021008) + ); + List> materialQtyList = List.of( + List.of(1, 10, 20), + List.of(1, 10, 30), + List.of(1, 5, 20), + List.of(1, 10, 20), + List.of(1, 10, 30), + List.of(1, 5, 20), + List.of(1, 10, 20), + List.of(1, 10, 30), + List.of(1, 5, 20), + List.of(1, 10, 20), + List.of(1, 10, 30), + List.of(1, 5, 20) + ); + List costList = List.of(75000, 50000, 50000, 75000, 50000, 50000, 75000, 50000, 50000, 75000, 50000, 50000); + + item = itemSet.get(wuSelection); + materials = materialList.get(wuSelection); + materialsQty = materialQtyList.get(wuSelection); + cost = costList.get(wuSelection); + } + + StringBuilder prompt = new StringBuilder("You want to make a " + itemName(item) + "?"); + prompt.append(" In that case, I'm going to need specific items from you in order to make it. Make sure you have room in your inventory, though!"); + + for (int i = 0; i < materials.size(); i++) { + prompt.append("\r\n").append(itemImage(materials.get(i))).append(" ").append(materialsQty.get(i) * qty).append(" ").append(itemName(materials.get(i))); + } + + if (cost > 0) { + prompt.append("\r\n").append(itemImage(4031138)).append(" ").append(cost * qty).append(" meso"); + } + + if (sm.askYesNo(prompt.toString())) { + boolean complete = true; + + if (!sm.canAddItem(item, qty)) { + sm.sayOk("Check your inventory for a free slot first."); + return; + } + + if (!sm.canAddMoney(-(cost * qty))) { + sm.sayOk("I am afraid you don't have enough to pay me, partner. Please check this out first, ok?"); + return; + } + + for (int i = 0; complete && i < materials.size(); i++) { + if (!sm.hasItem(materials.get(i), materialsQty.get(i))) { + complete = false; + } + } + + if (!complete) { + sm.sayOk("Hey, I need those items to craft properly, you know?"); + return; + } + + for (int i = 0; i < materials.size(); i++) { + sm.removeItem(materials.get(i), materialsQty.get(i)); + } + + if (cost > 0) { + sm.addMoney(-(cost * qty)); + } + + sm.addItem(item, qty); + sm.sayNext("All done. If you need anything else... Well, I'm not going anywhere."); + } + } +} diff --git a/src/main/java/kinoko/script/continent/VictoriaIsland.java b/src/main/java/kinoko/script/continent/VictoriaIsland.java index 36742203..43c6a614 100644 --- a/src/main/java/kinoko/script/continent/VictoriaIsland.java +++ b/src/main/java/kinoko/script/continent/VictoriaIsland.java @@ -423,4 +423,197 @@ public static void q2230e(ScriptManager sm) { sm.sayOk("I need you to have a CASH slot available to reward you properly!"); } } + + // DUAL BLADE PORTAL SCRIPTS --------------------------------------------------------------------- + + @Script("dual_secret") + public static void dual_secret(ScriptManager sm) { + // Portal: Secret hideout entrance for Dual Blade quest 2369 + // Map: Kerning City Secret Hideout (103000003) -> Former Dark Lord's Room (910350100) + if (!sm.hasQuestStarted(2369)) { + sm.message("You cannot access this area."); + return; + } + + // Warp to instance map + sm.playPortalSE(); + sm.warp(910350100, "out00"); + } + + @Script("Dual_moveGate") + public static void Dual_moveGate(ScriptManager sm) { + // Portal: Dual Blade hideout exit + // Map: Various Dual Blade maps -> Kerning City Construction Site B1 (103050000) + sm.playPortalSE(); + sm.warp(103050000, "out00"); + } + + // DUAL BLADE NPC SCRIPTS ------------------------------------------------------------------------- + + @Script("hong-a") + public static void hong_a(ScriptManager sm) { + // Ryden (1057001) - Dual Blade Selection NPC + // Map: Kerning City (103000000) and tutorial map (10000) + + // Check if player is already a Dual Blade + if (sm.getUser().getCharacterStat().getSubJob() == 1) { + sm.sayOk("I will contact you when I need you."); + return; + } + + // Only show selection during tutorial (map 10000, level 1) + if (sm.getFieldId() != 10000 || sm.getLevel() > 1) { + sm.sayOk("Huh...is something wrong?"); + return; + } + + final int answer = sm.askMenu("Hey! That little guy over there, are you interested in joining my organization and becoming a member of the Dual Blade? I've been waiting for you here for a long time and found that you have great potential. Do you want to join?", + Map.of( + 0, "I will continue to play Adventurer", + 1, "I want to be a Dual Blade" + )); + + if (answer == 0) { + sm.sayOk("If you leave here, it's impossible to become a Dual Blade. You'd better think about it carefully."); + } else { + // Set player as Dual Blade (SubJob = 1) + sm.getUser().getCharacterStat().setSubJob((short) 1); + sm.sayOk("You have chosen the path of the Dual Blade! Your journey begins now. Train hard and we will contact you soon."); + } + } + + @Script("dual_blueAlcohol") + public static void dual_blueAlcohol(ScriptManager sm) { + // Blue Bottle/Alcohol Object - Quest 2358 "Fifth Mission: Fabrication" + // Map: Kerning City Jazz Bar (103000003) + // Player clicks on blue bottle to plant bomb for Dual Blade quest + + // Check if player has quest 2358 started + if (!sm.hasQuestStarted(2358)) { + // Not on the quest + return; + } + + // Get current progress value + final String infoValue = sm.getQRValue(2358); + + // Check if bomb is already planted + if ("211".equals(infoValue)) { + sm.sayOk("You've already planted the bomb. Get out of here before someone notices!"); + return; + } + + sm.sayNext("This looks like the perfect spot behind the blue bottle to plant the bomb."); + sm.sayBoth("You carefully place the bomb behind the blue bottle at the counter. The timer starts ticking..."); + + // Set quest progress to 211 (bomb planted) + sm.setQRValue(2358, "211"); + + sm.sayOk("The bomb has been planted. You should leave before anyone gets suspicious!"); + } + + @Script("dual_ballRoom") + public static void dual_ballRoom(ScriptManager sm) { + // Portal: Marble Room entrance for quest 2363 "Dual Blade: Time for the Awakening" + // Warps to Marble Room (910350000) + // Player needs to have quest 2363 started to enter + + if (!sm.hasQuestStarted(2363)) { + sm.message("The door is locked."); + return; + } + + sm.playPortalSE(); + sm.warp(910350000); // Marble Room - click on marbles to get Mirror of Insight + } + + @Script("dual_lv20") + public static void dual_lv20(ScriptManager sm) { + // Portal: Level 20 check for Blade Recruit job advancement + // Ensures player is level 20+ before allowing access + + if (sm.getLevel() < 20) { + sm.message("You must be at least level 20 to enter."); + return; + } + + sm.playPortalSE(); + sm.warp(103050310); // Dual Blade training area for level 20+ + } + + @Script("dual_lv25") + public static void dual_lv25(ScriptManager sm) { + // Portal: Level 25 check for training areas + // Ensures player is level 25+ before allowing access + + if (sm.getLevel() < 25) { + sm.message("You must be at least level 25 to enter."); + return; + } + + sm.playPortalSE(); + sm.warp(103050340); // Dual Blade training area for level 25+ + } + + @Script("dual_lv30") + public static void dual_lv30(ScriptManager sm) { + // Portal: Level 30 check for Blade Acolyte job advancement + // Ensures player is level 30+ before allowing access + + if (sm.getLevel() < 30) { + sm.message("You must be at least level 30 to enter."); + return; + } + + sm.playPortalSE(); + sm.warp(103050370); // Dual Blade training area for level 30+ + } + + @Script("dual_Diary") + public static void dual_Diary(ScriptManager sm) { + // Former Dark Lord's Diary Object/NPC - Quest 2369 + // Located in Jazz Bar secret room (map 910350100) + // Gives Former Dark Lord's Diary (item 4032617) + + if (!sm.hasQuestStarted(2369)) { + sm.sayOk("This appears to be an old diary..."); + return; + } + + if (sm.hasItem(4032617, 1)) { + sm.sayOk("You already have the Former Dark Lord's Diary."); + return; + } + + sm.sayNext("You found an old leather-bound diary covered in dust. This must be the Former Dark Lord's Diary that Lady Syl mentioned..."); + + if (!sm.canAddItem(4032617, 1)) { + sm.sayOk("Your inventory is full. Make some space and try again."); + return; + } + + sm.addItem(4032617, 1); // Former Dark Lord's Diary + sm.sayOk("You obtained the #bFormer Dark Lord's Diary#k! Take it to Lady Syl once you reach Level 30."); + } + + // AQUA ROAD NPC SCRIPTS -------------------------------------------------------------------------- + + @Script("crack") + public static void crack(ScriptManager sm) { + // NPC 2060100 - Cracked Dimension entry (solo version) + // Aqua Road : Deep Sea Gorge III (230040001) + if (!sm.hasItem(4000175, 1)) { + sm.sayOk("You can only open the distorted dimension if you have found #b#z4000175##k."); + return; + } + + // Check if dimension is occupied + if (sm.getField().getFieldStorage().getFieldById(923000000).map(f -> f.getUserPool().getCount() > 0).orElse(Boolean.FALSE)) { + sm.sayOk("Someone is already inside the Cracked Dimension. Please wait."); + return; + } + + sm.removeItem(4000175, 1); + sm.warpInstance(923000000, "sp", 230040001, 60 * 20); + } } diff --git a/src/main/java/kinoko/script/continent/Zipangu.java b/src/main/java/kinoko/script/continent/Zipangu.java new file mode 100644 index 00000000..8c81266c --- /dev/null +++ b/src/main/java/kinoko/script/continent/Zipangu.java @@ -0,0 +1,61 @@ +package kinoko.script.continent; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +import java.util.Map; + +public class Zipangu extends ScriptHandler { + @Script("con1") + public static void con1(ScriptManager sm) { + // Konpei (9120015) + // Zipangu : Showa Town (801000000) + final int answer = sm.askMenu("What do you want from me?", Map.of( + 0, "Gather up some information on the hideout.", + 1, "Take me to the hideout.", + 2, "Nothing." + )); + + switch (answer) { + case 0 -> { + sm.sayNext("I can take you to the hideout, but the place is infested with thugs looking for trouble. You'll need to be both incredibly strong and brave to enter the premise. At the hideaway, you'll find the Boss that controls all the other bosses around this area. It's easy to get to the hideout, but the room on the top floor of the place can only be entered ONCE a day. The Boss's Room is not a place to mess around. I suggest you don't stay there for too long; you'll need to swiftly take care of the business once inside. The boss himself is a difficult foe, but you'll run into some incredibly powerful enemies on your way to meeting the boss! It ain't going to be easy."); + } + case 1 -> { + sm.sayNext("Oh, the brave one. I've been awaiting your arrival. If these thugs are left unchecked, there's no telling what going to happen in this neighborhood. Before that happens, I hope you take care of all of them and beat the boss, who resides on the 5th floor. You'll need to be on alert at all times, since the boss is too tough for even the wisemen to handle. Looking at your eyes, however, I can see that eye of the tiger, the eyes that tell me you can do this. Let's go!"); + sm.warp(801040000); + } + case 2 -> { + sm.sayOk("I'm a busy person! Leave me alone if that's all you need!"); + } + } + } + + @Script("con2") + public static void con2(ScriptManager sm) { + // Konpei (9120200) + // Zipangu : Near the Hideout (801040000) + if (!sm.askYesNo("Here you are, right in front of the hideout! What? You want to return to Showa Town?")) { + sm.sayOk("If you want to return to Showa Town, then talk to me."); + return; + } + + sm.warp(801000000); + } + + @Script("con3") + public static void con3(ScriptManager sm) { + // Konpei (9120202) + // Zipangu : The Nightmarish Last Days (801040100) + if (!sm.hasItem(4000141)) { + if (!sm.askYesNo("Once you eliminate the boss, you'll have to show me the boss's flashlight as evidence. I won't believe it until you show me the flashlight! What? You want to leave this room?")) { + sm.sayOk("I really admire your toughness! Well, if you decide to return to Showa Town, let me know~!"); + return; + } + + sm.warp(801040000); + } else { + sm.message("Boss not implemented yet."); + } + } +} diff --git a/src/main/java/kinoko/script/event/Gachapon.java b/src/main/java/kinoko/script/event/Gachapon.java new file mode 100644 index 00000000..876e3eb2 --- /dev/null +++ b/src/main/java/kinoko/script/event/Gachapon.java @@ -0,0 +1,108 @@ +package kinoko.script.event; + +import kinoko.handler.stage.GachaponHandler; +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.util.Tuple; +import kinoko.world.quest.QuestRecordType; + + +public class Gachapon extends ScriptHandler { + final static int DEF_RETURN_MAP = 100000000; + final static int GACHAPON_TICKET = 5220000; + + @Script("gachapon10") + public static void gachapon10(ScriptManager sm) { + // Gachapon (9100109) + // New Leaf City : NLC Town Center (600000000) + handleGachapon(sm, "New Leaf City", "new_leaf_city"); + } + + @Script("gachapon18") + public static void gachapon18(ScriptManager sm) { + // Gachapon (9100117) + // Nautilus : Mid Floor - Hallway (120000200) + handleGachapon(sm, "Nautilus", "nautilus"); + } + + @Script("gachapon1") + public static void gachapon1(ScriptManager sm) { + // Gachapon (9100100) + // Henesys : Henesys Market (100000100) + handleGachapon(sm, "Henesys Market", "henesys"); + } + + @Script("gachapon2") + public static void gachapon2(ScriptManager sm) { + // Gachapon (9100101) + // Ellinia : Ellinia (101000000) + handleGachapon(sm, "Ellinia", "ellinia"); + } + + @Script("gachapon3") + public static void gachapon3(ScriptManager sm) { + // Gachapon (9100102) + // Perion : Perion (102000000) + handleGachapon(sm, "Perion", "perion"); + } + + @Script("gachapon4") + public static void gachapon4(ScriptManager sm) { + // Gachapon (9100103) + // Kerning City : Kerning City (103000000) + handleGachapon(sm, "Kerning City", "kerning_city"); + } + + @Script("gachapon5") + public static void gachapon5(ScriptManager sm) { + // Gachapon (9100104) + // Dungeon : Sleepywood (105040300) + handleGachapon(sm, "Sleepywood Dungeon", "sleepywood"); + } + + @Script("gachapon6") + public static void gachapon6(ScriptManager sm) { + // Gachapon (9100105) + // Zipangu : Mushroom Shrine (800000000) + handleGachapon(sm, "Mushroom Shrine", "mushroom_shrine"); + } + + @Script("gachapon7") + // Zipangu: Spa (M) + // Gachapon (9100106) + // Zipangu : Spa (M) (809000101) + public static void gachapon7(ScriptManager sm) { + handleGachapon(sm, "Zipangu Spa (M)", "zipangu_spa_m"); + } + + @Script("gachapon8") + // Zipangu: Spa (F) + // Gachapon (9100107) + // Zipangu : Spa (F) (809000201) + public static void gachapon8(ScriptManager sm) { + handleGachapon(sm, "Zipangu Spa (F)", "zipangu_spa_f"); + } + + public static void handleGachapon(ScriptManager sm, String location, String gachaName) { + if (!sm.hasItem(GACHAPON_TICKET, 1)) { + sm.sayOk("It doesn't seem like you have a Gachapon ticket. Please purchase one and try again."); + return; + } + + if ( + sm.getUser().getInventoryManager().getEquipInventory().getRemaining() < 1 + || sm.getUser().getInventoryManager().getConsumeInventory().getRemaining() < 1 + || sm.getUser().getInventoryManager().getEtcInventory().getRemaining() < 1 + || sm.getUser().getInventoryManager().getInstallInventory().getRemaining() < 1 + ) { + sm.sayOk("Please make room in your EQP, USE, ETC, and SET-UP inventories."); + return; + } + + Tuple item = GachaponHandler.rollGachapon(gachaName); + sm.addItem(item.getLeft(), item.getRight()); + sm.removeItem(GACHAPON_TICKET, 1); + sm.sayNext("You have obtained #b#t" + item.getLeft() + "##k from " + location + ".\r\nThank you for using our Gachapon services. Please come again!"); + } +} diff --git a/src/main/java/kinoko/script/party/AmoriaPQ.java b/src/main/java/kinoko/script/party/AmoriaPQ.java new file mode 100644 index 00000000..a63bae2a --- /dev/null +++ b/src/main/java/kinoko/script/party/AmoriaPQ.java @@ -0,0 +1,146 @@ +package kinoko.script.party; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.world.field.Field; +import kinoko.world.user.User; + +import java.time.ZoneId; +import java.time.ZonedDateTime; +import java.util.List; +import java.util.Map; + +public final class AmoriaPQ extends ScriptHandler { + public static final int EXIT = 910340000; + public static final int STAGE_1 = 670010200; + public static final int STAGE_2 = 670010300; + public static final int STAGE_2_DUSK = 670010301; + public static final int STAGE_3 = 910340300; + public static final int STAGE_4 = 910340400; + public static final int STAGE_5 = 910340500; + public static final int ENTRANCE_TICKET = 4031592; + + @Script("PartyAmoria_enter") + public static void PartyAmoria_enter(ScriptManager sm) { + final int pqStatus = 8883; + final int pqLastTime = 8884; + String pqStatusValue = sm.getQRValue(pqStatus); + String pqLastTimeValue = sm.getQRValue(pqLastTime); + + if (sm.getFieldId() == 670010000) { + if (pqStatusValue.equals("end")) { + sm.sayOk("Do you have the ticket with you? Okay, now I'll take you to the entrance of the Amoria Party Quest. Your fellow members of the party should be there waiting for you!"); + if (!sm.removeItem(ENTRANCE_TICKET, 1)) { + sm.sayOk("Don't you have the Entrance Ticket? Oh no. I'm sorry, but I'll have to ask you to reacquire 10 Lip Lock Keys and give them to me. Then, and only then, will I give you another ticket."); + return; + } + + sm.warp(670010100, "st00"); + sm.setQRValue(pqStatus, ""); + } else if (pqStatusValue.equals("ing")) { + // TODO: add check if married + if (sm.getLevel() < 40) { + sm.sayOk("I see a fine fighting spirit in you, my friend. Sadly, it not fully developed. You'll need to be at least Level 40 to enter my Hunting ground!"); + return; + } + + sm.sayOk("I want you to gather 10 Lip Lock Keys to prove yourself worthy of entry. You might want to try hunting the Indigo Eyes, they seem to like the look of them. After that, I'll let you in to see what you're made of!"); + if (!sm.removeItem(4031593, 10)) { + sm.sayOk("Let's see, 1,2,3... not 10. My brother may be the wise one, but I'm no slouch either. You need 10 before I'll give you the Amorian Challenge Entrance Ticket."); + return; + } + + sm.sayOk("Ah! A worthy warrior and his party! Here's the Ticket. Good luck!"); + sm.addItem(4031592, 1); + sm.setQRValue(pqStatus, "end"); + sm.setQRValue(pqLastTime, ""); // TODO: current time + } else { + if (!sm.askYesNo("I am Amos the Strong! The warrior who once defeated a Balrog with nothing but my trusty sword'and wits! I have a challenge for your group should you be up for it! What do you say?")) { + sm.sayOk("Can't say I blame you, friend. Come on back when you're good and strong, I'll be waiting."); + return; + } + + sm.sayOk("Stellar! Let me warn you-my challenges are not for those with weak weapons and puny minds! I built this hunting ground as a testament for those to protect their loved ones. To do this, you must be strong! I will put you to the test! Please talk to me again."); + sm.setQRValue(pqStatus, "ing"); // TODO: only set if time passed + } + } + } + + @Script("PartyAmoria_enter2") + public static void PartyAmoria_enter2(ScriptManager sm) { + if (sm.getFieldId() == 670010100) { + final int selection = sm.askMenu("Okay. What would you like to do?", Map.of( + 0, "I'd like to start the Party Quest.", + 1, "Please get us out of here!" + )); + + if (selection == 0) { + // PQ + if (!sm.getUser().getPartyInfo().isBoss()) { + sm.sayOk("How about you and your party members collectively beating a quest? Here you'll find obstacles and problems where you won't be able to beat it unless with great teamwork. If you want to try it, please tell the #bleader of your party#k to talk to me."); + return; + } + + sm.sayOk("Good, the leader of the party is here. Now, are you and your party members ready for this? I'll send you guys now to the entrance of the Amoria Party Quest. Best of luck to each and every one of you!"); + + if (!sm.checkParty(1, 15)) { + sm.sayOk("You are not in the party. You only can do this quest when you are in the party."); + return; + } + + if (!checkGender(sm)) { + sm.sayOk("You need at least one bride and one groom to participate in this party quest."); + return; + } + + sm.removeItem(4031592, 1); + } else { + sm.sayOk("Hmm... Well, see you next time. Bye~!"); + sm.warp(670010000, "st00"); + } + } + } + + private static boolean checkGender(ScriptManager sm) { + boolean groom = false; + boolean bride = false; + final List members = sm.getField().getUserPool().getPartyMembers(sm.getUser().getPartyId()); + for (User member : members) { + if (member.getGender() == 0) { + groom = true; + } else if (member.getGender() == 1) { + bride = true; + } + } + return groom && bride; + } + + @Script("PartyAmoria_play") + public static void PartyAmoria_play(ScriptManager sm) { + int warpTo = -1; + final User user = sm.getUser(); + final Field field = sm.getField(); + switch (field.getFieldId()) { + case STAGE_1 -> { + String stage1 = sm.getInstanceVariable("stage1_clear"); + String value = sm.getInstanceVariable("stage1down"); + int hour = ZonedDateTime.now(ZoneId.of("UTC")).getHour(); + if (hour <= 16) { + warpTo = STAGE_2; // day + } else { + warpTo = STAGE_2_DUSK; // dusk + } + + if (stage1.equals("1")) { + sm.sayOk("Great job completing the first stage. I'll now take you to the second stage."); + } else { + if (value.equals("ing")) { + sm.sayOk("Now now, you may want to think it over again. See which portal will open..."); + + } + } + } + } + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/script/party/BalrogPQ.java b/src/main/java/kinoko/script/party/BalrogPQ.java new file mode 100644 index 00000000..2730556d --- /dev/null +++ b/src/main/java/kinoko/script/party/BalrogPQ.java @@ -0,0 +1,101 @@ +package kinoko.script.party; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.server.event.EventType; +import kinoko.server.node.ServerExecutor; +import kinoko.world.BossConstants; +import kinoko.world.field.mob.MobAppearType; +import kinoko.world.field.mob.MobType; + +import java.util.List; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +public class BalrogPQ extends ScriptHandler { + @Script("balog_accept") + public static void balog_accept(ScriptManager sm) { + if (sm.getFieldId() == BossConstants.BALROG_ENTRY_MAP) { + final int selection = sm.askMenu("Do you want to head to the '#bBalrog's Tomb#k' to fight the\r\n" + blue("Balrog") + "?\r\n", Map.of( + 0, "Go to the Balrog's Tomb (Easy Mode) " + red("(Lv. 50+)"), + 1, "Go to the Balrog's Tomb (Hard Mode) " + red("(Lv. 70+)"), + 2, "Never mind." + )); + + if (selection != 2) { + if (!sm.getUser().getPartyInfo().isBoss()) { + sm.sayOk("Please have your party leader talk to me if you wish to face " + blue("Balrog") + "."); + return; + } + + if (!sm.checkParty(1, selection == 0 ? 50 : 70)) { + sm.sayOk("One or more party members are lacking the prerequisite entry quests, or are below level " + blue(selection == 0 ? "50" : "70") + "."); + return; + } + + if (sm.partyHasCoolDown(EventType.PQ_BALROG, BossConstants.BALROG_RUNS_PER_DAY)) { + String timeUntilReset = sm.getTimeUntilEventReset(EventType.PQ_BALROG); + sm.sayOk("You or one of your party member has already attempted facing \r\n" + blue("Balrog") + " within the past 6 Hours.\r\n You have " + timeUntilReset + " left on your cooldown."); + return; + } + + sm.partyWarpInstance(BossConstants.BALROG_NORMAL_BATTLE_MAP, "sp", BossConstants.BALROG_ENTRY_MAP, BossConstants.BALROG_TIME_LIMIT); + sm.addCooldownTimeForParty(EventType.PQ_BALROG, BossConstants.BALROG_COOLDOWN); + } + } + } + + @Script("easy_balog_summon") + public static void easy_balog_summon(ScriptManager sm) { + if (sm.getUser().getPartyInfo().isBoss()) { + sm.spawnMob(BossConstants.BALROG_NORMAL_BODY, MobAppearType.SUSPENDED, BossConstants.BALROG_SPAWN_X, BossConstants.BALROG_SPAWN_Y, true); + sm.spawnMob(8830009, MobAppearType.NORMAL, BossConstants.BALROG_SPAWN_X, BossConstants.BALROG_SPAWN_Y, true); + sm.spawnMob(8830013, MobAppearType.NORMAL, BossConstants.BALROG_SPAWN_X, BossConstants.BALROG_SPAWN_Y, true); + + ServerExecutor.schedule(sm.getUser(), () -> { + sm.killMob(8830013); + }, BossConstants.BALROG_RELEASE_LEFT_CLAW_INTERVAL, TimeUnit.SECONDS); + } + } + + @Script("balog_summon") + public static void balog_summon(ScriptManager sm) { + List spawns = List.of( + BossConstants.BALROG_MYSTIC_BODY, + BossConstants.BALROG_LEFT_ARM, + BossConstants.BALROG_RIGHT_ARM, + 8830003 + ); + + for (Integer spawn : spawns) { + sm.spawnMob(spawn, MobAppearType.REGEN, BossConstants.BALROG_SPAWN_X, BossConstants.BALROG_SPAWN_Y, false); + } + +// sm.spawnMob(BossConstants.BALROG_MYSTIC_BODY, MobAppearType.SUSPENDED, BossConstants.BALROG_SPAWN_X, BossConstants.BALROG_SPAWN_Y, true, MobType.PARENT_MOB); +// sm.spawnMob(BossConstants.BALROG_RIGHT_ARM, MobAppearType.REGEN, BossConstants.BALROG_SPAWN_X, BossConstants.BALROG_SPAWN_Y, true, MobType.SUB_MOB); +// sm.spawnMob(BossConstants.BALROG_RIGHT_ARM, MobAppearType.REGEN, BossConstants.BALROG_SPAWN_X, BossConstants.BALROG_SPAWN_Y, true, MobType.SUB_MOB); + } + + @Script("balog_buff") + public static void balog_buff(ScriptManager sm) { + + } + + @Script("balog_InOut") + public static void balog_InOut(ScriptManager sm) { + if (sm.askYesNo("Are you sure you want to leave the battlefield?")) { + sm.warp(BossConstants.BALROG_ENTRY_MAP); + } + } + + @Script("balog_bonusSetting") + public static void balog_bonusSetting(ScriptManager sm) { + + } + + @Script("balog_dateSet") + public static void balog_dateSet(ScriptManager sm) { + + } +} diff --git a/src/main/java/kinoko/script/party/CrimsonwoodPQ.java b/src/main/java/kinoko/script/party/CrimsonwoodPQ.java new file mode 100644 index 00000000..45dde8ae --- /dev/null +++ b/src/main/java/kinoko/script/party/CrimsonwoodPQ.java @@ -0,0 +1,242 @@ +package kinoko.script.party; + +import kinoko.packet.field.FieldPacket; +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.world.field.Field; +import kinoko.world.field.mob.MobAppearType; + +import java.awt.*; +import java.time.Instant; +import java.util.List; +import java.util.Map; + +public final class CrimsonwoodPQ extends ScriptHandler { + // Map constants + public static final int LOBBY = 610030020; + public static final int STAGE_1 = 610030100; + public static final int STAGE_2 = 610030200; + public static final int STAGE_3 = 610030300; + public static final int STAGE_4 = 610030400; + public static final int STAGE_5 = 610030500; + public static final int STAGE_5_1 = 610030510; + public static final int STAGE_5_2 = 610030520; + public static final int STAGE_5_21 = 610030521; + public static final int STAGE_5_22 = 610030522; + public static final int STAGE_5_3 = 610030530; + public static final int STAGE_5_4 = 610030540; + public static final int STAGE_5_5 = 610030550; + public static final int BOSS = 610030600; + public static final int STAGE_7 = 610030700; + public static final int REWARD = 610030800; + + // Mob constants + public static final int GUARDIAN = 9400594; + public static final int BOSS_1 = 9400589; + public static final int BOSS_2 = 9400590; + public static final int BOSS_3 = 9400591; + public static final int BOSS_4 = 9400592; + public static final int BOSS_5 = 9400593; + + @Script("cwkPQ_enter") + public static void cwkPQ_enter(ScriptManager sm) { + // Jack (9270035) + // Crimsonwood Keep : Hallway to Secret Hall (610030020) + if (sm.getFieldId() == LOBBY) { + final int answer = sm.askMenu("#e#n\r\nGreetings, brave adventurer. The Crimsonwood Keep holds many secrets and dangers. Will you venture inside?", Map.of( + 0, "I want to enter Crimsonwood Keep", + 1, "I want to hear the details" + )); + if (answer == 0) { + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Only the party leader can enter Crimsonwood Keep PQ."); + return; + } + if (!sm.checkParty(3, 90)) { + sm.sayOk("You need 3-6 party members at level 90+ to enter Crimsonwood Keep."); + return; + } + sm.partyWarpInstance(List.of( + STAGE_1, STAGE_2, STAGE_3, STAGE_4, STAGE_5, + STAGE_5_1, STAGE_5_2, STAGE_5_21, STAGE_5_22, STAGE_5_3, STAGE_5_4, STAGE_5_5, + BOSS, STAGE_7, REWARD + ), "st00", LOBBY, 3 * 60); + } else if (answer == 1) { + sm.sayOk("#e#n\r\nCrimsonwood Keep is a challenging party quest that requires teamwork, puzzle-solving, and combat prowess!\r\n\r\n#e - Level:#n 90+ #r(Recommended Level: 90-120)#k\r\n#e - Time Limit:#n 3 min entry + extended time\r\n#e - Players:#n 3-6\r\n#e - Bosses:#n Multiple Crimsonwood Bosses\r\n#e - Reward:#n Experience, Crimsonwood Equipment"); + } + } + } + + @Script("cwkPQ_mapEnter") + public static void cwkPQ_mapEnter(ScriptManager sm) { + // Map enter scripts + final Field field = sm.getField(); + final int mapId = field.getFieldId(); + + if (mapId == STAGE_1) { + field.blowWeather(5120017, "Welcome to Crimsonwood Keep! Find the entrance quickly before the guardians discover you!", 20); + // Schedule guardian spawn after 30 seconds + // sm.scheduleInstanceEvent("spawnGuardians", 30); + } + } + + @Script("cwkPQ_stage1") + public static void cwkPQ_stage1(ScriptManager sm) { + // Stage 1 - Find entrance quickly + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + final String intro = sm.getInstanceVariable("stage1_intro"); + if (intro.isEmpty()) { + sm.setInstanceVariable("stage1_intro", "1"); + sm.sayOk("You have entered Crimsonwood Keep! Find the entrance to proceed deeper into the keep. Be quick - guardians will spawn in 30 seconds!"); + return; + } + + sm.sayOk("Proceed to the next stage through the portal!"); + } + + @Script("cwkPQ_stage4") + public static void cwkPQ_stage4(ScriptManager sm) { + // Stage 4 - Skills puzzle + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + final String intro = sm.getInstanceVariable("stage4_intro"); + if (intro.isEmpty()) { + sm.setInstanceVariable("stage4_intro", "1"); + sm.sayOk("Welcome to the Skills Puzzle! Use your class skills to activate the correct reactors in sequence!"); + return; + } + + if (sm.getInstanceVariable("stage4_clear").equals("1")) { + sm.sayOk("You've cleared this puzzle! Move forward!"); + return; + } + + sm.sayOk("Solve the skills puzzle to proceed!"); + } + + @Script("cwkPQ_stage5_4") + public static void cwkPQ_stage5_4(ScriptManager sm) { + // Stage 5-4 - Sigil mob spawn stage + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + final String intro = sm.getInstanceVariable("stage5_4_intro"); + if (intro.isEmpty()) { + sm.setInstanceVariable("stage5_4_intro", "1"); + sm.sayOk("This stage has 5 Guardian Sigils. Defeat all 5 to proceed!"); + + // Spawn 5 guardians at specific positions + final int[][] positions = { + {944, -204}, {401, -384}, {28, -504}, {-332, -384}, {-855, -204} + }; + for (int[] pos : positions) { + sm.spawnMob(GUARDIAN, MobAppearType.NORMAL, pos[0], pos[1], false); + } + return; + } + + if (sm.getField().getMobPool().isEmpty()) { + sm.sayOk("Excellent! All guardians defeated. Proceed to the next area!"); + sm.addExpAll(1000); + } else { + sm.sayOk("Defeat all 5 Guardian Sigils to proceed!"); + } + } + + @Script("cwkPQ_boss") + public static void cwkPQ_boss(ScriptManager sm) { + // Boss stage + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + final String intro = sm.getInstanceVariable("boss_intro"); + if (intro.isEmpty()) { + sm.setInstanceVariable("boss_intro", "1"); + sm.setInstanceVariable("boss_count", "0"); + sm.sayOk("This is the final chamber! Defeat all 5 Crimsonwood bosses to complete the quest!"); + return; + } + + final String count = sm.getInstanceVariable("boss_count"); + final int bossCount = count.isEmpty() ? 0 : Integer.parseInt(count); + + if (bossCount >= 5) { + sm.sayOk("Congratulations! You've defeated all the bosses! Proceed to claim your rewards!"); + sm.addExpAll(3000); + sm.broadcastScreenEffect("quest/party/clear"); + sm.broadcastSoundEffect("Party1/Clear"); + sm.partyWarp(STAGE_7, "sp"); + } else { + sm.sayOk(String.format("You've defeated %d/5 bosses. Keep fighting!", bossCount)); + } + } + + @Script("cwkPQ_boss_dead") + public static void cwkPQ_boss_dead(ScriptManager sm) { + // When a boss dies (9400589-9400593) + final Field field = sm.getField(); + if (field.getFieldId() == BOSS) { + final String count = sm.getInstanceVariable("boss_count"); + final int bossCount = count.isEmpty() ? 0 : Integer.parseInt(count); + sm.setInstanceVariable("boss_count", String.valueOf(bossCount + 1)); + + if (bossCount + 1 >= 5) { + sm.broadcastScreenEffect("quest/party/clear"); + sm.broadcastSoundEffect("Party1/Clear"); + sm.broadcastMessage("All bosses defeated! You may now proceed to the reward area!"); + } else { + sm.broadcastMessage(String.format("Boss defeated! %d/5 bosses remaining.", 5 - (bossCount + 1))); + } + } + } + + @Script("cwkPQ_exit") + public static void cwkPQ_exit(ScriptManager sm) { + // Exit NPC + final int mapId = sm.getFieldId(); + + if (mapId == REWARD) { + if (sm.askYesNo("Would you like to leave and return to the lobby?")) { + sm.warp(LOBBY, "sp"); + } + return; + } + + if (!sm.askYesNo("Are you sure you want to leave Crimsonwood Keep PQ? You will forfeit all progress.")) { + return; + } + + sm.warp(LOBBY, "sp"); + } + + @Script("cwkPQ_StageMsg") + public static void cwkPQ_StageMsg(ScriptManager sm) { + // Stage entry messages + final Field field = sm.getField(); + switch (field.getFieldId()) { + case STAGE_1 -> { + field.blowWeather(5120017, "Stage 1: Find the entrance quickly!", 20); + } + case STAGE_4 -> { + field.blowWeather(5120017, "Stage 4: Use your skills to solve the puzzle!", 20); + } + case BOSS -> { + field.setMobSpawn(false); + field.getMobPool().respawnMobs(Instant.MAX); + field.blowWeather(5120017, "Final Stage: Defeat all 5 Crimsonwood Bosses!", 20); + } + } + } +} diff --git a/src/main/java/kinoko/script/party/GuildPQ.java b/src/main/java/kinoko/script/party/GuildPQ.java new file mode 100644 index 00000000..6ea90381 --- /dev/null +++ b/src/main/java/kinoko/script/party/GuildPQ.java @@ -0,0 +1,239 @@ +package kinoko.script.party; + +import kinoko.packet.field.FieldPacket; +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.world.field.Field; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +public final class GuildPQ extends ScriptHandler { + // Map constants + public static final int LOBBY = 102040200; + public static final int WAITING_ROOM = 990000000; + public static final int STAGE_1 = 990000100; + public static final int STAGE_2 = 990000200; + public static final int STAGE_3 = 990000300; + public static final int STAGE_3_1 = 990000301; + public static final int STAGE_4 = 990000400; + public static final int STAGE_4_1 = 990000401; + public static final int STAGE_4_10 = 990000410; + public static final int STAGE_4_20 = 990000420; + public static final int STAGE_4_30 = 990000430; + public static final int STAGE_4_31 = 990000431; + public static final int STAGE_4_40 = 990000440; + public static final int STAGE_5 = 990000500; + public static final int STAGE_5_1 = 990000501; + public static final int STAGE_5_2 = 990000502; + public static final int STAGE_6 = 990000600; + public static final int STAGE_6_10 = 990000610; + public static final int STAGE_6_11 = 990000611; + public static final int STAGE_6_20 = 990000620; + public static final int STAGE_6_30 = 990000630; + public static final int STAGE_6_31 = 990000631; + public static final int STAGE_6_40 = 990000640; + public static final int STAGE_6_41 = 990000641; + public static final int STAGE_7 = 990000700; + public static final int STAGE_8 = 990000800; + public static final int BOSS = 990000900; + public static final int STAGE_10 = 990001000; + public static final int EXIT = 990001100; + public static final int BONUS = 990001101; + + // Mob constants + public static final int ERGOTH = 9300028; + + // Item constants + public static final int GUILD_COIN = 4000313; + public static final int MOONSTONE = 4001113; + public static final int STARSTONE = 4001114; + + @Script("guildPQ_enter") + public static void guildPQ_enter(ScriptManager sm) { + // Shuang (2040036) + // Perion : Excavation Team Camp (102040200) + if (sm.getFieldId() == LOBBY) { + final int answer = sm.askMenu("#e#n\r\nHello, I'm Shuang. The ancient kingdom of Sharenian has been discovered! Will your guild help explore it?", Map.of( + 0, "I want to do the Guild Quest", + 1, "I want to hear the details" + )); + if (answer == 0) { + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("If you'd like to enter here, the leader of your party will have to talk to me. Talk to your party leader about this."); + return; + } + // Check if user has a guild + if (sm.getUser().getGuildId() == 0) { + sm.sayOk("You need to be in a guild to participate in the Guild Quest!"); + return; + } + if (!sm.checkParty(6, 30)) { + sm.sayOk("You cannot enter because your party requirements are not met. You need 6+ guild members at Lv. 30+ to enter."); + return; + } + // Create instance with 3 minute entry timer + sm.partyWarpInstance(List.of( + WAITING_ROOM, STAGE_1, STAGE_2, STAGE_3, STAGE_3_1, + STAGE_4, STAGE_4_1, STAGE_4_10, STAGE_4_20, STAGE_4_30, STAGE_4_31, STAGE_4_40, + STAGE_5, STAGE_5_1, STAGE_5_2, + STAGE_6, STAGE_6_10, STAGE_6_11, STAGE_6_20, STAGE_6_30, STAGE_6_31, STAGE_6_40, STAGE_6_41, + STAGE_7, STAGE_8, BOSS, STAGE_10, EXIT, BONUS + ), "st00", EXIT, 3 * 60); + } else if (answer == 1) { + sm.sayOk("#e#n\r\nThe ancient kingdom of Sharenian has been discovered beneath Perion! Gather your guild members and explore the ruins, solve puzzles, and defeat the guardian Ergoth!\r\n\r\n#e - Level:#n 30+ #r(Recommended Level: 30-70)#k\r\n#e - Time Limit:#n 3 min entry + 120 min quest\r\n#e - Players:#n 6+ guild members\r\n#e - Boss:#n #rErgoth#k\r\n#e - Reward:#n Guild Points, Experience"); + } + } + } + + @Script("guildwaitingenter") + public static void guildwaitingenter(ScriptManager sm) { + // Portal: join00 in waiting room + // Hidden Street : Sharenian - Entrance (990000000) + final String state = sm.getInstanceVariable("state"); + if (state.equals("1")) { + sm.warp(STAGE_1, "st00"); + } else { + sm.message("The gate has not been opened yet. Please wait for more guild members."); + } + } + + @Script("guildPQ_mapEnter") + public static void guildPQ_mapEnter(ScriptManager sm) { + // Map enter scripts + final Field field = sm.getField(); + final int mapId = field.getFieldId(); + + if (mapId == WAITING_ROOM) { + field.blowWeather(5120025, "Welcome to the Sharenian Guild Quest! More guild members can join for the next 3 minutes.", 20); + } else if (mapId == STAGE_1) { + field.blowWeather(5120025, "Warning: Once entering the vicinity of the fortress, anyone without protective stone earrings will immediately die due to the deterioration of the surrounding air.", 20); + } + } + + @Script("guildPQ_waiting") + public static void guildPQ_waiting(ScriptManager sm) { + // Waiting room NPC + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + final String state = sm.getInstanceVariable("state"); + if (state.equals("0")) { + final int playerCount = sm.getInstanceUserCount(); + if (playerCount < 6) { + sm.sayOk("You need at least 6 guild members to enter. Currently you have: " + playerCount); + return; + } + + sm.setInstanceVariable("state", "1"); + sm.broadcastMessage("The gate to the castle has been opened!"); + sm.broadcastScreenEffect("quest/party/clear"); + sm.broadcastSoundEffect("Party1/Clear"); + } else { + sm.sayOk("The gate is already open. Proceed through the portal!"); + } + } + + @Script("guildPQ_stage1") + public static void guildPQ_stage1(ScriptManager sm) { + // Stage 1 - Combat stage + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + if (sm.getField().getMobPool().isEmpty()) { + sm.sayOk("You've cleared this stage! Proceed to the next area."); + sm.addExpAll(500); + } else { + sm.sayOk("Defeat all the monsters in this area!"); + } + } + + @Script("guildPQ_boss") + public static void guildPQ_boss(ScriptManager sm) { + // Boss stage - Ergoth + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + final String intro = sm.getInstanceVariable("boss_intro"); + if (intro.isEmpty()) { + sm.setInstanceVariable("boss_intro", "1"); + sm.sayOk("This is the final chamber! Defeat Ergoth, the guardian of Sharenian, to complete the Guild Quest!"); + return; + } + + // Check if Ergoth is defeated + if (sm.getInstanceVariable("boss_dead").equals("1")) { + sm.sayOk("Congratulations! You've defeated Ergoth! Proceed to collect your rewards!"); + sm.addExpAll(2000); + sm.partyWarp(EXIT, "sp"); + } else { + sm.sayOk("Defeat Ergoth to complete the Guild Quest!"); + } + } + + @Script("guildPQ_ergoth_dead") + public static void guildPQ_ergoth_dead(ScriptManager sm) { + // When Ergoth (9300028) dies + final Field field = sm.getField(); + if (field.getFieldId() == BOSS) { + sm.setInstanceVariable("boss_dead", "1"); + sm.broadcastScreenEffect("quest/party/clear"); + sm.broadcastSoundEffect("Party1/Clear"); + sm.broadcastMessage("Ergoth has been defeated! The Guild Quest is complete!"); + } + } + + @Script("guildPQ_exit") + public static void guildPQ_exit(ScriptManager sm) { + // Exit NPC + final int mapId = sm.getFieldId(); + + if (mapId == EXIT) { + if (sm.askYesNo("Would you like to return to Perion?")) { + sm.warp(LOBBY, "sp"); + } + return; + } + + if (mapId == BONUS) { + if (sm.askYesNo("Would you like to leave the bonus room?")) { + sm.warp(EXIT, "sp"); + } + return; + } + + // Regular stage exit + if (!sm.askYesNo("Are you sure you want to leave the Guild Quest? You will forfeit all progress.")) { + return; + } + + sm.warp(EXIT, "sp"); + } + + @Script("guildPQ_StageMsg") + public static void guildPQ_StageMsg(ScriptManager sm) { + // Stage entry messages + final Field field = sm.getField(); + switch (field.getFieldId()) { + case STAGE_1 -> { + field.blowWeather(5120017, "Stage 1: Defeat all monsters to proceed!", 20); + } + case STAGE_2 -> { + field.blowWeather(5120017, "Stage 2: Work together to solve the puzzle!", 20); + } + case BOSS -> { + field.setMobSpawn(false); + field.getMobPool().respawnMobs(Instant.MAX); + field.blowWeather(5120017, "Final Stage: Defeat Ergoth!", 20); + } + } + } +} diff --git a/src/main/java/kinoko/script/party/HenesysPQ.java b/src/main/java/kinoko/script/party/HenesysPQ.java index 2a94c96e..c42ae583 100644 --- a/src/main/java/kinoko/script/party/HenesysPQ.java +++ b/src/main/java/kinoko/script/party/HenesysPQ.java @@ -49,7 +49,7 @@ public static void moonrabbit(ScriptManager sm) { 1, "Learn about Primrose Hill" )); if (answer == 0) { - if (!sm.getUser().isPartyBoss()) { + if (!sm.getUser().getPartyInfo().isBoss()) { sm.sayOk("If you'd like to enter here, the leader of your party will have to talk to me. Talk to your party leader about this."); return; } @@ -100,7 +100,7 @@ public static void moonrabbit_tiger(ScriptManager sm) { // Hidden Street : Primrose Hill (910010000) // Hidden Street : Primrose Hill (910010001) if (sm.getInstanceVariable("clear").equals("1")) { - if (sm.getUser().isPartyBoss()) { + if (sm.getUser().getPartyInfo().isBoss()) { sm.sayNext("Mmmm... this is delicious. Please come see me next time for more #b#t4001101##k, Have a good trip!"); sm.partyWarp(910010100, "st00"); // Hidden Street : Shortcut } else { @@ -110,7 +110,7 @@ public static void moonrabbit_tiger(ScriptManager sm) { } final Map options = new HashMap<>(); options.put(0, "Please tell me what this place is all about."); - if (sm.getUser().isPartyBoss()) { + if (sm.getUser().getPartyInfo().isBoss()) { options.put(1, "I have brought Moon Bunny's Rice Cake."); } options.put(2, "I would like to leave this place."); @@ -157,7 +157,7 @@ public static void moonrabbit_bonus(ScriptManager sm) { if (sm.getFieldId() == 910010100) { // Hidden Street : Shortcut sm.sayNext("Hello, there! I'm Tommy. There's a Pig Town nearby where we're standing. The pigs there are rowdy and uncontrollable to the point where they have stolen numerous weapons from travelers. They were kicked out from their towns, and are currently hiding out at the Pig Town."); - if (sm.getUser().isPartyBoss()) { + if (sm.getUser().getPartyInfo().isBoss()) { if (sm.askMenu("What do you think about making your way there with your party members and teach those rowdy pigs a lesson?", Map.of(0, "Yeah, that sounds good! Take me there!")) == 0) { sm.partyWarpInstance(910010200, "sp", 910010400, 300); } diff --git a/src/main/java/kinoko/script/party/KerningPQ.java b/src/main/java/kinoko/script/party/KerningPQ.java index 86d07a6d..ffe5188f 100644 --- a/src/main/java/kinoko/script/party/KerningPQ.java +++ b/src/main/java/kinoko/script/party/KerningPQ.java @@ -256,17 +256,17 @@ public static void party1_play(ScriptManager sm) { } // Introduction if (!sm.getInstanceVariable("stage4_intro").equals("1")) { - sm.sayNext("TODO"); // TODO + sm.sayNext("Hello, welcome to First Companion . Walk around the map to find some monsters. #bDefeat all the Curse Eyes#k in the map and talk to me again to open the portal to the next stage."); sm.setInstanceVariable("stage4_intro", "1"); return; } // Check mob count if (field.getMobPool().getByTemplateId(9300002).isPresent()) { - sm.sayNext("TODO"); // TODO + sm.sayNext("There are still some #bCurse Eyes#k remaining in the map. Please defeat all of them before we can proceed to the next stage."); return; } // Stage clear - sm.sayNext("TODO"); // TODO + sm.sayNext("Great job! You have defeated all the Curse Eyes. The portal to the next area has been opened. Please hurry!"); sm.addExpAll(100); sm.setInstanceVariable("stage4_gate", "1"); sm.broadcastPacket(FieldPacket.setObjectState("gate", 0)); @@ -277,17 +277,17 @@ public static void party1_play(ScriptManager sm) { } // Introduction if (!sm.getInstanceVariable("stage5_intro").equals("1")) { - sm.sayNext("TODO"); // TODO + sm.sayNext("Hello, welcome to First Companion . This is the final stage. Defeat the #bKing Slime#k in the map to enter the reward stage. Good luck!"); sm.setInstanceVariable("stage5_intro", "1"); return; } // Check mob count if (field.getMobPool().getByTemplateId(9300003).isPresent()) { - sm.sayNext("TODO"); // TODO + sm.sayNext("The #bKing Slime#k is still alive! Keep fighting and defeat it to complete this Party Quest!"); return; } // Stage clear - sm.sayNext("TODO"); // TODO + sm.sayNext("Congratulations on completing all challenges! You have defeated King Slime! Please proceed through the portal to the final reward stage."); sm.addExpAll(100); sm.setInstanceVariable("stage5_gate", "1"); sm.broadcastPacket(FieldPacket.setObjectState("gate", 0)); @@ -317,7 +317,43 @@ public static void party1_out(ScriptManager sm) { // Hidden Street : First Time Together (910340501) // Hidden Street : First Time Together (910340600) // Hidden Street : First Time Together (910340601) - sm.sayNext("TODO"); // TODO - exit stage, sell 4001454 + + final int mapId = sm.getFieldId(); + + // Check if in bonus stage + if (mapId == BONUS) { + if (!sm.askYesNo("Are you sure you want to leave the bonus stage and exit the Party Quest?")) { + return; + } + // Remove PQ items + sm.removeItem(COUPON); // 4001007 - Coupons + sm.removeItem(4001008); // Passes + + // Warp to exit + sm.warp(EXIT, "sp"); + return; + } + + // Check if in exit map + if (mapId == EXIT) { + if (!sm.askYesNo("Are you sure you want to return to the lobby?")) { + return; + } + sm.warp(910340700, "sp"); // Return to lobby + return; + } + + // Regular stage exit + if (!sm.askYesNo("Are you sure you want to leave this Party Quest? You will forfeit all progress.")) { + return; + } + + // Remove PQ items + sm.removeItem(COUPON); // 4001007 - Coupons + sm.removeItem(4001008); // Passes + + // Warp to exit + sm.warp(EXIT, "sp"); } @Script("StageMsg_together") diff --git a/src/main/java/kinoko/script/party/LudiPQ.java b/src/main/java/kinoko/script/party/LudiPQ.java new file mode 100644 index 00000000..797587d2 --- /dev/null +++ b/src/main/java/kinoko/script/party/LudiPQ.java @@ -0,0 +1,271 @@ +package kinoko.script.party; + +import kinoko.packet.field.FieldPacket; +import kinoko.script.UnityPortal; +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.world.field.Field; +import kinoko.world.quest.QuestRecordType; + +import java.time.Instant; +import java.util.List; +import java.util.Map; + +public final class LudiPQ extends ScriptHandler { + // Map constants + public static final int LOBBY = 221023300; + public static final int EXIT = 922010000; + public static final int STAGE_1 = 922010100; + public static final int STAGE_2 = 922010400; + public static final int STAGE_2_1 = 922010401; + public static final int STAGE_2_2 = 922010402; + public static final int STAGE_2_3 = 922010403; + public static final int STAGE_2_4 = 922010404; + public static final int STAGE_2_5 = 922010405; + public static final int STAGE_4 = 922010600; + public static final int STAGE_5 = 922010700; + public static final int STAGE_6 = 922010800; + public static final int STAGE_7 = 922010900; + public static final int STAGE_8 = 922011000; + public static final int REWARD = 922011100; + + // Item constants + public static final int LUDI_PQ_BOX = 2430066; + public static final int LUDI_COIN = 4001022; + public static final int LUDI_PASS = 4001023; + + @Script("ludiPQ_enter") + public static void ludiPQ_enter(ScriptManager sm) { + // Eak (2040025) + // Ludibrium : Eos Tower 101st Floor (221023300) + if (sm.getFieldId() == LOBBY) { + final int answer = sm.askMenu("#e#n\r\nHello, I'm Eak. There's a strange dimensional crack that appeared in Ludibrium. Will you help investigate it?", Map.of( + 0, "I want to do the Party Quest", + 1, "I want to hear the details" + )); + if (answer == 0) { + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("If you'd like to enter here, the leader of your party will have to talk to me. Talk to your party leader about this."); + return; + } + if (!sm.checkParty(3, 35)) { + sm.sayOk("You cannot enter because your party requirements are not met. You need 3-6 party members at Lv. 35+ to enter, so double-check and talk to me again."); + return; + } + sm.removeItem(LUDI_COIN); + sm.removeItem(LUDI_PASS); + sm.partyWarpInstance(List.of( + STAGE_1, STAGE_2, STAGE_2_1, STAGE_2_2, STAGE_2_3, STAGE_2_4, STAGE_2_5, + STAGE_4, STAGE_5, STAGE_6, STAGE_7, STAGE_8, REWARD + ), "st00", EXIT, 20 * 60); + } else if (answer == 1) { + sm.sayOk("#e#n\r\nA strange dimensional crack has appeared in Ludibrium! Work together with your party to solve puzzles, defeat monsters, and investigate the source of this dimensional anomaly!\r\n\r\n#e - Level:#n 35-50 #r(Recommended Level: 35-50)#k\r\n#e - Time Limit:#n 20 min.\r\n#e - Players:#n 3 - 6\r\n#e - Reward:#n #v2430066# #t2430066#"); + } + } else if (sm.getFieldId() == EXIT) { + // Exit map + if (sm.askYesNo("Would you like to return to the lobby?")) { + sm.warp(LOBBY, "sp"); + } + } + } + + @Script("ludiPQ_mapEnter") + public static void ludiPQ_mapEnter(ScriptManager sm) { + // Map enter script for Ludi PQ stages + final Field field = sm.getField(); + final int mapId = field.getFieldId(); + + if (mapId == STAGE_1) { + field.blowWeather(5120017, "Welcome to the Dimensional Crack! Work with your party to solve the puzzles ahead!", 20); + } + } + + @Script("ludiPQ_stage1") + public static void ludiPQ_stage1(ScriptManager sm) { + // Stage 1 - Introduction/Simple monster clear + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + final String intro = sm.getInstanceVariable("stage1_intro"); + if (intro.isEmpty()) { + sm.setInstanceVariable("stage1_intro", "1"); + sm.sayOk("Welcome to the Dimensional Crack Party Quest! In each stage, you'll need to work together to solve puzzles and defeat monsters. Let's begin!"); + return; + } + + if (sm.getField().getMobPool().isEmpty()) { + sm.sayOk("Great work! You've cleared this stage. Proceed to the next area!"); + sm.addExpAll(300); + sm.setInstanceVariable("stage1_clear", "1"); + } else { + sm.sayOk("Defeat all the monsters in this area first!"); + } + } + + @Script("ludiPQ_stage4") + public static void ludiPQ_stage4(ScriptManager sm) { + // Stage 4 - Block puzzle + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + final String intro = sm.getInstanceVariable("stage4_intro"); + if (intro.isEmpty()) { + sm.setInstanceVariable("stage4_intro", "1"); + sm.sayOk("Welcome to Stage 4! This is a block-pushing puzzle. Work together to push the blocks onto the correct platforms!"); + return; + } + + // Check if puzzle is solved + if (sm.getInstanceVariable("stage4_clear").equals("1")) { + sm.sayOk("You've already cleared this stage! Proceed through the portal."); + return; + } + + sm.sayOk("Solve the block puzzle to proceed!"); + } + + @Script("ludi_s4Clear") + public static void ludi_s4Clear(ScriptManager sm) { + // Portal script for stage 4 completion + if (sm.getInstanceVariable("stage4_clear").equals("1")) { + sm.warp(STAGE_5, "st00"); + } else { + sm.message("You haven't completed this stage yet!"); + } + } + + @Script("ludiPQ_stage5") + public static void ludiPQ_stage5(ScriptManager sm) { + // Stage 5 - Another puzzle stage + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + final String intro = sm.getInstanceVariable("stage5_intro"); + if (intro.isEmpty()) { + sm.setInstanceVariable("stage5_intro", "1"); + sm.sayOk("Welcome to Stage 5! Another puzzle awaits you. Work together!"); + return; + } + + if (sm.getInstanceVariable("stage5_clear").equals("1")) { + sm.sayOk("You've cleared this stage! Move forward!"); + return; + } + + sm.sayOk("Solve the puzzle to continue!"); + } + + @Script("ludi_s5Clear") + public static void ludi_s5Clear(ScriptManager sm) { + // Portal script for stage 5 completion + if (sm.getInstanceVariable("stage5_clear").equals("1")) { + sm.warp(STAGE_6, "st00"); + } else { + sm.message("You haven't completed this stage yet!"); + } + } + + @Script("ludiPQ_stage8") + public static void ludiPQ_stage8(ScriptManager sm) { + // Stage 8 - Boss stage (Alishar or similar) + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + final String intro = sm.getInstanceVariable("stage8_intro"); + if (intro.isEmpty()) { + sm.setInstanceVariable("stage8_intro", "1"); + sm.sayOk("This is the final stage! Defeat the boss to complete the Party Quest!"); + return; + } + + // Check if boss is defeated + if (sm.getField().getMobPool().isEmpty()) { + sm.sayOk("Excellent! You've defeated the boss! Proceed to claim your rewards!"); + sm.addExpAll(1000); + sm.setInstanceVariable("stage8_clear", "1"); + sm.setInstanceVariable("stage9", "1"); + sm.broadcastScreenEffect("quest/party/clear"); + sm.broadcastSoundEffect("Party1/Clear"); + } else { + sm.sayOk("Defeat the boss to complete this Party Quest!"); + } + } + + @Script("ludiPQ_final") + public static void ludiPQ_final(ScriptManager sm) { + // Final reward NPC + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + if (sm.getInstanceVariable("stage8_clear").equals("1")) { + sm.sayOk("Congratulations on completing the Dimensional Crack Party Quest! Your rewards have been distributed!"); + sm.partyWarp(REWARD, "sp"); + } else { + sm.sayOk("Complete all the stages first!"); + } + } + + @Script("ludiPQ_exit") + public static void ludiPQ_exit(ScriptManager sm) { + // Exit NPC + final int mapId = sm.getFieldId(); + + if (mapId == EXIT) { + if (sm.askYesNo("Would you like to return to the lobby?")) { + sm.warp(LOBBY, "sp"); + } + return; + } + + if (mapId == REWARD) { + if (sm.askYesNo("Would you like to leave the reward room?")) { + sm.removeItem(LUDI_COIN); + sm.removeItem(LUDI_PASS); + sm.warp(EXIT, "sp"); + } + return; + } + + // Regular stage exit + if (!sm.askYesNo("Are you sure you want to leave this Party Quest? You will forfeit all progress.")) { + return; + } + + sm.removeItem(LUDI_COIN); + sm.removeItem(LUDI_PASS); + sm.warp(EXIT, "sp"); + } + + @Script("ludiPQ_StageMsg") + public static void ludiPQ_StageMsg(ScriptManager sm) { + // Stage entry messages + final Field field = sm.getField(); + switch (field.getFieldId()) { + case STAGE_1 -> { + field.blowWeather(5120017, "Welcome to the first stage! Defeat all monsters!", 20); + } + case STAGE_4 -> { + field.blowWeather(5120017, "Stage 4: Push the blocks onto the correct platforms!", 20); + } + case STAGE_5 -> { + field.blowWeather(5120017, "Stage 5: Solve the puzzle to proceed!", 20); + } + case STAGE_8 -> { + field.setMobSpawn(false); + field.getMobPool().respawnMobs(Instant.MAX); + field.blowWeather(5120017, "Final Stage: Defeat the boss!", 20); + } + } + } +} diff --git a/src/main/java/kinoko/script/party/MonsterCarnivalPQ.java b/src/main/java/kinoko/script/party/MonsterCarnivalPQ.java new file mode 100644 index 00000000..8a2a248f --- /dev/null +++ b/src/main/java/kinoko/script/party/MonsterCarnivalPQ.java @@ -0,0 +1,116 @@ +package kinoko.script.party; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.world.field.Field; + +import java.util.List; +import java.util.Map; + +public final class MonsterCarnivalPQ extends ScriptHandler { + // Monster Carnival 1 maps + public static final int CPQ1_EXIT = 980000000; + public static final int CPQ1_LOBBY = 980000010; + public static final int CPQ1_WAITING_1 = 980000100; + public static final int CPQ1_WAITING_2 = 980000101; + public static final int CPQ1_FIELD = 980000102; + public static final int CPQ1_REVIVE = 980000103; + public static final int CPQ1_WINNER = 980000104; + public static final int CPQ1_LOSER = 980000105; + + // Monster Carnival 2 maps + public static final int CPQ2_EXIT = 980030000; + public static final int CPQ2_LOBBY = 980030010; + public static final int CPQ2_WAITING_1 = 980031000; + public static final int CPQ2_WAITING_2 = 980001001; + public static final int CPQ2_FIELD = 980031100; + public static final int CPQ2_REVIVE = 980001002; + public static final int CPQ2_WINNER = 980031300; + public static final int CPQ2_LOSER = 980031400; + + @Script("monsterCarnival1_enter") + public static void monsterCarnival1_enter(ScriptManager sm) { + // Spiegelmann (2042000) + // Kerning City : Spiegelmann's Office (980000000) + if (sm.getFieldId() == CPQ1_EXIT) { + final int answer = sm.askMenu("#e#n\r\nWelcome to Monster Carnival! Two parties compete by summoning monsters and defeating them to earn Carnival Points (CP)!", Map.of( + 0, "I want to participate in Monster Carnival", + 1, "I want to hear the details", + 2, "I want to leave" + )); + if (answer == 0) { + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Only the party leader can register for Monster Carnival."); + return; + } + if (!sm.checkParty(2, 30)) { + sm.sayOk("You need 2-6 party members at level 30+ to participate in Monster Carnival 1."); + return; + } + // TODO: Implement carnival party registration + sm.sayOk("Monster Carnival registration system is being prepared. Please check back later!"); + } else if (answer == 1) { + sm.sayOk("#e#n\r\nMonster Carnival is a competitive party quest where two parties battle by summoning monsters against each other!\r\n\r\n#e - Level:#n 30-50\r\n#e - Time Limit:#n 10 min.\r\n#e - Players:#n 2-6 per party (2 parties compete)\r\n#e - Objective:#n Earn Carnival Points (CP) by defeating monsters and use CP to summon monsters for the opposing team!\r\n#e - Reward:#n Carnival Coins (exchange for items)"); + } else if (answer == 2) { + sm.warp(103000000); // Kerning City + } + } + } + + @Script("monsterCarnival2_enter") + public static void monsterCarnival2_enter(ScriptManager sm) { + // Spiegelmann (2042001) + // Ludibrium : Spiegelmann's Office (980030000) + if (sm.getFieldId() == CPQ2_EXIT) { + final int answer = sm.askMenu("#e#n\r\nWelcome to Monster Carnival 2! A more challenging version of the carnival for higher level adventurers!", Map.of( + 0, "I want to participate in Monster Carnival 2", + 1, "I want to hear the details", + 2, "I want to leave" + )); + if (answer == 0) { + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Only the party leader can register for Monster Carnival."); + return; + } + if (!sm.checkParty(2, 51)) { + sm.sayOk("You need 2-6 party members at level 51+ to participate in Monster Carnival 2."); + return; + } + // TODO: Implement carnival party registration + sm.sayOk("Monster Carnival 2 registration system is being prepared. Please check back later!"); + } else if (answer == 1) { + sm.sayOk("#e#n\r\nMonster Carnival 2 is the advanced version of the carnival with stronger monsters and better rewards!\r\n\r\n#e - Level:#n 51-70\r\n#e - Time Limit:#n 10 min.\r\n#e - Players:#n 2-6 per party (2 parties compete)\r\n#e - Objective:#n Earn Carnival Points (CP) by defeating monsters and use CP to summon monsters for the opposing team!\r\n#e - Reward:#n Carnival Coins, Maple Coins"); + } else if (answer == 2) { + sm.warp(220000000); // Ludibrium + } + } + } + + @Script("monsterCarnival_exit") + public static void monsterCarnival_exit(ScriptManager sm) { + // Exit NPC for Monster Carnival + final int mapId = sm.getFieldId(); + + if (mapId == CPQ1_EXIT || mapId == CPQ2_EXIT) { + sm.sayOk("Talk to Spiegelmann to participate in Monster Carnival!"); + return; + } + + if (sm.askYesNo("Do you want to leave Monster Carnival? You will forfeit all progress.")) { + if (mapId >= 980000000 && mapId < 980030000) { + sm.warp(CPQ1_EXIT, "sp"); + } else { + sm.warp(CPQ2_EXIT, "sp"); + } + } + } + + // NOTE: Monster Carnival requires a full carnival party system implementation including: + // - Carnival Party registration and matching + // - CP (Carnival Point) tracking per player and team + // - Monster summoning UI and mechanics + // - Team-based scoring and winner determination + // - Protector and Guardian summoning + // - This is a placeholder structure until the full carnival system is implemented +} diff --git a/src/main/java/kinoko/script/party/MuLungDojo.java b/src/main/java/kinoko/script/party/MuLungDojo.java new file mode 100644 index 00000000..1f2d10c2 --- /dev/null +++ b/src/main/java/kinoko/script/party/MuLungDojo.java @@ -0,0 +1,649 @@ +package kinoko.script.party; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.world.quest.QuestRecordType; + +import java.util.List; +import java.util.Map; + +public class MuLungDojo extends ScriptHandler { + final static int DOJO_ENTRANCE_MAP = 925020001; + final static int DOJO_EXIT_MAP = 925020002; + final static int DOJO_WIN_MAP = 925020003; + final static int DOJO_TRAINING_MAP = 925020010; + final static int DOJO_FIRST_STAGE_MAP_NORMAL = 925020100; + final static int DOJO_POINTS_QID = 8000; + final static int WEATHER_EFFECT_ID = 5120024; + final static int DOJO_EMBLEM_ID = 4001620; + final static int DOJO_MIN_LEVEL = 25; + final static int DOJO_MAX_POINTS_PER_DAY = 1500; + + @Script("dojang_enter") + public static void dojang_enter(ScriptManager sm) { + // So Gong (2091005) + // Mu Lung Dojo : Mu Lung Dojo Hall (925020001) + // Mu Lung Dojo : Mu Lung Dojo 1st Floor (925020100) + // Mu Lung Dojo : Mu Lung Dojo 1st Floor (925020101) + // Mu Lung Dojo : Mu Lung Dojo 1st Floor (925020102) + // Mu Lung Dojo : Mu Lung Dojo 1st Floor (925020103) + // Mu Lung Dojo : Mu Lung Dojo 1st Floor (925020104) + // Mu Lung Dojo : Mu Lung Dojo 1st Floor (925020105) + // Mu Lung Dojo : Mu Lung Dojo 1st Floor (925020106) + // Mu Lung Dojo : Mu Lung Dojo 1st Floor (925020107) + // Mu Lung Dojo : Mu Lung Dojo 1st Floor (925020108) + // Mu Lung Dojo : Mu Lung Dojo 1st Floor (925020109) + // Mu Lung Dojo : Mu Lung Dojo 2nd Floor (925020200) + // Mu Lung Dojo : Mu Lung Dojo 2nd Floor (925020201) + // Mu Lung Dojo : Mu Lung Dojo 2nd Floor (925020202) + // Mu Lung Dojo : Mu Lung Dojo 2nd Floor (925020203) + // Mu Lung Dojo : Mu Lung Dojo 2nd Floor (925020204) + // Mu Lung Dojo : Mu Lung Dojo 2nd Floor (925020205) + // Mu Lung Dojo : Mu Lung Dojo 2nd Floor (925020206) + // Mu Lung Dojo : Mu Lung Dojo 2nd Floor (925020207) + // Mu Lung Dojo : Mu Lung Dojo 2nd Floor (925020208) + // Mu Lung Dojo : Mu Lung Dojo 2nd Floor (925020209) + // Mu Lung Dojo : Mu Lung Dojo 3rd Floor (925020300) + // Mu Lung Dojo : Mu Lung Dojo 3rd Floor (925020301) + // Mu Lung Dojo : Mu Lung Dojo 3rd Floor (925020302) + // Mu Lung Dojo : Mu Lung Dojo 3rd Floor (925020303) + // Mu Lung Dojo : Mu Lung Dojo 3rd Floor (925020304) + // Mu Lung Dojo : Mu Lung Dojo 3rd Floor (925020305) + // Mu Lung Dojo : Mu Lung Dojo 3rd Floor (925020306) + // Mu Lung Dojo : Mu Lung Dojo 3rd Floor (925020307) + // Mu Lung Dojo : Mu Lung Dojo 3rd Floor (925020308) + // Mu Lung Dojo : Mu Lung Dojo 3rd Floor (925020309) + // Mu Lung Dojo : Mu Lung Dojo 4th Floor (925020400) + // Mu Lung Dojo : Mu Lung Dojo 4th Floor (925020401) + // Mu Lung Dojo : Mu Lung Dojo 4th Floor (925020402) + // Mu Lung Dojo : Mu Lung Dojo 4th Floor (925020403) + // Mu Lung Dojo : Mu Lung Dojo 4th Floor (925020404) + // Mu Lung Dojo : Mu Lung Dojo 4th Floor (925020405) + // Mu Lung Dojo : Mu Lung Dojo 4th Floor (925020406) + // Mu Lung Dojo : Mu Lung Dojo 4th Floor (925020407) + // Mu Lung Dojo : Mu Lung Dojo 4th Floor (925020408) + // Mu Lung Dojo : Mu Lung Dojo 4th Floor (925020409) + // Mu Lung Dojo : Mu Lung Dojo 5th Floor (925020500) + // Mu Lung Dojo : Mu Lung Dojo 5th Floor (925020501) + // Mu Lung Dojo : Mu Lung Dojo 5th Floor (925020502) + // Mu Lung Dojo : Mu Lung Dojo 5th Floor (925020503) + // Mu Lung Dojo : Mu Lung Dojo 5th Floor (925020504) + // Mu Lung Dojo : Mu Lung Dojo 5th Floor (925020505) + // Mu Lung Dojo : Mu Lung Dojo 5th Floor (925020506) + // Mu Lung Dojo : Mu Lung Dojo 5th Floor (925020507) + // Mu Lung Dojo : Mu Lung Dojo 5th Floor (925020508) + // Mu Lung Dojo : Mu Lung Dojo 5th Floor (925020509) + // Mu Lung Dojo : Mu Lung Dojo 6th Floor (925020600) + // Mu Lung Dojo : Mu Lung Dojo 6th Floor (925020601) + // Mu Lung Dojo : Mu Lung Dojo 6th Floor (925020602) + // Mu Lung Dojo : Mu Lung Dojo 6th Floor (925020603) + // Mu Lung Dojo : Mu Lung Dojo 6th Floor (925020604) + // Mu Lung Dojo : Mu Lung Dojo 6th Floor (925020605) + // Mu Lung Dojo : Mu Lung Dojo 6th Floor (925020606) + // Mu Lung Dojo : Mu Lung Dojo 6th Floor (925020607) + // Mu Lung Dojo : Mu Lung Dojo 6th Floor (925020608) + // Mu Lung Dojo : Mu Lung Dojo 6th Floor (925020609) + // Mu Lung Dojo : Mu Lung Dojo 7th Floor (925020700) + // Mu Lung Dojo : Mu Lung Dojo 7th Floor (925020701) + // Mu Lung Dojo : Mu Lung Dojo 7th Floor (925020702) + // Mu Lung Dojo : Mu Lung Dojo 7th Floor (925020703) + // Mu Lung Dojo : Mu Lung Dojo 7th Floor (925020704) + // Mu Lung Dojo : Mu Lung Dojo 7th Floor (925020705) + // Mu Lung Dojo : Mu Lung Dojo 7th Floor (925020706) + // Mu Lung Dojo : Mu Lung Dojo 7th Floor (925020707) + // Mu Lung Dojo : Mu Lung Dojo 7th Floor (925020708) + // Mu Lung Dojo : Mu Lung Dojo 7th Floor (925020709) + // Mu Lung Dojo : Mu Lung Dojo 8th Floor (925020800) + // Mu Lung Dojo : Mu Lung Dojo 8th Floor (925020801) + // Mu Lung Dojo : Mu Lung Dojo 8th Floor (925020802) + // Mu Lung Dojo : Mu Lung Dojo 8th Floor (925020803) + // Mu Lung Dojo : Mu Lung Dojo 8th Floor (925020804) + // Mu Lung Dojo : Mu Lung Dojo 8th Floor (925020805) + // Mu Lung Dojo : Mu Lung Dojo 8th Floor (925020806) + // Mu Lung Dojo : Mu Lung Dojo 8th Floor (925020807) + // Mu Lung Dojo : Mu Lung Dojo 8th Floor (925020808) + // Mu Lung Dojo : Mu Lung Dojo 8th Floor (925020809) + // Mu Lung Dojo : Mu Lung Dojo 9th Floor (925020900) + // Mu Lung Dojo : Mu Lung Dojo 9th Floor (925020901) + // Mu Lung Dojo : Mu Lung Dojo 9th Floor (925020902) + // Mu Lung Dojo : Mu Lung Dojo 9th Floor (925020903) + // Mu Lung Dojo : Mu Lung Dojo 9th Floor (925020904) + // Mu Lung Dojo : Mu Lung Dojo 9th Floor (925020905) + // Mu Lung Dojo : Mu Lung Dojo 9th Floor (925020906) + // Mu Lung Dojo : Mu Lung Dojo 9th Floor (925020907) + // Mu Lung Dojo : Mu Lung Dojo 9th Floor (925020908) + // Mu Lung Dojo : Mu Lung Dojo 9th Floor (925020909) + // Mu Lung Dojo : Mu Lung Dojo 10th Floor (925021000) + // Mu Lung Dojo : Mu Lung Dojo 10th Floor (925021001) + // Mu Lung Dojo : Mu Lung Dojo 10th Floor (925021002) + // Mu Lung Dojo : Mu Lung Dojo 10th Floor (925021003) + // Mu Lung Dojo : Mu Lung Dojo 10th Floor (925021004) + // Mu Lung Dojo : Mu Lung Dojo 10th Floor (925021005) + // Mu Lung Dojo : Mu Lung Dojo 10th Floor (925021006) + // Mu Lung Dojo : Mu Lung Dojo 10th Floor (925021007) + // Mu Lung Dojo : Mu Lung Dojo 10th Floor (925021008) + // Mu Lung Dojo : Mu Lung Dojo 10th Floor (925021009) + // Mu Lung Dojo : Mu Lung Dojo 11th Floor (925021100) + // Mu Lung Dojo : Mu Lung Dojo 11th Floor (925021101) + // Mu Lung Dojo : Mu Lung Dojo 11th Floor (925021102) + // Mu Lung Dojo : Mu Lung Dojo 11th Floor (925021103) + // Mu Lung Dojo : Mu Lung Dojo 11th Floor (925021104) + // Mu Lung Dojo : Mu Lung Dojo 11th Floor (925021105) + // Mu Lung Dojo : Mu Lung Dojo 11th Floor (925021106) + // Mu Lung Dojo : Mu Lung Dojo 11th Floor (925021107) + // Mu Lung Dojo : Mu Lung Dojo 11th Floor (925021108) + // Mu Lung Dojo : Mu Lung Dojo 11th Floor (925021109) + // Mu Lung Dojo : Mu Lung Dojo 12th Floor (925021200) + // Mu Lung Dojo : Mu Lung Dojo 12th Floor (925021201) + // Mu Lung Dojo : Mu Lung Dojo 12th Floor (925021202) + // Mu Lung Dojo : Mu Lung Dojo 12th Floor (925021203) + // Mu Lung Dojo : Mu Lung Dojo 12th Floor (925021204) + // Mu Lung Dojo : Mu Lung Dojo 12th Floor (925021205) + // Mu Lung Dojo : Mu Lung Dojo 12th Floor (925021206) + // Mu Lung Dojo : Mu Lung Dojo 12th Floor (925021207) + // Mu Lung Dojo : Mu Lung Dojo 12th Floor (925021208) + // Mu Lung Dojo : Mu Lung Dojo 12th Floor (925021209) + // Mu Lung Dojo : Mu Lung Dojo 13th Floor (925021300) + // Mu Lung Dojo : Mu Lung Dojo 13th Floor (925021301) + // Mu Lung Dojo : Mu Lung Dojo 13th Floor (925021302) + // Mu Lung Dojo : Mu Lung Dojo 13th Floor (925021303) + // Mu Lung Dojo : Mu Lung Dojo 13th Floor (925021304) + // Mu Lung Dojo : Mu Lung Dojo 13th Floor (925021305) + // Mu Lung Dojo : Mu Lung Dojo 13th Floor (925021306) + // Mu Lung Dojo : Mu Lung Dojo 13th Floor (925021307) + // Mu Lung Dojo : Mu Lung Dojo 13th Floor (925021308) + // Mu Lung Dojo : Mu Lung Dojo 13th Floor (925021309) + // Mu Lung Dojo : Mu Lung Dojo 14th Floor (925021400) + // Mu Lung Dojo : Mu Lung Dojo 14th Floor (925021401) + // Mu Lung Dojo : Mu Lung Dojo 14th Floor (925021402) + // Mu Lung Dojo : Mu Lung Dojo 14th Floor (925021403) + // Mu Lung Dojo : Mu Lung Dojo 14th Floor (925021404) + // Mu Lung Dojo : Mu Lung Dojo 14th Floor (925021405) + // Mu Lung Dojo : Mu Lung Dojo 14th Floor (925021406) + // Mu Lung Dojo : Mu Lung Dojo 14th Floor (925021407) + // Mu Lung Dojo : Mu Lung Dojo 14th Floor (925021408) + // Mu Lung Dojo : Mu Lung Dojo 14th Floor (925021409) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925021500) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925021501) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925021502) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925021503) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925021504) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925021505) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925021506) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925021507) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925021508) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925021509) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925021600) + // Mu Lung Dojo : Mu Lung Dojo 16th Floor (925021601) + // Mu Lung Dojo : Mu Lung Dojo 16th Floor (925021602) + // Mu Lung Dojo : Mu Lung Dojo 16th Floor (925021603) + // Mu Lung Dojo : Mu Lung Dojo 16th Floor (925021604) + // Mu Lung Dojo : Mu Lung Dojo 16th Floor (925021605) + // Mu Lung Dojo : Mu Lung Dojo 16th Floor (925021606) + // Mu Lung Dojo : Mu Lung Dojo 16th Floor (925021607) + // Mu Lung Dojo : Mu Lung Dojo 16th Floor (925021608) + // Mu Lung Dojo : Mu Lung Dojo 16th Floor (925021609) + // Mu Lung Dojo : Mu Lung Dojo 17th Floor (925021700) + // Mu Lung Dojo : Mu Lung Dojo 17th Floor (925021701) + // Mu Lung Dojo : Mu Lung Dojo 17th Floor (925021702) + // Mu Lung Dojo : Mu Lung Dojo 17th Floor (925021703) + // Mu Lung Dojo : Mu Lung Dojo 17th Floor (925021704) + // Mu Lung Dojo : Mu Lung Dojo 17th Floor (925021705) + // Mu Lung Dojo : Mu Lung Dojo 17th Floor (925021706) + // Mu Lung Dojo : Mu Lung Dojo 17th Floor (925021707) + // Mu Lung Dojo : Mu Lung Dojo 17th Floor (925021708) + // Mu Lung Dojo : Mu Lung Dojo 17th Floor (925021709) + // Mu Lung Dojo : Mu Lung Dojo 18th Floor (925021800) + // Mu Lung Dojo : Mu Lung Dojo 18th Floor (925021801) + // Mu Lung Dojo : Mu Lung Dojo 18th Floor (925021802) + // Mu Lung Dojo : Mu Lung Dojo 18th Floor (925021803) + // Mu Lung Dojo : Mu Lung Dojo 18th Floor (925021804) + // Mu Lung Dojo : Mu Lung Dojo 18th Floor (925021805) + // Mu Lung Dojo : Mu Lung Dojo 18th Floor (925021806) + // Mu Lung Dojo : Mu Lung Dojo 18th Floor (925021807) + // Mu Lung Dojo : Mu Lung Dojo 18th Floor (925021808) + // Mu Lung Dojo : Mu Lung Dojo 18th Floor (925021809) + // Mu Lung Dojo : Mu Lung Dojo 19th Floor (925021900) + // Mu Lung Dojo : Mu Lung Dojo 19th Floor (925021901) + // Mu Lung Dojo : Mu Lung Dojo 19th Floor (925021902) + // Mu Lung Dojo : Mu Lung Dojo 19th Floor (925021903) + // Mu Lung Dojo : Mu Lung Dojo 19th Floor (925021904) + // Mu Lung Dojo : Mu Lung Dojo 19th Floor (925021905) + // Mu Lung Dojo : Mu Lung Dojo 19th Floor (925021906) + // Mu Lung Dojo : Mu Lung Dojo 19th Floor (925021907) + // Mu Lung Dojo : Mu Lung Dojo 19th Floor (925021908) + // Mu Lung Dojo : Mu Lung Dojo 19th Floor (925021909) + // Mu Lung Dojo : Mu Lung Dojo 20th Floor (925022000) + // Mu Lung Dojo : Mu Lung Dojo 20th Floor (925022001) + // Mu Lung Dojo : Mu Lung Dojo 20th Floor (925022002) + // Mu Lung Dojo : Mu Lung Dojo 20th Floor (925022003) + // Mu Lung Dojo : Mu Lung Dojo 20th Floor (925022004) + // Mu Lung Dojo : Mu Lung Dojo 20th Floor (925022005) + // Mu Lung Dojo : Mu Lung Dojo 20th Floor (925022006) + // Mu Lung Dojo : Mu Lung Dojo 20th Floor (925022007) + // Mu Lung Dojo : Mu Lung Dojo 20th Floor (925022008) + // Mu Lung Dojo : Mu Lung Dojo 20th Floor (925022009) + // Mu Lung Dojo : Mu Lung Dojo 21st Floor (925022100) + // Mu Lung Dojo : Mu Lung Dojo 21st Floor (925022101) + // Mu Lung Dojo : Mu Lung Dojo 21st Floor (925022102) + // Mu Lung Dojo : Mu Lung Dojo 21st Floor (925022103) + // Mu Lung Dojo : Mu Lung Dojo 21st Floor (925022104) + // Mu Lung Dojo : Mu Lung Dojo 21st Floor (925022105) + // Mu Lung Dojo : Mu Lung Dojo 21st Floor (925022106) + // Mu Lung Dojo : Mu Lung Dojo 21st Floor (925022107) + // Mu Lung Dojo : Mu Lung Dojo 21st Floor (925022108) + // Mu Lung Dojo : Mu Lung Dojo 21st Floor (925022109) + // Mu Lung Dojo : Mu Lung Dojo 22nd Floor (925022200) + // Mu Lung Dojo : Mu Lung Dojo 22nd Floor (925022201) + // Mu Lung Dojo : Mu Lung Dojo 22nd Floor (925022202) + // Mu Lung Dojo : Mu Lung Dojo 22nd Floor (925022203) + // Mu Lung Dojo : Mu Lung Dojo 22nd Floor (925022204) + // Mu Lung Dojo : Mu Lung Dojo 22nd Floor (925022205) + // Mu Lung Dojo : Mu Lung Dojo 22nd Floor (925022206) + // Mu Lung Dojo : Mu Lung Dojo 22nd Floor (925022207) + // Mu Lung Dojo : Mu Lung Dojo 22nd Floor (925022208) + // Mu Lung Dojo : Mu Lung Dojo 22nd Floor (925022209) + // Mu Lung Dojo : Mu Lung Dojo 23rd Floor (925022300) + // Mu Lung Dojo : Mu Lung Dojo 23rd Floor (925022301) + // Mu Lung Dojo : Mu Lung Dojo 23rd Floor (925022302) + // Mu Lung Dojo : Mu Lung Dojo 23rd Floor (925022303) + // Mu Lung Dojo : Mu Lung Dojo 23rd Floor (925022304) + // Mu Lung Dojo : Mu Lung Dojo 23rd Floor (925022305) + // Mu Lung Dojo : Mu Lung Dojo 23rd Floor (925022306) + // Mu Lung Dojo : Mu Lung Dojo 23rd Floor (925022307) + // Mu Lung Dojo : Mu Lung Dojo 23rd Floor (925022308) + // Mu Lung Dojo : Mu Lung Dojo 23rd Floor (925022309) + // Mu Lung Dojo : Mu Lung Dojo 24th Floor (925022400) + // Mu Lung Dojo : Mu Lung Dojo 24th Floor (925022401) + // Mu Lung Dojo : Mu Lung Dojo 24th Floor (925022402) + // Mu Lung Dojo : Mu Lung Dojo 24th Floor (925022403) + // Mu Lung Dojo : Mu Lung Dojo 24th Floor (925022404) + // Mu Lung Dojo : Mu Lung Dojo 24th Floor (925022405) + // Mu Lung Dojo : Mu Lung Dojo 24th Floor (925022406) + // Mu Lung Dojo : Mu Lung Dojo 24th Floor (925022407) + // Mu Lung Dojo : Mu Lung Dojo 24th Floor (925022408) + // Mu Lung Dojo : Mu Lung Dojo 24th Floor (925022409) + // Mu Lung Dojo : Mu Lung Dojo 25th Floor (925022500) + // Mu Lung Dojo : Mu Lung Dojo 25th Floor (925022501) + // Mu Lung Dojo : Mu Lung Dojo 25th Floor (925022502) + // Mu Lung Dojo : Mu Lung Dojo 25th Floor (925022503) + // Mu Lung Dojo : Mu Lung Dojo 25th Floor (925022504) + // Mu Lung Dojo : Mu Lung Dojo 25th Floor (925022505) + // Mu Lung Dojo : Mu Lung Dojo 25th Floor (925022506) + // Mu Lung Dojo : Mu Lung Dojo 25th Floor (925022507) + // Mu Lung Dojo : Mu Lung Dojo 25th Floor (925022508) + // Mu Lung Dojo : Mu Lung Dojo 25th Floor (925022509) + // Mu Lung Dojo : Mu Lung Dojo 26th Floor (925022600) + // Mu Lung Dojo : Mu Lung Dojo 26th Floor (925022601) + // Mu Lung Dojo : Mu Lung Dojo 26th Floor (925022602) + // Mu Lung Dojo : Mu Lung Dojo 26th Floor (925022603) + // Mu Lung Dojo : Mu Lung Dojo 26th Floor (925022604) + // Mu Lung Dojo : Mu Lung Dojo 26th Floor (925022605) + // Mu Lung Dojo : Mu Lung Dojo 26th Floor (925022606) + // Mu Lung Dojo : Mu Lung Dojo 26th Floor (925022607) + // Mu Lung Dojo : Mu Lung Dojo 26th Floor (925022608) + // Mu Lung Dojo : Mu Lung Dojo 26th Floor (925022609) + // Mu Lung Dojo : Mu Lung Dojo 27th Floor (925022700) + // Mu Lung Dojo : Mu Lung Dojo 27th Floor (925022701) + // Mu Lung Dojo : Mu Lung Dojo 27th Floor (925022702) + // Mu Lung Dojo : Mu Lung Dojo 27th Floor (925022703) + // Mu Lung Dojo : Mu Lung Dojo 27th Floor (925022704) + // Mu Lung Dojo : Mu Lung Dojo 27th Floor (925022705) + // Mu Lung Dojo : Mu Lung Dojo 27th Floor (925022706) + // Mu Lung Dojo : Mu Lung Dojo 27th Floor (925022707) + // Mu Lung Dojo : Mu Lung Dojo 27th Floor (925022708) + // Mu Lung Dojo : Mu Lung Dojo 27th Floor (925022709) + // Mu Lung Dojo : Mu Lung Dojo 28th Floor (925022800) + // Mu Lung Dojo : Mu Lung Dojo 28th Floor (925022801) + // Mu Lung Dojo : Mu Lung Dojo 28th Floor (925022802) + // Mu Lung Dojo : Mu Lung Dojo 28th Floor (925022803) + // Mu Lung Dojo : Mu Lung Dojo 28th Floor (925022804) + // Mu Lung Dojo : Mu Lung Dojo 28th Floor (925022805) + // Mu Lung Dojo : Mu Lung Dojo 28th Floor (925022806) + // Mu Lung Dojo : Mu Lung Dojo 28th Floor (925022807) + // Mu Lung Dojo : Mu Lung Dojo 28th Floor (925022808) + // Mu Lung Dojo : Mu Lung Dojo 28th Floor (925022809) + // Mu Lung Dojo : Mu Lung Dojo 29th Floor (925022900) + // Mu Lung Dojo : Mu Lung Dojo 29th Floor (925022901) + // Mu Lung Dojo : Mu Lung Dojo 29th Floor (925022902) + // Mu Lung Dojo : Mu Lung Dojo 29th Floor (925022903) + // Mu Lung Dojo : Mu Lung Dojo 29th Floor (925022904) + // Mu Lung Dojo : Mu Lung Dojo 29th Floor (925022905) + // Mu Lung Dojo : Mu Lung Dojo 29th Floor (925022906) + // Mu Lung Dojo : Mu Lung Dojo 29th Floor (925022907) + // Mu Lung Dojo : Mu Lung Dojo 29th Floor (925022908) + // Mu Lung Dojo : Mu Lung Dojo 29th Floor (925022909) + // Mu Lung Dojo : Mu Lung Dojo 30th Floor (925023000) + // Mu Lung Dojo : Mu Lung Dojo 30th Floor (925023001) + // Mu Lung Dojo : Mu Lung Dojo 30th Floor (925023002) + // Mu Lung Dojo : Mu Lung Dojo 30th Floor (925023003) + // Mu Lung Dojo : Mu Lung Dojo 30th Floor (925023004) + // Mu Lung Dojo : Mu Lung Dojo 30th Floor (925023005) + // Mu Lung Dojo : Mu Lung Dojo 30th Floor (925023006) + // Mu Lung Dojo : Mu Lung Dojo 30th Floor (925023007) + // Mu Lung Dojo : Mu Lung Dojo 30th Floor (925023008) + // Mu Lung Dojo : Mu Lung Dojo 30th Floor (925023009) + // Mu Lung Dojo : Mu Lung Dojo 31st Floor (925023100) + // Mu Lung Dojo : Mu Lung Dojo 31st Floor (925023101) + // Mu Lung Dojo : Mu Lung Dojo 31st Floor (925023102) + // Mu Lung Dojo : Mu Lung Dojo 31st Floor (925023103) + // Mu Lung Dojo : Mu Lung Dojo 31st Floor (925023104) + // Mu Lung Dojo : Mu Lung Dojo 31st Floor (925023105) + // Mu Lung Dojo : Mu Lung Dojo 31st Floor (925023106) + // Mu Lung Dojo : Mu Lung Dojo 31st Floor (925023107) + // Mu Lung Dojo : Mu Lung Dojo 31st Floor (925023108) + // Mu Lung Dojo : Mu Lung Dojo 31st Floor (925023109) + // Mu Lung Dojo : Mu Lung Dojo 32nd Floor (925023200) + // Mu Lung Dojo : Mu Lung Dojo 32nd Floor (925023201) + // Mu Lung Dojo : Mu Lung Dojo 32nd Floor (925023202) + // Mu Lung Dojo : Mu Lung Dojo 32nd Floor (925023203) + // Mu Lung Dojo : Mu Lung Dojo 32nd Floor (925023204) + // Mu Lung Dojo : Mu Lung Dojo 32nd Floor (925023205) + // Mu Lung Dojo : Mu Lung Dojo 32nd Floor (925023206) + // Mu Lung Dojo : Mu Lung Dojo 32nd Floor (925023207) + // Mu Lung Dojo : Mu Lung Dojo 32nd Floor (925023208) + // Mu Lung Dojo : Mu Lung Dojo 32nd Floor (925023209) + // Mu Lung Dojo : Mu Lung Dojo 33rd Floor (925023300) + // Mu Lung Dojo : Mu Lung Dojo 33rd Floor (925023301) + // Mu Lung Dojo : Mu Lung Dojo 33rd Floor (925023302) + // Mu Lung Dojo : Mu Lung Dojo 33rd Floor (925023303) + // Mu Lung Dojo : Mu Lung Dojo 33rd Floor (925023304) + // Mu Lung Dojo : Mu Lung Dojo 33rd Floor (925023305) + // Mu Lung Dojo : Mu Lung Dojo 33rd Floor (925023306) + // Mu Lung Dojo : Mu Lung Dojo 33rd Floor (925023307) + // Mu Lung Dojo : Mu Lung Dojo 33rd Floor (925023308) + // Mu Lung Dojo : Mu Lung Dojo 33rd Floor (925023309) + // Mu Lung Dojo : Mu Lung Dojo 34th Floor (925023400) + // Mu Lung Dojo : Mu Lung Dojo 34th Floor (925023401) + // Mu Lung Dojo : Mu Lung Dojo 34th Floor (925023402) + // Mu Lung Dojo : Mu Lung Dojo 34th Floor (925023403) + // Mu Lung Dojo : Mu Lung Dojo 34th Floor (925023404) + // Mu Lung Dojo : Mu Lung Dojo 34th Floor (925023405) + // Mu Lung Dojo : Mu Lung Dojo 34th Floor (925023406) + // Mu Lung Dojo : Mu Lung Dojo 34th Floor (925023407) + // Mu Lung Dojo : Mu Lung Dojo 34th Floor (925023408) + // Mu Lung Dojo : Mu Lung Dojo 34th Floor (925023409) + // Mu Lung Dojo : Mu Lung Dojo 35th Floor (925023500) + // Mu Lung Dojo : Mu Lung Dojo 35th Floor (925023501) + // Mu Lung Dojo : Mu Lung Dojo 35th Floor (925023502) + // Mu Lung Dojo : Mu Lung Dojo 35th Floor (925023503) + // Mu Lung Dojo : Mu Lung Dojo 35th Floor (925023504) + // Mu Lung Dojo : Mu Lung Dojo 35th Floor (925023505) + // Mu Lung Dojo : Mu Lung Dojo 35th Floor (925023506) + // Mu Lung Dojo : Mu Lung Dojo 35th Floor (925023507) + // Mu Lung Dojo : Mu Lung Dojo 35th Floor (925023508) + // Mu Lung Dojo : Mu Lung Dojo 35th Floor (925023509) + // Mu Lung Dojo : Mu Lung Dojo 36th Floor (925023600) + // Mu Lung Dojo : Mu Lung Dojo 36th Floor (925023601) + // Mu Lung Dojo : Mu Lung Dojo 36th Floor (925023602) + // Mu Lung Dojo : Mu Lung Dojo 36th Floor (925023603) + // Mu Lung Dojo : Mu Lung Dojo 36th Floor (925023604) + // Mu Lung Dojo : Mu Lung Dojo 36th Floor (925023605) + // Mu Lung Dojo : Mu Lung Dojo 36th Floor (925023606) + // Mu Lung Dojo : Mu Lung Dojo 36th Floor (925023607) + // Mu Lung Dojo : Mu Lung Dojo 36th Floor (925023608) + // Mu Lung Dojo : Mu Lung Dojo 36th Floor (925023609) + // Mu Lung Dojo : Mu Lung Dojo 37th Floor (925023700) + // Mu Lung Dojo : Mu Lung Dojo 37th Floor (925023701) + // Mu Lung Dojo : Mu Lung Dojo 37th Floor (925023702) + // Mu Lung Dojo : Mu Lung Dojo 37th Floor (925023703) + // Mu Lung Dojo : Mu Lung Dojo 37th Floor (925023704) + // Mu Lung Dojo : Mu Lung Dojo 37th Floor (925023705) + // Mu Lung Dojo : Mu Lung Dojo 37th Floor (925023706) + // Mu Lung Dojo : Mu Lung Dojo 37th Floor (925023707) + // Mu Lung Dojo : Mu Lung Dojo 37th Floor (925023708) + // Mu Lung Dojo : Mu Lung Dojo 37th Floor (925023709) + // Mu Lung Dojo : Mu Lung Dojo 38th Floor (925023800) + // Mu Lung Dojo : Mu Lung Dojo 38th Floor (925023801) + // Mu Lung Dojo : Mu Lung Dojo 38th Floor (925023802) + // Mu Lung Dojo : Mu Lung Dojo 38th Floor (925023803) + // Mu Lung Dojo : Mu Lung Dojo 38th Floor (925023804) + // Mu Lung Dojo : Mu Lung Dojo 38th Floor (925023805) + // Mu Lung Dojo : Mu Lung Dojo 38th Floor (925023806) + // Mu Lung Dojo : Mu Lung Dojo 38th Floor (925023807) + // Mu Lung Dojo : Mu Lung Dojo 38th Floor (925023808) + // Mu Lung Dojo : Mu Lung Dojo 38th Floor (925023809) + // Mu Lung Dojo : Mu Lung Dojo 1st Floor (925030100) + // Mu Lung Dojo : Mu Lung Dojo 1st Floor (925030101) + // Mu Lung Dojo : Mu Lung Dojo 1st Floor (925030102) + // Mu Lung Dojo : Mu Lung Dojo 1st Floor (925030103) + // Mu Lung Dojo : Mu Lung Dojo 1st Floor (925030104) + // Mu Lung Dojo : Mu Lung Dojo 2nd Floor (925030200) + // Mu Lung Dojo : Mu Lung Dojo 2nd Floor (925030201) + // Mu Lung Dojo : Mu Lung Dojo 2nd Floor (925030202) + // Mu Lung Dojo : Mu Lung Dojo 2nd Floor (925030203) + // Mu Lung Dojo : Mu Lung Dojo 2nd Floor (925030204) + // Mu Lung Dojo : Mu Lung Dojo 3rd Floor (925030300) + // Mu Lung Dojo : Mu Lung Dojo 3rd Floor (925030301) + // Mu Lung Dojo : Mu Lung Dojo 3rd Floor (925030302) + // Mu Lung Dojo : Mu Lung Dojo 3rd Floor (925030303) + // Mu Lung Dojo : Mu Lung Dojo 3rd Floor (925030304) + // Mu Lung Dojo : Mu Lung Dojo 4th Floor (925030400) + // Mu Lung Dojo : Mu Lung Dojo 4th Floor (925030401) + // Mu Lung Dojo : Mu Lung Dojo 4th Floor (925030402) + // Mu Lung Dojo : Mu Lung Dojo 4th Floor (925030403) + // Mu Lung Dojo : Mu Lung Dojo 4th Floor (925030404) + // Mu Lung Dojo : Mu Lung Dojo 5th Floor (925030500) + // Mu Lung Dojo : Mu Lung Dojo 5th Floor (925030501) + // Mu Lung Dojo : Mu Lung Dojo 5th Floor (925030502) + // Mu Lung Dojo : Mu Lung Dojo 5th Floor (925030503) + // Mu Lung Dojo : Mu Lung Dojo 5th Floor (925030504) + // Mu Lung Dojo : Mu Lung Dojo 6th Floor (925030600) + // Mu Lung Dojo : Mu Lung Dojo 6th Floor (925030601) + // Mu Lung Dojo : Mu Lung Dojo 6th Floor (925030602) + // Mu Lung Dojo : Mu Lung Dojo 6th Floor (925030603) + // Mu Lung Dojo : Mu Lung Dojo 6th Floor (925030604) + // Mu Lung Dojo : Mu Lung Dojo 7th Floor (925030700) + // Mu Lung Dojo : Mu Lung Dojo 7th Floor (925030701) + // Mu Lung Dojo : Mu Lung Dojo 7th Floor (925030702) + // Mu Lung Dojo : Mu Lung Dojo 7th Floor (925030703) + // Mu Lung Dojo : Mu Lung Dojo 7th Floor (925030704) + // Mu Lung Dojo : Mu Lung Dojo 8th Floor (925030800) + // Mu Lung Dojo : Mu Lung Dojo 8th Floor (925030801) + // Mu Lung Dojo : Mu Lung Dojo 8th Floor (925030802) + // Mu Lung Dojo : Mu Lung Dojo 8th Floor (925030803) + // Mu Lung Dojo : Mu Lung Dojo 8th Floor (925030804) + // Mu Lung Dojo : Mu Lung Dojo 9th Floor (925030900) + // Mu Lung Dojo : Mu Lung Dojo 9th Floor (925030901) + // Mu Lung Dojo : Mu Lung Dojo 9th Floor (925030902) + // Mu Lung Dojo : Mu Lung Dojo 9th Floor (925030903) + // Mu Lung Dojo : Mu Lung Dojo 9th Floor (925030904) + // Mu Lung Dojo : Mu Lung Dojo 10th Floor (925031000) + // Mu Lung Dojo : Mu Lung Dojo 10th Floor (925031001) + // Mu Lung Dojo : Mu Lung Dojo 10th Floor (925031002) + // Mu Lung Dojo : Mu Lung Dojo 10th Floor (925031003) + // Mu Lung Dojo : Mu Lung Dojo 10th Floor (925031004) + // Mu Lung Dojo : Mu Lung Dojo 11th Floor (925031100) + // Mu Lung Dojo : Mu Lung Dojo 11th Floor (925031101) + // Mu Lung Dojo : Mu Lung Dojo 11th Floor (925031102) + // Mu Lung Dojo : Mu Lung Dojo 11th Floor (925031103) + // Mu Lung Dojo : Mu Lung Dojo 11th Floor (925031104) + // Mu Lung Dojo : Mu Lung Dojo 12th Floor (925031200) + // Mu Lung Dojo : Mu Lung Dojo 12th Floor (925031201) + // Mu Lung Dojo : Mu Lung Dojo 12th Floor (925031202) + // Mu Lung Dojo : Mu Lung Dojo 12th Floor (925031203) + // Mu Lung Dojo : Mu Lung Dojo 12th Floor (925031204) + // Mu Lung Dojo : Mu Lung Dojo 13th Floor (925031300) + // Mu Lung Dojo : Mu Lung Dojo 13th Floor (925031301) + // Mu Lung Dojo : Mu Lung Dojo 13th Floor (925031302) + // Mu Lung Dojo : Mu Lung Dojo 13th Floor (925031303) + // Mu Lung Dojo : Mu Lung Dojo 13th Floor (925031304) + // Mu Lung Dojo : Mu Lung Dojo 14th Floor (925031400) + // Mu Lung Dojo : Mu Lung Dojo 14th Floor (925031401) + // Mu Lung Dojo : Mu Lung Dojo 14th Floor (925031402) + // Mu Lung Dojo : Mu Lung Dojo 14th Floor (925031403) + // Mu Lung Dojo : Mu Lung Dojo 14th Floor (925031404) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925031500) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925031501) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925031502) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925031503) + // Mu Lung Dojo : Mu Lung Dojo 15th Floor (925031504) + // Mu Lung Dojo : Mu Lung Dojo 16th Floor (925031600) + // Mu Lung Dojo : Mu Lung Dojo 16th Floor (925031601) + // Mu Lung Dojo : Mu Lung Dojo 16th Floor (925031602) + // Mu Lung Dojo : Mu Lung Dojo 16th Floor (925031603) + // Mu Lung Dojo : Mu Lung Dojo 16th Floor (925031604) + // Mu Lung Dojo : Mu Lung Dojo 17th Floor (925031700) + // Mu Lung Dojo : Mu Lung Dojo 17th Floor (925031701) + // Mu Lung Dojo : Mu Lung Dojo 17th Floor (925031702) + // Mu Lung Dojo : Mu Lung Dojo 17th Floor (925031703) + // Mu Lung Dojo : Mu Lung Dojo 17th Floor (925031704) + // Mu Lung Dojo : Mu Lung Dojo 18th Floor (925031800) + // Mu Lung Dojo : Mu Lung Dojo 18th Floor (925031801) + // Mu Lung Dojo : Mu Lung Dojo 18th Floor (925031802) + // Mu Lung Dojo : Mu Lung Dojo 18th Floor (925031803) + // Mu Lung Dojo : Mu Lung Dojo 18th Floor (925031804) + // Mu Lung Dojo : Mu Lung Dojo 19th Floor (925031900) + // Mu Lung Dojo : Mu Lung Dojo 19th Floor (925031901) + // Mu Lung Dojo : Mu Lung Dojo 19th Floor (925031902) + // Mu Lung Dojo : Mu Lung Dojo 19th Floor (925031903) + // Mu Lung Dojo : Mu Lung Dojo 19th Floor (925031904) + // Mu Lung Dojo : Mu Lung Dojo 20th Floor (925032000) + // Mu Lung Dojo : Mu Lung Dojo 20th Floor (925032001) + // Mu Lung Dojo : Mu Lung Dojo 20th Floor (925032002) + // Mu Lung Dojo : Mu Lung Dojo 20th Floor (925032003) + // Mu Lung Dojo : Mu Lung Dojo 20th Floor (925032004) + // Mu Lung Dojo : Mu Lung Dojo 21st Floor (925032100) + // Mu Lung Dojo : Mu Lung Dojo 21st Floor (925032101) + // Mu Lung Dojo : Mu Lung Dojo 21st Floor (925032102) + // Mu Lung Dojo : Mu Lung Dojo 21st Floor (925032103) + // Mu Lung Dojo : Mu Lung Dojo 21st Floor (925032104) + // Mu Lung Dojo : Mu Lung Dojo 22nd Floor (925032200) + // Mu Lung Dojo : Mu Lung Dojo 22nd Floor (925032201) + // Mu Lung Dojo : Mu Lung Dojo 22nd Floor (925032202) + // Mu Lung Dojo : Mu Lung Dojo 22nd Floor (925032203) + // Mu Lung Dojo : Mu Lung Dojo 22nd Floor (925032204) + // Mu Lung Dojo : Mu Lung Dojo 23rd Floor (925032300) + // Mu Lung Dojo : Mu Lung Dojo 23rd Floor (925032301) + // Mu Lung Dojo : Mu Lung Dojo 23rd Floor (925032302) + // Mu Lung Dojo : Mu Lung Dojo 23rd Floor (925032303) + // Mu Lung Dojo : Mu Lung Dojo 23rd Floor (925032304) + // Mu Lung Dojo : Mu Lung Dojo 24th Floor (925032400) + // Mu Lung Dojo : Mu Lung Dojo 24th Floor (925032401) + // Mu Lung Dojo : Mu Lung Dojo 24th Floor (925032402) + // Mu Lung Dojo : Mu Lung Dojo 24th Floor (925032403) + // Mu Lung Dojo : Mu Lung Dojo 24th Floor (925032404) + // Mu Lung Dojo : Mu Lung Dojo 25th Floor (925032500) + // Mu Lung Dojo : Mu Lung Dojo 25th Floor (925032501) + // Mu Lung Dojo : Mu Lung Dojo 25th Floor (925032502) + // Mu Lung Dojo : Mu Lung Dojo 25th Floor (925032503) + // Mu Lung Dojo : Mu Lung Dojo 25th Floor (925032504) + // Mu Lung Dojo : Mu Lung Dojo 26th Floor (925032600) + // Mu Lung Dojo : Mu Lung Dojo 26th Floor (925032601) + // Mu Lung Dojo : Mu Lung Dojo 26th Floor (925032602) + // Mu Lung Dojo : Mu Lung Dojo 26th Floor (925032603) + // Mu Lung Dojo : Mu Lung Dojo 26th Floor (925032604) + // Mu Lung Dojo : Mu Lung Dojo 27th Floor (925032700) + // Mu Lung Dojo : Mu Lung Dojo 27th Floor (925032701) + // Mu Lung Dojo : Mu Lung Dojo 27th Floor (925032702) + // Mu Lung Dojo : Mu Lung Dojo 27th Floor (925032703) + // Mu Lung Dojo : Mu Lung Dojo 27th Floor (925032704) + // Mu Lung Dojo : Mu Lung Dojo 28th Floor (925032800) + // Mu Lung Dojo : Mu Lung Dojo 28th Floor (925032801) + // Mu Lung Dojo : Mu Lung Dojo 28th Floor (925032802) + // Mu Lung Dojo : Mu Lung Dojo 28th Floor (925032803) + // Mu Lung Dojo : Mu Lung Dojo 28th Floor (925032804) + // Mu Lung Dojo : Mu Lung Dojo 29th Floor (925032900) + // Mu Lung Dojo : Mu Lung Dojo 29th Floor (925032901) + // Mu Lung Dojo : Mu Lung Dojo 29th Floor (925032902) + // Mu Lung Dojo : Mu Lung Dojo 29th Floor (925032903) + // Mu Lung Dojo : Mu Lung Dojo 29th Floor (925032904) + // Mu Lung Dojo : Mu Lung Dojo 30th Floor (925033000) + // Mu Lung Dojo : Mu Lung Dojo 30th Floor (925033001) + // Mu Lung Dojo : Mu Lung Dojo 30th Floor (925033002) + // Mu Lung Dojo : Mu Lung Dojo 30th Floor (925033003) + // Mu Lung Dojo : Mu Lung Dojo 30th Floor (925033004) + // Mu Lung Dojo : Mu Lung Dojo 31st Floor (925033100) + // Mu Lung Dojo : Mu Lung Dojo 31st Floor (925033101) + // Mu Lung Dojo : Mu Lung Dojo 31st Floor (925033102) + // Mu Lung Dojo : Mu Lung Dojo 31st Floor (925033103) + // Mu Lung Dojo : Mu Lung Dojo 31st Floor (925033104) + // Mu Lung Dojo : Mu Lung Dojo 32nd Floor (925033200) + // Mu Lung Dojo : Mu Lung Dojo 32nd Floor (925033201) + // Mu Lung Dojo : Mu Lung Dojo 32nd Floor (925033202) + // Mu Lung Dojo : Mu Lung Dojo 32nd Floor (925033203) + // Mu Lung Dojo : Mu Lung Dojo 32nd Floor (925033204) + // Mu Lung Dojo : Mu Lung Dojo 33rd Floor (925033300) + // Mu Lung Dojo : Mu Lung Dojo 33rd Floor (925033301) + // Mu Lung Dojo : Mu Lung Dojo 33rd Floor (925033302) + // Mu Lung Dojo : Mu Lung Dojo 33rd Floor (925033303) + // Mu Lung Dojo : Mu Lung Dojo 33rd Floor (925033304) + // Mu Lung Dojo : Mu Lung Dojo 34th Floor (925033400) + // Mu Lung Dojo : Mu Lung Dojo 34th Floor (925033401) + // Mu Lung Dojo : Mu Lung Dojo 34th Floor (925033402) + // Mu Lung Dojo : Mu Lung Dojo 34th Floor (925033403) + // Mu Lung Dojo : Mu Lung Dojo 34th Floor (925033404) + // Mu Lung Dojo : Mu Lung Dojo 35th Floor (925033500) + // Mu Lung Dojo : Mu Lung Dojo 35th Floor (925033501) + // Mu Lung Dojo : Mu Lung Dojo 35th Floor (925033502) + // Mu Lung Dojo : Mu Lung Dojo 35th Floor (925033503) + // Mu Lung Dojo : Mu Lung Dojo 35th Floor (925033504) + // Mu Lung Dojo : Mu Lung Dojo 36th Floor (925033600) + // Mu Lung Dojo : Mu Lung Dojo 36th Floor (925033601) + // Mu Lung Dojo : Mu Lung Dojo 36th Floor (925033602) + // Mu Lung Dojo : Mu Lung Dojo 36th Floor (925033603) + // Mu Lung Dojo : Mu Lung Dojo 36th Floor (925033604) + // Mu Lung Dojo : Mu Lung Dojo 37th Floor (925033700) + // Mu Lung Dojo : Mu Lung Dojo 37th Floor (925033701) + // Mu Lung Dojo : Mu Lung Dojo 37th Floor (925033702) + // Mu Lung Dojo : Mu Lung Dojo 37th Floor (925033703) + // Mu Lung Dojo : Mu Lung Dojo 37th Floor (925033704) + // Mu Lung Dojo : Mu Lung Dojo 38th Floor (925033800) + // Mu Lung Dojo : Mu Lung Dojo 38th Floor (925033801) + // Mu Lung Dojo : Mu Lung Dojo 38th Floor (925033802) + // Mu Lung Dojo : Mu Lung Dojo 38th Floor (925033803) + // Mu Lung Dojo : Mu Lung Dojo 38th Floor (925033804) + if (sm.getLevel() < DOJO_MIN_LEVEL) { + sm.sayOk("Hey! Are you mocking my master? Who do you think you are to challenge him? This is a joke! You should at least be level #b25#k."); + return; + } + + if (sm.getFieldId() == DOJO_ENTRANCE_MAP) { + final int answer = sm.askMenu("My master is the strongest person in Mu Lung, and you want to challenge him? Fine, but you'll regret it later.", Map.of( + 0, "#bI want to challenge him alone.", + 1, "I want to challenge him with a party.\r\n\r\n", + 2, "I want to receive a belt.", + 3, "I want to reset my training points.", + 4, "I want to receive a medal.", + 5, "What is a Mu Lung Dojo#k" + )); + + switch (answer) { + case 0 -> { + if (!sm.getQRValue(QuestRecordType.MuLungDojoTutorial).equals("1")) { + if (sm.askYesNo("Hey there! You! This is your first time, huh? Well, my master doesn't just meet with anyone. He's a busy man. And judging by your looks, I don't think he'd bother. Ha! But, today's your lucky day... I tell you what, if you can defeat me, I'll allow you to see my Master. So what do you say?")) { + System.out.println("test"); + } else { + sm.sayOk("Haha! Who are you trying to impress with a heart like that? Go back home where you belong!"); + } + } + } + case 5 -> { + sm.sayOk("Our master is the strongest person in Mu Lung. The place he built is called the Mu Lung Dojo, a building that is #r38 stories#k tall! You can train yourself as you go up each level. Of course, it'll be hard for someone at your level to reach the top."); + } + } + } else { + if (sm.askYesNo("What, you're giving up? You just need to get to the next level! Do you really want to quit and leave?")) { + + } + } + } + + @Script("dojang_Msg") + public static void dojang_Msg(ScriptManager sm) { + // Mu Lung Dojo : Mu Lung Dojo Entrance (925020000) + // Mu Lung Dojo : So Gong's Room (925020010) + // Mu Lung Dojo : So Gong's Room (925020011) + // Mu Lung Dojo : So Gong's Room (925020012) + // Mu Lung Dojo : So Gong's Room (925020013) + // Mu Lung Dojo : So Gong's Room (925020014) + List messages = List.of("Your courage for challenging the Mu Lung Dojo is commendable!", "If you want to taste the bitterness of defeat, come on in!", "I will make you thoroughly regret challenging the Mu Lung Dojo! Hurry up!"); + if (sm.getFieldId() == 925020000) { + sm.getField().blowWeather(5120024, messages.get((int) (Math.random() * messages.size())), 20); + sm.getUser().resetDojoEnergy(); + } else { + sm.getField().blowWeather(5120024, "Ha! Let's see what you got! I won't let you leave unless you defeat me first!", 20); + } + } +} diff --git a/src/main/java/kinoko/script/party/OrbisPQ.java b/src/main/java/kinoko/script/party/OrbisPQ.java new file mode 100644 index 00000000..26573064 --- /dev/null +++ b/src/main/java/kinoko/script/party/OrbisPQ.java @@ -0,0 +1,273 @@ +package kinoko.script.party; + +import kinoko.packet.field.FieldPacket; +import kinoko.script.UnityPortal; +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.util.Util; +import kinoko.world.field.Field; +import kinoko.world.field.mob.MobAppearType; +import kinoko.world.quest.QuestRecordType; + +import java.awt.*; +import java.util.List; +import java.util.Map; + +public final class OrbisPQ extends ScriptHandler { + // Map constants + public static final int LOBBY = 200080101; + public static final int EXIT = 920011200; + public static final int CLEAR_EXIT = 920011300; + public static final int STAGE_1 = 920010000; + public static final int STAGE_2 = 920010100; + public static final int STAGE_3 = 920010200; + public static final int STAGE_4 = 920010300; + public static final int STAGE_5 = 920010400; + public static final int STAGE_6 = 920010500; + public static final int STAGE_7 = 920010600; + public static final int STAGE_8 = 920010700; + public static final int STAGE_9 = 920010800; + public static final int BONUS_1 = 920010900; + public static final int BONUS_2 = 920010910; + public static final int BONUS_3 = 920010920; + public static final int BONUS_4 = 920010930; + public static final int FINAL_STAGE = 920011000; + public static final int REWARD = 920011100; + + // Mob constants + public static final int SEALED_CHEST = 9300049; + public static final int PAPA_PIXIE = 9300039; + public static final int RED_CELLION = 9300040; + public static final int KING_SLIME = 9300010; + + // Item constants + public static final int ORBIS_PQ_BOX = 2430066; + public static final int ORBIS_PQ_REWARD = 4001043; // Star Rock + + // Buff items (given on entry) + public static final int[] ENTRY_BUFFS = {2022090, 2022091, 2022092, 2022093}; + + @Script("orbisPQ_enter") + public static void orbisPQ_enter(ScriptManager sm) { + // Icarus (9020005) + // Orbis : Orbis Tower <20th Floor> (200080101) + // Hidden Street : Sealed Garden (920011200) + if (sm.getFieldId() == LOBBY) { + final int answer = sm.askMenu("#e#n\r\nI am Icarus, the steward of the goddess. The goddess has been sealed away in the tower. Will you help us break the seal?", Map.of( + 0, "I want to do the Party Quest", + 1, "I want to hear the details" + )); + if (answer == 0) { + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("If you'd like to enter here, the leader of your party will have to talk to me. Talk to your party leader about this."); + return; + } + if (!sm.checkParty(3, 20)) { + sm.sayOk("You cannot enter because your party requirements are not met. You need 3-6 party members at Lv. 20+ to enter, so double-check and talk to me again."); + return; + } + sm.partyWarpInstance(List.of( + STAGE_1, STAGE_2, STAGE_3, STAGE_4, STAGE_5, + STAGE_6, STAGE_7, STAGE_8, STAGE_9, + BONUS_1, BONUS_2, BONUS_3, BONUS_4, + FINAL_STAGE, REWARD + ), "sp", EXIT, 20 * 60); + } else if (answer == 1) { + sm.sayOk("#e#n\r\nThe goddess Minerva has been sealed in the Tower of Goddess. Work together with your party to solve puzzles, defeat monsters, and break the seal! Defeat all the monsters in each stage to proceed.\r\n\r\n#e - Level:#n 20-30 #r(Recommended Level: 20-30)#k\r\n#e - Time Limit:#n 20 min.\r\n#e - Players:#n 3 - 6\r\n#e - Reward:#n #v2430066# #t2430066#"); + } + } else if (sm.getFieldId() == EXIT) { + // Exit map + if (sm.askYesNo("Would you like to return to the lobby?")) { + sm.warp(LOBBY, "sp"); + } + } + } + + @Script("orbisPQ_mapEnter") + public static void orbisPQ_mapEnter(ScriptManager sm) { + // Hidden Street : Sealed Garden Stage 1-9 + final Field field = sm.getField(); + final int mapId = field.getFieldId(); + + // Apply entry buff to player + if (mapId == STAGE_1) { + // TODO: Apply random buff from ENTRY_BUFFS array on entry + field.blowWeather(5120019, "Hi, I am the steward of the goddess. I have been sealed, so you cannot see me now. Can you help me unseal it?", 20); + } + } + + @Script("orbisPQ_stage1") + public static void orbisPQ_stage1(ScriptManager sm) { + // First stage - defeat all Jr. Neckis and Neckis + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + if (sm.getField().getMobPool().isEmpty()) { + sm.sayOk("Excellent! You've cleared this stage. You may now proceed to the next area."); + sm.addExpAll(200); + } else { + sm.sayOk("Defeat all the monsters in this area to proceed!"); + } + } + + @Script("orbisPQ_stage2") + public static void orbisPQ_stage2(ScriptManager sm) { + // Stage 2 - Minerva puzzle + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + final String intro = sm.getInstanceVariable("stage2_intro"); + if (intro.isEmpty()) { + sm.setInstanceVariable("stage2_intro", "1"); + sm.sayOk("Welcome to Stage 2! Find the correct combination to unlock the seal. Work together with your party!"); + return; + } + + // Check if stage cleared + if (sm.getInstanceVariable("stage2_clear").equals("1")) { + sm.sayOk("You've already cleared this stage. Proceed to the next area!"); + return; + } + + // Add stage 2 logic here based on requirements + sm.sayOk("Work together to solve this puzzle!"); + } + + @Script("orbisPQ_stage4") + public static void orbisPQ_stage4(ScriptManager sm) { + // Stage 4 - Red Cellion spawning stage + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + final String intro = sm.getInstanceVariable("stage4_intro"); + if (intro.isEmpty()) { + sm.setInstanceVariable("stage4_intro", "1"); + sm.sayOk("Welcome to Stage 4! Defeat all the Red Cellions that appear. They will keep spawning until you've defeated 15 of them!"); + sm.setInstanceVariable("stage4_count", "0"); + return; + } + + final String count = sm.getInstanceVariable("stage4_count"); + final int cellionCount = count.isEmpty() ? 0 : Integer.parseInt(count); + + if (cellionCount >= 14) { + sm.sayOk("Excellent work! You've defeated all the Red Cellions. Proceed to the next stage!"); + sm.addExpAll(300); + } else { + sm.sayOk(String.format("You've defeated %d/15 Red Cellions. Keep fighting!", cellionCount + 1)); + } + } + + @Script("orbisPQ_stage9") + public static void orbisPQ_stage9(ScriptManager sm) { + // Stage 9 - Papa Pixie boss + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + final String intro = sm.getInstanceVariable("stage9_intro"); + if (intro.isEmpty()) { + sm.setInstanceVariable("stage9_intro", "1"); + sm.sayOk("This is the final combat stage! Defeat all the Sealed Chests to summon Papa Pixie, then defeat Papa Pixie to obtain the Life Grass!"); + return; + } + + // Check if Papa Pixie is dead + if (sm.getInstanceVariable("papa_dead").equals("1")) { + sm.sayOk("Excellent! You've defeated Papa Pixie! Collect the Life Grass and proceed to save the goddess!"); + } else { + sm.sayOk("Defeat the Sealed Chests to summon Papa Pixie!"); + } + } + + @Script("orbisPQ_sealedChest_dead") + public static void orbisPQ_sealedChest_dead(ScriptManager sm) { + // When Sealed Chest (9300049) dies, spawn Papa Pixie + final Field field = sm.getField(); + if (field.getFieldId() == STAGE_9) { + sm.broadcastMessage("Papa Pixie has been spawned."); + sm.spawnMob(PAPA_PIXIE, MobAppearType.NORMAL, -830, 563, false); + } + } + + @Script("orbisPQ_papaPixie_dead") + public static void orbisPQ_papaPixie_dead(ScriptManager sm) { + // When Papa Pixie (9300039) dies + final Field field = sm.getField(); + if (field.getFieldId() == STAGE_9) { + sm.setInstanceVariable("papa_dead", "1"); + sm.broadcastMessage("Please bring the Life Grass and go save the goddess as soon as possible."); + sm.broadcastScreenEffect("quest/party/clear"); + sm.broadcastSoundEffect("Party1/Clear"); + } + } + + @Script("orbisPQ_redCellion_dead") + public static void orbisPQ_redCellion_dead(ScriptManager sm) { + // When Red Cellion (9300040) dies, spawn another one + final Field field = sm.getField(); + if (field.getFieldId() == STAGE_4) { + final String count = sm.getInstanceVariable("stage4_count"); + final int cellionCount = count.isEmpty() ? 0 : Integer.parseInt(count); + + if (cellionCount < 14) { + sm.setInstanceVariable("stage4_count", String.valueOf(cellionCount + 1)); + // Spawn another Red Cellion at random reactor position + sm.spawnMob(RED_CELLION, MobAppearType.NORMAL, 0, 0, false); + sm.broadcastMessage("Cellion has been spawned somewhere in the map."); + } + } + } + + @Script("orbisPQ_exit") + public static void orbisPQ_exit(ScriptManager sm) { + // Nella (9020002) or similar exit NPC + final int mapId = sm.getFieldId(); + + if (mapId == EXIT || mapId == CLEAR_EXIT) { + if (sm.askYesNo("Would you like to return to the lobby?")) { + // TODO: Remove entry buffs on exit + sm.warp(LOBBY, "sp"); + } + return; + } + + // Regular stage exit + if (!sm.askYesNo("Are you sure you want to leave this Party Quest? You will forfeit all progress.")) { + return; + } + + // TODO: Remove entry buffs on exit + sm.warp(EXIT, "sp"); + } + + @Script("orbisPQ_final") + public static void orbisPQ_final(ScriptManager sm) { + // Final NPC - Minerva or similar + if (!sm.getUser().isPartyBoss()) { + sm.sayOk("Please have your party leader talk to me."); + return; + } + + // Check reactor state + final String reactorState = sm.getInstanceVariable("minerva_state"); + final boolean cleared = reactorState.equals("5"); + + if (cleared) { + sm.sayOk("Thank you for freeing me! You may now claim your rewards."); + sm.addExpAll(800); + sm.setInstanceVariable("final_clear", "1"); + sm.partyWarp(CLEAR_EXIT, "sp"); + } else { + sm.sayOk("Please complete the final puzzle to free me from the seal!"); + } + } +} diff --git a/src/main/java/kinoko/script/quest/AndroidQuest.java b/src/main/java/kinoko/script/quest/AndroidQuest.java new file mode 100644 index 00000000..abf7a112 --- /dev/null +++ b/src/main/java/kinoko/script/quest/AndroidQuest.java @@ -0,0 +1,30 @@ +package kinoko.script.quest; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +/** + * Android and Special Event Quest Scripts + * Contains android-related and special event join scripts + */ +public final class AndroidQuest extends ScriptHandler { + + @Script("android_GL") + public static void android_GL(ScriptManager sm) { + // Android GL Quest + sm.sayOk("Android GL quest is not yet implemented."); + } + + @Script("eDay_join") + public static void eDay_join(ScriptManager sm) { + // eDay Join Event + sm.sayOk("eDay join event is not yet implemented."); + } + + @Script("eDay_join2") + public static void eDay_join2(ScriptManager sm) { + // eDay Join Event 2 + sm.sayOk("eDay join 2 event is not yet implemented."); + } +} diff --git a/src/main/java/kinoko/script/quest/AranQuest.java b/src/main/java/kinoko/script/quest/AranQuest.java index 8dcf4804..0de9f38c 100644 --- a/src/main/java/kinoko/script/quest/AranQuest.java +++ b/src/main/java/kinoko/script/quest/AranQuest.java @@ -5,10 +5,32 @@ import kinoko.script.common.ScriptHandler; import kinoko.script.common.ScriptManager; import kinoko.script.common.ScriptMessageParam; +import kinoko.world.field.mob.MobAppearType; import kinoko.world.job.Job; import kinoko.world.quest.QuestRecordType; +/** + * Aran Quest System - Post-Tutorial Implementation + * Area 6 - Aran Quests (21100-21767) + * + * NOTE: Tutorial quests (21000-21018) are in AranTutorial.java + * + * Quest Breakdown: + * - Initial Hero Quests: 21100-21101 (2 quests) + * - Job Advancement Quests: + * - 2nd Job (Lv 30): 21200-21202 (3 quests) + * - 3rd Job (Lv 70): 21300-21303 (4 quests) + * - 4th Job (Lv 120): 21400-21401 (2 quests) + * - Hero's Echo (Lv 200): 21500 (1 quest) + * - Training Quests: 21700-21767 (68 quests) + * - Main Storyline: 21600-21618 (19 quests) + * + * Total: 180 script handlers for 90 quests + */ public final class AranQuest extends ScriptHandler { + + // PORTAL SCRIPTS ------------------------------------------------------------------------------------------------------------ + @Script("rien") public static void rien(ScriptManager sm) { // Snow Island : Rien (140000000) @@ -38,10 +60,16 @@ public static void enterGym(ScriptManager sm) { @Script("enterPort") public static void enterPort(ScriptManager sm) { - // Snow Island : Snow-covered Field 3 (140020200) + // Snow Island : Snow-covered Field 3 (140020200) -> Penguin Port (140020300) // east00 (4769, 84) sm.playPortalSE(); sm.warp(140020300, "west00"); + + // Quest 21301: Catch that Thief! - Spawn Thief Crow in Penguin Port + // Use originalField=false to spawn in user's current field (after warp) + if (sm.hasQuestStarted(21301)) { + sm.spawnMob(9001013, MobAppearType.NORMAL.getValue(), 2407, 3, true, false); + } } @Script("enterInfo") @@ -52,6 +80,13 @@ public static void enterInfo(ScriptManager sm) { sm.warp(104000004, "out00"); } + + // NOTE: Tutorial quests (21000-21018) are handled in AranTutorial.java + // Do not duplicate them here to avoid script registration conflicts + + + // INITIAL HERO QUESTS (21100-21101) ------------------------------------------------------------------------------------------------------------ + @Script("q21100s") public static void q21100s(ScriptManager sm) { // The Five Heroes (21100 - start) @@ -73,21 +108,317 @@ public static void q21100s(ScriptManager sm) { @Script("q21101s") public static void q21101s(ScriptManager sm) { // The Polearm-Wielding Hero (21101 - start) + sm.sayNext("#b(You touch the #p1201001#. The polearm is supposed to be ice cold, but it feels so…warm. Makes you feel like your old memories are starting to return.)#k"); + sm.sayBoth("#b(…the hero that wielded the polearm was also the master of melee combat, with amazing strength and stamina...)#k"); + sm.sayBoth("#b(…the hero had high levels of STR but also some DEX, which meant the hero moved with agility...)#k"); + sm.sayBoth("#b(Is this from your memories or the memories of a fellow hero…? In order to find out for sure, you have to touch the #p1201001# one more time.)#k"); if (!sm.askYesNo("#b(Are you certain that you were the hero that wielded the #p1201001#? Yes, you're sure. You better grab the #p1201001# really tightly. Surely it will react to you.)#k")) { sm.sayNext("#b(You need to think about this for a second...)#k"); return; } + sm.forceStartQuest(21101); + } + + @Script("q21101e") + public static void q21101e(ScriptManager sm) { + // The Polearm-Wielding Hero (21101 - end) if (!sm.addItem(1142129, 1)) { sm.sayNext("Please check if your inventory is full or not."); return; } sm.setJob(Job.ARAN_1); + sm.addSkill(21001003, 0, 20); // Polearm Booster (1st job skill) + sm.addExp(500); sm.forceCompleteQuest(21101); sm.sayNext("#b(You might be starting to remember something...)#k", ScriptMessageParam.NOT_CANCELLABLE, ScriptMessageParam.PLAYER_AS_SPEAKER); sm.setDirectionMode(true, 0); sm.warp(914090100); } + + // 2ND JOB ADVANCEMENT (Level 30) - Quest 21200-21202 ------------------------------------------------------------------------------------------------------------ + + @Script("q21200s") + public static void q21200s(ScriptManager sm) { + // In Search of Its Rightful Owner (21200 - start) + // Level 30, 2100 → 2110 + sm.sayNext("How is training going? Wow, you've reached such a high level! That's amazing. I knew you would do just fine on Victoria Island... Oh, look at me. I'm wasting your time. I know you're busy, but you'll have to return to the island for a bit."); + if (!sm.askYesNo("Your #b#p1201001##k in #b#m140000000##k is acting strange all of a sudden. According to the records, the Polearm acts this way when it is calling for its master. #bPerhaps it's calling for you.#k Please return to the island and check things out.")) { + sm.sayNext("Did you know that you can use even more powerful skills if you undergo job advancement when you've reached Lv.30? Don't save your SP, though, because you can't apply the to your 2nd job skills. Well, it doesn't necessarily mean that #p1201001# will allow job advancement, but you should still keep that in mind."); + return; + } + sm.forceStartQuest(21200); + sm.sayOk("Anyway, I thought it was really something that a weapon had its own identity, but this weapon gets extremely annoying. It cries, saying that I'm not paying attention to its needs, and now... Oh, please keep this a secret from the Polearm. I don't think it's a good idea to upset the weapon any more than I already have."); + } + + @Script("q21200e") + public static void q21200e(ScriptManager sm) { + // In Search of Its Rightful Owner (21200 - end) + // NPC 1201002 - Maha + sm.sayNext("Who are you? What are you doing here?"); + sm.sayBoth("I heard you were looking for me...", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("What? Me? Looking for you? Don't make me laugh. What would I want with a weak little..."); + sm.sayBoth("Wait a minute. That aura, that power. It's starting to come back to you. You're starting to remember who you were! Yes! YES! I knew I wasn't wrong about you!"); + if (!sm.askYesNo("You ARE Aran! The legendary hero! You may have forgotten, but I never did. I've been waiting for you all this time! Now, please. Take me with you. Let me help you become the hero you once were!")) { + sm.sayNext("What? You don't believe me? I am the weapon that helped you fight the Black Mage! I know you've lost your memory, but surely you can feel it! The power within you!"); + return; + } + sm.setJob(Job.ARAN_2); + // Add all Aran 2nd job skills + sm.addSkill(21100001, 0, 20); // Triple Swing + sm.addSkill(21100000, 0, 20); // Polearm Mastery (invisible until Triple Swing 3) + sm.addSkill(21100002, 0, 30); // Final Charge (invisible until Triple Swing 20) + sm.addSkill(21101003, 0, 20); // Body Pressure + sm.addSkill(21100004, 0, 20); // Combo Smash (invisible until Combo Ability 1) + sm.addSkill(21100005, 0, 20); // Combo Drain (invisible until Combo Ability 1) + sm.addExp(500); + sm.forceCompleteQuest(21200); + sm.sayNext("That's it! Now I can feel your power! Let's go, Aran! We have a lot of work to do!"); + } + + @Script("q21201s") + public static void q21201s(ScriptManager sm) { + // The Mirror of Desire (21201 - start) + // This quest is part of the Aran storyline at level 30+ + // Note: Polearm Booster is now given in quest 21101 (1st job advancement) + sm.sayNext("You look into the Mirror of Desire and see glimpses of your past..."); + if (!sm.askAccept("Through the mirror, you remember training with your polearm. Will you accept this memory?")) { + return; + } + sm.forceStartQuest(21201); + sm.warp(140030000); // Warp to Mirror of Desire map + } + + @Script("q21201e") + public static void q21201e(ScriptManager sm) { + // The Mirror of Desire (21201 - end) + sm.sayNext("You were able to faintly encounter your past through the mirror. You saw the time you first got your polearm."); + sm.sayBoth("I'm so relieved! You're starting to remember me! Thanks to your memories returning, you've recovered some of your dormant abilities!"); + sm.addExp(2000); + sm.forceCompleteQuest(21201); + sm.sayOk("Let's continue training together, Aran!"); + } + + @Script("q21202s") + public static void q21202s(ScriptManager sm) { + // Training with Maha (21202 - start) + sm.sayNext("Let's start training together!"); + if (!sm.askAccept("Defeat 100 monsters to prove your strength!")) { + return; + } + sm.forceStartQuest(21202); + sm.sayOk("Show me what you've learned!"); + } + + @Script("q21202e") + public static void q21202e(ScriptManager sm) { + // Training with Maha (21202 - end) + sm.addExp(1000); + sm.forceCompleteQuest(21202); + sm.sayOk("Excellent work! Your strength is returning!"); + } + + + // 3RD JOB ADVANCEMENT (Level 70) - Quest 21300-21303 ------------------------------------------------------------------------------------------------------------ + + @Script("q21300s") + public static void q21300s(ScriptManager sm) { + // A Weapon Never Leaves Its Owner (21300 - start) + // Level 70, 2110 → 2111 + sm.sayNext("How is training going? Hm, Lv. 70? You still have a long way to go, but it's definitely praiseworthy compared to the first time I met you. Continue to train diligently, and I'm sure you'll regain your strength soon!"); + if (!sm.askYesNo("But first, you must head to #b#m140000000##k. Your #b#p1201001##k is acting weird again. I think it has something to tell you. It might be able to restore your abilities, so please hurry.")) { + sm.sayNext("Did you know that you can use even more powerful skills if you undergo job advancement when you've reached Lv.70? Don't save your SP, though, because you can't apply the to your 3rd job skills. Well, it doesn't necessarily mean that #p1201001# will allow job advancement, but you should still keep that in mind."); + return; + } + sm.forceStartQuest(21300); + sm.sayOk("Anyway, I thought it was really something that a weapon had its own identity, but this weapon gets extremely annoying. It cries, saying that I'm not paying attention to its needs, and now... Oh, please keep this a secret from the Polearm. I don't think it's a good idea to upset the weapon any more than I already have."); + } + + @Script("q21300e") + public static void q21300e(ScriptManager sm) { + // A Weapon Never Leaves Its Owner (21300 - end) + sm.sayNext("Where the heck did you go while leaving me alone here? What? Training? Hmmm, you're Level 70! Wow, that's a lot better than last time, when you couldn't even hear my voice! That's amazing...wait, wait, why are we talking about you? That's not why you're here!"); + sm.sayBoth("Someone stole a Red Jade from me! A thief came in the middle of the night and took it while I wasn't paying attention! I need you to get it back!"); + if (!sm.askYesNo("The thief headed toward #b#m140020300##k. Please go there and retrieve my Red Jade!")) { + sm.sayNext("What? You're not going to help me? But I thought we were partners!"); + return; + } + sm.forceStartQuest(21301); + sm.sayOk("Thank you! Please hurry and get my Red Jade back!"); + } + + @Script("q21301e") + public static void q21301e(ScriptManager sm) { + // Recovering the Red Jade (21301 - end) + if (!sm.hasItem(4032339, 1)) { + sm.sayOk("Please find the Red Jade first!"); + return; + } + sm.removeItem(4032339, 1); + sm.setJob(Job.ARAN_3); + // Add all Aran 3rd job skills + sm.addSkill(21110000, 0, 20); // Combo Ability - Critical (Passive) + sm.addSkill(21110003, 0, 30); // Final Toss (requires Triple Swing 20) + sm.addSkill(21111005, 0, 20); // Snow Charge + sm.addSkill(21110006, 0, 20); // Rolling Spin + sm.addSkill(21111001, 0, 20); // Freeze Standing + sm.addSkill(21110004, 0, 30); // Combo Fenrir (requires Combo Smash 10) + sm.addSkill(21110007, 0, 20); // Full Swing - Double Attack (hidden) + sm.addSkill(21110008, 0, 20); // Full Swing - Triple Attack (hidden) + // Note: Skill 21110002 is given via quest 21758, not at job advancement + sm.addExp(1500); + sm.forceCompleteQuest(21301); + sm.sayNext("You got it back! Thank you! Now I feel complete again!"); + sm.sayPrev("As a reward, I'll help you advance to 3rd job! Your power is truly returning!"); + } + + @Script("q21302s") + public static void q21302s(ScriptManager sm) { + // Making Red Jade (21302 - start) + sm.sayNext("What happened this time? Hmmm, so you need to make the polearm's gem yourself? My goodness, that weapon definitely has an attitude, but you can't ignore its pleas either...."); + sm.sayBoth("#t4032312# is probably a gem that has the power to exponentially expand the weapon's powers, which means you'll have to cater to its wishes and make it a #t4032312# as it wishes."); + sm.sayBoth("What? You don't know how? That's easy! This is directly related to you, you have a specific target, and you even have a specific wish. That means you can use the #bMirror of Desire#k! Go ahead!"); + if (!sm.askYesNo("You remember where the Mirror of Desire is, right? Head west, out of town, until you reach a dead end near the ice cliff, inside #m140030000#. There, your task is to find out how to make a #b#t4032312##k, and then make it. The #bMaker Skill#k should come in handy for you.")) { + sm.sayNext("…Eh? What is it? Are there side effects to using the Mirror of Desire? Or do you really not like anything that seems to have to do with the occult? I refuse to put up with whiners!"); + return; + } + sm.forceStartQuest(21302); + } + + @Script("q21302e") + public static void q21302e(ScriptManager sm) { + // Making Red Jade (21302 - end) + if (!sm.hasItem(4032312, 1)) { + sm.sayOk("A master who'd forgotten how to make a #b#t4032312##k…?! I can't believe I am owned by this 'hero.' No reason to dream, no reason to hope… *Sigh*"); + return; + } + sm.removeItem(4032312, 1); + sm.sayNext("You successfully made a #t4032312#. Thankfully, I seem quite satisfied with it."); + sm.sayBoth("Having recovered the powers of the Red Jade, I'll unlock more abilities for you!"); + sm.addExp(3000); + sm.forceCompleteQuest(21302); + sm.sayOk("Continue training, and together we'll become even stronger!"); + } + + @Script("q21303s") + public static void q21303s(ScriptManager sm) { + // Further Training (21303 - start) + sm.sayNext("Your power is growing, but you need more training!"); + if (!sm.askAccept("Defeat 200 monsters to prove you're ready for greater challenges!")) { + return; + } + sm.forceStartQuest(21303); + sm.sayOk("Show me your improved skills!"); + } + + @Script("q21303e") + public static void q21303e(ScriptManager sm) { + // Further Training (21303 - end) + sm.addExp(2500); + sm.forceCompleteQuest(21303); + sm.sayOk("Impressive! You're becoming the hero you once were!"); + } + + + // 4TH JOB ADVANCEMENT (Level 120) - Quest 21400-21401 ------------------------------------------------------------------------------------------------------------ + + @Script("q21400s") + public static void q21400s(ScriptManager sm) { + // Weapon Starts a Fight… with His Owner? (21400 - start) + // Level 120, 2111 → 2112 + sm.sayNext("How is the training going? I know you're busy, but please come to #b#m140000000##k immediately. The #b#p1201002##k has started to act weird again... But it's even weirder now. It's different from before. It's...darker than usual."); + if (!sm.askYesNo("I have a bad feeling about this. Please come back here. I've never seen or heard #p1201002#, but I can sense the suffering it's going through. #bOnly you, the master of #p1201002#, can do something about it#k!")) { + sm.sayNext("I'm not joking! Something is seriously wrong... Something must have happened to #p1201002#!"); + return; + } + sm.forceStartQuest(21400); + sm.sayOk("Please hurry! I'm really worried about #p1201002#!"); + } + + @Script("q21400e") + public static void q21400e(ScriptManager sm) { + // Weapon Starts a Fight… with His Owner? (21400 - end) + sm.sayNext("Ahhh…"); + sm.sayBoth("Aren't you… Aran? What are you doing here? I see… So that cocky little girl brought you here. I warned her not to…"); + sm.forceCompleteQuest(21400); + sm.forceStartQuest(21401); + } + + @Script("q21401s") + public static void q21401s(ScriptManager sm) { + // Taming the Polearm (21401 - start) + sm.sayNext("Why do I look like this, you ask? I don't want to talk about it, but I suppose I can't hide from you since you're my master..."); + sm.sayBoth("While you were trapped inside ice for hundreds of years, I, too, was frozen. It was a long time to be away from you. That's when the seed of darkness was planted in my heart."); + sm.sayBoth("But since you awoke, I thought the darkness had gone away. I thought things would return to the way they were, but I was mistaken."); + sm.sayBoth("Please, Aran. Please stop me from becoming enraged. Only you can control me. It's out of my hands now. Please do whatever it takes to #rstop me from going berserk#k!"); + if (!sm.askYesNo("Will you help me defeat the rage within me?")) { + sm.sayNext("Please... I can't control it much longer!"); + return; + } + // Warp to instance and spawn Enraged Maha + sm.warpInstance(914090200, "sp", 140000000, 300); // 5 minute time limit + sm.spawnMob(9001014, MobAppearType.NORMAL.getValue(), -88, 120, false, false); + } + + @Script("q21401e") + public static void q21401e(ScriptManager sm) { + // Taming the Polearm (21401 - end) + sm.sayNext("*Pant* *Pant* You… you really are Aran…"); + sm.sayBoth("I'm sorry. I don't know what came over me. There was this dark power… it tried to consume me. But your strength… it drove it away."); + sm.sayBoth("You've proven yourself. You are the true hero. As a reward, I will grant you the power of 4th job!"); + sm.setJob(Job.ARAN_4); + // Add all Aran 4th job skills + sm.addSkill(21120002, 0, 30); // Advanced skill (requires 21110002 lv20) + sm.addSkill(21120001, 0, 30); // Advanced Polearm Mastery (Passive) + sm.addSkill(21120005, 0, 30); // Final Blow (requires Triple Swing 20) + sm.addSkill(21121003, 0, 30); // Advanced Freeze Standing + sm.addSkill(21120004, 0, 30); // Hidden passive skill + sm.addSkill(21120006, 0, 30); // Tempest (requires Combo Fenrir 10) + sm.addSkill(21120007, 0, 30); // High Mastery (requires Combo Drain 10) + sm.addSkill(21121008, 0, 5); // Berserk + sm.addSkill(21120009, 0, 30); // Over Swing - Double Attack (hidden) + sm.addSkill(21120010, 0, 30); // Over Swing - Triple Attack (hidden) + sm.addSkill(21121000, 0, 30); // Maple Warrior + sm.addExp(5000); + sm.forceCompleteQuest(21401); + sm.sayOk("Congratulations, Aran! You've reached the pinnacle of power!"); + } + + + // HERO'S ECHO (Level 200) - Quest 21500 ------------------------------------------------------------------------------------------------------------ + + @Script("q21500s") + public static void q21500s(ScriptManager sm) { + // Weapon Acknowledges Its Owner (21500 - start) + // Level 200 - Hero's Echo skill + sm.sayNext("Aran, do you hear me?"); + sm.sayBoth("Finally! Just like the old times, I can talk to you directly. Now that you have the ability to hear me, there's only one more thing you'll need to learn…"); + sm.sayBoth("Just come to #b#m140000000##k first. I'll give you the details once you get here."); + sm.forceStartQuest(21500); + } + + @Script("q21500e") + public static void q21500e(ScriptManager sm) { + // Weapon Acknowledges Its Owner (21500 - end) + sm.sayNext("We spent a few hundred years apart thanks to the Black Mage, and it was hard to recognize you when you first emerged from the ice. You weren't the hero I remembered; instead, you were just another person who couldn't even handle the #p1201001#. Worst of all, you didn't even remember me. Unforgivable."); + sm.sayBoth("But as time went by, you changed. You diligently leveled up, and I saw that you were trying your hardest to remember me. That's when I knew... You may have drastically changed, but you're still Aran. The old you is still inside."); + sm.sayBoth("You may have yet to retrieve all your memories, but your abilities are almost on par with your old self, I'd say. As proof, there's a skill that you can now use, and that's the reason why I brought you here."); + if (!sm.askYesNo("The skill's called #bHero's Echo#k. It's the perfect skill for someone with the role of the protector, which is exactly what you were when you faced the Black Mage. I want you to put that power to good use again, because its perfect for you.")) { + sm.sayNext("You don't want this incredible power? Think about it carefully!"); + return; + } + if (!sm.addItem(1142133, 1)) { + sm.sayNext("Please make room in your inventory first."); + return; + } + sm.addSkill(20001005, 1, 1); // Hero's Echo + sm.forceCompleteQuest(21500); + sm.sayOk("Use this power wisely, hero. The world needs you now more than ever!"); + } + + + // TRAINING QUESTS (21700-21767) ------------------------------------------------------------------------------------------------------------ + @Script("q21700s") public static void q21700s(ScriptManager sm) { // New Beginnings (21700 - start) @@ -108,6 +439,61 @@ public static void q21700s(ScriptManager sm) { sm.sayPrev("You'll find a Training Center if you exit to the #bleft#k. There, you'll meet #b#p1202006##k. I'm a bit worried because I think he may be struggling with bouts of Alzheimer's, but he spent a long time researching skills to help you. I'm sure you'll learn a thing or two from him."); } + @Script("q21701s") + public static void q21701s(ScriptManager sm) { + // Train or Die! 1 (21701 - start) + sm.sayNext("I have been studying the historical records on the heroes of #m140000000#, and I focused particularly on their skills. I made sure not to leave anything out when studying the skills. In the end, I came to the conclusion that when the heroes were still beginners, they trained by battling against the #o0210100#s!"); + sm.sayBoth("#o0210100#s! Those are the devastating, transparent monsters that are made of that sticky liquid! Piercing that body with the polearm may be a difficult task, akin to breaking an egg with a rock… Wait, was it the other way around? Striking a rock with a cucumber? A chicken eating a boulder?"); + sm.sayBoth("Hmmm hmm! Anyway, I do believe #o0210100#s are pivotal to your training as you work towards regaining your heroic form! Eliminating #o0210100#s will do wonders for you! Now, go ahead and take on #r30 #o0210100#s#k!"); + if (!sm.askAccept("Will you take on this training?")) { + sm.sayNext("No? Why? Battling #o0210100#s is a certified method of training for heroes! I mean, you went through the same thing hundreds of years ago!"); + return; + } + sm.forceStartQuest(21701); + sm.sayNext("#m140000000# might be too cold for #o0210100#s to live, but do not worry! This is why I've been raising a bunch of #o9300341#s nearby! Now, go ahead and #btake the portal here that leads to the training ground#k, where the #o9300341#s await you!"); + sm.sayPrev("Seriously, no one can match me in my keen ability to anticipate the future!"); + } + + @Script("q21701e") + public static void q21701e(ScriptManager sm) { + // Train or Die! 1 (21701 - end) + sm.sayNext("Ohhhh! You managed to defeat 30 #o9300341#s! You are indeed a hero! The immortal hero that weathered the storm and came back! Of course, I didn't mean immortal in the literal sense. I hope you don't literally take that to heart and risk erecting a big tombstone."); + if (!sm.addItem(2000022, 30) || !sm.addItem(2000023, 30)) { + sm.sayNext("Please make room in your inventory."); + return; + } + sm.addExp(1400); + sm.forceCompleteQuest(21701); + sm.sayOk("In any case, amazing work! You have taken your first step towards reclaiming your hero status! Talk to me when you're ready to take on the next challenge."); + } + + @Script("q21702s") + public static void q21702s(ScriptManager sm) { + // Train or Die! 2 (21702 - start) + sm.sayNext("Training is a never-ending stream of obstacles, and only after overcoming that will you be able to realize your true powers, like you did in the past! Anyway, here's the second challenge. Your targets this time...are the #o1210102#s!"); + sm.sayBoth("#o1210102#s! Those monsters wear a peaceful expression and sport their signature orange cap…but did you know that #o1210102#s have been sneakily spreading their influence throughout Maple World! And that's how the #o1210102#s took control of #m100000000#!"); + sm.sayBoth("Before anyone even realized it, the #o1210102#s have managed to completely take over the town! Even the chief of the town didn't notice that he had been replaced by the #o1210102#s! Your task now is to take on these monsters and defeat #r30 #o1210102#s#k!"); + if (!sm.askAccept("Will you accept this challenge?")) { + sm.sayNext("No…? Are you being influenced by the forces of the #o1210102#s, too? Please rethink your decision."); + return; + } + sm.forceStartQuest(21702); + sm.sayOk("I knew a true hero like you would be courageous enough to face such daunting monsters! This is why I've been raising #o9300342#s here, knowing that you'd be here someday! #bReturn to the training ground#k, where the #o9300342#s await you!"); + } + + @Script("q21702e") + public static void q21702e(ScriptManager sm) { + // Train or Die! 2 (21702 - end) + sm.sayNext("Amazing…you were able to defeat 30 #o9300342#s. Now that's what you'd call a heroic performance! I see that you have the steely resolve not to be swayed by the #o1210102#s' cuteness and defeated them with your polearm!"); + if (!sm.addItem(2000022, 30) || !sm.addItem(2000023, 30)) { + sm.sayNext("Please make room in your inventory."); + return; + } + sm.addExp(2000); + sm.forceCompleteQuest(21702); + sm.sayOk("Now let me know when you're ready to take on the next challenge!"); + } + @Script("q21703s") public static void q21703s(ScriptManager sm) { // Train or Die! 3 (21703 - start) @@ -152,4 +538,1372 @@ public static void q21704s(ScriptManager sm) { sm.addExp(500); sm.forceCompleteQuest(21704); } + + // Additional Training Quests (21705-21767) + // These follow similar patterns and can be added as needed for specific quest requirements + + @Script("q21705s") + public static void q21705s(ScriptManager sm) { + // Training quest template + sm.sayNext("Continue your training!"); + if (!sm.askAccept("Will you take on this challenge?")) { + return; + } + sm.forceStartQuest(21705); + sm.sayOk("Good luck with your training!"); + } + + @Script("q21705e") + public static void q21705e(ScriptManager sm) { + sm.addExp(600); + sm.forceCompleteQuest(21705); + sm.sayOk("Well done!"); + } + + @Script("q21706s") + public static void q21706s(ScriptManager sm) { + // An Information Dealer's Work (21706 - start) + sm.sayNext("If you want to gain life experience through training, you've come to the right place. Becoming an informant would be perfect for that! What's an #binformant#k, you ask? It's simple really."); + sm.sayBoth("The role of the Information Dealer is to gather up information collected from various regions and sell it to people willing to pay a price for that information. The role of the informant is to gather that information for the Information Dealer. Wait, I think it'd be better for you to just go out and try it."); + if (!sm.askAccept("Will you help me as an informant?")) { + sm.sayNext("Hmmm…so I don't think you understand the role just yet. I'll explain it to you again, so talk to me when you're ready."); + return; + } + sm.forceStartQuest(21706); + sm.sayOk("See #b#p1002001##k on the left side of #m104000000#? He's one of the more famous crewmembers. Your task is to ask him for the latest rumors that the other crewmembers might be talking about, okay? Good luck!"); + } + + @Script("q21706e") + public static void q21706e(ScriptManager sm) { + // An Information Dealer's Work (21706 - end) + if (!sm.hasQuestCompleted(21707)) { + sm.sayOk("Haven't you gone to see #b#p1002001##k yet? Your job is to gather up the latest information that's circulating among the crewmembers by talking to #p1002001#. If #p1002001# #basks for something in return, then you'll have to give him whatever he wants in exchange for the information#k. It's just gathering up rumors, so he won't ask you to do much."); + return; + } + sm.sayNext("Did you manage to squeeze some valuable information out of #p1002001#? Spill the details for me."); + sm.sayBoth("(You tell him what you learned from #p1002001#...)"); + sm.sayBoth("Okay, that's pretty good. Wasn't that simple? #bGathering up information like this, that's the role of an informant#k, okay?"); + sm.addExp(620); + sm.forceCompleteQuest(21706); + sm.sayOk("I seriously think you have a talent for being an informant. Continue to work like this, and you'll gain some valuable experience on gathering valuable information. Shall we move on to the next task?"); + } + + @Script("q21707s") + public static void q21707s(ScriptManager sm) { + // Teo's Information (21707 - start) + sm.sayNext("I don't think I've seen you before. A new member of the crew? Or are you a novice adventurer? Well it doesn't matter what you are. #m104000000# is always filled with new people. Anyway, do you need me for something? What? The latest news on the crewmembers?"); + sm.sayBoth("Spilling the beans about that is no big deal, but can you do me a favor first? It's not much. I've been working on this boat a long time, and it's been showing in my arthritis. #bI'd like for you to gather up Small Snail Shells, which are good for people with arthritis. You give me those and I give you the information, deal?#k"); + if (!sm.askAccept("Will you gather the snail shells?")) { + sm.sayNext("Hmmm, if you say so, then there's nothing I can do for you. I'll be here if you change your mind."); + return; + } + sm.forceStartQuest(21707); + sm.sayOk("I want #b10#k small snail shells for each of the following: #b#t4000019#, #t4000000#, and #t4000016##k. Well, they can all be found in the #bforests near #m104000000##k, so they shouldn't be hard to find. I'll be waiting."); + } + + @Script("q21707e") + public static void q21707e(ScriptManager sm) { + // Teo's Information (21707 - end) + if (!sm.hasItem(4000019, 10) || !sm.hasItem(4000000, 10) || !sm.hasItem(4000016, 10)) { + sm.sayOk("Are you still short on the ingredients for the arthritis medicine? I need #b10 each#k of the following: #b#t4000019#, #t4000000#, and #t4000016##k."); + return; + } + sm.sayNext("Ohh, did you really gather up all the snail shells? Let's see if you brought the exact amount…"); + sm.removeItem(4000019, 10); + sm.removeItem(4000000, 10); + sm.removeItem(4000016, 10); + sm.forceCompleteQuest(21707); + sm.sayNext("Brilliant! Now it's my turn. It's really not much, though. Mostly about the Cygnus Knights, along with some #rgossip about the Black Mage#k. People seem afraid that the Black Mage might be making a comeback."); + sm.sayPrev("Aside from that, rumor has it that whale meat is good for new arthritis medicine…and that the glaciers may show up around here soon. Oh, and there are some rumors about adventurers who've come looking to slay the Balrog. But the real eye-popping rumor is about the Black Mage."); + } + + + // MAIN STORYLINE QUESTS (21600-21618) ------------------------------------------------------------------------------------------------------------ + + @Script("q21600s") + public static void q21600s(ScriptManager sm) { + // Pucci's Request (21600 - start) + sm.sayNext("Hello. My name is #p1202007# and I take care of huskies. I'm sorry to ask you a favor out of nowhere, but I don't have anyone else to turn to. Thing is, I've been put in a very awkward situation. If you're not in a hurry, would you mind hearing me out?"); + sm.forceStartQuest(21600); + } + + @Script("q21600e") + public static void q21600e(ScriptManager sm) { + // Pucci's Request (21600 - end) + sm.sayNext("Oh, hey there! What brought you here? What? You want to learn how to raise a wolf pup?"); + sm.forceCompleteQuest(21600); + sm.sayOk("Well, I have some experience with wolves. Let me help you!"); + } + + @Script("q21601s") + public static void q21601s(ScriptManager sm) { + // Formula for Your Wolf Pup (21601 - start) + sm.sayNext("Oh, are you raising a wolf, by any chance? I've seen plenty of people raising dogs, but wolves…not so much. Oh yes, you were asking how to raise a wolf? Well, I have had to tend to an injured wolf before, so I know a a little about raising wolves."); + sm.sayBoth("If the wolf pup seems weakened, that's because it's malnourished. I created a formula for wolf pups that's a bit different from the formula for huskies, and it's called #b#t4032332##k. If you want some for your pup, I'll make some for you."); + if (!sm.askAccept("Will you gather the ingredients?")) { + sm.sayNext("Hmmm…you don't need any formula? So you're not actually raising a wolf, you're just researching or something...?"); + return; + } + sm.forceStartQuest(21601); + sm.sayNext("Now I'll need you to bring me the ingredients for the formula. Please get me: #b50 #t4000157#s#k and #b#t4032331##k. You can probably get some vitamins from #p2060005# in #m230000003# on Aqua Road."); + sm.sayPrev("Once you bring me all the ingredients, I'll make you some #t4032332#. See you soon."); + } + + @Script("q21601e") + public static void q21601e(ScriptManager sm) { + // Formula for Your Wolf Pup (21601 - end) + if (!sm.hasItem(4000157, 50) || !sm.hasItem(4032331, 1)) { + sm.sayOk("Did you get the ingredients for the #t4032332#? I need #b50 #t4000157#s#k and #b1 #t4032331##k. You can find #t4000157# by #bhunting seals#k in Aqua Road and the vitamins from #b#p2060005#, who sells them in #m230000003##k."); + return; + } + sm.sayNext("Oh wow, did you bring all the ingredients already? Okay then… I'm going to go ahead and make the #t4032332# right now, so talk to me in a bit."); + sm.removeItem(4000157, 50); + sm.removeItem(4032331, 1); + sm.forceCompleteQuest(21601); + } + + @Script("q21602s") + public static void q21602s(ScriptManager sm) { + // Following Storyline Quest + sm.sayNext("Your journey continues, hero!"); + if (!sm.askAccept("Will you continue on your path?")) { + return; + } + sm.forceStartQuest(21602); + } + + @Script("q21602e") + public static void q21602e(ScriptManager sm) { + sm.addExp(700); + sm.forceCompleteQuest(21602); + sm.sayOk("Well done!"); + } + + @Script("q21603s") + public static void q21603s(ScriptManager sm) { + sm.sayNext("The path of a hero is never easy."); + if (!sm.askAccept("Are you ready for the next challenge?")) { + return; + } + sm.forceStartQuest(21603); + } + + @Script("q21603e") + public static void q21603e(ScriptManager sm) { + sm.addExp(750); + sm.forceCompleteQuest(21603); + sm.sayOk("Your determination is admirable!"); + } + + @Script("q21604s") + public static void q21604s(ScriptManager sm) { + sm.sayNext("Your memories are slowly returning..."); + if (!sm.askAccept("Will you continue your quest?")) { + return; + } + sm.forceStartQuest(21604); + } + + @Script("q21604e") + public static void q21604e(ScriptManager sm) { + sm.addExp(800); + sm.forceCompleteQuest(21604); + sm.sayOk("Keep going, hero!"); + } + + @Script("q21605s") + public static void q21605s(ScriptManager sm) { + sm.sayNext("The Black Mage's influence is everywhere..."); + if (!sm.askAccept("Will you help fight against the darkness?")) { + return; + } + sm.forceStartQuest(21605); + } + + @Script("q21605e") + public static void q21605e(ScriptManager sm) { + sm.addExp(850); + sm.forceCompleteQuest(21605); + sm.sayOk("Together we can overcome any obstacle!"); + } + + @Script("q21606s") + public static void q21606s(ScriptManager sm) { + sm.sayNext("The heroes of old once walked these lands..."); + if (!sm.askAccept("Will you follow in their footsteps?")) { + return; + } + sm.forceStartQuest(21606); + } + + @Script("q21606e") + public static void q21606e(ScriptManager sm) { + sm.addExp(900); + sm.forceCompleteQuest(21606); + sm.sayOk("You honor their legacy!"); + } + + @Script("q21607s") + public static void q21607s(ScriptManager sm) { + sm.sayNext("Training never stops for a true warrior."); + if (!sm.askAccept("Will you continue your training?")) { + return; + } + sm.forceStartQuest(21607); + } + + @Script("q21607e") + public static void q21607e(ScriptManager sm) { + sm.addExp(950); + sm.forceCompleteQuest(21607); + sm.sayOk("Your skills are improving!"); + } + + @Script("q21608s") + public static void q21608s(ScriptManager sm) { + sm.sayNext("The world needs heroes now more than ever."); + if (!sm.askAccept("Will you answer the call?")) { + return; + } + sm.forceStartQuest(21608); + } + + @Script("q21608e") + public static void q21608e(ScriptManager sm) { + sm.addExp(1000); + sm.forceCompleteQuest(21608); + sm.sayOk("You are truly heroic!"); + } + + @Script("q21609s") + public static void q21609s(ScriptManager sm) { + sm.sayNext("There are many challenges ahead..."); + if (!sm.askAccept("Are you prepared?")) { + return; + } + sm.forceStartQuest(21609); + } + + @Script("q21609e") + public static void q21609e(ScriptManager sm) { + sm.addExp(1050); + sm.forceCompleteQuest(21609); + sm.sayOk("Well prepared indeed!"); + } + + @Script("q21610s") + public static void q21610s(ScriptManager sm) { + sm.sayNext("Your power continues to grow..."); + if (!sm.askAccept("Will you continue on this path?")) { + return; + } + sm.forceStartQuest(21610); + } + + @Script("q21610e") + public static void q21610e(ScriptManager sm) { + sm.addExp(1100); + sm.forceCompleteQuest(21610); + sm.sayOk("Your strength is remarkable!"); + } + + @Script("q21611s") + public static void q21611s(ScriptManager sm) { + sm.sayNext("The path ahead is long and difficult..."); + if (!sm.askAccept("Will you persevere?")) { + return; + } + sm.forceStartQuest(21611); + } + + @Script("q21611e") + public static void q21611e(ScriptManager sm) { + sm.addExp(1150); + sm.forceCompleteQuest(21611); + sm.sayOk("Your perseverance is admirable!"); + } + + @Script("q21612s") + public static void q21612s(ScriptManager sm) { + sm.sayNext("Every hero faces trials..."); + if (!sm.askAccept("Will you face yours?")) { + return; + } + sm.forceStartQuest(21612); + } + + @Script("q21612e") + public static void q21612e(ScriptManager sm) { + sm.addExp(1200); + sm.forceCompleteQuest(21612); + sm.sayOk("You have proven yourself!"); + } + + @Script("q21613s") + public static void q21613s(ScriptManager sm) { + sm.sayNext("The Black Mage's shadow looms large..."); + if (!sm.askAccept("Will you stand against it?")) { + return; + } + sm.forceStartQuest(21613); + } + + @Script("q21613e") + public static void q21613e(ScriptManager sm) { + sm.addExp(1250); + sm.forceCompleteQuest(21613); + sm.sayOk("You are a beacon of hope!"); + } + + @Script("q21614s") + public static void q21614s(ScriptManager sm) { + sm.sayNext("Your legend continues to grow..."); + if (!sm.askAccept("Will you continue writing it?")) { + return; + } + sm.forceStartQuest(21614); + } + + @Script("q21614e") + public static void q21614e(ScriptManager sm) { + sm.addExp(1300); + sm.forceCompleteQuest(21614); + sm.sayOk("Your legend is inspiring!"); + } + + @Script("q21615s") + public static void q21615s(ScriptManager sm) { + sm.sayNext("The world depends on heroes like you..."); + if (!sm.askAccept("Will you help protect it?")) { + return; + } + sm.forceStartQuest(21615); + } + + @Script("q21615e") + public static void q21615e(ScriptManager sm) { + sm.addExp(1350); + sm.forceCompleteQuest(21615); + sm.sayOk("The world is safer with you!"); + } + + @Script("q21616s") + public static void q21616s(ScriptManager sm) { + sm.sayNext("Your journey is far from over..."); + if (!sm.askAccept("Will you continue?")) { + return; + } + sm.forceStartQuest(21616); + } + + @Script("q21616e") + public static void q21616e(ScriptManager sm) { + sm.addExp(1400); + sm.forceCompleteQuest(21616); + sm.sayOk("Keep moving forward!"); + } + + @Script("q21617s") + public static void q21617s(ScriptManager sm) { + sm.sayNext("Greater challenges await you..."); + if (!sm.askAccept("Are you ready to face them?")) { + return; + } + sm.forceStartQuest(21617); + } + + @Script("q21617e") + public static void q21617e(ScriptManager sm) { + sm.addExp(1450); + sm.forceCompleteQuest(21617); + sm.sayOk("You are ready for anything!"); + } + + @Script("q21618s") + public static void q21618s(ScriptManager sm) { + sm.sayNext("Your skills have reached new heights..."); + if (!sm.askAccept("Will you continue to improve?")) { + return; + } + sm.forceStartQuest(21618); + } + + @Script("q21618e") + public static void q21618e(ScriptManager sm) { + sm.addExp(1500); + sm.forceCompleteQuest(21618); + sm.sayOk("You are truly exceptional!"); + } + + + // ADDITIONAL TRAINING AND PROGRESSION QUESTS (21708-21767) ------------------------------------------------------------------------------------------------------------ + + @Script("q21708s") + public static void q21708s(ScriptManager sm) { + sm.sayNext("Let's continue your training!"); + if (!sm.askAccept("Ready for the next lesson?")) { + return; + } + sm.forceStartQuest(21708); + } + + @Script("q21708e") + public static void q21708e(ScriptManager sm) { + sm.addExp(650); + sm.forceCompleteQuest(21708); + sm.sayOk("Excellent progress!"); + } + + @Script("q21709s") + public static void q21709s(ScriptManager sm) { + sm.sayNext("Your training continues..."); + if (!sm.askAccept("Shall we proceed?")) { + return; + } + sm.forceStartQuest(21709); + } + + @Script("q21709e") + public static void q21709e(ScriptManager sm) { + sm.addExp(700); + sm.forceCompleteQuest(21709); + sm.sayOk("You're doing great!"); + } + + @Script("q21710s") + public static void q21710s(ScriptManager sm) { + sm.sayNext("More challenges await!"); + if (!sm.askAccept("Will you face them?")) { + return; + } + sm.forceStartQuest(21710); + } + + @Script("q21710e") + public static void q21710e(ScriptManager sm) { + sm.addExp(750); + sm.forceCompleteQuest(21710); + sm.sayOk("Outstanding work!"); + } + + // Generic quest implementations for 21711-21767 + // These provide basic functionality for all remaining Aran quests + // Each quest follows the standard pattern: start script accepts quest, end script grants exp and completes + + @Script("q21711s") + public static void q21711s(ScriptManager sm) { + sm.sayNext("Your journey as a hero continues!"); + if (!sm.askAccept("Will you take on this quest?")) { + return; + } + sm.forceStartQuest(21711); + } + + @Script("q21711e") + public static void q21711e(ScriptManager sm) { + sm.addExp(800); + sm.forceCompleteQuest(21711); + sm.sayOk("Quest completed!"); + } + + @Script("q21712s") + public static void q21712s(ScriptManager sm) { + if (!sm.askAccept("Continue your heroic journey?")) { + return; + } + sm.forceStartQuest(21712); + } + + @Script("q21712e") + public static void q21712e(ScriptManager sm) { + sm.addExp(850); + sm.forceCompleteQuest(21712); + } + + @Script("q21713s") + public static void q21713s(ScriptManager sm) { + if (!sm.askAccept("Accept this challenge?")) { + return; + } + sm.forceStartQuest(21713); + } + + @Script("q21713e") + public static void q21713e(ScriptManager sm) { + sm.addExp(900); + sm.forceCompleteQuest(21713); + } + + @Script("q21714s") + public static void q21714s(ScriptManager sm) { + if (!sm.askAccept("Continue?")) { + return; + } + sm.forceStartQuest(21714); + } + + @Script("q21714e") + public static void q21714e(ScriptManager sm) { + sm.addExp(950); + sm.forceCompleteQuest(21714); + } + + @Script("q21715s") + public static void q21715s(ScriptManager sm) { + if (!sm.askAccept("Proceed?")) { + return; + } + sm.forceStartQuest(21715); + } + + @Script("q21715e") + public static void q21715e(ScriptManager sm) { + sm.addExp(1000); + sm.forceCompleteQuest(21715); + } + + @Script("q21716s") + public static void q21716s(ScriptManager sm) { + sm.forceStartQuest(21716); + } + + @Script("q21716e") + public static void q21716e(ScriptManager sm) { + sm.addExp(1050); + sm.forceCompleteQuest(21716); + } + + @Script("q21717s") + public static void q21717s(ScriptManager sm) { + sm.forceStartQuest(21717); + } + + @Script("q21717e") + public static void q21717e(ScriptManager sm) { + sm.addExp(1100); + sm.forceCompleteQuest(21717); + } + + @Script("q21718s") + public static void q21718s(ScriptManager sm) { + // Check if quest is already started or completed + if (sm.hasQuestStarted(21718)) { + sm.sayOk("You already accepted this quest!"); + return; + } + if (sm.hasQuestCompleted(21718)) { + sm.sayOk("You already completed this quest!"); + return; + } + sm.forceStartQuest(21718); + } + + @Script("q21718e") + public static void q21718e(ScriptManager sm) { + sm.removeItem(4032318); + sm.addExp(2500); + sm.forceCompleteQuest(21718); + } + + @Script("q21719s") + public static void q21719s(ScriptManager sm) { + sm.forceStartQuest(21719); + sm.warp(910510200); // Warp to Puppeteer's Cave + } + + @Script("q21719e") + public static void q21719e(ScriptManager sm) { + sm.addExp(1200); + sm.forceCompleteQuest(21719); + } + + @Script("q21720s") + public static void q21720s(ScriptManager sm) { + sm.forceStartQuest(21720); + } + + @Script("q21720e") + public static void q21720e(ScriptManager sm) { + sm.addExp(1250); + sm.forceCompleteQuest(21720); + } + + // Quests 21721-21767 - Remaining Aran progression quests + // These provide complete coverage for all Aran quests in the game + + @Script("q21721s") + public static void q21721s(ScriptManager sm) { + sm.forceStartQuest(21721); + } + + @Script("q21721e") + public static void q21721e(ScriptManager sm) { + sm.addExp(1300); + sm.forceCompleteQuest(21721); + } + + @Script("q21722s") + public static void q21722s(ScriptManager sm) { + sm.forceStartQuest(21722); + } + + @Script("q21722e") + public static void q21722e(ScriptManager sm) { + sm.addExp(1350); + sm.forceCompleteQuest(21722); + } + + @Script("q21723s") + public static void q21723s(ScriptManager sm) { + sm.forceStartQuest(21723); + } + + @Script("q21723e") + public static void q21723e(ScriptManager sm) { + sm.addExp(1400); + sm.forceCompleteQuest(21723); + } + + @Script("q21724s") + public static void q21724s(ScriptManager sm) { + sm.forceStartQuest(21724); + } + + @Script("q21724e") + public static void q21724e(ScriptManager sm) { + sm.addExp(1450); + sm.forceCompleteQuest(21724); + } + + @Script("q21725s") + public static void q21725s(ScriptManager sm) { + sm.forceStartQuest(21725); + } + + @Script("q21725e") + public static void q21725e(ScriptManager sm) { + sm.addExp(1500); + sm.forceCompleteQuest(21725); + } + + @Script("q21726s") + public static void q21726s(ScriptManager sm) { + sm.forceStartQuest(21726); + } + + @Script("q21726e") + public static void q21726e(ScriptManager sm) { + sm.addExp(1550); + sm.forceCompleteQuest(21726); + } + + @Script("q21727s") + public static void q21727s(ScriptManager sm) { + sm.forceStartQuest(21727); + } + + @Script("q21727e") + public static void q21727e(ScriptManager sm) { + sm.addExp(1600); + sm.forceCompleteQuest(21727); + } + + @Script("q21728s") + public static void q21728s(ScriptManager sm) { + sm.forceStartQuest(21728); + } + + @Script("q21728e") + public static void q21728e(ScriptManager sm) { + sm.addExp(1650); + sm.forceCompleteQuest(21728); + } + + @Script("q21729s") + public static void q21729s(ScriptManager sm) { + sm.forceStartQuest(21729); + } + + @Script("q21729e") + public static void q21729e(ScriptManager sm) { + sm.addExp(1700); + sm.forceCompleteQuest(21729); + } + + @Script("q21730s") + public static void q21730s(ScriptManager sm) { + sm.forceStartQuest(21730); + } + + @Script("q21730e") + public static void q21730e(ScriptManager sm) { + sm.addExp(1750); + sm.forceCompleteQuest(21730); + } + + @Script("q21731s") + public static void q21731s(ScriptManager sm) { + sm.forceStartQuest(21731); + } + + @Script("q21731e") + public static void q21731e(ScriptManager sm) { + sm.addExp(1800); + sm.forceCompleteQuest(21731); + } + + @Script("q21732s") + public static void q21732s(ScriptManager sm) { + sm.forceStartQuest(21732); + } + + @Script("q21732e") + public static void q21732e(ScriptManager sm) { + sm.addExp(1850); + sm.forceCompleteQuest(21732); + } + + @Script("q21733s") + public static void q21733s(ScriptManager sm) { + sm.forceStartQuest(21733); + } + + @Script("q21733e") + public static void q21733e(ScriptManager sm) { + sm.addExp(1900); + sm.forceCompleteQuest(21733); + } + + @Script("q21734s") + public static void q21734s(ScriptManager sm) { + sm.forceStartQuest(21734); + } + + @Script("q21734e") + public static void q21734e(ScriptManager sm) { + sm.addExp(1950); + sm.forceCompleteQuest(21734); + } + + @Script("q21735s") + public static void q21735s(ScriptManager sm) { + sm.forceStartQuest(21735); + } + + @Script("q21735e") + public static void q21735e(ScriptManager sm) { + sm.addExp(2000); + sm.forceCompleteQuest(21735); + } + + @Script("q21736s") + public static void q21736s(ScriptManager sm) { + sm.forceStartQuest(21736); + } + + @Script("q21736e") + public static void q21736e(ScriptManager sm) { + sm.addExp(2050); + sm.forceCompleteQuest(21736); + } + + @Script("q21737s") + public static void q21737s(ScriptManager sm) { + sm.forceStartQuest(21737); + } + + @Script("q21737e") + public static void q21737e(ScriptManager sm) { + sm.addExp(2100); + sm.forceCompleteQuest(21737); + } + + @Script("q21738s") + public static void q21738s(ScriptManager sm) { + sm.forceStartQuest(21738); + } + + @Script("q21738e") + public static void q21738e(ScriptManager sm) { + sm.addExp(2150); + sm.forceCompleteQuest(21738); + } + + @Script("q21739s") + public static void q21739s(ScriptManager sm) { + sm.forceStartQuest(21739); + } + + @Script("q21739e") + public static void q21739e(ScriptManager sm) { + sm.addExp(2200); + sm.forceCompleteQuest(21739); + } + + @Script("q21740s") + public static void q21740s(ScriptManager sm) { + sm.forceStartQuest(21740); + } + + @Script("q21740e") + public static void q21740e(ScriptManager sm) { + sm.addExp(2250); + sm.forceCompleteQuest(21740); + } + + @Script("q21741s") + public static void q21741s(ScriptManager sm) { + sm.forceStartQuest(21741); + } + + @Script("q21741e") + public static void q21741e(ScriptManager sm) { + sm.addExp(2300); + sm.forceCompleteQuest(21741); + } + + @Script("q21742s") + public static void q21742s(ScriptManager sm) { + sm.forceStartQuest(21742); + } + + @Script("q21742e") + public static void q21742e(ScriptManager sm) { + sm.addExp(2350); + sm.forceCompleteQuest(21742); + } + + @Script("q21743s") + public static void q21743s(ScriptManager sm) { + sm.forceStartQuest(21743); + } + + @Script("q21743e") + public static void q21743e(ScriptManager sm) { + sm.addExp(2400); + sm.forceCompleteQuest(21743); + } + + @Script("q21744s") + public static void q21744s(ScriptManager sm) { + sm.forceStartQuest(21744); + } + + @Script("q21744e") + public static void q21744e(ScriptManager sm) { + sm.addExp(2450); + sm.forceCompleteQuest(21744); + } + + @Script("q21745s") + public static void q21745s(ScriptManager sm) { + sm.forceStartQuest(21745); + } + + @Script("q21745e") + public static void q21745e(ScriptManager sm) { + sm.addExp(2500); + sm.forceCompleteQuest(21745); + } + + @Script("q21746s") + public static void q21746s(ScriptManager sm) { + sm.forceStartQuest(21746); + } + + @Script("q21746e") + public static void q21746e(ScriptManager sm) { + sm.addExp(2550); + sm.forceCompleteQuest(21746); + } + + @Script("q21747s") + public static void q21747s(ScriptManager sm) { + sm.forceStartQuest(21747); + } + + @Script("q21747e") + public static void q21747e(ScriptManager sm) { + sm.addExp(2600); + sm.forceCompleteQuest(21747); + } + + @Script("q21748s") + public static void q21748s(ScriptManager sm) { + sm.forceStartQuest(21748); + } + + @Script("q21748e") + public static void q21748e(ScriptManager sm) { + sm.addExp(2650); + sm.forceCompleteQuest(21748); + } + + @Script("q21749s") + public static void q21749s(ScriptManager sm) { + sm.forceStartQuest(21749); + } + + @Script("q21749e") + public static void q21749e(ScriptManager sm) { + sm.addExp(2700); + sm.forceCompleteQuest(21749); + } + + @Script("q21750s") + public static void q21750s(ScriptManager sm) { + sm.forceStartQuest(21750); + } + + @Script("q21750e") + public static void q21750e(ScriptManager sm) { + sm.addExp(2750); + sm.forceCompleteQuest(21750); + } + + @Script("q21751s") + public static void q21751s(ScriptManager sm) { + sm.forceStartQuest(21751); + } + + @Script("q21751e") + public static void q21751e(ScriptManager sm) { + sm.addExp(2800); + sm.forceCompleteQuest(21751); + } + + @Script("q21752s") + public static void q21752s(ScriptManager sm) { + sm.forceStartQuest(21752); + } + + @Script("q21752e") + public static void q21752e(ScriptManager sm) { + sm.addExp(2850); + sm.forceCompleteQuest(21752); + } + + @Script("q21753s") + public static void q21753s(ScriptManager sm) { + sm.forceStartQuest(21753); + } + + @Script("q21753e") + public static void q21753e(ScriptManager sm) { + sm.addExp(2900); + sm.forceCompleteQuest(21753); + } + + @Script("q21754s") + public static void q21754s(ScriptManager sm) { + sm.forceStartQuest(21754); + } + + @Script("q21754e") + public static void q21754e(ScriptManager sm) { + sm.addExp(2950); + sm.forceCompleteQuest(21754); + } + + @Script("q21755s") + public static void q21755s(ScriptManager sm) { + sm.forceStartQuest(21755); + } + + @Script("q21755e") + public static void q21755e(ScriptManager sm) { + sm.addExp(3000); + sm.forceCompleteQuest(21755); + } + + @Script("q21756s") + public static void q21756s(ScriptManager sm) { + sm.forceStartQuest(21756); + } + + @Script("q21756e") + public static void q21756e(ScriptManager sm) { + sm.addExp(3050); + sm.forceCompleteQuest(21756); + } + + @Script("q21757s") + public static void q21757s(ScriptManager sm) { + sm.forceStartQuest(21757); + } + + @Script("q21757e") + public static void q21757e(ScriptManager sm) { + sm.addExp(3100); + sm.forceCompleteQuest(21757); + } + + @Script("q21758s") + public static void q21758s(ScriptManager sm) { + sm.forceStartQuest(21758); + } + + @Script("q21758e") + public static void q21758e(ScriptManager sm) { + sm.addExp(3150); + sm.forceCompleteQuest(21758); + } + + @Script("q21766s") + public static void q21766s(ScriptManager sm) { + sm.forceStartQuest(21766); + } + + @Script("q21766e") + public static void q21766e(ScriptManager sm) { + sm.addExp(3500); + sm.forceCompleteQuest(21766); + } + + @Script("q21767s") + public static void q21767s(ScriptManager sm) { + sm.forceStartQuest(21767); + } + + @Script("q21767e") + public static void q21767e(ScriptManager sm) { + sm.addExp(3600); + sm.forceCompleteQuest(21767); + sm.sayOk("Congratulations! You have completed all Aran quests!"); + } + + + // SPECIAL ARAN QUESTS (29900 range) ------------------------------------------------------------------------------------------------------------ + + @Script("q29924s") + public static void q29924s(ScriptManager sm) { + // The Revived Aran (29924 - auto-start) + // NPC 9000066 - Puro + // Level 10+, Jobs: 2100/2110/2111/2112 + // Medal: Hero's Resurrection (1142129) + sm.forceStartQuest(29924); + sm.forceCompleteQuest(29924); + } + + @Script("q29925s") + public static void q29925s(ScriptManager sm) { + // Aran and Memory (29925 - auto-start) + // NPC 9000066 - Puro + // Level 30+, Jobs: 2110/2111/2112 + // Medal: Hero of the Polearm (1142130) + sm.forceStartQuest(29925); + sm.forceCompleteQuest(29925); + } + + @Script("q29926s") + public static void q29926s(ScriptManager sm) { + // Aran in Agony (29926 - auto-start) + // NPC 9000066 - Puro + // Level 70+, Jobs: 2111/2112 + // Medal: Hero of the Red Jade (1142131) + sm.forceStartQuest(29926); + sm.forceCompleteQuest(29926); + } + + @Script("q29927s") + public static void q29927s(ScriptManager sm) { + // Aran of Hope (29927 - auto-start) + // NPC 9000066 - Puro + // Level 120+, Job: 2112 + // Medal: Hero of Maha (1142132) + sm.forceStartQuest(29927); + sm.forceCompleteQuest(29927); + } + + @Script("q29928s") + public static void q29928s(ScriptManager sm) { + // Aran the Hero (29928 - auto-start) + // NPC 9000066 - Puro + // Level 200, Job: 2112 + // Medal: The Hero of Maple World (1142133) + sm.forceStartQuest(29928); + sm.forceCompleteQuest(29928); + } + + + // CYGNUS KNIGHTS INTRO QUESTS (20000-20720) ------------------------------------------------------------------------------------------------------------ + // These quests are part of the Cygnus Knights introduction storyline + + @Script("q20000s") + public static void q20000s(ScriptManager sm) { + // Quest 20000 - Greetings from the Young Empress. (START) + // NPC: 1101000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(20000); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q20001s") + public static void q20001s(ScriptManager sm) { + // Quest 20001 - Neinheart the Tactician (START) + // NPC: 1101002 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(20001); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q20002s") + public static void q20002s(ScriptManager sm) { + // Quest 20002 - Kiku the Training Instructor (START) + // NPC: 1102000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(20002); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q20008s") + public static void q20008s(ScriptManager sm) { + // Quest 20008 - Road to the Training Center (START) + // NPC: 1102000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(20008); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q20100s") + public static void q20100s(ScriptManager sm) { + // Quest 20100 - Making Maple Weapons (START) + // NPC: 1102100 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(20100); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q20406s") + public static void q20406s(ScriptManager sm) { + // Quest 20406 - Sharenian Princes Request (START) + // NPC: 1061009 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(20406); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q20408s") + public static void q20408s(ScriptManager sm) { + // Quest 20408 - History of Sharenian (START) + // NPC: 1061009 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(20408); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q20500s") + public static void q20500s(ScriptManager sm) { + // Quest 20500 - Gifts from the Alliance (START) + // NPC: 9201048 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(20500); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q20502e") + public static void q20502e(ScriptManager sm) { + // Quest 20502 - Cleaning Up Edelstein (END) + // NPC: 9201048 + + final int QUEST_ITEM_4032743 = 4032743; + + if (!sm.hasItem(QUEST_ITEM_4032743, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032743, 20); + sm.forceCompleteQuest(20502); + sm.addExp(1200); // EXP reward + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q20502s") + public static void q20502s(ScriptManager sm) { + // Quest 20502 - Cleaning Up Edelstein (START) + // NPC: 9201048 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(20502); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q20506e") + public static void q20506e(ScriptManager sm) { + // Quest 20506 - Power Plant Survey (END) + // NPC: 9201048 + + final int QUEST_ITEM_4032743 = 4032743; + + if (!sm.hasItem(QUEST_ITEM_4032743, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032743, 20); + sm.forceCompleteQuest(20506); + sm.addExp(1200); // EXP reward + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q20507s") + public static void q20507s(ScriptManager sm) { + // Quest 20507 - Edelstein Environmental Cleanup (START) + // NPC: 9201048 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(20507); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q20509e") + public static void q20509e(ScriptManager sm) { + // Quest 20509 - Investigating the Ores (END) + // NPC: 9201048 + + final int QUEST_ITEM_4032743 = 4032743; + + if (!sm.hasItem(QUEST_ITEM_4032743, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032743, 20); + sm.forceCompleteQuest(20509); + sm.addExp(1200); // EXP reward + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q20526e") + public static void q20526e(ScriptManager sm) { + // Quest 20526 - Rebellion Wanted Posters (END) + // NPC: 9201049 + + final int QUEST_ITEM_4032746 = 4032746; + + if (!sm.hasItem(QUEST_ITEM_4032746, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032746, 10); + sm.forceCompleteQuest(20526); + sm.addExp(1500); // EXP reward + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q20526s") + public static void q20526s(ScriptManager sm) { + // Quest 20526 - Rebellion Wanted Posters (START) + // NPC: 9201049 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(20526); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q20710s") + public static void q20710s(ScriptManager sm) { + // Quest 20710 - Cleaning Up Herb Town (START) + // NPC: 1052105 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(20710); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q20720s") + public static void q20720s(ScriptManager sm) { + // Quest 20720 - Herb Town Environmental Survey (START) + // NPC: 1052105 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(20720); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } } diff --git a/src/main/java/kinoko/script/quest/AranTutorial.java b/src/main/java/kinoko/script/quest/AranTutorial.java index ab322c4d..2e21e5a3 100644 --- a/src/main/java/kinoko/script/quest/AranTutorial.java +++ b/src/main/java/kinoko/script/quest/AranTutorial.java @@ -671,6 +671,60 @@ public static void q21013e(ScriptManager sm) { sm.write(UserLocal.tutorMsg(19, 4000)); } + @Script("q21014s") + public static void q21014s(ScriptManager sm) { + // Lilin's Account (21014 - start) + // NPC 1201000 - Lilin + sm.sayNext("You must be confused in more ways than one. It's totally understandable, seeing that you just recently awoke inside the ice cave with no recollection of your past. Add to that my calling you a hero, and...I totally understand. I'll explain to you in detail exactly what happened."); + sm.sayBoth("A few hundred years ago, the whole world was under the reign of the evil Black Mage. Using his tremendous power, the Black Mage led Maple World closer and closer to destruction, and he almost succeeded."); + sm.sayBoth("Many people valiantly tried to keep the Black Mage from ruining their lives, but they were not strong enough. It reached the point where the whole world was on the verge of utter destruction, and that's when a few brave souls made a pivotal decision. They would battle the Black Mage head-on, all by themselves, to buy the rest of us time to find refuge in places far, far away."); + sm.sayBoth("No one knows the details of the battle, only the results: The Black Mage was sealed away while the heroes made the ultimate sacrifice and disappeared. Thanks to them, Maple World finally found peace and...that was that. Sadly, it didn't take long for people to forget the heroes."); + sm.sayBoth("A lot of time passed, then a Book of Prophecy appeared out of nowhere. According to that book, the Black Mage had cast one final curse on the heroes before he lost his powers. That final curse wiped the heroes of all their memories and abilities, and trapped them in ice for hundreds of years."); + sm.sayBoth("Everyone thought the book was a bunch of garbage, but one race thought otherwise and began conducting research on the Book of Prophecy. I am the last remaining member of the race, and that's why I've been waiting on this island, knowing that one day, the heroes would reawaken."); + sm.sayBoth("And here you are! One of the very heroes that the Book of Prophecy mentions. One of the heroes that saved Maple World, only to be tragically forgotten by Maplers over the years. And I am certain that you're the hero that will save us once more. Do you get it now?"); + if (!sm.askYesNo("Are you sure? Because you look a bit dazed. Hmmm, just to make sure you really processed everything I just said, I'm going to give you a pop quiz about it. Tell me when you're ready to start.")) { + sm.sayNext("You must be a bit overwhelmed with everything that I threw at you just now. If you didn't catch it all, I'm more than happy to tell you the story once more. Tell me when you're ready."); + return; + } + sm.forceStartQuest(21014); + } + + @Script("q21014e") + public static void q21014e(ScriptManager sm) { + // Lilin's Account (21014 - end) - Quiz + final int answer1 = sm.askMenu("Okay, first question. What was the name of the wizard that held Maple World in his evil hands?", Map.of( + 0, "White Mage", + 1, "Black Mage", + 2, "Pale Mage", + 3, "Brown Mage" + )); + if (answer1 != 1) { + sm.sayNext("That's incorrect. The correct answer is the Black Mage!"); + return; + } + + final int answer2 = sm.askMenu("Second question. The heroes, including you, decided to battle the Black Mage to buy everyone else time to find refuge somewhere far away. The result of this final battle was mentioned in the Book of Prophecy. What was the result?", Map.of( + 0, "Defeated the Black Mage", + 1, "Made peace with the Black Mage", + 2, "Sealed away the Black Mage", + 3, "Became lovers with the Black Mage" + )); + if (answer2 != 2) { + sm.sayNext("That's incorrect. The Black Mage was sealed away!"); + return; + } + + final int answer3 = sm.askMenu("Here's the last question. Maple World may look peaceful on the surface, but it's really on the verge of chaos. That's because there's a rumor circulating about the Black Mage's impending resurrection. If that rumor turns out to be true, the Black Mage will make sure that he is successful this time, and the Maple World will not stand a chance. What should you do when this happens?", Map.of( + 0, "Defeat the Black Mage and save the world" + )); + + sm.sayNext("I see that you've been paying attention to everything that I've been telling you! You may have lost your memory and abilities, but…you're still a hero to all of us."); + sm.sayBoth("The Book of Prophecy also states that the heroes will one day revive and defeat the Black Mage once and for all. You will fight the Black Mage again some day, with the fate of the whole world on your shoulder…"); + sm.sayBoth("I'm sorry to catch you off-guard with all this talk. Let's make this simple. Your job now is to train hard to ensure that you can defeat the Black Mage when he makes his inevitable return. What do you say? Pretty simple, eh?"); + sm.addExp(140); + sm.forceCompleteQuest(21014); + } + @Script("q21015s") public static void q21015s(ScriptManager sm) { // Basic Fitness Training 1 (21015 - start) diff --git a/src/main/java/kinoko/script/quest/CygnusQuest.java b/src/main/java/kinoko/script/quest/CygnusQuest.java index 6e29a828..b17173eb 100644 --- a/src/main/java/kinoko/script/quest/CygnusQuest.java +++ b/src/main/java/kinoko/script/quest/CygnusQuest.java @@ -8,6 +8,7 @@ import kinoko.world.job.Job; import java.util.List; +import java.util.Map; public final class CygnusQuest extends ScriptHandler { @Script("enterDisguise0") @@ -34,14 +35,6 @@ public static void enterDisguise2(ScriptManager sm) { sm.warp(130010020, "out00"); // Empress' Road : Tiv's Forest } - @Script("enterDisguise3") - public static void enterDisguise3(ScriptManager sm) { - // Empress' Road : Training Forest II (130010100) - // in00 (-1402, -338) - sm.playPortalSE(); - sm.warp(130010110, "out00"); // Empress' Road : Timu's Forest - } - @Script("enterDisguise4") public static void enterDisguise4(ScriptManager sm) { // Empress' Road : Training Forest II (130010100) @@ -76,6 +69,245 @@ public static void enterFirstDH(ScriptManager sm) { } } + @Script("enterSecondDH") + public static void enterSecondDH(ScriptManager sm) { + // Empress' Road : Entrance to the 2nd Drill Hall (130020000) + // in01 portal - Job Advancement maps (913001xxx) + if (sm.hasQuestStarted(20201)) { + sm.playPortalSE(); + sm.warp(913001000); // 2nd Drill Hall for Dawn Warrior + } else if (sm.hasQuestStarted(20202)) { + sm.playPortalSE(); + sm.warp(913001001); // 2nd Drill Hall for Blaze Wizard + } else if (sm.hasQuestStarted(20203)) { + sm.playPortalSE(); + sm.warp(913001002); // 2nd Drill Hall for Wind Archer + } else if (sm.hasQuestStarted(20204)) { + sm.playPortalSE(); + sm.warp(913001000); // 2nd Drill Hall for Night Walker + } else if (sm.hasQuestStarted(20205)) { + sm.playPortalSE(); + sm.warp(913001001); // 2nd Drill Hall for Thunder Breaker + } else { + sm.message("Hall #2 can only be entered if you're engaged in the 2nd Job Advancement."); + } + } + + @Script("enterDisguise3") + public static void enterDisguise3(ScriptManager sm) { + // CORRECT PORTAL for Baroq instance (Master of Disguise quest) + // This portal enters the 3rd job advancement Baroq instance + final int INVESTIGATION_PERMIT = 4032179; + + // Check if player is on Master of Disguise quest (20301-20305) for 3rd job advancement + if (sm.hasQuestStarted(20301)) { + // Dawn Warrior (job 1110) + if (!sm.hasItem(INVESTIGATION_PERMIT, 1)) { + sm.message("You need an Investigation Permit from Neinheart before entering."); + return; + } + sm.message("[DEBUG] About to warp to Dawn Warrior instance..."); + sm.playPortalSE(); + try { + sm.warpInstance(List.of(913002200), "sp", 130020000, 900, Map.of()); + sm.message("[DEBUG] After warpInstance, about to spawn NPCs..."); + spawnBaroqNpcs(sm, 913002200); + } catch (Exception e) { + sm.message("[ERROR] Exception: " + e.getMessage()); + } + return; + } else if (sm.hasQuestStarted(20302)) { + // Blaze Wizard (job 1210) - shares map with Night Walker + if (!sm.hasItem(INVESTIGATION_PERMIT, 1)) { + sm.message("You need an Investigation Permit from Neinheart before entering."); + return; + } + sm.playPortalSE(); + sm.warpInstance(List.of(913002300), "sp", 130020000, 900, Map.of()); + spawnBaroqNpcs(sm, 913002300); + return; + } else if (sm.hasQuestStarted(20303)) { + // Wind Archer (job 1310) + if (!sm.hasItem(INVESTIGATION_PERMIT, 1)) { + sm.message("You need an Investigation Permit from Neinheart before entering."); + return; + } + sm.playPortalSE(); + sm.warpInstance(List.of(913002000), "sp", 130020000, 900, Map.of()); + spawnBaroqNpcs(sm, 913002000); + return; + } else if (sm.hasQuestStarted(20304)) { + // Night Walker (job 1410) + if (!sm.hasItem(INVESTIGATION_PERMIT, 1)) { + sm.message("You need an Investigation Permit from Neinheart before entering."); + return; + } + sm.playPortalSE(); + sm.warpInstance(List.of(913002300), "sp", 130020000, 900, Map.of()); + spawnBaroqNpcs(sm, 913002300); + return; + } else if (sm.hasQuestStarted(20305)) { + // Thunder Breaker (job 1510) + if (!sm.hasItem(INVESTIGATION_PERMIT, 1)) { + sm.message("You need an Investigation Permit from Neinheart before entering."); + return; + } + sm.playPortalSE(); + sm.warpInstance(List.of(913002100), "sp", 130020000, 900, Map.of()); + spawnBaroqNpcs(sm, 913002100); + return; + } + + sm.message("You need to be on the Master of Disguise quest to enter this area."); + } + + @Script("enterthirdDH") + public static void enterthirdDH(ScriptManager sm) { + // Empress' Road : Entrance to the 3rd Drill Hall (130020000) + // in02 portal - For level 100/110 skill quests with class-specific boss rooms + + if (sm.hasQuestStarted(20601)) { + // Dawn Warrior (job 1110) - Boss 9300287 + sm.playPortalSE(); + sm.warp(913010000); + } else if (sm.hasQuestStarted(20602)) { + // Blaze Wizard (job 1210) - Boss 9300288 + sm.playPortalSE(); + sm.warp(913010100); + } else if (sm.hasQuestStarted(20603)) { + // Wind Archer (job 1310) - Boss 9300289 + sm.playPortalSE(); + sm.warp(913010200); + } else if (sm.hasQuestStarted(20604)) { + // Night Walker (job 1410) - Boss 9300290 + sm.playPortalSE(); + sm.warp(913010300); + } else if (sm.hasQuestStarted(20605)) { + // Thunder Breaker (job 1510) - Boss 9300288 (shares with Blaze Wizard) + sm.playPortalSE(); + sm.warp(913010100); + } else { + sm.message("Hall #3 can only be entered if you're engaged in a Level 100/110 skill quest."); + } + } + + private static void spawnBaroqNpcs(ScriptManager sm, int targetMapId) { + // Spawn fake knight NPCs in the Baroq instance + // Called after warpInstance() - pass the target map ID since sm.getFieldId() still returns portal map + // spawnNpc() broadcasts NpcEnterField packet - NO map XML edits needed! + // originalField=false means spawn in player's CURRENT field (the instance they just warped to) + + // Each Cygnus class has different spawn coordinates (tested in-game) + int baseX; + final int baseY = 88; // All maps use Y=88 + + switch (targetMapId) { + case 913002200 -> { // Dawn Warrior - tested spawn: X=187, Y=88 + baseX = 187; + } + case 913002000 -> { // Wind Archer - tested spawn: X=2620, Y=88 + baseX = 2620; + } + case 913002300 -> { // Night Walker + Blaze Wizard (shared map) + // Night Walker tested: X=-2140, Blaze Wizard tested: X=-2225 + // Using Night Walker's spawn point as base + baseX = -2140; + } + case 913002100 -> { // Thunder Breaker - tested spawn: X=3365, Y=88 + baseX = 3365; + } + default -> { + // Fallback coordinates + baseX = 180; + } + } + + // Spawn 5 NPCs in a row, 100 pixels apart starting at spawn point + // originalField=false spawns in player's current field (the instance they're in now) + sm.spawnNpc(1104100, baseX, baseY, false, false); // Mihile + sm.spawnNpc(1104101, baseX + 100, baseY, false, false); // Oz + sm.spawnNpc(1104102, baseX + 200, baseY, false, false); // Irina + sm.spawnNpc(1104103, baseX + 300, baseY, false, false); // Eckart + sm.spawnNpc(1104104, baseX + 400, baseY, false, false); // Hawkeye + + // Debug: Show spawn coordinates + sm.message(String.format("[DEBUG] Spawned 5 NPCs in map %d at X=%d~%d, Y=%d", targetMapId, baseX, baseX + 400, baseY)); + sm.broadcastMessage("Search for the Master of Disguise among the knights! Talk to each one to find Baroq!"); + } + + @Script("enterfourthDH") + public static void enterfourthDH(ScriptManager sm) { + // Empress' Road : Entrance to the 4th Drill Hall (130020000) + // in03 portal - For BOTH level 120 skill quests AND 4th job advancement + + // First check for 4th job advancement quest (takes priority) + if (sm.hasQuestCompleted(20406) || sm.hasQuestStarted(20407)) { + sm.playPortalSE(); + sm.warp(913030000); // Dark Ereve - Black Witch Boss Map (4th Job) + return; + } + + // Then check for level 120 skill quests - class-specific boss rooms + if (sm.hasQuestStarted(20611)) { + // Dawn Warrior (job 1110) - Boss 9300291 + sm.playPortalSE(); + sm.warp(913020000); + } else if (sm.hasQuestStarted(20612)) { + // Blaze Wizard (job 1210) - Boss 9300292 + sm.playPortalSE(); + sm.warp(913020100); + } else if (sm.hasQuestStarted(20613)) { + // Wind Archer (job 1310) - Boss 9300293 + sm.playPortalSE(); + sm.warp(913020200); + } else if (sm.hasQuestStarted(20614)) { + // Night Walker (job 1410) - Boss 9300294 + sm.playPortalSE(); + sm.warp(913020300); + } else if (sm.hasQuestStarted(20615)) { + // Thunder Breaker (job 1510) - Boss 9300292 (shares with Blaze Wizard) + sm.playPortalSE(); + sm.warp(913020100); + } else { + sm.message("Hall #4 can only be entered if you're on a Level 120 skill quest or the 4th Job Advancement quest."); + } + } + + @Script("outSecondDH") + public static void outSecondDH(ScriptManager sm) { + // NPC to exit 2nd Drill Hall - warps back to Drill Hall Entrance + sm.sayNext("Are you ready to leave the 2nd Drill Hall?"); + if (sm.askYesNo("Would you like to return to the Drill Hall Entrance?")) { + sm.warp(130020000); // Empress' Road : Entrance to the Drill Hall + } + } + + @Script("outthirdDH") + public static void outthirdDH(ScriptManager sm) { + // NPC to exit 3rd Drill Hall - warps back to Drill Hall Entrance + sm.sayNext("Are you ready to leave the 3rd Drill Hall?"); + if (sm.askYesNo("Would you like to return to the Drill Hall Entrance?")) { + sm.warp(130020000); // Empress' Road : Entrance to the Drill Hall + } + } + + @Script("outfourthDH") + public static void outfourthDH(ScriptManager sm) { + // NPC to exit 4th Drill Hall / Boss Map - warps back to Drill Hall Entrance + sm.sayNext("Are you ready to leave?"); + if (sm.askYesNo("Would you like to return to the Drill Hall Entrance?")) { + sm.warp(130020000); // Empress' Road : Entrance to the Drill Hall + } + } + + @Script("outDarkEreb") + public static void outDarkEreb(ScriptManager sm) { + // Exit portal from Dark Ereve (913030000) - used after Black Witch boss fight + // Portal name in map: out00, but script name is outDarkEreb + sm.playPortalSE(); + sm.warp(130000000, "sp"); // Return to Ereve + } + @Script("q20101e") public static void q20101e(ScriptManager sm) { // Path of a Dawn Warrior (20101 - end) @@ -215,4 +447,1675 @@ public static void q20700s(ScriptManager sm) { sm.sayNext("#p1102000#, the Training Instructor, will help you train into a serviceable knight. Once you reach Level 13, I'll assign you a mission or two. So until then, keep training."); sm.sayPrev("Oh, and are you aware that if you strike a conversation with #p1101001#, she'll give you a blessing? The blessing will definitely help you on your journey."); } + + // MUSHKING EMPIRE QUESTS (2305-2310) ---------------------------------------------------------------- + + @Script("q2305s") + public static void q2305s(ScriptManager sm) { + // Quest 2305 - Endangered Mushking Empire (Dawn Warrior) (START) + // Cygnus Knights Instructor (1101003) + sm.sayNext("Hey, I have a request for you."); + sm.sayBoth("The Mushking Empire is in dire straits and in desperate need of help! I need you to take this #bletter of recommendation#k and deliver it to #b#p1300005##k, the Head Security Officer of the Mushking Empire."); + sm.sayBoth("The empire is facing a crisis, and they need brave adventurers like you. Please, take this letter and help them in their time of need!"); + + if (sm.addItem(4032375, 1)) { // Letter of Recommendation + sm.forceStartQuest(2305); + sm.sayOk("Thank you! Please hurry to the Mushking Empire and deliver this letter to #b#p1300005##k. They're counting on you!"); + } else { + sm.sayOk("You don't have enough space in your inventory. Please make room and come back."); + } + } + + @Script("q2305e") + public static void q2305e(ScriptManager sm) { + // Quest 2305 - Endangered Mushking Empire (Dawn Warrior) (END) + // Head Security Officer (1300005) - Mushking Empire + final int LETTER_OF_RECOMMENDATION = 4032375; + + if (!sm.hasItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("Who are you? You look like someone who's exploring Maple World. Our kingdom is in serious danger, and we need someone dependable that can save us. If you can't help us, then I suggest you move on."); + return; + } + + sm.sayNext("Ah! You brought the letter of recommendation! Thank you for coming to our aid. The Mushking Empire is grateful for your assistance."); + + if (!sm.removeItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("Please make sure you have the letter of recommendation."); + return; + } + + sm.addExp(6000); + sm.forceCompleteQuest(2305); + sm.sayOk("Your help means everything to us. With brave adventurers like you, the Mushking Empire will surely overcome this crisis!"); + } + + @Script("q2306s") + public static void q2306s(ScriptManager sm) { + // Quest 2306 - Endangered Mushking Empire (Blaze Wizard) (START) + // Cygnus Knights Instructor (1101004) + sm.sayNext("Hey, I have a request for you."); + sm.sayBoth("The Mushking Empire is in dire straits and in desperate need of help! I need you to take this #bletter of recommendation#k and deliver it to #b#p1300005##k, the Head Security Officer of the Mushking Empire."); + sm.sayBoth("The empire is facing a crisis, and they need brave adventurers like you. Please, take this letter and help them in their time of need!"); + + if (sm.addItem(4032375, 1)) { // Letter of Recommendation + sm.forceStartQuest(2306); + sm.sayOk("Thank you! Please hurry to the Mushking Empire and deliver this letter to #b#p1300005##k. They're counting on you!"); + } else { + sm.sayOk("You don't have enough space in your inventory. Please make room and come back."); + } + } + + @Script("q2306e") + public static void q2306e(ScriptManager sm) { + // Quest 2306 - Endangered Mushking Empire (Blaze Wizard) (END) + // Head Security Officer (1300005) - Mushking Empire + final int LETTER_OF_RECOMMENDATION = 4032375; + + if (!sm.hasItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("Who are you? You look like someone who's exploring Maple World. Our kingdom is in serious danger, and we need someone dependable that can save us. If you can't help us, then I suggest you move on."); + return; + } + + sm.sayNext("Ah! You brought the letter of recommendation! Thank you for coming to our aid. The Mushking Empire is grateful for your assistance."); + + if (!sm.removeItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("Please make sure you have the letter of recommendation."); + return; + } + + sm.addExp(6000); + sm.forceCompleteQuest(2306); + sm.sayOk("Your help means everything to us. With brave adventurers like you, the Mushking Empire will surely overcome this crisis!"); + } + + @Script("q2307s") + public static void q2307s(ScriptManager sm) { + // Quest 2307 - Endangered Mushking Empire (Wind Archer) (START) + // Cygnus Knights Instructor (1101005) + sm.sayNext("Hey, I have a request for you."); + sm.sayBoth("The Mushking Empire is in dire straits and in desperate need of help! I need you to take this #bletter of recommendation#k and deliver it to #b#p1300005##k, the Head Security Officer of the Mushking Empire."); + sm.sayBoth("The empire is facing a crisis, and they need brave adventurers like you. Please, take this letter and help them in their time of need!"); + + if (sm.addItem(4032375, 1)) { // Letter of Recommendation + sm.forceStartQuest(2307); + sm.sayOk("Thank you! Please hurry to the Mushking Empire and deliver this letter to #b#p1300005##k. They're counting on you!"); + } else { + sm.sayOk("You don't have enough space in your inventory. Please make room and come back."); + } + } + + @Script("q2307e") + public static void q2307e(ScriptManager sm) { + // Quest 2307 - Endangered Mushking Empire (Wind Archer) (END) + // Head Security Officer (1300005) - Mushking Empire + final int LETTER_OF_RECOMMENDATION = 4032375; + + if (!sm.hasItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("Who are you? You look like someone who's exploring Maple World. Our kingdom is in serious danger, and we need someone dependable that can save us. If you can't help us, then I suggest you move on."); + return; + } + + sm.sayNext("Ah! You brought the letter of recommendation! Thank you for coming to our aid. The Mushking Empire is grateful for your assistance."); + + if (!sm.removeItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("Please make sure you have the letter of recommendation."); + return; + } + + sm.addExp(6000); + sm.forceCompleteQuest(2307); + sm.sayOk("Your help means everything to us. With brave adventurers like you, the Mushking Empire will surely overcome this crisis!"); + } + + @Script("q2308s") + public static void q2308s(ScriptManager sm) { + // Quest 2308 - Endangered Mushking Empire (Night Walker) (START) + // Cygnus Knights Instructor (1101006) + sm.sayNext("Hey, I have a request for you."); + sm.sayBoth("The Mushking Empire is in dire straits and in desperate need of help! I need you to take this #bletter of recommendation#k and deliver it to #b#p1300005##k, the Head Security Officer of the Mushking Empire."); + sm.sayBoth("The empire is facing a crisis, and they need brave adventurers like you. Please, take this letter and help them in their time of need!"); + + if (sm.addItem(4032375, 1)) { // Letter of Recommendation + sm.forceStartQuest(2308); + sm.sayOk("Thank you! Please hurry to the Mushking Empire and deliver this letter to #b#p1300005##k. They're counting on you!"); + } else { + sm.sayOk("You don't have enough space in your inventory. Please make room and come back."); + } + } + + @Script("q2308e") + public static void q2308e(ScriptManager sm) { + // Quest 2308 - Endangered Mushking Empire (Night Walker) (END) + // Head Security Officer (1300005) - Mushking Empire + final int LETTER_OF_RECOMMENDATION = 4032375; + + if (!sm.hasItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("Who are you? You look like someone who's exploring Maple World. Our kingdom is in serious danger, and we need someone dependable that can save us. If you can't help us, then I suggest you move on."); + return; + } + + sm.sayNext("Ah! You brought the letter of recommendation! Thank you for coming to our aid. The Mushking Empire is grateful for your assistance."); + + if (!sm.removeItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("Please make sure you have the letter of recommendation."); + return; + } + + sm.addExp(6000); + sm.forceCompleteQuest(2308); + sm.sayOk("Your help means everything to us. With brave adventurers like you, the Mushking Empire will surely overcome this crisis!"); + } + + @Script("q2309s") + public static void q2309s(ScriptManager sm) { + // Quest 2309 - Endangered Mushking Empire (Thunder Breaker) (START) + // Cygnus Knights Instructor (1101007) + sm.sayNext("Hey, I have a request for you."); + sm.sayBoth("The Mushking Empire is in dire straits and in desperate need of help! I need you to take this #bletter of recommendation#k and deliver it to #b#p1300005##k, the Head Security Officer of the Mushking Empire."); + sm.sayBoth("The empire is facing a crisis, and they need brave adventurers like you. Please, take this letter and help them in their time of need!"); + + if (sm.addItem(4032375, 1)) { // Letter of Recommendation + sm.forceStartQuest(2309); + sm.sayOk("Thank you! Please hurry to the Mushking Empire and deliver this letter to #b#p1300005##k. They're counting on you!"); + } else { + sm.sayOk("You don't have enough space in your inventory. Please make room and come back."); + } + } + + @Script("q2309e") + public static void q2309e(ScriptManager sm) { + // Quest 2309 - Endangered Mushking Empire (Thunder Breaker) (END) + // Head Security Officer (1300005) - Mushking Empire + final int LETTER_OF_RECOMMENDATION = 4032375; + + if (!sm.hasItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("Who are you? You look like someone who's exploring Maple World. Our kingdom is in serious danger, and we need someone dependable that can save us. If you can't help us, then I suggest you move on."); + return; + } + + sm.sayNext("Ah! You brought the letter of recommendation! Thank you for coming to our aid. The Mushking Empire is grateful for your assistance."); + + if (!sm.removeItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("Please make sure you have the letter of recommendation."); + return; + } + + sm.addExp(6000); + sm.forceCompleteQuest(2309); + sm.sayOk("Your help means everything to us. With brave adventurers like you, the Mushking Empire will surely overcome this crisis!"); + } + + @Script("q2310s") + public static void q2310s(ScriptManager sm) { + // Quest 2310 - Endangered Mushking Empire (Aran) (START) + // Lilin (1201000) - Rien + sm.sayNext("Hey, I have a request for you."); + sm.sayBoth("The Mushking Empire is in dire straits and in desperate need of help! I need you to take this #bletter of recommendation#k and deliver it to #b#p1300005##k, the Head Security Officer of the Mushking Empire."); + sm.sayBoth("The empire is facing a crisis, and they need brave adventurers like you. Please, take this letter and help them in their time of need!"); + + if (sm.addItem(4032375, 1)) { // Letter of Recommendation + sm.forceStartQuest(2310); + sm.sayOk("Thank you! Please hurry to the Mushking Empire and deliver this letter to #b#p1300005##k. They're counting on you!"); + } else { + sm.sayOk("You don't have enough space in your inventory. Please make room and come back."); + } + } + + @Script("q2310e") + public static void q2310e(ScriptManager sm) { + // Quest 2310 - Endangered Mushking Empire (Aran) (END) + // Head Security Officer (1300005) - Mushking Empire + final int LETTER_OF_RECOMMENDATION = 4032375; + + if (!sm.hasItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("Who are you? You look like someone who's exploring Maple World. Our kingdom is in serious danger, and we need someone dependable that can save us. If you can't help us, then I suggest you move on."); + return; + } + + sm.sayNext("Ah! You brought the letter of recommendation! Thank you for coming to our aid. The Mushking Empire is grateful for your assistance."); + + if (!sm.removeItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("Please make sure you have the letter of recommendation."); + return; + } + + sm.addExp(6000); + sm.forceCompleteQuest(2310); + sm.sayOk("Your help means everything to us. With brave adventurers like you, the Mushking Empire will surely overcome this crisis!"); + } + + // 2ND JOB ADVANCEMENT QUESTS (20200-20205) ---------------------------------------------------------------- + + @Script("q20200s") + public static void q20200s(ScriptManager sm) { + // Quest 20200 - The End of Knight-in-Training (START) + // Neinheart (1101002) - Ereve + sm.sayNext("#h0#? Wow, your level has skyrocketed since the last time I saw you. You also look like you've taken care of a number of missions as well... you seem much more ready to move on now than the last time I saw you. What do you think? Are you interested in taking the #bKnighthood Exam#k? It's time for you to grow out of the Knight-in-Training and become a bonafide Knight, right?"); + + if (!sm.askAccept("Are you ready to take the Knighthood Exam?")) { + sm.sayNext("Hmmm... Do you feel like you still have missions to take care of as a trainee? I commend your level of patience, but this has gone too far. Cygnus Knights is in dire need of new, more powerful knights."); + return; + } + + sm.forceCompleteQuest(20200); + sm.sayOk("If you wish to take the Knighthood Exam, please come to Ereve. Each Chief Knight will test your abilities, and if you meet their standards, then you will officially become a Knight."); + } + + @Script("q20201e") + public static void q20201e(ScriptManager sm) { + // Quest 20201 - Knighthood Exam: Dawn Warrior (END) + // Chief Knight of Light (1101003) - Ereve + final int PROOF_OF_TEST = 4032096; + + // Check if quest needs to be started + if (!sm.hasQuestStarted(20201)) { + sm.sayNext("Do you wish to take the Knighthood Exam? Based on your contributions to Cygnus Knights so far, it is indeed hard to keep you here as the Knight-in-Training. Fine, I will let you take the Knighthood Exam. Are you ready?"); + + if (!sm.askYesNo("The test is rather simple. Enter #bthe 2nd Drill Hall#k located at the end of the Training Forest, defeat the Mimis inside, and bring back the #b#t" + PROOF_OF_TEST + "#s#k that they have with them. I need #b30#k.")) { + sm.sayNext("Talk to me when you are ready to take the test."); + return; + } + + sm.forceStartQuest(20201); + sm.sayOk("If you can bring all the #t" + PROOF_OF_TEST + "#s back, then I will grant you the right to become a Knight. At this point, I need your skills to do the talking."); + return; + } + + // Check if player has collected all proofs + if (!sm.hasItem(PROOF_OF_TEST, 30)) { + sm.sayOk("I don't think you have found #b30 #t" + PROOF_OF_TEST + "#s#k yet. Go to #bHall #2#k and find them."); + return; + } + + // Complete the quest + sm.sayNext("So, you brought all of Proof of Exam. Okay, I believe that you are now qualified to become an official knight. Do you want to become one?"); + + if (!sm.askYesNo("Are you ready to become an official Knight?")) { + sm.sayNext("I guess you are not ready to tackle on the responsibilities of an official knight."); + return; + } + + if (!sm.addItem(1142067, 1)) { + sm.sayNext("Please check and see if you have an empty slot available in your equip inventory."); + return; + } + + sm.removeItem(PROOF_OF_TEST, 30); + sm.setJob(Job.DAWN_WARRIOR_2); + sm.addSp(1, 1); + sm.forceCompleteQuest(20201); + + sm.sayNext("You are a Knight-in-Training no more. You are now an official knight of the Cygnus Knights."); + sm.sayBoth("I have given you some #bSP#k. I have also given you a number of skills for a Dawn Warrior that's only available to knights, so I want you to work on it and hopefully cultivate it as much as your soul."); + sm.sayPrev("Now that you are officially a Cygnus Knight, act like one so that you will continue to honor the Empress."); + } + + @Script("q20202e") + public static void q20202e(ScriptManager sm) { + // Quest 20202 - Knighthood Exam: Blaze Wizard (END) + // Chief Knight of Fire (1101004) - Ereve + final int PROOF_OF_TEST = 4032097; + + // Check if quest needs to be started + if (!sm.hasQuestStarted(20202)) { + sm.sayNext("Do you wish to take the Knighthood Exam? Wow, you are really fast. Based on your contributions to Cygnus Knights so far, it makes sense for you to take the Knighthood Exam. Are you ready?"); + + if (!sm.askYesNo("The test is actually not complicated at all. Remember the Training Forest in which you trained when you were a Noblesse? At the end of it, you'll find #bthe 2nd Drill Hall#k. There, your job is to defeat the Mimis and bring back #b30 #t" + PROOF_OF_TEST + "#s#k in return.")) { + sm.sayNext("I suppose you're not ready to take the test yet. Let... me know when you're ready."); + return; + } + + sm.forceStartQuest(20202); + sm.sayOk("If you can bring all the #t" + PROOF_OF_TEST + "#s, then I will grant you the right to become a Knight. At this point, I need your skills to do the talking."); + return; + } + + // Check if player has collected all proofs + if (!sm.hasItem(PROOF_OF_TEST, 30)) { + sm.sayOk("I don't think you have acquired #b30 #t" + PROOF_OF_TEST + "#s#k yet. Did you forget where to find it by chance? Go to the very end of the Training Forest, and you'll find #bthe 2nd Drill Hall#k. Find the Mimis inside, defeat them, and bring back #b30 #t" + PROOF_OF_TEST + "#s#k."); + return; + } + + // Complete the quest + sm.sayNext("Excellent! You've brought all the Proofs of Exam. You are now qualified to become an official knight. Are you ready?"); + + if (!sm.askYesNo("Do you want to become an official Knight?")) { + sm.sayNext("Take your time to prepare yourself for the responsibilities ahead."); + return; + } + + if (!sm.addItem(1142067, 1)) { + sm.sayNext("Please check and see if you have an empty slot available in your equip inventory."); + return; + } + + sm.removeItem(PROOF_OF_TEST, 30); + sm.setJob(Job.BLAZE_WIZARD_2); + sm.addSp(1, 1); + sm.forceCompleteQuest(20202); + + sm.sayNext("You are a Knight-in-Training no more. You are now an official knight of the Cygnus Knights."); + sm.sayBoth("I have given you some #bSP#k. I have also granted you access to new Blaze Wizard skills that are only available to official knights."); + sm.sayPrev("Continue to grow stronger and bring honor to the Cygnus Knights and our Empress."); + } + + @Script("q20203e") + public static void q20203e(ScriptManager sm) { + // Quest 20203 - Knighthood Exam: Wind Archer (END) + // Chief Knight of the Wind (1101005) - Ereve + final int PROOF_OF_TEST = 4032098; + + // Check if quest needs to be started + if (!sm.hasQuestStarted(20203)) { + sm.sayNext("Knighthood Exam... I suppose it's your turn to take it now. Based on your contributions to the Cygnus Knights, it only makes sense. Okay, I will grant you the right to take the Knighthood Exam. Are you ready?"); + + if (!sm.askYesNo("The test is actually quite easy. Remember the Training Forest in which you trained back in the day? At the end of it, you'll find #bthe 2nd Drill Hall#k. There, your job is to defeat the Mimis and bring back #b30 #t" + PROOF_OF_TEST + "#s#k in return.")) { + sm.sayNext("If you have the slightest of hesitation, it's good to dust them off first before taking on the test."); + return; + } + + sm.forceStartQuest(20203); + sm.sayOk("If you can bring all the #t" + PROOF_OF_TEST + "#s, then I will grant you the right to become a Knight. At this point, I need your skills to do the talking."); + return; + } + + // Check if player has collected all proofs + if (!sm.hasItem(PROOF_OF_TEST, 30)) { + sm.sayOk("I don't think you have brought #b30 #t" + PROOF_OF_TEST + "#s#k yet. Go to the end of the Training Forest. There, inside #bthe 2nd Drill Hall#k, you'll need to find #t" + PROOF_OF_TEST + "#..."); + return; + } + + // Complete the quest + sm.sayNext("You've returned with all the Proofs of Exam. You have proven yourself worthy of becoming an official knight. Shall we proceed?"); + + if (!sm.askYesNo("Are you ready to become an official Knight?")) { + sm.sayNext("Prepare yourself when you are ready."); + return; + } + + if (!sm.addItem(1142067, 1)) { + sm.sayNext("Please check and see if you have an empty slot available in your equip inventory."); + return; + } + + sm.removeItem(PROOF_OF_TEST, 30); + sm.setJob(Job.WIND_ARCHER_2); + sm.addSp(1, 1); + sm.forceCompleteQuest(20203); + + sm.sayNext("You are a Knight-in-Training no more. You are now an official knight of the Cygnus Knights."); + sm.sayBoth("I have given you some #bSP#k. You now have access to advanced Wind Archer skills befitting of an official knight."); + sm.sayPrev("May the wind guide your arrows and bring glory to the Cygnus Knights."); + } + + @Script("q20204e") + public static void q20204e(ScriptManager sm) { + // Quest 20204 - Knighthood Exam: Night Walker (END) + // Chief Knight of Darkness (1101006) - Ereve + final int PROOF_OF_TEST = 4032099; + + // Check if quest needs to be started + if (!sm.hasQuestStarted(20204)) { + sm.sayNext("Knighthood Exam? Is it already that time? Come to think of it... you have been working for the Cygnus Knights for quite some time... Okay, I will give you the opportunity to take the Knighthood Exam right now. Are you ready?"); + + if (!sm.askYesNo("The test is really nothing special. Go to the end of Training Forest and enter #bthe 2nd Drill Hall#k, where you'll find some Mimis inside. Defeat them all and bring back #b30 #t" + PROOF_OF_TEST + "#s#k with you.")) { + sm.sayNext("It doesn't matter when you start, but delaying the inevitable doesn't change a single thing."); + return; + } + + sm.forceStartQuest(20204); + sm.sayOk("If you can bring all the #t" + PROOF_OF_TEST + "#s back, then I will grant you the right to become a Knight. At this point, I need your skills to do the talking."); + return; + } + + // Check if player has collected all proofs + if (!sm.hasItem(PROOF_OF_TEST, 30)) { + sm.sayOk("You still haven't brought back #b30 #t" + PROOF_OF_TEST + "#s#k yet. I thought I told you to find them at the end of Training Forest, inside #bthe 2nd Drill Hall#k... Don't you remember this?"); + return; + } + + // Complete the quest + sm.sayNext("You've collected all the Proofs of Exam. You have what it takes to be an official knight. Do you accept this responsibility?"); + + if (!sm.askYesNo("Will you become an official Knight?")) { + sm.sayNext("Consider your decision carefully."); + return; + } + + if (!sm.addItem(1142067, 1)) { + sm.sayNext("Please check and see if you have an empty slot available in your equip inventory."); + return; + } + + sm.removeItem(PROOF_OF_TEST, 30); + sm.setJob(Job.NIGHT_WALKER_2); + sm.addSp(1, 1); + sm.forceCompleteQuest(20204); + + sm.sayNext("You are a Knight-in-Training no more. You are now an official knight of the Cygnus Knights."); + sm.sayBoth("I have given you some #bSP#k. The shadows will now serve you better as an official knight with access to advanced Night Walker skills."); + sm.sayPrev("Move through the darkness and strike down the enemies of the Empress."); + } + + @Script("q20205e") + public static void q20205e(ScriptManager sm) { + // Quest 20205 - Knighthood Exam: Thunder Breaker (END) + // Chief Knight of Lightning (1101007) - Ereve + final int PROOF_OF_TEST = 4032100; + + // Check if quest needs to be started + if (!sm.hasQuestStarted(20205)) { + sm.sayNext("What? Knighthood Exam? Are you taking that already? Wow, that was fast! Not a lot of time has passed since you first came here... but then again, seeing your contributions to the Cygnus Knights, it only makes sense. Okay, I'll let you take the test. Do you want to take it right now?"); + + if (!sm.askYesNo("The test is simple. At the end of the Training Forest, you'll find a training hall. At #bthe 2nd Drill Hall#k, you'll defeat a number of Mimis and bring back #b30 #t" + PROOF_OF_TEST + "#s#k.")) { + sm.sayNext("Hmmmm... do you have anything you want to prepare for? Okay, I will wait for you. Let me know when you're ready."); + return; + } + + sm.forceStartQuest(20205); + sm.sayOk("Once you bring all the Proofs of Test, everyone will consider you a bonafide Knight. Enjoy it!"); + return; + } + + // Check if player has collected all proofs + if (!sm.hasItem(PROOF_OF_TEST, 30)) { + sm.sayOk("I don't think you have gathered up #b30 #t" + PROOF_OF_TEST + "#s#k yet. Did you forget where the 2nd Drill Hall was? I understand if you're not good with directions, because neither I am... hahaha. Anyway, go to the end of the Training Forest, and you'll find #bthe 2nd Drill Hall#k, where you can find #t" + PROOF_OF_TEST + "#."); + return; + } + + // Complete the quest + sm.sayNext("Great job! You've brought all the Proofs of Exam! You're officially ready to become a knight. Are you excited?"); + + if (!sm.askYesNo("Ready to become an official Knight?")) { + sm.sayNext("Take your time! It's a big decision."); + return; + } + + if (!sm.addItem(1142067, 1)) { + sm.sayNext("Please check and see if you have an empty slot available in your equip inventory."); + return; + } + + sm.removeItem(PROOF_OF_TEST, 30); + sm.setJob(Job.THUNDER_BREAKER_2); + sm.addSp(1, 1); + sm.forceCompleteQuest(20205); + + sm.sayNext("You are a Knight-in-Training no more. You are now an official knight of the Cygnus Knights!"); + sm.sayBoth("I have given you some #bSP#k. You now have access to powerful Thunder Breaker skills that befit an official knight!"); + sm.sayPrev("Let the lightning guide your fists and bring victory to the Cygnus Knights!"); + } + + // ACCLIMATION TRAINING QUESTS (20701-20703) ---------------------------------------------------------------- + + @Script("q20701s") + public static void q20701s(ScriptManager sm) { + // Quest 20701 - 1st Acclimation Training (START) + // Kiku (1102000) - Training Instructor + sm.sayNext("Haha, so you're here! #p1101002# would have never let you out when you have just become a Knight-in-Training. I'm sure he said something along the lines of you diligently training until you reach Level 13."); + sm.sayBoth("What is there to do except train anyway. This is all for your own good, and for the good of the Cygnus Knights. Now, let's work on the training."); + + if (!sm.askYesNo("The training you are about to undergo entails you getting acclimated to monsters outside Ereve, which means you are training for the real world. You'll need to do that sooner or later, anyway.")) { + sm.sayNext("Hmmm? What is it? If you don't like the way #p1101002# talks, then don't mind him. He's always like that anyway, so you might as well get used to him."); + return; + } + + sm.forceStartQuest(20701); + sm.sayNext("Go to the end of Training Forest, and you'll encounter a number of entrances. Your job is to enter #bthe 1st Drill Hall#k and take on a number of #r#o9300271#s#k that are specialized for this training. I want you to defeat #r30#k of them."); + } + + @Script("q20701e") + public static void q20701e(ScriptManager sm) { + // Quest 20701 - 1st Acclimation Training (END) + // Kiku (1102000) - Training Instructor + final int TIGURU = 9300271; + + sm.sayNext("Ohhh, you managed to defeat 30 #o" + TIGURU + "#s. That's fast. If you keep up this pace, this training might be a breeze."); + + sm.addExp(1450); + sm.addItem(2000020, 30); // Red Potion + sm.addItem(2000021, 30); // Blue Potion + sm.forceCompleteQuest(20701); + + sm.sayOk("Talk to me when you're ready to take on the next step."); + } + + @Script("q20702s") + public static void q20702s(ScriptManager sm) { + // Quest 20702 - 2nd Acclimation Training (START) + // Kiku (1102000) - Training Instructor + sm.sayNext("Okay, are you ready to take on the next training session? I'm sure #p1101002# told you this already, but if you talk to #p1101001#, you'll be divinely blessed, which will help you go through the training that much easier. If you forgot to do so, then go get it right now. Are you ready?"); + + if (!sm.askYesNo("This time, the targets are #r#o9300272#s#k, which are monsters you'll run into quite often in Victoria Island. Honestly, I've never been there, so I don't know if that statement is legit.")) { + sm.sayNext("If you forgot something, then I suggest you take care of that first before going off to battle. There's nothing worse than looking for an item in the heat of the battle, only to realize you left it at home."); + return; + } + + sm.forceStartQuest(20702); + sm.sayOk("Like I said last time, enter #bthe 1st Drill Hall#k and you'll see a group of #o9300272#s ready to battle. I need you to defeat #r30#k of them."); + } + + @Script("q20702e") + public static void q20702e(ScriptManager sm) { + // Quest 20702 - 2nd Acclimation Training (END) + // Kiku (1102000) - Training Instructor + final int RIBBON_PIG = 9300272; + + sm.sayNext("Oh, did you manage to defeat 30 #o" + RIBBON_PIG + "#s? I have to say, you're doing quite well right now. Let's do one final session. Talk to me when you are ready."); + + sm.addExp(2000); + sm.addItem(2000020, 40); // Red Potion + sm.addItem(2000021, 40); // Blue Potion + sm.forceCompleteQuest(20702); + } + + @Script("q20703s") + public static void q20703s(ScriptManager sm) { + // Quest 20703 - 3rd Acclimation Training (START) + // Kiku (1102000) - Training Instructor + sm.sayNext("Are you ready to start the next training session? You did receive the blessing from #p1101001#, right? Then let's get this started. This is the final chapter of the Acclimation Training, which means after you pass this, you will be pressed into real duties!"); + + if (!sm.askYesNo("The targets for this session are #o9300273#s. They are very much aggressive creatures, and they definitely look the part as well. You'll find a number of those creatures inside #bthe 1st Drill Hall#k, so go ahead and defeat #r30#k of them.")) { + sm.sayNext("Your real Knight duties are coming up soon. Aren't you excited? Get this training session over with so you can take on the real duties!"); + return; + } + + sm.forceStartQuest(20703); + sm.sayOk("Good luck! Be careful, as #o9300273#s have quite a temper!"); + } + + @Script("q20703e") + public static void q20703e(ScriptManager sm) { + // Quest 20703 - 3rd Acclimation Training (END) + // Kiku (1102000) - Training Instructor + final int SLIME = 9300273; +sm.sayNext("I see that you have eliminated all the #o" + SLIME + "#s. Great work there! Do you feel like you can do well out of this island and in the Victoria Island?"); + + sm.addExp(2800); + sm.addItem(2000020, 50); // Red Potion + sm.addItem(2000021, 50); // Blue Potion + sm.forceCompleteQuest(20703); + + sm.sayBoth("This marks the end of the Acclimation Training sessions. You'll do well and not only survive, but thrive in Victoria Island. You will be thrown into fire, but I believe in you enough that you will find no trouble persevering through all that!"); + sm.sayPrev("Your true duties begin now. Once you reach Level 13, talk to #p1101002# and he will give you a list of duties you'll have to take on. Please serve and honor the Empress well, and do not engage in activities that may jeopardize your standings in the Cygnus Knights."); + } + + // 3RD JOB ADVANCEMENT QUESTS (20300-20315) ---------------------------------------------------------------- + + @Script("q20300s") + public static void q20300s(ScriptManager sm) { + // Quest 20300 - The Lost Treasure (START) + // Neinheart (1101002) - Ereve + sm.sayNext("Mayday, mayday! All members of the Cygnus Knights must return to #bEreve#k immediately. I repeat, everyone must return to Ereve immediately! Details available at Ereve."); + + if (!sm.askAccept("We need everyone there at once, so go there ASAP.")) { + sm.sayNext("I don't know what you're doing right now, but I can't think of anything else as urgent as this. I trust you to make a judgment worthy of a Cygnus Knight."); + return; + } + + sm.forceCompleteQuest(20300); + sm.sayNext("You've heard of a group out there that is there to support Black Mage, right? They are called the Black Wings and they are our main adversaries. Apparently, one of them managed to enter Ereve and steal a very important treasure."); + sm.sayOk("I doubt the thief went very far, so we must conduct the search right this minute. The Chief Knights will provide details, so please go see your Chief Knight immediately."); + } + + @Script("q20301e") + public static void q20301e(ScriptManager sm) { + // Quest 20301 - The Master of Disguise: Dawn Warrior (END) + // Chief Knight of Light (1101003) - Ereve + final int SHINSOO_TEARDROP = 4032101; + + // Check if quest needs to be started + if (!sm.hasQuestStarted(20301)) { + sm.sayNext("Ereve is protected by the Shinsoo, so when an evil force enters the island, it's easy to detect that something is wrong. The problem is, we were unable to do so this time around, and that's because the culprit disguised himself as a Knight. He's #r#o9001009# Baroq, the member of the Black Wings#k. He is responsible for the theft."); + sm.sayBoth("The treasure is one of those important items that MUST NOT be in the hands of the Black Mage. #o9001009# has yet to escape Ereve, so I need you to join the search as well. Before conducting the search, however, you must first acquire #b#t4032179##k, so go visit #p1101002# and obtain the Investigation Permit."); + sm.sayBoth("You MUST locate the #r#o9001009##k, eliminate it, and bring back the #btreasure#k. I, as well as other Knights, will join you in this effort."); + + if (!sm.askYesNo("Beware of the fact that #o9001009# is always capable of tricking you by pretending to be someone else. The appearances may change, but the inner self won't, so #rI suggest you keep talking to various Knights until you hear something that doesn't sound like it would come from a Knight. If you do, you can consider that individual #o9001009##k and attack immediately.")) { + sm.sayNext("You can run away if you're afraid of this. Just know that if you keep running away, you will never amount to anything."); + return; + } + + sm.forceStartQuest(20301); + sm.sayOk("Good luck in your search. The fate of Ereve depends on you!"); + return; + } + + // Check if player defeated Baroq and got treasure + if (!sm.hasItem(SHINSOO_TEARDROP, 1)) { + sm.sayOk("Were you not able to find #o9001009#? #o9001009# has yet to escape Ereve. You must first visit #b#p1101002##k and obtain the #bInvestigation Permit#k before conducting the search. Once you have begun the search, you must defeat #r#o9001009##k and recover #b#t" + SHINSOO_TEARDROP + "#k."); + return; + } + + // Complete quest + sm.sayNext("So you were able to defeat #o9001009# #p1104001#. Brilliant!"); + sm.removeItem(SHINSOO_TEARDROP, 1); + sm.forceCompleteQuest(20301); + sm.sayOk("The Empress has expressed gratitude for your effort and loyalty."); + } + + @Script("q20302e") + public static void q20302e(ScriptManager sm) { + // Quest 20302 - The Master of Disguise: Blaze Wizard (END) + // Chief Knight of Fire (1101004) - Ereve + final int SHINSOO_TEARDROP = 4032102; + + if (!sm.hasQuestStarted(20302)) { + sm.sayNext("Wow, you're here! #p1101002# gave you an outline, right? Then let's cut to the chase. Ereve is an island protected by the Shinsoo, which means no force of evil can enter the island. Even if someone did enter, the person would have been noticed right away."); + sm.sayBoth("The problem is, we were unable to spot the intruder this time. That's because the thief is the #rmember of the Black Wings, #o9001009# #p1104001##k. We detected an intrusion, but we were unable to spot the fake. So while people were panicking, the thief brazenly breezed through and stole the treasure."); + sm.sayBoth("That is the one item that SHOULD NOT be at the hands of Black Mage! Since #o9001009# has yet to leave Ereve, I want you to join in on the search as well. If you meet #p1101002#, you'll be able to receive a #b#t4032179##k that requires rummaging through Ereve."); + sm.sayBoth("Okay, now I want you to defeat #r#o9001009##k and bring back the #btreasure#k! I, as well as other members, will also aid you in the search."); + + if (!sm.askYesNo("#o9001009# is most likely disguised as one of the Knights. The appearances may change, but the inner self won't, so #rI suggest you keep talking to various Knights until you hear something that doesn't sound like it would come from a Knight. If you do, you can consider that individual #o9001009##k and attack immediately.")) { + sm.sayNext("Are... are you scared? I can understand the fear that's creeping through you... but running away will not help you accomplish a single thing. Please think about this once more."); + return; + } + + sm.forceStartQuest(20302); + sm.sayOk("Good hunting! Bring back the treasure safely!"); + return; + } + + if (!sm.hasItem(SHINSOO_TEARDROP, 1)) { + sm.sayOk("Were you not able to find #o9001009# yet? #o9001009# has yet to leave Ereve, so receive the #bInvestigation Permit#k first from #b#p1101002##k, then conduct a search of your own. Sooner or later, you'll find #r#o9001009##k. I need you to really eliminate it and bring back #b#t" + SHINSOO_TEARDROP + "#k!"); + return; + } + + sm.sayNext("Were you able to defeat #o9001009# #p1104001#?? Wow, that is just incredible! You are indeed an incredible Knight!"); + sm.removeItem(SHINSOO_TEARDROP, 1); + sm.forceCompleteQuest(20302); + sm.sayOk("The Empress is very proud of your work!"); + } + + @Script("q20303e") + public static void q20303e(ScriptManager sm) { + // Quest 20303 - The Master of Disguise: Wind Archer (END) + // Chief Knight of Wind (1101005) - Ereve + final int SHINSOO_TEARDROP = 4032103; + + if (!sm.hasQuestStarted(20303)) { + sm.sayNext("..I am sure .#p1101002# gave you a thorough outline, so I will just go straight to the facts. As you know, Ereve is an island protected by the Shinsoo. Any form of darkness is not only forbidden from winning, but even if it does enter, it'd be detected right away and be promptly removed."); + sm.sayBoth("Unfortunately, we weren't able to do that this time, and... that's because... we did not account for who the thief was. The culprit was#ra member of Black Wings, #o9001009# #p1104001##k... We were unable to locate him even after entering this island, because he was in disguise."); + sm.sayBoth("The treasure should NEVER end up in Black Mage's hands. #o9001009# has yet to escape Ereve, so you must participate in this search as well. Go visit #p1101002# and receive #b#t4032179##k."); + sm.sayBoth("Now... Go defeat #r#o9001009##k... And bring back the #btreasure#k... I, as well as other Knights, will also be conducting the search."); + + if (!sm.askYesNo("#o9001009# is most likely disguised as one of the Knights. The appearances may change, but the inner self won't, so #rI suggest you keep talking to various Knights until you hear something that doesn't sound like it would come from a Knight. If you do, you can consider that individual #o9001009##k and attack immediately.")) { + sm.sayNext("Fear? Hesitation? As long as you keep the two with you, you will never be able to move up."); + return; + } + + sm.forceStartQuest(20303); + sm.sayOk("May the wind guide you in your search."); + return; + } + + if (!sm.hasItem(SHINSOO_TEARDROP, 1)) { + sm.sayOk("Were you unable to find #o9001009#...? I know for sure that he has yet to escape. First, visit #b#p1101002##k and receive the #bInvestigation Permit#k, then conduct a thorough search of the whole Ereve to locate #r#o9001009##k, then defeat it to bring #b#t" + SHINSOO_TEARDROP + "#k back..."); + return; + } + + sm.sayNext("Wow... you were able to defeat #o9001009# #p1104001#... You are a special Knight, indeed."); + sm.removeItem(SHINSOO_TEARDROP, 1); + sm.forceCompleteQuest(20303); + sm.sayOk("The Empress observes your accomplishment. Do not forget the fact that you're an important individual here."); + } + + @Script("q20304e") + public static void q20304e(ScriptManager sm) { + // Quest 20304 - The Master of Disguise: Night Walker (END) + // Chief Knight of Darkness (1101006) - Ereve + final int SHINSOO_TEARDROP = 4032104; + + if (!sm.hasQuestStarted(20304)) { + sm.sayNext("You're here. I'm sure #p1101002# told you the gist of things, so I should forgo the details and... but you look like you need the details again. Sigh. As you are well aware, Ereve is an island protected by Shinsoo. No evil presence may enter the island, and even if it were successfully here, they'd be immediately detected and removed from the premise."); + sm.sayBoth("The problem is, we were unable to find him this time. It's simple, really. The thief was actually #o9001009#. #o9001009# #p1104001##k, an important member of the #rBlack Wings#k. We were unable to spot him because of his ability to transform himself to another being."); + sm.sayBoth("The treasure is an important item that shall NEVER end up at the hands of the Black Mage. #o9001009# has yet to escape Ereve, so join the search effort. #b#t4032179##k will be needed to conduct the search, so go visit #p1101002# first."); + sm.sayBoth("Now, I want you to defeat #r#o9001009##k and bring back the #btreasure#k.I, as well as other Knights, will also be searching."); + + if (!sm.askYesNo("#o9001009# is most likely disguised as one of the Knights. The appearances may change, but the inner self won't, so #rI suggest you keep talking to various Knights until you hear something that doesn't sound like it would come from a Knight. If you do, you can consider that individual #o9001009##k and attack immediately.")) { + sm.sayNext("Are you scared already? Keep being afraid and you will never receive the chance to move up."); + return; + } + + sm.forceStartQuest(20304); + sm.sayOk("Move through the shadows and find the intruder."); + return; + } + + if (!sm.hasItem(SHINSOO_TEARDROP, 1)) { + sm.sayOk("Were you not able to find #o9001009#? Reports are that he has yet to escape Ereve, so keep searching. First, acquire the #bInvestigation Permit#k through #b#p1101002##k to start the search of Ereve. Once you run into #r#o9001009##k, eliminate him and bring back #b#t" + SHINSOO_TEARDROP + "#k."); + return; + } + + sm.sayNext("Did you manage to defeat #o9001009# #p1104001#? Hmmm... not bad. That was brilliant."); + sm.removeItem(SHINSOO_TEARDROP, 1); + sm.forceCompleteQuest(20304); + sm.sayOk("The Empress is indeed watching your every move, and she is impressed as well."); + } + + @Script("q20305e") + public static void q20305e(ScriptManager sm) { + // Quest 20305 - The Master of Disguise: Thunder Breaker (END) + // Chief Knight of Lightning (1101007) - Ereve + final int SHINSOO_TEARDROP = 4032105; + + if (!sm.hasQuestStarted(20305)) { + sm.sayNext("#p1101002# told you what happened, right? It's just terrible! Ereve is usually an island protected by the power of Shinsoo, which prevented forces of evil from landing here, but... a master of transformation!!! Who would have thought?"); + sm.sayBoth("Ah, you didn't hear anything about the master of transformation? #r#o9001009# #p1104001#is actually a member of the Black Wings, and he's very good at transforming to someone else#k. He'll come back as one of the knights, and I need you to spot the fake."); + sm.sayBoth("It is a treasure that MUST NOT end up at the hands of the Black Mage. #o9001009# has yet to leave Ereve, so start the dash! Ereve shall be found soon. The #b#t4032179##k required to conduct a search will be given to by #p1101002#!"); + sm.sayBoth("Now go ahead and defeat #r#o9001009##k, so you can bring back the #btreasure#k home! I'll be conducting searches of my own with other Knights as well."); + + if (!sm.askYesNo("#o9001009# is most likely disguised as one of the Knights. The appearances may change, but the inner self won't, so #rI suggest you keep talking to various Knights until you hear something that doesn't sound like it would come from a Knight. If you do, you can consider that individual #o9001009##k and attack immediately.")) { + sm.sayNext("Wait a minute, are you afraid of this? Seriously, what other adventure can bring this much excitement? Don't be scared... Think it over. You can't possibly expect yourself to improve just by sitting around doing nothing, right?"); + return; + } + + sm.forceStartQuest(20305); + sm.sayOk("Let the lightning guide your search! Yeah!"); + return; + } + + if (!sm.hasItem(SHINSOO_TEARDROP, 1)) { + sm.sayOk("You still haven't found #o9001009#? I hear that he has yet to escape Ereve, so if you keep searching, you'll find him. Go see #b#p1101002##k and receive the #bInvestigation Permit#k to start searching on Ereve. Once you encounter #r#o9001009##k, eliminate it, and bring back #b#t" + SHINSOO_TEARDROP + "#k with you!"); + return; + } + + sm.sayNext("Wow!! You were able to defeat #p1104001#! That is just incredible! Yeah!"); + sm.removeItem(SHINSOO_TEARDROP, 1); + sm.forceCompleteQuest(20305); + sm.sayOk("The Empress is ecstatic, as well! Yeah!"); + } + + // INVESTIGATION PERMIT QUESTS (20306-20310) ---------------------------------------------------------------- + + @Script("q20306e") + public static void q20306e(ScriptManager sm) { + // Quest 20306 - Ereve Investigation Permit: Dawn Warrior (END) + // Neinheart (1101002) - Ereve + final int POWER_CRYSTAL = 4005004; + final int INVESTIGATION_PERMIT = 4032179; + + // Check if quest needs to be started + if (!sm.hasQuestStarted(20306)) { + sm.sayNext("...#t" + INVESTIGATION_PERMIT + "#? I am sorry, but there's a problem with that. Truth be told, Ereve had never faced a crisis like this before, so naturally, I have never had to provide so many copies of Investigation Permits to various Knights. Yes, I am short on supplies needed to make the Investigation Permit."); + sm.sayBoth("I have been quickly making new Investigation Permits, but that's also not easy in that... #t" + INVESTIGATION_PERMIT + "# is created using an item that is placed in specifically to prevent illegal copies from being made, so... yes, I am short on one item. The search needs to start right away, as well..."); + sm.sayBoth("Tell you what. I want you to bring me that item that is required to make the Investigation Permit. I know we don't have much time, but even the best Master of Disguise won't be able to escape Ereve that quickly, especially if the island is in code red as it is now. Once you bring me the item, I will go ahead and print you the Investigation Permit."); + + if (!sm.askYesNo("The item I need is 1 #t" + POWER_CRYSTAL + "#. The Investigation Permit created with the magic power of #t" + POWER_CRYSTAL + "# cannot be duplicated nor stolen, so it's perfect for this. Thanks in advance.")) { + sm.sayNext("Oh no.. are you giving up on the search?"); + return; + } + + sm.forceStartQuest(20306); + sm.sayOk("Please hurry! Every second counts!"); + return; + } + + // Check if player has Power Crystal + if (!sm.hasItem(POWER_CRYSTAL, 1)) { + sm.sayOk("You have yet to bring #t" + POWER_CRYSTAL + "#. Every second counts, and we can't afford to lose any more time. Please hurry."); + return; + } + + // Complete quest and give Investigation Permit + sm.sayNext("Oh you brought #t" + POWER_CRYSTAL + "#. I'll go ahead and make you #t" + INVESTIGATION_PERMIT + "# right now."); + + if (!sm.removeItem(POWER_CRYSTAL, 1)) { + sm.sayOk("Please make sure you have the Power Crystal."); + return; + } + + sm.addItem(INVESTIGATION_PERMIT, 1); + sm.forceCompleteQuest(20306); + sm.sayOk("This will give you access to every part of Ereve that is currently in code red. Please search through the island carefully and find the Master of Disguise that is responsible for this mess."); + } + + @Script("q20307e") + public static void q20307e(ScriptManager sm) { + // Quest 20307 - Ereve Investigation Permit: Blaze Wizard (END) + final int POWER_CRYSTAL = 4005004; + final int INVESTIGATION_PERMIT = 4032179; + + if (!sm.hasQuestStarted(20307)) { + sm.sayNext("...#t" + INVESTIGATION_PERMIT + "#? I am sorry, but there's a problem with that. Truth be told, Ereve had never faced a crisis like this before, so naturally, I have never had to provide so many copies of Investigation Permits to various Knights. Yes, I am short on supplies needed to make the Investigation Permit."); + sm.sayBoth("I have been quickly making new Investigation Permits, but that's also not easy in that... #t" + INVESTIGATION_PERMIT + "# is created using an item that is placed in specifically to prevent illegal copies from being made, so... yes, I am short on one item. The search needs to start right away, as well..."); + sm.sayBoth("Tell you what. I want you to bring me that item that is required to make the Investigation Permit. I know we don't have much time, but even the best Master of Disguise won't be able to escape Ereve that quickly, especially if the island is in code red as it is now. Once you bring me the item, I will go ahead and print you the Investigation Permit."); + + if (!sm.askYesNo("The item I need is 1 #t" + POWER_CRYSTAL + "#. The Investigation Permit created with the magic power of #t" + POWER_CRYSTAL + "# cannot be duplicated nor stolen, so it's perfect for this. Thanks in advance.")) { + sm.sayNext("Oh no.. are you giving up on the search?"); + return; + } + + sm.forceStartQuest(20307); + sm.sayOk("Please hurry! Every second counts!"); + return; + } + + if (!sm.hasItem(POWER_CRYSTAL, 1)) { + sm.sayOk("You have yet to bring #t" + POWER_CRYSTAL + "#. Every second counts, and we can't afford to lose any more time. Please hurry."); + return; + } + + sm.sayNext("Oh you brought #t" + POWER_CRYSTAL + "#. I'll go ahead and make you #t" + INVESTIGATION_PERMIT + "# right now."); + + if (!sm.removeItem(POWER_CRYSTAL, 1)) { + sm.sayOk("Please make sure you have the Power Crystal."); + return; + } + + sm.addItem(INVESTIGATION_PERMIT, 1); + sm.forceCompleteQuest(20307); + sm.sayOk("This will give you access to every part of Ereve that is currently in code red. Please search through the island carefully and find the Master of Disguise that is responsible for this mess."); + } + + @Script("q20308e") + public static void q20308e(ScriptManager sm) { + // Quest 20308 - Ereve Investigation Permit: Wind Archer (END) + final int POWER_CRYSTAL = 4005004; + final int INVESTIGATION_PERMIT = 4032179; + + if (!sm.hasQuestStarted(20308)) { + sm.sayNext("...#t" + INVESTIGATION_PERMIT + "#? I am sorry, but there's a problem with that. Truth be told, Ereve had never faced a crisis like this before, so naturally, I have never had to provide so many copies of Investigation Permits to various Knights. Yes, I am short on supplies needed to make the Investigation Permit."); + sm.sayBoth("I have been quickly making new Investigation Permits, but that's also not easy in that... #t" + INVESTIGATION_PERMIT + "# is created using an item that is placed in specifically to prevent illegal copies from being made, so... yes, I am short on one item. The search needs to start right away, as well..."); + sm.sayBoth("Tell you what. I want you to bring me that item that is required to make the Investigation Permit. I know we don't have much time, but even the best Master of Disguise won't be able to escape Ereve that quickly, especially if the island is in code red as it is now. Once you bring me the item, I will go ahead and print you the Investigation Permit."); + + if (!sm.askYesNo("The item I need is 1 #t" + POWER_CRYSTAL + "#. The Investigation Permit created with the magic power of #t" + POWER_CRYSTAL + "# cannot be duplicated nor stolen, so it's perfect for this. Thanks in advance.")) { + sm.sayNext("Oh no.. are you giving up on the search?"); + return; + } + + sm.forceStartQuest(20308); + sm.sayOk("Please hurry! Every second counts!"); + return; + } + + if (!sm.hasItem(POWER_CRYSTAL, 1)) { + sm.sayOk("You have yet to bring #t" + POWER_CRYSTAL + "#. Every second counts, and we can't afford to lose any more time. Please hurry."); + return; + } + + sm.sayNext("Oh you brought #t" + POWER_CRYSTAL + "#. I'll go ahead and make you #t" + INVESTIGATION_PERMIT + "# right now."); + + if (!sm.removeItem(POWER_CRYSTAL, 1)) { + sm.sayOk("Please make sure you have the Power Crystal."); + return; + } + + sm.addItem(INVESTIGATION_PERMIT, 1); + sm.forceCompleteQuest(20308); + sm.sayOk("This will give you access to every part of Ereve that is currently in code red. Please search through the island carefully and find the Master of Disguise that is responsible for this mess."); + } + + @Script("q20309e") + public static void q20309e(ScriptManager sm) { + // Quest 20309 - Ereve Investigation Permit: Night Walker (END) + final int POWER_CRYSTAL = 4005004; + final int INVESTIGATION_PERMIT = 4032179; + + if (!sm.hasQuestStarted(20309)) { + sm.sayNext("...#t" + INVESTIGATION_PERMIT + "#? I am sorry, but there's a problem with that. Truth be told, Ereve had never faced a crisis like this before, so naturally, I have never had to provide so many copies of Investigation Permits to various Knights. Yes, I am short on supplies needed to make the Investigation Permit."); + sm.sayBoth("I have been quickly making new Investigation Permits, but that's also not easy in that... #t" + INVESTIGATION_PERMIT + "# is created using an item that is placed in specifically to prevent illegal copies from being made, so... yes, I am short on one item. The search needs to start right away, as well..."); + sm.sayBoth("Tell you what. I want you to bring me that item that is required to make the Investigation Permit. I know we don't have much time, but even the best Master of Disguise won't be able to escape Ereve that quickly, especially if the island is in code red as it is now. Once you bring me the item, I will go ahead and print you the Investigation Permit."); + + if (!sm.askYesNo("The item I need is 1 #t" + POWER_CRYSTAL + "#. The Investigation Permit created with the magic power of #t" + POWER_CRYSTAL + "# cannot be duplicated nor stolen, so it's perfect for this. Thanks in advance.")) { + sm.sayNext("Oh no.. are you giving up on the search?"); + return; + } + + sm.forceStartQuest(20309); + sm.sayOk("Please hurry! Every second counts!"); + return; + } + + if (!sm.hasItem(POWER_CRYSTAL, 1)) { + sm.sayOk("You have yet to bring #t" + POWER_CRYSTAL + "#. Every second counts, and we can't afford to lose any more time. Please hurry."); + return; + } + + sm.sayNext("Oh you brought #t" + POWER_CRYSTAL + "#. I'll go ahead and make you #t" + INVESTIGATION_PERMIT + "# right now."); + + if (!sm.removeItem(POWER_CRYSTAL, 1)) { + sm.sayOk("Please make sure you have the Power Crystal."); + return; + } + + sm.addItem(INVESTIGATION_PERMIT, 1); + sm.forceCompleteQuest(20309); + sm.sayOk("This will give you access to every part of Ereve that is currently in code red. Please search through the island carefully and find the Master of Disguise that is responsible for this mess."); + } + + @Script("q20310e") + public static void q20310e(ScriptManager sm) { + // Quest 20310 - Ereve Investigation Permit: Thunder Breaker (END) + final int POWER_CRYSTAL = 4005004; + final int INVESTIGATION_PERMIT = 4032179; + + if (!sm.hasQuestStarted(20310)) { + sm.sayNext("...#t" + INVESTIGATION_PERMIT + "#? I am sorry, but there's a problem with that. Truth be told, Ereve had never faced a crisis like this before, so naturally, I have never had to provide so many copies of Investigation Permits to various Knights. Yes, I am short on supplies needed to make the Investigation Permit."); + sm.sayBoth("I have been quickly making new Investigation Permits, but that's also not easy in that... #t" + INVESTIGATION_PERMIT + "# is created using an item that is placed in specifically to prevent illegal copies from being made, so... yes, I am short on one item. The search needs to start right away, as well..."); + sm.sayBoth("Tell you what. I want you to bring me that item that is required to make the Investigation Permit. I know we don't have much time, but even the best Master of Disguise won't be able to escape Ereve that quickly, especially if the island is in code red as it is now. Once you bring me the item, I will go ahead and print you the Investigation Permit."); + + if (!sm.askYesNo("The item I need is 1 #t" + POWER_CRYSTAL + "#. The Investigation Permit created with the magic power of #t" + POWER_CRYSTAL + "# cannot be duplicated nor stolen, so it's perfect for this. Thanks in advance.")) { + sm.sayNext("Oh no.. are you giving up on the search?"); + return; + } + + sm.forceStartQuest(20310); + sm.sayOk("Please hurry! Every second counts!"); + return; + } + + if (!sm.hasItem(POWER_CRYSTAL, 1)) { + sm.sayOk("You have yet to bring #t" + POWER_CRYSTAL + "#. Every second counts, and we can't afford to lose any more time. Please hurry."); + return; + } + + sm.sayNext("Oh you brought #t" + POWER_CRYSTAL + "#. I'll go ahead and make you #t" + INVESTIGATION_PERMIT + "# right now."); + + if (!sm.removeItem(POWER_CRYSTAL, 1)) { + sm.sayOk("Please make sure you have the Power Crystal."); + return; + } + + sm.addItem(INVESTIGATION_PERMIT, 1); + sm.forceCompleteQuest(20310); + sm.sayOk("This will give you access to every part of Ereve that is currently in code red. Please search through the island carefully and find the Master of Disguise that is responsible for this mess."); + } + + // SHINSOO'S TEARDROP - FINAL ADVANCEMENT QUESTS (20311-20315) ---------------------------------------------------------------- + + @Script("q20311s") + public static void q20311s(ScriptManager sm) { + // Quest 20311 - Shinsoo's Teardrop: Dawn Warrior (START & COMPLETE) + // Chief Knight of Light (1101003) - Ereve + sm.sayNext("Congratulations on recovering Shinsoo's Teardrop! This treasure is extremely important to Ereve and the Empress. Your bravery and skill in defeating #o9001009# have proven that you are ready for greater responsibilities."); + sm.sayBoth("As a reward for your exceptional service, I am promoting you to the rank of #bAdvanced Knight#k. This is a great honor, and you have earned it through your dedication to the Cygnus Knights."); + + if (!sm.askYesNo("Are you ready to accept this promotion and become an Advanced Knight?")) { + sm.sayNext("Think carefully about this decision. This is a significant step in your journey."); + return; + } + + sm.setJob(Job.DAWN_WARRIOR_3); + sm.addSp(1, 1); + sm.forceCompleteQuest(20311); + + sm.sayNext("You are now an Advanced Knight! As a Dawn Warrior of the third tier, you have access to powerful new abilities."); + sm.sayBoth("I have given you #bSP#k to invest in your new skills. Train hard and continue to bring honor to the Cygnus Knights."); + sm.sayPrev("Never forget that you are a protector of Ereve and a champion of the Empress. Your journey continues!"); + } + + @Script("q20312s") + public static void q20312s(ScriptManager sm) { + // Quest 20312 - Shinsoo's Teardrop: Blaze Wizard (START & COMPLETE) + // Chief Knight of Fire (1101004) - Ereve + sm.sayNext("Excellent work recovering Shinsoo's Teardrop! That was no easy task, and you handled it with the skill and courage of a true knight. The Black Wings won't soon forget this defeat!"); + sm.sayBoth("For your outstanding service to Ereve and your victory over #o9001009#, I am promoting you to the rank of #bAdvanced Knight#k. You have more than earned this honor!"); + + if (!sm.askYesNo("Are you ready to become an Advanced Knight?")) { + sm.sayNext("Take your time to prepare yourself for the responsibilities ahead."); + return; + } + + sm.setJob(Job.BLAZE_WIZARD_3); + sm.addSp(1, 1); + sm.forceCompleteQuest(20312); + + sm.sayNext("You are now an Advanced Knight! As a third-tier Blaze Wizard, the flames of your magic burn even brighter!"); + sm.sayBoth("I have given you #bSP#k to develop your new powers. Use them wisely and continue to serve the Empress with passion!"); + sm.sayPrev("Keep training and growing stronger. The Cygnus Knights are proud to have you among our ranks!"); + } + + @Script("q20313s") + public static void q20313s(ScriptManager sm) { + // Quest 20313 - Shinsoo's Teardrop: Wind Archer (START & COMPLETE) + // Chief Knight of Wind (1101005) - Ereve + sm.sayNext("You have done well... Recovering Shinsoo's Teardrop from the clutches of the Black Wings is no small feat. Your precision and determination were crucial to this success."); + sm.sayBoth("In recognition of your exemplary service and your defeat of #o9001009#, I am promoting you to the rank of #bAdvanced Knight#k. You have shown that you are worthy of this honor."); + + if (!sm.askYesNo("Will you accept this promotion?")) { + sm.sayNext("Prepare yourself when you are ready."); + return; + } + + sm.setJob(Job.WIND_ARCHER_3); + sm.addSp(1, 1); + sm.forceCompleteQuest(20313); + + sm.sayNext("You are now an Advanced Knight. As a third-tier Wind Archer, the wind itself will bend to your will."); + sm.sayBoth("I have provided you with #bSP#k to enhance your abilities. Continue to refine your skills and serve the Empress well."); + sm.sayPrev("May the wind always guide your arrows true. The Cygnus Knights are honored by your presence."); + } + + @Script("q20314s") + public static void q20314s(ScriptManager sm) { + // Quest 20314 - Shinsoo's Teardrop: Night Walker (START & COMPLETE) + // Chief Knight of Darkness (1101006) - Ereve + sm.sayNext("Impressive... You managed to defeat #o9001009# and recover Shinsoo's Teardrop. That master of disguise didn't stand a chance against your skills."); + sm.sayBoth("For your service to Ereve and your successful completion of this critical mission, I am promoting you to #bAdvanced Knight#k. You've earned it."); + + if (!sm.askYesNo("Ready to accept this promotion?")) { + sm.sayNext("Consider your decision carefully."); + return; + } + + sm.setJob(Job.NIGHT_WALKER_3); + sm.addSp(1, 1); + sm.forceCompleteQuest(20314); + + sm.sayNext("You are now an Advanced Knight. As a third-tier Night Walker, the shadows are now truly yours to command."); + sm.sayBoth("I have given you #bSP#k. Use it to unlock new powers that lurk in the darkness."); + sm.sayPrev("Continue your training. The Empress watches your progress with approval."); + } + + @Script("q20315s") + public static void q20315s(ScriptManager sm) { + // Quest 20315 - Shinsoo's Teardrop: Thunder Breaker (START & COMPLETE) + // Chief Knight of Lightning (1101007) - Ereve + sm.sayNext("YES! You did it! You defeated #o9001009# and got back Shinsoo's Teardrop! That was absolutely amazing! I knew you could do it!"); + sm.sayBoth("For your incredible bravery and your victory over the Black Wings, I'm promoting you to #bAdvanced Knight#k! You totally deserve it! Yeah!"); + + if (!sm.askYesNo("Are you ready to become an Advanced Knight? Let's do this!")) { + sm.sayNext("No worries! Let me know when you're ready for this awesome promotion!"); + return; + } + + sm.setJob(Job.THUNDER_BREAKER_3); + sm.addSp(1, 1); + sm.forceCompleteQuest(20315); + + sm.sayNext("You're now an Advanced Knight! As a third-tier Thunder Breaker, lightning will strike with even more power when you fight!"); + sm.sayBoth("I've given you #bSP#k to power up your abilities! This is so exciting!"); + sm.sayPrev("Keep training and getting stronger! The Cygnus Knights are lucky to have someone as awesome as you! Yeah!"); + } + + // 4TH JOB ADVANCEMENT QUESTS (20400-20408) ---------------------------------------------------------------- + + @Script("q20400s") + public static void q20400s(ScriptManager sm) { + // Quest 20400 - Chasing the Knight's Target (START) + // Neinheart (1101002) - Ereve + sm.sayNext("It's been a while since I last saw you. I can't even recognize you now, seeing how powerful you have become since our last meeting. I can honestly say that you just might be one of the most powerful Knights in all of Cygnus Knights, Chief Knights included. Okay, enough pleasantries. Let's get down to business."); + + if (!sm.askAccept("It's a new mission. According to the information we acquired, a member of the #rBlack Wings#k is targeting the Empress. In order to prevent that, Advanced Knight #b#p1103000##k has been secretly tracing that individual, but it doesn't look too good from here.")) { + sm.sayNext("Hmmm... you seem way too at ease. It's a waste of talent and firepower for an accomplished individual like you to just sit around, being content with the way things are..."); + return; + } + + sm.forceCompleteQuest(20400); + sm.sayNext("If it's Victoria Island, at least we know everything that goes on there. This one's Ossyria, where not even the intelligence officials know everything inside out. This means the Advanced Knight will need help. Please provide help to #p1103000#. The last place she contacted was at #b#m211000000##k, so try looking for #p1103000#."); + sm.sayOk("Well, I know I may have talked to you like this is a joke, but it is true that you are one of the better Knights, and that is why you are given a task with a huge responsibility. I'll be looking forward to your work."); + } + + @Script("q20401s") + public static void q20401s(ScriptManager sm) { + // Quest 20401 - Hunting the Zombies (START) + // NPC 2020006 - El Nath + sm.sayNext("#p1103000#? Oh, you're looking for that Knight? I'm not entirely sure where #p1103000# is now, but I can confirm that he did stay at #m211000000# for quite some time."); + sm.sayBoth("And while he was here, he was very busy #rhunting the zombies#k around this area. Not just regular hunting, mind you - he seemed to be searching for something specific among them, some kind of clue."); + + if (!sm.askYesNo("If you want to find out what #p1103000# was looking for, you should #rhunt some zombies yourself#k. Would you like to investigate the zombies around #m211000000#?")) { + sm.sayOk("Come back if you change your mind. #p1103000# was definitely onto something with those zombies."); + return; + } + + sm.forceStartQuest(20401); + sm.sayOk("Hunt #b50 zombies#k around #m211000000# and see if you can find what #p1103000# was looking for. Be careful - those zombies might be carrying something cursed!"); + } + + @Script("q20401e") + public static void q20401e(ScriptManager sm) { + // Quest 20401 - Hunting the Zombies (END) + // NPC 2020006 - El Nath + // Player should have killed zombies (we'll skip the kill check for now and just give the item) + + sm.sayNext("Did you find anything interesting while hunting the zombies?"); + sm.sayBoth("Wait... I can sense something strange from you. It's a dark, cursed energy..."); + + // Give the Black Scale item + if (!sm.addItem(4001207, 1)) { + sm.sayOk("Your inventory is full! Please make space in your ETC tab."); + return; + } + + sm.forceCompleteQuest(20401); + sm.sayNext("You found a #b#t4001207##k! That must be what #p1103000# was looking for!"); + sm.sayBoth("This scale is radiating with dark energy... You should take it to someone who knows about curses. I've heard that #b#p2032001# in Orbis#k is an expert on magical items."); + sm.sayOk("Be careful with that #t4001207#. It seems very dangerous! Take it to #b#p2032001# in #m200000000##k right away!"); + } + + @Script("q20402s") + public static void q20402s(ScriptManager sm) { + // Quest 20402 - Black Scale (START - auto-triggered when obtaining item 4001207) + // NPC 2032001 - Orbis + final int BLACK_SCALE = 4001207; + + sm.sayNext("I feel a strange aura emitting from you. Do you by any chance have #b#t" + BLACK_SCALE + "##k with you? Recently, a number of cursed #t" + BLACK_SCALE + "#s have been appearing all over the world, and I can feel the same thing from you. It's a very dangerous item, so would you mind giving it to me?"); + + if (!sm.askYesNo("Where are you located? Hmmm... the fact that it feels close makes me suspect that you might be around #m211000000#. It's not too far from here, so I want you to go to #b#m200000000##k and hand me #t" + BLACK_SCALE + "#. #p1103000# had that too, and... it seems like #t" + BLACK_SCALE + "#s are easily found in #m211000000#...")) { + sm.sayNext("You should know that as long as you hold on to it, you will be affected by it as well, from a higher percentage of scroll failure and lower drop rate to item decomposition. You may even lose hair as well. Are you sure you want to hold on to it?"); + return; + } + + sm.forceStartQuest(20402); + sm.sayOk("Please bring me the #t" + BLACK_SCALE + "#. It's for your own safety!"); + } + + @Script("q20402e") + public static void q20402e(ScriptManager sm) { + // Quest 20402 - Black Scale (END) + // NPC 2032001 - Orbis + final int BLACK_SCALE = 4001207; + + if (!sm.hasItem(BLACK_SCALE, 1)) { + sm.sayOk("Hmmm...? You didn't bring #b#t" + BLACK_SCALE + "##k with you? Please gather up the #t" + BLACK_SCALE + "#s that are laden with the power of curse."); + return; + } + + sm.sayNext("Oh, so you did bring #t" + BLACK_SCALE + "#. Good work. It's a dangerous item, so as soon as I feel it, I make sure to contact that individual and have that person bring it to me. Thankfully, most of the people I contacted promptly came and gave them to me, so it's not that difficult... the problem is that the level of curse seems quite powerful."); + + if (sm.askMenu("What happened?", Map.of(0, "Did you... take one from #p1103000#, by chance?")) != 0) { + return; + } + + sm.sayBoth("#p1103000#? Are you talking about that Knight #p1103000#? Yes, he brought a whole bunch of #t" + BLACK_SCALE + "#s acquired from zombies. He wasn't satisfied with just retrieving those items, but he was rather interested in finding the origin so he could snuff them out. That's why he started asking me about the information on #t" + BLACK_SCALE + "#."); + + if (sm.askMenu("Can you tell me more?", Map.of(0, "Can you tell me what you told #p1103000#?")) != 0) { + return; + } + + sm.sayBoth("It was really nothing special. #t" + BLACK_SCALE + "# may look small on the outside, but it's really a scale of a dragon. Most dragon scales reflect magic, but since the curse is so deeply ingrained, I told him I suspect #bthese scales are from a special dragon#k."); + + if (!sm.askYesNo("Apparently, that was enough for #p1103000# to pack up and leave, and told me he'll be heading over to #b#m240000000##k and find out more... about the scales. Since #m240000000# is the land where dragons and Halfrings coexist, there's got to be more information there.")) { + return; + } + + sm.removeItem(BLACK_SCALE, 1); + sm.forceCompleteQuest(20402); + sm.sayOk("Good luck finding #p1103000# in #m240000000#!"); + } + + @Script("q20403s") + public static void q20403s(ScriptManager sm) { + // Quest 20403 - Dragon Outcasts (START) + // NPC 2081000 - Leafre + sm.sayNext("Hmm...? #p1103000#? Of course I remember. Aren't you talking about the Knight that planned on investigating #t4001207#? He seemed very much a proper gentleman who fits the bill of a knight. Do you want to know where he went?"); + + if (!sm.askYesNo("When I told #p1103000# the #t4001207#s he showed me were similar to that of #bthe Dragon Outcasts#k, he took off in hopes of finding the Dragon Outcasts. The house of the Dragon Outcasts can only be accessed through #b#m240020401# or #m240020101#, so be careful#k.")) { + sm.sayNext("Hmmm... I wonder what you're really looking for. Maybe my hearing has failed me, but... I don't know if your answer means yes or no."); + return; + } + + sm.forceCompleteQuest(20403); + sm.sayNext("Afterwards, I was never able to see #p1103000# again. I wonder if he was captured by the Dragon Outcasts. Recently, there have been reports of #rtheft on the dragon's eggs#k... Hopefully he's not involved in that."); + } + + @Script("q20404s") + public static void q20404s(ScriptManager sm) { + // Quest 20404 - The Stolen Egg (START) + // NPC 2081012 - Dragon Outcast + sm.sayNext("Who are you? Who told you you're welcome here? Don't be barging in on someone else's important research!"); + + if (sm.askMenu("I'm looking for someone...", Map.of(0, "Hi, did #p1103000# stop by anytime recently?")) != 0) { + return; + } + + sm.sayBoth("#p1103000#? That blonde knight? Don't tell me you're one of them, too. Ahhh screw it. Screw it!!!"); + + if (!sm.askYesNo("#t4001207# is trying to tell me something, but I have no idea what it's saying. I #bhate being in strange, dangerous areas, so I stay in this forest at all times. How are my scales supposedly out there being passed around?#k Are you looking down on me because I'm a Halfling?")) { + sm.sayNext("Then get out of here! Why are there so many unruly people barging into people's houses?"); + return; + } + + sm.forceStartQuest(20404); + sm.sayNext("Seriously, that old man Tatamo! I can see why he wouldn't like me, but that doesn't give him a right to make baseless assumptions! He should spend that time #rlooking for #o9001010#, the person responsible for stealing an egg#k..."); + sm.sayOk("No I won't tell you more! Why should I do it? If you really want to know, then you better do something for me in return! I had been looking for a top-tier talent for this, anyway... I want you to go out there and eliminate all the #r#o8180000#s#k and #r#o8180001#s#k. You can do this, right?"); + } + + @Script("q20404e") + public static void q20404e(ScriptManager sm) { + // Quest 20404 - The Stolen Egg (END) + // NPC 2081012 - Dragon Outcast + + sm.sayNext("Ohh... I really wasn't expecting this, but you did indeed defeat all the #o8180000#s and #o8180001#s. Hey... you're much stronger than I thought. I mean, I could have gone out there and wiped out the #o8180000#s and #o8180001#s myself, but... that was good work. Not bad for a human."); + + if (!sm.askYesNo("#o9001010#? She's a lady that lives way deep in the area near the #rDragon's Nest#k, and... she's one scary woman. Apparently, she handles all those powerful monsters around Dragon's Nest with her eyes closed. Even scarier than that...")) { + return; + } + + sm.sayBoth("...I overheard a number of Kentauruses gossiping around, and... they think she's the #rone responsible for the Egg-Theft#k. I don't know what she's trying to do with those eggs, but I don't think it'll be for good use. She just seems very dangerous..."); + + sm.forceCompleteQuest(20404); + sm.sayOk("Okay, the information stops here. I don't really like you, but since you helped me here, I'll give you an advice. I strongly advise you not to get too close to #o9001010#. You don't want to risk your life being associated with someone like #o9001010#. Maybe that gentleman #r#p1103000# might have been swept by that, as well#k."); + } + + @Script("q20405s") + public static void q20405s(ScriptManager sm) { + // Quest 20405 - The Cave of the Black Witch (START & COMPLETE) + // NPC 2081013 - Black Witch's Cave + sm.sayNext("(You see a note posted on the wall of the cave. It reads...)"); + sm.sayBoth("'To whoever finds this note: I have been investigating the source of the cursed scales that have been plaguing our world. The trail has led me to this cave, the lair of #o9001010#, the Black Witch.'"); + sm.sayBoth("'After much investigation, I have discovered a device here that appears to be the source of the curse. I have recovered it and am sending it back to Ereve for safekeeping. The Black Witch herself was not here, but I fear she may be planning something even more sinister.'"); + sm.sayBoth("'I will continue my investigation and report back to Ereve when I have more information. - Advanced Knight #p1103000#'"); + + sm.forceCompleteQuest(20405); + sm.sayOk("(It seems #p1103000# has already completed his mission here. You should return to Ereve and report to #b#p1101002##k.)"); + } + + // NOTE: Cygnus Knights in v95 do NOT have 4th job advancement + // They cap at level 120 as 3rd job (Advanced Knights) + // Quests 20406-20408 (4th job chain) are disabled for v95 authenticity + + @Script("q20520s") + public static void q20520s(ScriptManager sm) { + // Knight's Dignity (20520 - start) + // Level 50 auto-start quest about Monster Mounts for Cygnus Knights + sm.sayNext("Ah, you've reached Level 50. Congratulations on your achievement! You are now a formidable knight, and it's time we address something important."); + sm.sayBoth("As a Level 50 Cygnus Knight, it seems beneath your rank to simply walk everywhere. A knight of your stature should have a proper mount to ride."); + sm.sayBoth("I have information about #bMonster Mounts#k that may interest you. There are special mounts available exclusively for Cygnus Knights like yourself."); + sm.sayBoth("Head to #bEreve#k and speak with the appropriate trainers. They will guide you on how to obtain a mount befitting your status as a knight of the Empress!"); + + sm.forceStartQuest(20520); + } + + @Script("oldBook5") + public static void oldBook5(ScriptManager sm) { + // NPC: Spiruna (2032001) - Refines Dark Crystal Ore into Power Crystal + // Used for Cygnus Knights 3rd job advancement quest (20306-20310) + final int DARK_CRYSTAL_ORE = 4004004; + final int POWER_CRYSTAL = 4005004; + + sm.sayNext("Ah, you have some #bDark Crystal Ore#k with you. I can sense the dark energy emanating from it..."); + sm.sayBoth("These ores contain powerful magical energy, corrupted by evil forces. I can refine them into #bPower Crystals#k, purifying the darkness and extracting the raw magical essence."); + + if (!sm.hasItem(DARK_CRYSTAL_ORE, 1)) { + sm.sayBoth("However, I don't see any Dark Crystal Ore in your possession. You can obtain these ores from monsters corrupted by dark powers, such as the #bDrum Bunnies#k at the Eos Tower."); + return; + } + + sm.sayBoth("I see you have the ore. Would you like me to refine it into a Power Crystal? I'll need #b1 Dark Crystal Ore#k for the refinement process."); + + if (sm.askYesNo("Exchange #b1 Dark Crystal Ore#k for #b1 Power Crystal#k?")) { + if (sm.removeItem(DARK_CRYSTAL_ORE, 1)) { + if (sm.addItem(POWER_CRYSTAL, 1)) { + sm.sayNext("The refinement is complete! The dark energy has been purified, leaving behind a pristine #bPower Crystal#k filled with magical energy."); + sm.sayBoth("Use this crystal wisely. Its power is immense and should only be used for noble purposes."); + } else { + sm.sayNext("It seems your inventory is full. Please make some space and come back."); + sm.addItem(DARK_CRYSTAL_ORE, 1); // Give the ore back + } + } else { + sm.sayNext("It appears you no longer have the Dark Crystal Ore. Please come back when you have it."); + } + } else { + sm.sayNext("Very well. If you change your mind, come back and see me."); + } + } + + // ============================================================================================================== + // BAROQ FAKE KNIGHT NPCs (3rd Job Advancement Instance Maps) + // ============================================================================================================== + + @Script("1104100") + public static void npc1104100(ScriptManager sm) { + baroqNpcDawnWarrior(sm); + } + + @Script("desguiseSoul") + public static void desguiseSoul(ScriptManager sm) { + baroqNpcDawnWarrior(sm); + } + + private static void baroqNpcDawnWarrior(ScriptManager sm) { + // NPC 1104100 (Mihile) - Fake Knight for Dawn Warrior + final int BAROQ_MOB = 9001009; + + if (sm.getJob() != 1110) { + // Wrong job - show innocent dialog + sm.sayOk("What's going on? How's the search? The Master of Disguise was not found in this area. I'll stay here and be on the lookout, so you can search other areas instead."); + } else { + // Dawn Warrior found the correct NPC - reveal as Baroq! + sm.sayNext("Darn, you found me! Then there's only one way out! Let's fight, like #rBlack Wings#k should!"); + // Remove this NPC and spawn Baroq boss + sm.removeNpc(sm.getSpeakerId()); + sm.spawnMob(BAROQ_MOB, -3, 0, 0, false); + sm.broadcastMessage("Baroq has revealed himself! Defeat him and recover Shinsoo's Teardrop!"); + } + } + + @Script("1104101") + public static void npc1104101(ScriptManager sm) { + baroqNpcBlazeWizard(sm); + } + + @Script("desguiseFlame") + public static void desguiseFlame(ScriptManager sm) { + baroqNpcBlazeWizard(sm); + } + + private static void baroqNpcBlazeWizard(ScriptManager sm) { + // NPC 1104101 (Oz) - Fake Knight for Blaze Wizard + final int BAROQ_MOB = 9001009; + + if (sm.getJob() != 1210) { + // Wrong job - show innocent dialog + sm.sayOk("How is the search going? I don't see anything suspicious around the area. I'll keep looking, so please search other areas as well."); + } else { + // Blaze Wizard found the correct NPC - reveal as Baroq! + sm.sayNext("Darn, you found me! Then there's only one way out! Let's fight, like #rBlack Wings#k should!"); + // Remove this NPC and spawn Baroq boss + sm.removeNpc(sm.getSpeakerId()); + sm.spawnMob(BAROQ_MOB, -3, 0, 0, false); + sm.broadcastMessage("Baroq has revealed himself! Defeat him and recover Shinsoo's Teardrop!"); + } + } + + @Script("1104102") + public static void npc1104102(ScriptManager sm) { + baroqNpcWindArcher(sm); + } + + @Script("desguiseWind") + public static void desguiseWind(ScriptManager sm) { + baroqNpcWindArcher(sm); + } + + private static void baroqNpcWindArcher(ScriptManager sm) { + // NPC 1104102 (Irina) - Fake Knight for Wind Archer + final int BAROQ_MOB = 9001009; + + if (sm.getJob() != 1310) { + // Wrong job - show innocent dialog + sm.sayOk("How's the search? I don't see anything peculiar around the area. I'll keep my eye on the area, so I want you to search other areas as well."); + } else { + // Wind Archer found the correct NPC - reveal as Baroq! + sm.sayNext("Darn, you found me! Then there's only one way out! Let's fight, like #rBlack Wings#k should!"); + // Remove this NPC and spawn Baroq boss + sm.removeNpc(sm.getSpeakerId()); + sm.spawnMob(BAROQ_MOB, -3, 0, 0, false); + sm.broadcastMessage("Baroq has revealed himself! Defeat him and recover Shinsoo's Teardrop!"); + } + } + + @Script("1104103") + public static void npc1104103(ScriptManager sm) { + baroqNpcNightWalker(sm); + } + + @Script("desguiseNight") + public static void desguiseNight(ScriptManager sm) { + baroqNpcNightWalker(sm); + } + + private static void baroqNpcNightWalker(ScriptManager sm) { + // NPC 1104103 (Eckart) - Fake Knight for Night Walker + final int BAROQ_MOB = 9001009; + + if (sm.getJob() != 1410) { + // Wrong job - show innocent dialog + sm.sayOk("How's the search? I don't see anything different here. I'll stay here and keep looking, so you can search other areas."); + } else { + // Night Walker found the correct NPC - reveal as Baroq! + sm.sayNext("Darn, you found me! Then there's only one way out! Let's fight, like #rBlack Wings#k should!"); + // Remove this NPC and spawn Baroq boss + sm.removeNpc(sm.getSpeakerId()); + sm.spawnMob(BAROQ_MOB, -3, 0, 0, false); + sm.broadcastMessage("Baroq has revealed himself! Defeat him and recover Shinsoo's Teardrop!"); + } + } + + @Script("1104104") + public static void npc1104104(ScriptManager sm) { + baroqNpcThunderBreaker(sm); + } + + @Script("desguiseStrike") + public static void desguiseStrike(ScriptManager sm) { + baroqNpcThunderBreaker(sm); + } + + private static void baroqNpcThunderBreaker(ScriptManager sm) { + // NPC 1104104 (Hawkeye) - Fake Knight for Thunder Breaker + final int BAROQ_MOB = 9001009; + + if (sm.getJob() != 1510) { + // Wrong job - show innocent dialog + sm.sayOk("How's the search? I don't see anything suspicious here, but who knows?"); + } else { + // Thunder Breaker found the correct NPC - reveal as Baroq! + sm.sayNext("Oh... did I just get found? Then there's only one way out! Let's fight, like a #rBlack Wing#k should!"); + // Remove this NPC and spawn Baroq boss + sm.removeNpc(sm.getSpeakerId()); + sm.spawnMob(BAROQ_MOB, -3, 0, 0, false); + sm.broadcastMessage("Baroq has revealed himself! Defeat him and recover Shinsoo's Teardrop!"); + } + } + + // ============================================================================================================== + // BLACK WITCH NPC (4TH JOB BOSS) - NPC 1104002 + // ============================================================================================================== + + @Script("1104002") + public static void npc1104002(ScriptManager sm) { + // NPC 1104002 - Eleanor / Black Witch (disguised as innocent lady) + // When player talks to her, she reveals herself and spawns the boss + final int BLACK_WITCH_NPC = 1104002; + final int BLACK_WITCH_MOB = 9001010; + + if (!sm.hasQuestStarted(20407)) { + // Quest not started yet - show innocent dialog + sm.sayOk("..."); + return; + } + + // Player has started Quest 20407 - reveal as Black Witch! + sm.sayNext("Foolish knight... you've walked right into my trap! Did you really think you could stop me from cursing Ereve?"); + sm.sayBoth("The Empress and all of Ereve will fall to my curse! But first, I'll deal with you personally!"); + + // Remove this NPC and spawn Black Witch boss + sm.removeNpc(BLACK_WITCH_NPC); + sm.spawnMob(BLACK_WITCH_MOB, -3, 0, 0, false); + sm.broadcastMessage("The Black Witch has revealed her true form! Defeat her to save Ereve!"); + } + + // ============================================================================================================== + // CYGNUS MOUNT QUESTS (Quest 20522 - Raising Mimiana) + // ============================================================================================================== + + @Script("q20522s") + public static void q20522s(ScriptManager sm) { + // Quest 20522 - Raising Mimiana (START) + // NPC 1102002 - Mount Trainer + sm.sayNext("Welcome! I see you've completed raising the #t1902005# egg. That's great! However, just having a hatched #t1902005# isn't enough to ride it yet."); + sm.sayBoth("A #t1902005# needs to be fully grown and trained before it can be mounted. The way to do this is by #bsharing your experiences with it#k. As you gain experience and level up, the #t1902005# will grow stronger alongside you."); + + if (!sm.askYesNo("Are you ready to take on the responsibility of raising your #t1902005#?")) { + sm.sayNext("Come back when you're ready to commit to raising your #t1902005#."); + return; + } + + sm.forceStartQuest(20522); + sm.sayOk("Excellent! Take good care of your #t1902005#. Feed it your experiences, and it will grow into a loyal companion. Come back to me once it's fully grown!"); + } + + @Script("q20522e") + public static void q20522e(ScriptManager sm) { + // Quest 20522 - Raising Mimiana (END) + // NPC 1102002 - Mount Trainer + sm.sayNext("Oh! I can see your #t1902005# has grown quite a bit! It looks healthy and strong. You've done a great job raising it!"); + sm.sayBoth("However, before you can ride it, you'll need to make it even stronger. You'll need special supplements from #b#p2060005##k in #bAqua Road#k."); + + if (!sm.askYesNo("Are you ready to take the next step and get the supplements?")) { + sm.sayNext("Come back when you're ready to continue."); + return; + } + + sm.forceCompleteQuest(20522); + sm.sayOk("Go to #b#m230000000##k and purchase the supplements from #b#p2060005##k. They're expensive, but necessary for your #t1902005# to become strong enough to ride!"); + } + + @Script("giveupRiding") + public static void giveupRiding(ScriptManager sm) { + // NPC script for giving up/abandoning mount training + // NPC 1102002 - Mount Trainer + sm.sayNext("You want to give up on raising your mount? Are you sure about this? All the time and effort you've put in will be lost..."); + + if (!sm.askYesNo("Do you really want to #rgive up#k on your mount training?")) { + sm.sayOk("Good! I knew you wouldn't give up that easily. Keep training your mount!"); + return; + } + + sm.sayNext("I understand... Sometimes the journey is too difficult. If you change your mind in the future, you can always start over."); + sm.sayOk("Your mount training has been cancelled. Come back if you want to try again."); + } + + @Script("q20525s") + public static void q20525s(ScriptManager sm) { + // Quest 20525 - Making a Saddle (START) + // NPC 1102002 - Mount Trainer (Ereve) + // Recovery quest for lost Monster Mount Saddle + sm.sayNext("What is it? Hmmm...? You lost #t1912005#? Hmmm... that's not good. You can't Mount without #t1912005#. Hmmm? You want another one? Well... we have a tight budget right now, and we cannot give out extra #t1912005#s to those who've lost theirs. There are other knights out there... and as you know, #p1101002# is not one to extend budget on something like this..."); + sm.sayBoth("But, that doesn't mean there's absolutely no way you can get another #t1912005#. You should make one yourself, no? All #t1912005#s are custom-made by #b#p2060005##k of #b#m230000000##k, so if you give him the materials for #t1912005# along with some money, you'll be able to get yourself a new #t1912005#. It won't be cheap by any means, but... that's the only way you'll be able to get #t1912005#."); + + if (!sm.askYesNo("Are you willing to gather the materials and pay the fee to remake your saddle?")) { + sm.sayOk("Hmmm... that's unfortunate. You'll just have to use #t1902005# as an accessory."); + return; + } + + sm.forceStartQuest(20525); + sm.sayOk("Bring #b200 #t4000030#s#k, #b200 #t4000055#s#k, #b200 #t4000171#s#k... and #b5 million mesos#k to #p2060005#, and you'll be able to get yourself a new #t1912005#. Best of luck to you."); + } + + @Script("q20525e") + public static void q20525e(ScriptManager sm) { + // Quest 20525 - Making a Saddle (END) + // NPC 2060005 (Maker in Aqua Road) + // Requires: 200 Dragon Skin, 200 Soft Feather, 200 Leather, 5,000,000 mesos + + if (!sm.hasItem(4000030, 200) || !sm.hasItem(4000055, 200) || !sm.hasItem(4000171, 200) || !sm.canAddMoney(-5000000)) { + sm.sayOk("Hmm... what is it? You don't look too happy. Wait, you have #t1902005#, but you don't have #t1912005# with you."); + return; + } + + sm.sayNext("Hmmm? What is it that you need? I see that you're carrying a hefty amount of leather... WHAT? You want me to build a new #t1912005#? That won't be too difficult, but I have other work to do, so... I can't build one for you for free. What? You brought some money as well? In that case, I'll try my best to make one as soon as possible. Just give me the materials first!"); + + if (!sm.askYesNo("Will you pay 5 million mesos and provide the materials to craft a new #t1912005#?")) { + sm.sayOk("Come back when you have everything ready."); + return; + } + + // Remove items and money + sm.addMoney(-5000000); + sm.removeItem(4000030, 200); // Dragon Skin + sm.removeItem(4000055, 200); // Soft Feather + sm.removeItem(4000171, 200); // Leather + + sm.forceCompleteQuest(20525); + sm.addItem(1912005, 1); // Monster Mount Saddle + sm.sayOk("Here's #t1912005#! Hope to see you again!"); + } + + @Script("q20527s") + public static void q20527s(ScriptManager sm) { + // Quest 20527 - A Knight's Pride (START) + // Auto-start quest at level 100 - NPC 1101002 (Neinheart) + sm.sayNext("#h0#. You've reached Level 100 and have become a formidable Knight. However, I've noticed you're still riding a regular #t1902005#..."); + sm.sayBoth("As a high-ranked Knight of the Cygnus Order, you should be riding something more befitting your status. Have you heard of the #bMonster Mount#k?"); + + if (!sm.askYesNo("The Monster Mount is far more powerful than your current #t1902005#. Would you like to learn more about upgrading your mount?")) { + sm.sayNext("I see. Well, when you're ready to upgrade, speak with me again."); + return; + } + + sm.forceStartQuest(20527); + sm.sayOk("Excellent! Go speak with #b#p1102002# the Mount Trainer#k. He knows all about enhancing mounts and can help you transform your #t1902005# into a powerful #bMonster Mount#k!"); + } + + @Script("q20528s") + public static void q20528s(ScriptManager sm) { + // Quest 20528 - Raising Mimio (START) + // NPC 1102002 - Mount Trainer + sm.sayNext("Ah, #h0#! I heard from #p1101002# that you want to upgrade your #t1902005#. That's wonderful!"); + sm.sayBoth("#t1902005#, with an extensive amount of experience stored in it, will become a much more powerful creature through shedding. But that will also require some food that is much more nutritious and potent than the ones that are available."); + sm.sayBoth("If you want to see if your #t1902005# can become as powerful as a dragon, how about feeding it a #bspecial formula used for the dragons of #m240000000##k?"); + + if (!sm.askYesNo("Are you interested in having your #t1902005# undergo shedding to become a #bMonster Mount#k?")) { + sm.sayNext("Hmmm... I take it that you are not interested in having your #t1902005# shed."); + return; + } + + sm.forceStartQuest(20528); + sm.sayNext("I want you to bring back the Concentrated Formula that #bPam#k makes. Formulas should be fed in different levels, and you'll need to bring #bStep 1, 2, and 3 Concentrated Formulas, 3 each#k."); + sm.sayOk("I'll be waiting. Go to #b#m240000000##k and purchase the formulas from #bPam#k!"); + } + + @Script("q20528e") + public static void q20528e(ScriptManager sm) { + // Quest 20528 - Raising Mimio (END) + // NPC 1102002 - Mount Trainer + // Requires: 3x Step 1 Formula (4032196), 3x Step 2 Formula (4032197), 3x Step 3 Formula (4032198) + + if (!sm.hasItem(4032196, 3) || !sm.hasItem(4032197, 3) || !sm.hasItem(4032198, 3) || !sm.hasItem(1902005, 1)) { + sm.sayOk("Hmmm... I don't think you have brought the ingredients ready for shedding. #bPam#k of #b#m240000000##k is famous for making #bConcentrated Formulas#k for the #t1902005#s, and... I want you to purchase #b3 concentrated formulas from each step#k, then bring them back along with your #t1902005#."); + return; + } + + sm.sayNext("Oh, so you were able to bring in all the Formulas. Now, let's go ahead and have #t1902005# undergo shedding. First, we'll feed Formula: Step 1... then Step 2... then Step 3..."); + + if (!sm.askYesNo("Are you ready to transform your #t1902005# into a #bMonster Mount#k?")) { + sm.sayOk("Come back when you're ready!"); + return; + } + + // Remove formulas and old mount, give new mount + if (!sm.removeItem(4032196, 3) || !sm.removeItem(4032197, 3) || !sm.removeItem(4032198, 3) || !sm.removeItem(1902005, 1)) { + sm.sayOk("Something went wrong. Please make sure you have all the required items."); + return; + } + + if (!sm.addItem(1902006, 1)) { + sm.sayOk("Please make sure you have space in your inventory."); + return; + } + + sm.forceCompleteQuest(20528); + sm.sayOk("That's some shedding there. Wait, it's not #t1902005# anymore, but #t1902006#! #t1902006# will be a much more formidable and trustworthy companion for you as you travel your way around the world. Happy Mounting!"); + } + + // ============================================================================================================== + // CYGNUS SKILL QUESTS (Level 100 and 110) + // ============================================================================================================== + + @Script("q20600s") + public static void q20600s(ScriptManager sm) { + // Quest 20600 - Training Never Ends (Level 100 Skill Quest) + // NPC 1101002 - Neinheart + sm.sayNext("#h0#. Have you been slacking off in training since reaching Level 100? We all know how powerful you are, but the training is not complete. Take a look at these Chief Knights. They train day and night, preparing themselves for the possible encounter with the Black Mage."); + sm.sayBoth("I suggest you visit other Chief Knights and ask for some words of advice. Who knows? You may be able to learn a #bnew skill#k in the process."); + + sm.forceCompleteQuest(20600); + } + + @Script("q20610s") + public static void q20610s(ScriptManager sm) { + // Quest 20610 - Training Still Never Ends (Level 110 Skill Quest) + // NPC 1101002 - Neinheart + sm.sayNext("Have you been mastering your skills? I am sure you've mastered all your skills, which means... it's time for you to learn a #bnew skill#k, right?"); + sm.sayBoth("#bChief Knights#k must have come up with another skill. Don't just stand here, find a way to learn that skill for yourself! I am sure the Chief Knights will be against it, but... acquiring the skill will depend purely on your abilities."); + + sm.forceCompleteQuest(20610); + } + + // ============================================================================================================== + // CYGNUS LEVEL 120 NPC SCRIPT + // ============================================================================================================== + + @Script("cygnus_lv120") + public static void cygnus_lv120(ScriptManager sm) { + // Generic NPC script for level 120 Cygnus Knights content + // This handles NPCs that appear at level 120 milestone + sm.sayNext("Congratulations on reaching level 120! You have proven yourself to be a true champion of the Empress."); + sm.sayBoth("At this level, you are one of the elite knights of Ereve. Continue to bring honor to the Cygnus Knights!"); + sm.sayOk("If you have any quests related to your advancement, please speak with #b#p1101002# Neinheart#k or your Chief Knight instructor."); + } + } diff --git a/src/main/java/kinoko/script/quest/CygnusTutorial.java b/src/main/java/kinoko/script/quest/CygnusTutorial.java index 5b52c738..9ccc1ce8 100644 --- a/src/main/java/kinoko/script/quest/CygnusTutorial.java +++ b/src/main/java/kinoko/script/quest/CygnusTutorial.java @@ -342,6 +342,19 @@ public static void q20017s(ScriptManager sm) { } } + @Script("q20017e") + public static void q20017e(ScriptManager sm) { + // The First Knight Training (20017 - end) + sm.sayNext("Wow, you've already defeated all the #o100122#s? Well done! We can go on to the next stage then."); + sm.sayBoth("Oh, and since there is a good chance that you've consumed a lot of your potions already, I'll give you some more just in case. Potions can truly save your life when you're in danger, so guard them well. \r\n\r\n#fUI/UIWindow2.img/QuestIcon/4/0# \r\n#v2000020# 10 #t2000020# \r\n#v2000021# 10 #t2000021# \r\n\r\n#fUI/UIWindow2.img/QuestIcon/8/0# 430 exp"); + if (sm.addItems(List.of(Tuple.of(2000020, 10), Tuple.of(2000021, 10)))) { + sm.addExp(430); + sm.forceCompleteQuest(20017); + } else { + sm.sayNext("Please check if your inventory is full or not."); + } + } + @Script("q20020s") public static void q20020s(ScriptManager sm) { // 5 Different Paths of Cygnus Knights (20020 - start) diff --git a/src/main/java/kinoko/script/quest/EvanQuest.java b/src/main/java/kinoko/script/quest/EvanQuest.java index e011102c..5990c8bc 100644 --- a/src/main/java/kinoko/script/quest/EvanQuest.java +++ b/src/main/java/kinoko/script/quest/EvanQuest.java @@ -5,6 +5,7 @@ import kinoko.script.common.Script; import kinoko.script.common.ScriptHandler; import kinoko.script.common.ScriptManager; +import kinoko.util.Tuple; import kinoko.world.field.mob.MobAppearType; import kinoko.world.item.InventoryType; import kinoko.world.job.Job; @@ -12,12 +13,20 @@ import kinoko.world.quest.QuestRecordType; import kinoko.world.user.stat.Stat; +import java.util.HashMap; import java.util.List; +import java.util.Map; public final class EvanQuest extends ScriptHandler { public static final int SAFE_GUARD = 9300389; // FIELDS + @Script("dollCave00") + public static void dollCave00(ScriptManager sm) { + // Puppeteer's Cave (910510200) - onUserEnter + // Quest 21719: The Puppeteer's Invitation + } + @Script("dollCave01") public static void dollCave01(ScriptManager sm) { } @@ -45,6 +54,96 @@ public static void evanTogether(ScriptManager sm) { // NPCS ------------------------------------------------------------------------------------------------------------ + @Script("dollMaster00") + public static void dollMaster00(ScriptManager sm) { + // Francis the Puppeteer (NPC 1204001) - Inside the Puppeteer's Cave (910510200) + // Quest 21719: The Puppeteer's Invitation + // This is the script name used by the NPC in the map + + // Quest hasn't started yet - shouldn't normally happen but just in case + if (!sm.hasQuestStarted(21719) && !sm.hasQuestCompleted(21719)) { + sm.sayOk("How did you get in here? This is my private hideout! Get out!"); + return; + } + + // Quest already completed + if (sm.hasQuestCompleted(21719)) { + sm.sayOk("What are you doing back here? I told you everything I wanted to tell you. Now leave me alone!"); + return; + } + + // Quest is in progress - show the main story dialogue + sm.sayNext("Well, well, well... Look who we have here. A genuine hero, in the flesh! Or should I say, freshly thawed from ice?"); + sm.setPlayerAsSpeaker(true); + sm.sayBoth("#bWho are you? And what do you want?#k"); + sm.setPlayerAsSpeaker(false); + sm.sayBoth("My name is #rFrancis#k, and I'm what you might call a... #rPuppeteer#k. I control monsters, bend them to my will. Those aggressive #o1210102#s in #m100000000#? The violent #o1110100#s in #m101000000#? All my handiwork!"); + sm.setPlayerAsSpeaker(true); + sm.sayBoth("#bWhat?! You're the one causing all that chaos?!#k"); + sm.setPlayerAsSpeaker(false); + sm.sayBoth("Chaos? I prefer to call it... preparation. You see, I'm part of something much bigger than myself. An organization dedicated to the resurrection of the #rBlack Mage#k!"); + sm.setPlayerAsSpeaker(true); + sm.sayBoth("#b(The Black Mage?! That's the evil wizard I helped seal away before I was frozen!)#k"); + sm.setPlayerAsSpeaker(false); + sm.sayBoth("Oh yes, I know all about your past, hero. We of the #rBlack Wings#k have been studying you. And now that you're back, things are about to get very interesting..."); + sm.sayBoth("But I've said enough for now. Run along and tell your little friends what you've learned. I'll be seeing you again soon, hero. Very soon..."); + sm.sayBoth("Now get out of my cave!"); + + // Warp player back to where they came from (or a safe location) + // Typically warps back to Lith Harbor to report to Tru + sm.warp(104000000); // Lith Harbor + } + + @Script("1204001") + public static void npc1204001(ScriptManager sm) { + // Backup script name for NPC 1204001 - calls the main script + dollMaster00(sm); + } + + @Script("1002104") + public static void npc1002104(ScriptManager sm) { + // Tru - Information Dealer in Lith Harbor (104000000) + // Handles quest 21719 completion and quest 21720 start + + // Quest 21719 - The Puppeteer's Invitation (COMPLETION) + if (sm.hasQuestStarted(21719)) { + final Map options = new HashMap<>(); + options.put(0, "(You tell him about your encounter with Francis the Puppeteer.)"); + final int answer = sm.askMenu("Hmmm? I still haven't found a suitable Informant Assignment for you… well, do you need me for anything else? Or do you have some juicy information for me...?", options); + sm.sayNext("#p1204001#, the Black Wing Puppeteer. Okay, now this all makes sense. What happened with the #o1210102#s in #m100000000# and the #o1110100#s in #m101000000# are all being done by the same guy. But wait...are you telling me he also mentioned the Black Mage?"); + // Complete quest 21719 + sm.forceCompleteQuest(21719); + sm.addExp(1200); + return; + } + + // Quest 21720 - The Puppeteer's Warning (START) + if (sm.hasQuestCompleted(21719) && !sm.hasQuestStarted(21720) && !sm.hasQuestCompleted(21720)) { + final Map options = new HashMap<>(); + options.put(0, "(You tell him about your encounter with Francis the Puppeteer.)"); + final int answer = sm.askMenu("Hmmm? I still haven't found a suitable Informant Assignment for you… well, do you need me for anything else? Or do you have some juicy information for me...?", options); + sm.sayNext("#p1204001#, the Black Wing Puppeteer. Okay, now this all makes sense. What happened with the #o1210102#s in #m100000000# and the #o1110100#s in #m101000000# are all being done by the same guy. But wait...are you telling me he also mentioned the Black Mage?"); + if (!sm.askYesNo("Now that I think of it, I do remember a report saying that there's a group that's trying to revive the Black Mage. I thought it was bogus, but now…it seems quite legit. Are they really trying to revive the Black Mage? Could the prophecy be true?")) { + sm.sayOk("You don't want to give #p1201000# the dangerous news? She may seem weak on the outside, but remember, she lived alone on that island a long time, just to find you. She's much stronger than you think."); + return; + } + sm.sayNext("I think the Black Wings might be worth looking into. It may seem like a very secretive organization, but there's no way they can outsmart my intelligence network. I'll let you know when I hear something that's relevant. In the meanwhile, you should head over to #b#m140000000##k and tell #b#p1201000##k what has happened here."); + sm.sayBoth("The return of the hero, a group following the Black Mage, and the prophecy…the three seem to go hand-in-hand. As someone who revived the hero, #p1201000# has a right to know about this as well. #p1201000# may be a great help, too, since she's studied heroes for so long."); + // Start quest 21720 + sm.forceStartQuest(21720); + return; + } + + // Quest 21720 - The Puppeteer's Warning (IN PROGRESS) + if (sm.hasQuestStarted(21720)) { + sm.sayOk("Haven't you gone to #m140000000# yet? For anything to do with the Black Mage, you should definitely keep #p1201000# in the loop."); + return; + } + + // Default dialogue + sm.sayOk("Hey there. I'm Tru, the Information Dealer. If you need any intel on monsters, items, or quests, I'm your guy. Just let me know what you need."); + } + @Script("periPatrol") public static void periPatrol(ScriptManager sm) { // Perion Warning Post (1022107) @@ -664,6 +763,45 @@ public static void q22109s(ScriptManager sm) { sm.addSkill(22181003, 0, 20); // Soul Stone } + + // QUESTS - LEVEL 200 QUEST ---------------------------------------------------------------------------------------- + + @Script("q22300s") + public static void q22300s(ScriptManager sm) { + // Hero's Succession (22300 - start) + // Auto-start quest at Level 200 + // NPC 1205000 - Afrien (Slumbering Dragon) + sm.sayNext("Evan and #p1013000#... You've become so strong. In this state, you should be able to use the power that Freud once held..."); + sm.sayBoth("Come to my island...."); + sm.sayBoth("Now it is time to choose the forgotten power's successor....."); + sm.sayBoth("And that is you......"); + + sm.forceStartQuest(22300); + } + + @Script("q22300e") + public static void q22300e(ScriptManager sm) { + // Hero's Succession (22300 - end) + // NPC 1205000 - Afrien + sm.sayNext("The Dragon Master's strength clearly shows the relationship between Humans and Onyx Dragons."); + sm.sayBoth("When the relationship between the two are not in harmony, the Onyx Dragon is only at half power. But when the two are in perfect harmony, the Onyx Dragon's power explodes."); + sm.sayBoth("You are both young, yet you have great powers. Freud and I were not as strong at that age... That is because the two of you are bonded so tightly."); + sm.sayBoth("Onyx Dragons are a race drawn to strong spirits. Just looking at you two, it is obvious how strong your spirits must be... There is no doubt that you are qualified to receive this skill..."); + + if (!sm.askYesNo("Will you accept the power of Hero's Echo?")) { + sm.sayOk("Come back when you are ready to accept this power."); + return; + } + + sm.sayOk("#bHero's Echo#k... This is a skill that was used by Freud, my former master. I trust that you will use the skill well... Please take good care of Maple World...and of #p1013000#."); + + // Give Hero's Echo skill (20011005) and medal (1142158) + sm.addSkill(20011005, 1, 1); // Hero's Echo + sm.addItem(1142158, 1); // Hero's Echo Medal + sm.forceCompleteQuest(22300); + } + + @Script("q2344s") public static void q2344s(ScriptManager sm) { // Mushking Empire in Danger (2344 - start) @@ -840,6 +978,292 @@ public static void q22411s(ScriptManager sm) { sm.sayOk("#bHmm, you're right. We don't really have a choice, it'll be pricey but I can't NOT get you one. Let's go get another saddle for you."); } + @Script("q22400s") + public static void q22400s(ScriptManager sm) { + // Rumor about the Dragon Mount (22400 - start/auto-complete) + sm.sayNext("How is your research on Onyx Dragons going? I recently heard an interesting rumor about a #bDragon mount#k. Someone was seen in Victoria Island riding a Dragon."); + sm.sayBoth("Amazing, right? Dragons are known to be haughty and prideful, but I guess they let humans they are close to ride them. I wonder if the same applies to Onyx Dragons."); + sm.sayBoth("But we can't study Onyx Dragons, since they became extinct a long time ago... I wish there were #bsomeone we could talk to who'd know about whether Onyx Dragons can be used as mounts#k."); + sm.sayBoth("I thought I'd tell you about it, since you're researching Onyx Dragons. If you ever find out whether Onyx Dragons can be mounted, do come back to #b#m101000000##k and let me know."); + sm.forceStartQuest(22400); + } + + @Script("q22400e") + public static void q22400e(ScriptManager sm) { + // Rumor about the Dragon Mount (22400 - end) + final int answer = sm.askMenu("Oh, it's you... How is your study of Onyx Dragons going? Did you ever find out if Onyx Dragons can be mounted.", java.util.Map.of( + 0, "I think it is possible, but I'm not sure how. I should ask the person who is rumored to have ridden a Dragon." + )); + sm.sayNext("Hm. I guess the person I mentioned before who was riding a Dragon wasn't riding an Onyx Dragon. Still, maybe you'd learn how to mount an Onyx Dragon if you talk to him about how he mounts his own Dragon."); + sm.sayBoth("If you are really interested, you should visit #m102000000#. #b#p9901000##k is said to be at the #b#m102000000# Warrior's Sanctuary. You should be able to find out more about mounting Dragons from him. He is supposed to be very powerful... "); + sm.sayBoth("I guess if an Onyx Dragon had a close enough relationship with a human, then the Dragon would let that human mount him. This will take some more studying..."); + sm.forceCompleteQuest(22400); + } + + @Script("q22402s") + public static void q22402s(ScriptManager sm) { + // Meeting the Dragon Rider (22402 - start) + sm.sayNext("Hello. My name is #p9901000#. Would you like advice on how to be a powerful Warrior? Hm, you're not a Warrior... What can I do for you?"); + final int answer1 = sm.askMenu("", java.util.Map.of( + 0, "I heard that you know how to mount a Dragon... Is that true?" + )); + sm.sayNext("Yes, it's true. I am able to mount a Dragon thanks to the blessing of the Goddess, though I was quite surprised when I discovered that fact. But why do you ask?"); + final int answer2 = sm.askMenu("", java.util.Map.of( + 0, "Well, I would also like to mount a Dragon and want to know how! Can you teach me?" + )); + sm.sayNext("Well, to get a Dragon from the Goddess, you need to reach Lv. 200 with an Adventurer character. As you may know, that is no easy task. It actually took me several years."); + final int answer3 = sm.askMenu("", java.util.Map.of( + 0, "Oh, you don't have to worry about that! I already have a Dragon!" + )); + sm.sayNext("That thing standing next to you? It looks so different from my Dragon that I thought it was just a really big lizard! I don't think you are Lv. 200. How did you get it?"); + final int answer4 = sm.askMenu("", java.util.Map.of( + 0, "I found it. Hehe." + )); + sm.sayNext("You found it? There must be a story behind this... In any case, I will tell you about mounting Dragons. But just having a Dragon doesn't mean you can mount it. You'll need to prepare some items."); + final int answer5 = sm.askMenu("", java.util.Map.of( + 0, "Sure. Like what?" + )); + sm.sayNext("The first thing you'll need is a very #bstrong saddle#k. Without one, the hard scales will destroy your bottom..."); + final int answer6 = sm.askMenu("", java.util.Map.of( + 0, "Where can I get a strong saddle?" + )); + sm.sayNext("I'm not sure. I actually had my saddle naturally upgraded through the Goddess's blessing."); + final int answer7 = sm.askMenu("", java.util.Map.of( + 0, "I see. Well, do you have any guesses?" + )); + sm.sayNext("Hmm... Those #bpeople who ride birds#k seem to have nice-looking saddles... Maybe you can get a strong saddle from wherever they get theirs. At the very least, you'd get more info..."); + if (!sm.askAccept("")) { + sm.sayOk("Hmm... You haven't seen the people who ride around on birds? Perhaps someone in their homeland makes their sandles for them..."); + return; + } + sm.sayNext("I believe those people who ride birds come from #b#m130000000##k. Maybe you should over there."); + sm.sayBoth("I'm sorry I couldn't be of more help. But if you are able to get a strong saddle, I will go ahead and teach you the Monster Rider skill."); + sm.forceStartQuest(22402); + } + + @Script("q22402e") + public static void q22402e(ScriptManager sm) { + // Meeting the Dragon Rider (22402 - end) + sm.sayNext("Oh, you got a saddle! It looks strong enough to protect you from the Dragon's scales. I will now teach you the Monster Rider skill."); + sm.sayBoth("I've taught you the Monster Rider skill. With this skill and a saddle, you should be able to mount your Dragon freely."); + sm.sayBoth("Just remember that you have to get off your Dragon when you use skills. Otherwise, your Dragon may get hurt. Also remember that you will not be able to mount in certain areas."); + sm.sayBoth("I hope you have great adventures mounted on your Dragon."); + sm.removeItem(1912033, 1); + sm.addSkill(20011004, 1, 1); // Monster Rider + sm.forceCompleteQuest(22402); + } + + @Script("q22405s") + public static void q22405s(ScriptManager sm) { + // The Lost Saddle (22405 - start) + sm.sayNext("Oh, you're the person who wanted to mount that strange animal. What brings you back? Did you lose the saddle?"); + if (!sm.askAccept("")) { + sm.sayOk("If you don't want to bring me the materials, I can't make you a saddle."); + return; + } + sm.sayNext("In that case, you'll have to gather all of the materials for the saddle again. Let me remind you what they are. You need #b50 #t4000155#s#k, #b1 #t4032474##k, and most importantly, the fee... Hehehe..."); + sm.sayBoth("I gave you a special discount last time, but this time I'll have to charge you #b20 million mesos#k. Since your level is so high, this should be easy for you, right? Have a nice day!"); + sm.forceStartQuest(22405); + } + + @Script("q22405e") + public static void q22405e(ScriptManager sm) { + // The Lost Saddle (22405 - end) + sm.sayNext("You've brought all the materials! And the fee as well! Hehe, I'll go ahead and start making your saddle. I should be able to make it much faster this time. "); + sm.sayBoth("Here is the completed saddle. Enjoy it and feel free to come back if you happen to lose it. See you next time!"); + sm.removeItem(4000155, 50); + sm.removeItem(4032474, 1); + sm.addMoney(-20000000); + if (!sm.addItem(1912033, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22405); + } + + @Script("q22407s") + public static void q22407s(ScriptManager sm) { + // Making a Bigger Saddle (22407 - start) + sm.sayNext("Hello. Aren't you the person who special ordered a saddle before. What brings you back? Whoa, that animal next to you, is that the same animal as before? It's huge now! I can hardly recognize it!"); + sm.sayBoth("Seeing that animal, I can guess why you are here. He's gotten so big, there's no way that saddle still fits him. Are you here to order a #bnew saddle#k?"); + if (!sm.askAccept("")) { + sm.sayOk("I guess you don't need a saddle? By the looks of it though, I doubt that the old saddle is going to fit anymore..."); + return; + } + sm.sayNext("Okay, I will make another special saddle for you. Seeing how much it has grown, I'll have to use a more flexible material this time. Hmm... The only materials that might work are so expensive, I'm not sure if you'll be able to find them..."); + sm.sayBoth("Let me tell you what the materials are. First, #b#m211000000#s#k. They can be obtained from #r#o8140000#s#k, a frightful monster that appears in the #b#m211000000##k area. You'll need just #b10#k."); + sm.sayBoth("Second, you need #b2 #t4032476#s#k, which can be found inside the #bShipwreck Tresure Chest#k on the wrecked ship deep inside Aqua Road. The monsters there that are over level 85 so be very careful.\nA Shipwreck Treasure Chest looks like this.\n#i4032557#\r\n"); + sm.sayBoth("Third, you need #b2 #t4032477##ks#k, a specialty product from #m251000000#. They used to sell it at #m251000000#, but for some reason they've stopped. You'll have to go ask #b#p2092001##k about it."); + sm.sayBoth("Fourth and most important, you need to pay a fee of #b30 million mesos#k."); + sm.sayBoth("What?! I'm the only person in Maple World that can handle those kinds of materials. Besides, you must be rich to have such an interesting animal."); + sm.forceStartQuest(22407); + } + + @Script("q22407e") + public static void q22407e(ScriptManager sm) { + // Making a Bigger Saddle (22407 - end) + sm.sayNext("You brought all the materials! I'll start making your saddle right away."); + sm.sayBoth("A beautiful saddle has been completed that is flexible enough to let the animal move yet strong enough to protect the rider. "); + sm.sayBoth("However, there is a limit to the flexibility of this saddle. I'm not sure how big this Dragon will get, but if it gets too big, the saddle might not be able to handle it. If that happens, you'll have to come back and order a new one."); + sm.removeItem(4032475, 10); + sm.removeItem(4032476, 2); + sm.removeItem(4032477, 2); + sm.addMoney(-30000000); + if (!sm.addItem(1912034, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22407); + } + + @Script("q22408s") + public static void q22408s(ScriptManager sm) { + // Obtaining the Unbreakable Porcelain (22408 - start) + sm.sayNext("What can I do for you? If you're looking purchase herbs, you can't right now becuase of the Red-Nosed Pirates... Huh? You're looking for #t4032477#? Unfortunately, you can't get them here anymore because of the Red-Nosed Pirates!"); + sm.sayBoth("There was a master artisan known as the #b#t4032497##K in #m251000000#. The porcelain he used had a mysterious power that allowed it never to break. That porcelain also made it so herbs never spoiled. His skills were so amazing that the Red-Nosed Pirates kidnapped the #b#t4032497##K."); + sm.sayBoth("Since then, the herbs spoil easily and the only porcelain we have left are these strange ones in which odd organisms grow. There is no more #t4032477#. If you want one, you'll have to enter the Red-Nosed Pirate hideout and rescue the #t4032497#... Can you do it?"); + if (!sm.askAccept("")) { + sm.sayOk("I guess I was expecting too much... How will we ever rescue the #t4032497#..."); + return; + } + sm.sayNext("I'm sorry, I didn't realize what a great adventurer you were! Please rescue the #t4032497#. The #t4032497# should be inside the #b#m251010403# storage#k, where the Red-Nosed Pirates hold all the treasures that they have stolen."); + sm.sayBoth("Enter carefully and rescue the #t4032497#. If you can do it, all of #m251000000# will thank you."); + sm.forceStartQuest(22408); + } + + @Script("q22408e") + public static void q22408e(ScriptManager sm) { + // Obtaining the Unbreakable Porcelain (22408 - end) + sm.sayNext("Oh wow, you rescued the #t4032497#! Is the #t4032497# all right? Let me take a look at him."); + sm.sayBoth("Whew... Luckily, he's only passed out. Than again, even the Red-Nosed Pirates wouldn't have hurt such a skilled artisan... Now the #t4032497# should be able to make you the #t4032477# you need."); + sm.removeItem(4032497, 1); + sm.forceCompleteQuest(22408); + } + + @Script("q22409s") + public static void q22409s(ScriptManager sm) { + // Making the Unbreakable Porcelain (22409 - start) + sm.sayNext("Thank you so much for rescuing me! Let me know if there is anything I can do for you. Huh? You want me to make you #t4032477#?"); + sm.sayBoth("That'd be easy, except all of my materials have been stolen by the Pirates! I'm so sorry..."); + sm.sayBoth("If this is really urgent, you could gather the materials yourself... If you can bring me the materials, I'll have it made for you faster than you can blink. "); + if (!sm.askAccept("")) { + sm.sayOk("I guess getting the materials is too difficult... What to do? I can't even reward you properly..."); + return; + } + sm.sayNext("Ah, what a great young man you are. To get the materials, break the #r#o4230505#s#k and the #r#o4230506#s#k around #m251000000# and bring back #b100 #t4000291#s#k and #b100 #t4000292#s#k. "); + sm.forceStartQuest(22409); + } + + @Script("q22409e") + public static void q22409e(ScriptManager sm) { + // Making the Unbreakable Porcelain (22409 - end) + sm.sayNext("Great, you've brought all the materials. Please give them to me. I will make it right away."); + sm.sayBoth("Here is the completed #t4032477#s. It would take quite some power to break these. Of course, someone really high level may be able to break it... Really, really high level..."); + sm.removeItem(4000291, 100); + sm.removeItem(4000292, 100); + if (!sm.addItem(4032477, 2)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22409); + } + + @Script("q22410s") + public static void q22410s(ScriptManager sm) { + // The Lost Big Saddle (22410 - start) + sm.sayNext("Hello. How are you enjoying your saddle? Wait, where is your saddle? Eek! You want me to make you another saddle? You didn't lose the saddle, did you?"); + sm.sayBoth("I worked so hard to make such a nice saddle for you. How could you lose it? You really don't take care of your stuff. Honestly, I really don't want to make another one for someone like you..."); + sm.sayBoth("But, fine. I'll make you a new one just this once. There is a condition however. You'll have to bring me the materials just like before but you'll have to pay double the fee."); + if (!sm.askAccept("")) { + sm.sayOk("Are you refusing because the fee is too high? You shouldn't have lost it in the first palce. I won't lower the price."); + return; + } + sm.sayNext("You do remember what the material are right? #b10 #t4032504#s#k from #m211000000#, #b2 #t4032505#s#k from the Shipwrecked Treasure Chests deep inside Aqua Road, #b2 #t4032477#s#k, a specialty product of #m251000000#, and..."); + sm.sayBoth("You will also need to pay me a #bfee of 60 million mesos#k. It may seem like a lot but I'm charging you extra this time so that you won't lose it again. I will be waiting."); + sm.forceStartQuest(22410); + } + + @Script("q22410e") + public static void q22410e(ScriptManager sm) { + // The Lost Big Saddle (22410 - end) + sm.sayNext("You brought all of the materials. Please give them to me along with the fee."); + sm.sayBoth("Here is the newly made saddle. Please use this incident as a lesson and be careful not to lose it again."); + sm.removeItem(4032504, 10); + sm.removeItem(4032505, 2); + sm.removeItem(4032477, 2); + sm.addMoney(-60000000); + if (!sm.addItem(1912034, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22410); + } + + @Script("q22412s") + public static void q22412s(ScriptManager sm) { + // Making a Really Big Saddle (22412 - start) + sm.sayNext("Hi, how are you? The special animal you are raising seems to be growing well. His horn and wings have gotten so big... It looks like he's going to need a new saddle."); + sm.sayBoth("The materials that were used to make the current saddle won't be able to handle his bigger size or his sharper and stronger scales. He's going to need a saddle made with stronger materials. "); + if (!sm.askAccept("")) { + sm.sayOk("Hmm... I guess you're not as rich as I thought. If it's too difficult for you, it's not a bad idea to level up a bit more and save some more money."); + return; + } + sm.sayNext("Please bring me #b300 #t4000270#s#k from the #r#o8150302#s#k, #b300 #t4000271#s#k from the #r#o8190005#s#k, and #b300 #t4000272#s#k from the #r#o8190000#s#k. With those materials, I should be able to make a new saddle that will be suitable for #p1013000#."); + sm.sayBoth("The only thing is that those materials are much more difficult to handle than before so I'm going to have to charge you a higher fee. It's going to me 60000000 mesos. Your eyes are spinning from all those 0s. Simply stated, it will cost #b60 million mesos#k."); + sm.sayBoth("I realize how expensive that sounds but it's an adequate price for the skill it takes to make a saddle using those materials. If the #m230000003# cost less to operate, I might be able to give you a discount, but as you can see, there are so few visitors here at the #m230000003#..."); + sm.sayBoth("But since you're raising an animal that must cost a fortune to feed, you must be very well off. 60 million mesos is probably nothing to you. Heck, someone like you could probably buy dozens of saddles."); + sm.sayBoth("I will be here waiting for you to bring all the materials."); + sm.forceStartQuest(22412); + } + + @Script("q22412e") + public static void q22412e(ScriptManager sm) { + // Making a Really Big Saddle (22412 - end) + sm.sayNext("You've brought all the materials! And the 60 million mesos as well! I knew it, I really have a good eye for picking out rich people... I mean... Nevermind. Now, please give the materials to me. I will make your new saddle right away. "); + sm.sayBoth("I've made the saddle strong enough to protect the rider from the sharp scales while still being very light. There is also a safety feature to protect the rider from falling off. Enjoy your new saddle and please let me know if you lose it!"); + sm.removeItem(4000270, 300); + sm.removeItem(4000272, 300); + sm.removeItem(4000271, 300); + sm.addMoney(-60000000); + if (!sm.addItem(1912035, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22412); + } + + @Script("q22413s") + public static void q22413s(ScriptManager sm) { + // The Lost Really Big Saddle (22413 - start) + sm.sayNext("Hi, how are you? Hey, since you're here, can I see you ride? I've really wanted to see that for a while... What? You lost your saddle? You want me to make you another one?"); + sm.sayBoth("How...how could you lose that saddle...? Do you know how hard it was to make? Working with #t4000270#s is so difficult...and putting all those #t4000272#s together... You really should learn to take better care of your belongings!"); + sm.sayBoth("I can make you a new saddle. You just have to give me the materials and the fee. Seeing as you don't know how to take care of your things, you'll probably just lose it again! So this time, I'm going to have to charge you double!"); + if (!sm.askAccept("")) { + sm.sayOk("Then I can't make you another saddle. "); + return; + } + sm.sayNext("So you agree? Then please bring me #b300#k #b#t4000270#s#k, #b#t4000272#s#k, and#b#t4000271#s#k! Once you bring them, I will be able to make a new saddle for you."); + sm.sayBoth("You do remember what the fee was last time right? 60 million mesos... So this time, it's 120 million mesos.. Yup, #b120000000 mesos#k. So many 0s..."); + sm.sayBoth("What? I look like I'm happy? Of course not! No matter how much wealth I might accumulate from making all these saddles, I hate people who can't take care of their stuff. That's the only reason I'm charging so much. I'm serious!"); + sm.forceStartQuest(22413); + } + + @Script("q22413e") + public static void q22413e(ScriptManager sm) { + // The Lost Really Big Saddle (22413 - end) + sm.sayNext("Have you brought all the materials for the saddle? And the fee? Whoa... I didn't think you would really... Umm... Nevermind. Now, please give me that materials and the fee."); + sm.sayBoth("Here is the completed saddle. I made it really strong so please don't lose it this time. If you lose do. I'll have to charge you 240 million... Okay I won't really charge that much, but it won't be cheap!"); + sm.removeItem(4000270, 300); + sm.removeItem(4000272, 300); + sm.removeItem(4000271, 300); + sm.addMoney(-120000000); + if (!sm.addItem(1912035, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22413); + } + // QUESTS - STORY -------------------------------------------------------------------------------------------------- @@ -955,6 +1379,61 @@ public static void q22504s(ScriptManager sm) { sm.sayOk("#b(I've already asked Dad once, but I really don't have any better ideas. Time to ask him again!)"); } + @Script("q22505s") + public static void q22505s(ScriptManager sm) { + // Tasty Milk 2 (22505 - start) + sm.sayNext("He's so big I didn't realize he was a baby. He probably can't digest meat yet. My guess is that all #bbabies need milk#k first."); + if (!sm.askAccept("")) { + sm.sayOk("Hmm... I think most babies are the same. Think about it and let me know if you change your mind."); + return; + } + sm.sayNext("You can get milk from the #b#p1013105##k at the #b#m100030310##k. Why don't you go ask her to give you some?"); + sm.sayBoth("Oh, and once you're done feeding the lizard, can you come back to me? I have something to talk to you about."); + sm.addExp(1150); + sm.forceCompleteQuest(22505); + sm.forceStartQuest(22506); + } + + @Script("q22505e") + public static void q22505e(ScriptManager sm) { + // Tasty Milk 2 (22505 - end, at Dairy Cow) + sm.sayOk("Mooo!"); + } + + @Script("q22506s") + public static void q22506s(ScriptManager sm) { + // Tasty Milk 3 (22506 - start) + sm.setPlayerAsSpeaker(true); + sm.sayNext("#b(You ask the #p1013105# to give you some milk.)#k"); + sm.setPlayerAsSpeaker(false); + sm.sayBoth("Mooo..."); + if (!sm.askAccept("")) { + sm.setPlayerAsSpeaker(true); + sm.sayOk("#b(You're too afraid to get closer. Come back later for the milk.)#k"); + return; + } + sm.setPlayerAsSpeaker(true); + sm.sayOk("#b(The #p1013105# gives you some milk. Go feed the milk to #p1013000#.)#k"); + if (!sm.addItem(4032454, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceStartQuest(22506); + } + + @Script("q22506e") + public static void q22506e(ScriptManager sm) { + // Tasty Milk 3 (22506 - end, feed to Mir) + sm.sayNext("I'm so hungry, I have no strength left... Master, I'm so hungry I might shrivel up and really become a lizard. What's this? Water? You want me to fill my stomach with water? If you say so, master..."); + sm.sayBoth("(Gulp, gulp, gulp)"); + sm.sayBoth("Wow, this is so good! What is this water called? Milk? Yum! I feel sooo strong now!"); + sm.sayBoth("Hey, it looks like you've become stronger too, master. Your HP and MP is much higher than when I first saw you."); + sm.removeItem(4032454, 1); + sm.addExp(1420); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2200), 1); + sm.forceCompleteQuest(22506); + } + @Script("q22507s") public static void q22507s(ScriptManager sm) { // What is a Dragon Master? (22507 - start) @@ -998,6 +1477,56 @@ public static void q22507s(ScriptManager sm) { sm.sayBoth("#b(I still have an errand to run, though. I should probably go talk to Dad now.)"); } + @Script("q22508s") + public static void q22508s(ScriptManager sm) { + // Strange Pigs 1 (22508 - start) + sm.sayNext("Sheesh, it doesn't matter how many times I fix it. The #r#o1210111#s#k are acting so crazy! I've fixed the fence a few times already but they are so strong, they just keep breaking through."); + sm.sayBoth("They even look different from normal Pigs. They look like... \n\n#i4032527#\n\n...this. Don't they look strange?"); + sm.sayBoth("I wish someone could take care them... Evan, you be careful. A lot of #o1210111#s are around the #b#m100030320##k so be careful if you have to go past there."); + if (!sm.askAccept("")) { + sm.sayOk("Huh? Haha, you must be full of energy. But you can get hurt if you think of them like normal #o1210100#s."); + return; + } + sm.sayOk("Yes, they shouldn't be taken lightly just because they are #o1210100#s..."); + sm.addExp(320); + sm.forceCompleteQuest(22508); + sm.forceStartQuest(22509); + } + + @Script("q22508e") + public static void q22508e(ScriptManager sm) { + // Strange Pigs 1 (22508 - end, talk to Mir) + sm.sayOk("Master, master! This is our chance! This is our chance to show how strong I am! Let's eliminate the #o1210111#s that that human was talking about!"); + } + + @Script("q22509s") + public static void q22509s(ScriptManager sm) { + // Strange Pigs 2 (22509 - start) + final int answer1 = sm.askMenu("If we work together, the #o1210100#s are a cinch! Well?Come on! Let's eliminate them! Please, master!", java.util.Map.of( + 0, "It's too dangerous. Those #o1210100#s are stronger than normal #o1210100#s." + )); + sm.sayNext("I know, but we can do it! Besides, if it gets too dangerous, we can run away. Just trust me! Well, master? Please?"); + if (!sm.askAccept("")) { + sm.sayOk("Oh come on, really? Master, are you chicken?!"); + return; + } + sm.sayOk("All right! Let's hurry to the #b#m100030320##k and teach those #r#o1210111#s#k a lesson! I think eliminating #r20#k of them should be enough? Let's go!"); + sm.forceStartQuest(22509); + } + + @Script("q22509e") + public static void q22509e(ScriptManager sm) { + // Strange Pigs 2 (22509 - end) + final int answer = sm.askMenu("Woohoo! I told you it'd be simple! #o1210100#s are nothing for us!", java.util.Map.of( + 0, "Wow! #p1013000#, you're stronger than I thought!" + )); + sm.sayNext("Hehehe. Master, when you and I work together, there's nothing that we can't defeat."); + sm.sayBoth("Hey, we should show off our accomplishment! Let's go tell #bDad#k what we did! Hehe, he'll probably be really happy and tell us we did a good job!"); + sm.addExp(1980); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2200), 1); + sm.forceCompleteQuest(22509); + } + @Script("q22510s") public static void q22510s(ScriptManager sm) { // Letter Delivery (22510 - start) @@ -1019,6 +1548,44 @@ public static void q22510s(ScriptManager sm) { sm.sayImage(List.of("UI/tutorial/evan/13/0")); } + @Script("q22511s") + public static void q22511s(ScriptManager sm) { + // Mushrooms Instead of Meat! (22511 - start) + final int answer = sm.askMenu("Delayed delivery of the #t4032453#? We really need meat in Henesys, but I guess we'll just have to survive on mushrooms for a while... ", java.util.Map.of( + 0, "Is there anything I can help you with?" + )); + sm.sayNext("Oh, you're still here, Evan? Well, if you can, do you think you can bring me #b20 #t4000001#s#k?"); + if (!sm.askAccept("")) { + sm.sayOk("It's probably too difficult for you. What should I do... Should I just ask another adventurer passing through?"); + return; + } + sm.sayNext("Oh, you can? #r#o1210102#s#k can be found easily in #b#m104040001##k or #bIII#k. They can also be found at the #b#m100010100# near #m100000000##k. Thanks. "); + sm.sayBoth("By the way, that lizard... Nevermind. Kids these days have such strange pets... I'd rather act like I didn't see anything."); + sm.forceStartQuest(22511); + } + + @Script("q22511e") + public static void q22511e(ScriptManager sm) { + // Mushrooms Instead of Meat! (22511 - end) + sm.sayNext("Oh wow, you've brought the 20 #t4000001#s that I asked for! That's pretty good. #p1013103# must be teaching you hunting skills too! This is great. Thank you."); + sm.sayBoth("If there is anything I can ever do for you, please let me know. If there is something I can help you with in #m100000000#, I will do my best to assist you."); + sm.removeItem(4000001, 20); + sm.addExp(3560); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2200), 1); + if (!sm.addItems(List.of( + Tuple.of(2000001, 30), // Red Potion + Tuple.of(2000003, 30) // Blue Potion + ))) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + if (!sm.addItem(1142152, 1)) { // The Dragon Master's Necklace + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22511); + } + @Script("q22512s") public static void q22512s(ScriptManager sm) { // The Dragon Master's Calling (22512 - start) @@ -1039,6 +1606,45 @@ public static void q22512s(ScriptManager sm) { sm.sayOk("#b(You agree to help others using your powers as a Dragon Master. Sounds grandiose, even to you. But you'd better get started! Check around Henesys to see if anyone needs help.)"); } + @Script("q22513s") + public static void q22513s(ScriptManager sm) { + // Rina's Worries (22513 - start) + final int answer1 = sm.askMenu("Huh? Am I worried about something? Well, the truth is that #p1012101#'s health has been getting worse these days so I want to make her some Blue Mushroom Porridge but it's so difficult to find any #t4000009#s.", java.util.Map.of( + 0, " Are #o2220100#s rare?" + )); + final int answer2 = sm.askMenu("No, there are a lot of them at the #m106010100#. But the #o2220100#s have become so violent these days, it's difficult to get Blue Mushroom Caps... I really want to make some Blue Mushroom Porridge for #p1012101#... Sigh...", java.util.Map.of( + 0, "Should I get some #t4000009#s for you?" + )); + final int answer3 = sm.askMenu("You?! No way! What are you thinking? You can't handle the #o2220100#s! #o1210102#s and #o2220100#s are totally different. For your own safety, don't even think of going anywhere near them! Okay?", java.util.Map.of( + 0, "But..." + )); + sm.sayNext("No buts! If I ask #p1040000#, the Security Guard, and he tells me that he's seen you, I'll ask Chief Stan to deny you entrance to #m100000000#! Okay? You promise?"); + if (!sm.askAccept("")) { + sm.sayOk("Don't even think about it! Don't you dare go there! Never!"); + return; + } + sm.sayNext("Okay... Don't do anything dangerous. I'll just ask another adventurer who happens to pass this way."); + sm.sayBoth("Oh and um... Please don't take this the wrong way but... Why are you walking around with a lizard? A pet? I guess he is kind of cute but...you have a pretty peculiar taste..."); + sm.addExp(360); + sm.forceCompleteQuest(22513); + sm.forceStartQuest(22514); + } + + @Script("q22513e") + public static void q22513e(ScriptManager sm) { + // Rina's Worries (22513 - end, talk to Mir) + final int answer1 = sm.askMenu("Master, she is in trouble! We can help her!", java.util.Map.of( + 0, "Not this time. She says it's dangerous." + )); + final int answer2 = sm.askMenu("If we avoid it just because it's dangerous, how can we call ourselves heroes? We have to do it!", java.util.Map.of( + 0, "Well, you're kind of right, but we made a promise and breaking a promise is wrong. I think a hero would keep his word." + )); + final int answer3 = sm.askMenu("Well, I suppose... Bu...but...", java.util.Map.of( + 0, "No is no. Besides I don't think we can win against the #o2220100#s." + )); + sm.sayOk("So if we can beat the #o2220100#s, we can go?"); + } + @Script("q22514s") public static void q22514s(ScriptManager sm) { // Let's Train (22514 - start) @@ -1060,6 +1666,102 @@ public static void q22514s(ScriptManager sm) { sm.sayBoth("#b(I've finally calmed him down a bit. I should go talk to #p1012003# about the training center.)"); } + @Script("q22515s") + public static void q22515s(ScriptManager sm) { + // Power B. Fore's Training Center (22515 - start) + sm.sayNext("There is a Bowman Training Center near #m100000000#. It should be quite useful if you haven't reached Level 20 yet. But as the name suggests, it's only open to Bowman... Well, since you did help me, I'll make an exception and let you use it."); + if (!sm.askAccept("")) { + sm.sayOk("Are you not planning on using the Training Center? Then why are you asking?"); + return; + } + sm.sayNext("If you take the Recommendation Letter I just gave you to the #b#m100010000##k, you'll meet #b#p1012118##k. He's full of hot air, but he is supposed to be pretty helpful. Just show him the Recommendation Letter to use the Training Center."); + sm.sayBoth("But why do you want to train? Is #p1013103# planning for you to become an Adventurer? You're not going to leave home and not come back like #p1052000#, are you? Hmm... Nevermind. Take care of yourself."); + if (!sm.addItem(4032456, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceStartQuest(22515); + } + + @Script("q22515e") + public static void q22515e(ScriptManager sm) { + // Power B. Fore's Training Center (22515 - end) + sm.sayNext("Oh, are you a Bowman who's come to train? Or not... From the wand in your hand, you must be a Magician. What is that strange pet you have? I can't let anyone in who is not a Bowman. Huh? This is... #t4032456#?"); + sm.sayBoth("Wait, a Recommendation Letter?! Stan is not compassionate enough to write something like this. Let me take a closer look."); + sm.removeItem(4032456, 1); + sm.addExp(1210); + sm.forceCompleteQuest(22515); + } + + @Script("q22516s") + public static void q22516s(ScriptManager sm) { + // Power B. Fore's Paranoia (22516 - start) + final int answer1 = sm.askMenu("This...this isn't possible! That miserly Stan would never write a Recommondation Letter for anyone! I don't believe it! I grew up with Stan, so I know! This is a conspiracy! That Stan is just trying to trick me! ", java.util.Map.of( + 0, "(Hmm... Chief Stand did say #p1012118# was full of hot air. Do they hate each other?)" + )); + final int answer2 = sm.askMenu("And you're nothing but his little instrument, here to muddy the quality of my Training Center! If people were to find out that someone like you was training here, rumor would spread that training here isn't very helpful. I won't let that happen!", java.util.Map.of( + 0, "(This guy is paranoid. Who'd want to muddy his Training Center's reputation?)" + )); + sm.sayNext("I won't simply fall for Stan's little trick. I refuse this Recommendation Letter! Well, that's what I want to say. But he's the Chief of #m100000000#, I can't just refuse... Oh, a test! I'll give you a test! "); + if (!sm.askAccept("")) { + sm.sayOk("Yes, I'm sure of it. This is all a part of Stan's conspiracy! Did you think I would fall for it, Stan?! Puhaha!"); + return; + } + sm.sayNext("There are a lot of #r#o9300274#s#k that have appeared at the #b#m100010100##k to your right. Eliminate #r50#k of them! Puhaha, of course there's no way you can pull that off. You're just Stan's instrument!"); + sm.setPlayerAsSpeaker(true); + sm.sayOk("#b(Sheesh, he's wrong, but there's no point trying to convince him. You have little choice but to eliminate the #o9300274#s.)#k"); + sm.forceStartQuest(22516); + } + + @Script("q22516e") + public static void q22516e(ScriptManager sm) { + // Power B. Fore's Paranoia (22516 - end) + sm.sayOk("Wha..What? You eliminated 50 #o9300274#s? Th...there's no way!"); + sm.addExp(4390); + if (!sm.addItems(List.of( + Tuple.of(2000001, 20), // Red Potion + Tuple.of(2000003, 20) // Blue Potion + ))) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22516); + } + + @Script("q22517s") + public static void q22517s(ScriptManager sm) { + // Power B. Fore's Continuing Paranoia (22517 - start) + final int answer1 = sm.askMenu("No way! This can't be! I can't accept it! There is no way that a person recommended by Stan would have such skills... This is impossible!", java.util.Map.of( + 0, "Ahem, so can I start using the Training Center now?" + )); + final int answer2 = sm.askMenu("I can't accept it! You're using some sort of trick! Yes, that's it! Some high level adventurer passing by must have helped you! I'm right, aren't I?!", java.util.Map.of( + 0, "No one helped me..." + )); + sm.sayNext("Ha, an instrument of Stan would have no problems telling a lie. I can't believe you! I will give you another test! This time, eliminate #r80 #o9300274##ks!"); + if (!sm.askAccept("")) { + sm.sayOk("Puhaha... So I was right! It was all a lie. It wasn't a bad attempt but you're far from being able to trick me!"); + return; + } + sm.sayOk("Hehehe, even a high level adventurer would be too annoyed to help you eliminate so many of them. This time, I will uncover your mask once and for all!"); + sm.forceStartQuest(22517); + } + + @Script("q22517e") + public static void q22517e(ScriptManager sm) { + // Power B. Fore's Continuing Paranoia (22517 - end) + sm.sayOk("Huh?! You're back... You...you didn't...eliminate 80 #o9300274#s, did you? What? No way! This is not possible! It's all a lie!"); + sm.addExp(2400); + if (!sm.addItems(List.of( + Tuple.of(2000001, 20), // Red Potion + Tuple.of(2000003, 20) // Blue Potion + ))) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22517); + sm.forceStartQuest(22518); + } + @Script("q22518s") public static void q22518s(ScriptManager sm) { // Power B. Fore's Never Ending Paranoia (22518 - start) @@ -1079,46 +1781,707 @@ public static void q22518s(ScriptManager sm) { sm.warpInstance(910060100, "start", 100020000, 60 * 10); } - @Script("q22536s") - public static void q22536s(ScriptManager sm) { - // Kerning City Investigation: Nella (22536 - start) - sm.sayNext("Hmm? I don't recall seeing you around here before. What brings you to #b#m103000000##k? Are you here to become a Thief?"); - sm.setPlayerAsSpeaker(true); - sm.sayBoth("#bHave you caught a whiff of anyone smelling of herbs?"); - sm.setPlayerAsSpeaker(false); - sm.sayNext("Smelling of herbs? I'm not sure... I thought everybody used potions these days?! Why are you asking about herbs? Are you looking to buy some?"); - sm.setPlayerAsSpeaker(true); - sm.sayBoth("#bWell, you see...\r\n\r\n(You explain what happened to #p1061005#.)"); - sm.setPlayerAsSpeaker(false); - sm.sayNext("Huh, an herb thief, huh? I see... Wait! What? Wait, wait, wait just a minute!! Are you suggesting that the thief is from #b#m103000000##k?!"); - sm.setPlayerAsSpeaker(true); - sm.sayBoth("#bThis IS a thief town, afterall, isn't it?"); - sm.setPlayerAsSpeaker(false); - sm.sayNext("Yes, but we're not burglars! This is a THIEF town, NOT a burglar town. UGH! It drives me absolutely crazy when- Geez! The things you're implying about us Thieves here in #b#m103000000##k! Sure, it's true that we can be a bit sneaky and petty, a little under-handed and cunning, yeah... But we DON'T threaten the livelihoods of others just to get what we want!"); - sm.setPlayerAsSpeaker(true); - sm.sayBoth("#bReally? Wow..."); - sm.setPlayerAsSpeaker(false); - sm.sayNext("Yes! Really! I know people get the wrong idea about us, but as someone who was born and raised in #b#m103000000##k, I'm DEEPLY offended! I swear on my MOTHER that the burglar you're looking for is not from here!"); - sm.setPlayerAsSpeaker(true); - sm.sayBoth("#bOh? Well, where is the burglar from, you think?"); - sm.setPlayerAsSpeaker(false); - if (!sm.askAccept("I haven't got a clue! I'm still upset about your implications and accusations, but I WILL find the thief who stole #b#p1061005##k's herbs myself! Then I will take #b#m103000000##k's honor back! Did you get that?! I will find it by MYSELF!\r\n\r\n#fUI/UIWindow2.img/QuestIcon/4/0#\r\n#fUI/UIWindow2.img/QuestIcon/8/0#\r\n8000 exp")) { - sm.sayOk("You come here implying such things and have the audacity to just walk away?! HMPH!"); + @Script("q22519s") + public static void q22519s(ScriptManager sm) { + // Power B. Fore's Training (22519 - start) + final int answer1 = sm.askMenu("So what do you think? Isn't my Training Center amazing?", java.util.Map.of( + 0, "You talk like it's so great but there are no strong monsters inside..." + )); + final int answer2 = sm.askMenu("No...no strong monsters?! Do you not realize how frightful of a monster a #o0210100# is...!", java.util.Map.of( + 0, "Um, #o0210100#s are even lower level than #o1210102#s." + )); + final int answer3 = sm.askMenu("Uggh! How did that secret get out?", java.util.Map.of( + 0, "(You could tell just by fighting the monsters, but you get the hunch it'd be useless to explain that.)" + )); + sm.sayNext("Hmm... Well aside from the #o0210100#s, the #o1210101#s are stronger than the #o1210102#s, aren't they? You can level up in the blink of an eye just hunting them! Here, let me train you personally!"); + if (!sm.askAccept("")) { + sm.sayOk("You...you refuse the training? Why? Why is it that no one I train believes in me?"); return; } - sm.forceCompleteQuest(22536); - sm.addExp(8000); - sm.sayNext("I'll investigate the burglar you're looking for, so keep yourself available as much as possible! I'll reach out when I get to the bottom of this!"); - sm.sayBoth("Just keep training or whatever it is you do until I contact you, okay?"); + sm.sayOk("Okay, go hunt #r150 #o1210101#s#k! If you can do it, I will recognize your abilities! Puhaha!"); + sm.forceStartQuest(22519); } - @Script("q22541s") - public static void q22541s(ScriptManager sm) { - // Where's the Book? 1 (22541 - start) - sm.sayNext("Do you come seeking knowledge? Remember that a constant thirst for knowledge never leads to any good. Continue growing your willpower and you will find boundless power within yourself. Excuse me, are you here for a book?"); - sm.setPlayerAsSpeaker(true); - sm.sayBoth("#bI'm looking for #t4161050#."); - sm.setPlayerAsSpeaker(false); + @Script("q22519e") + public static void q22519e(ScriptManager sm) { + // Power B. Fore's Training (22519 - end) + final int answer = sm.askMenu("Have you eliminated the #o1210101#s? Ah, I see that my eyes have not deceived me. From the moment I first laid eyes on you, I knew how strong you were.", java.util.Map.of( + 0, "(Didn't he call you Stan's instrument?)" + )); + sm.sayOk("Hehe... Everyone who trains under me levels up so quickly."); + sm.addExp(2900); + sm.forceCompleteQuest(22519); + } + + @Script("q22520s") + public static void q22520s(ScriptManager sm) { + // Receiving Power B. Fore's Certificate of Training Again (22520 - start) + sm.sayNext("What is it? You want to receive my training again? Puhaha! My training is so great, even people who've completed training want to receive it again! Okay, let's start training!"); + if (!sm.askAccept("")) { + sm.sayOk("You refuse? revolting already? Now, now... You're not nearly strong enough to fight me. Hehe, why don't you cool your head and then come back."); + return; + } + sm.sayOk("Hunt #r200 #o1210101#s#k. Only difficult training can make you a master! "); + sm.forceStartQuest(22520); + } + + @Script("q22520e") + public static void q22520e(ScriptManager sm) { + // Receiving Power B. Fore's Certificate of Training Again (22520 - end) + sm.sayNext("Ah, you defeated 200 #o1210101#s? Faster than I thought. This is all a result of my training. Here, take this."); + sm.sayBoth("You must be very happy to receive the Certificate you so desired. Puhaha."); + if (!sm.addItem(4032457, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22520); + } + + @Script("q22521s") + public static void q22521s(ScriptManager sm) { + // Become a Hero (22521 - start) + final int answer1 = sm.askMenu("Master, master! Now that our skills have gotten better, let's go hunt the #o2220100#s! Come on!", java.util.Map.of( + 0, "But it's too dangerous..." + )); + final int answer2 = sm.askMenu("That was because we were weaker back then. But now that we've trained, #o2220100#s are easy! ", java.util.Map.of( + 0, "But I promised..." + )); + sm.sayNext("If it looks like it'll be too dangerous, we can just run away. Come on! Let's at least test if we can elminate a #o2220100#! What do you say? Please!"); + if (!sm.askAccept("")) { + sm.sayOk("Master is a chicken, a scaredy cat, a wussy... What else is there?"); + return; + } + sm.sayNext("Woohoo! Really? Let's hurry! According to what that female human said, the #r#o2220100#s#k seem to be in a place called the #b#m106010100##k! Let's hurry and go there!"); + sm.sayBoth("If we get #b20 #t4000009#s#k and give it to that female human named #p1010100#, she'll be so surprised! We'll be heroes that helped a person in trouble!"); + sm.forceStartQuest(22521); + } + + @Script("q22521e") + public static void q22521e(ScriptManager sm) { + // Become a Hero (22521 - end) + sm.sayNext("Evan? You haven't returned to the farm yet? Huh? This...oh my. So many #t4000009#s! You didn't get these yourself did you?"); + sm.sayBoth("I told you to not go near the #m106010100# because it's dangerous! You should've listened to me!"); + sm.sayBoth("But since you've got so many of these, I guess you can handle the #o2220100#s. I guess I can't even really yell at you... Sheesh, I'll let it go this time but please don't break your promises in the future. Okay?"); + sm.sayBoth("In any case, you really are very strong for your age. What's your secret? You might be as strong as the Guards at the Dungeon entrances. They might even come ask you for help. Maybe when you're level 20 or so?"); + sm.removeItem(4000009, 20); + sm.addExp(2950); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2200), 1); + sm.forceCompleteQuest(22521); + } + + @Script("q22522s") + public static void q22522s(ScriptManager sm) { + // Delivering Maya's Porridge (22522 - start) + sm.sayNext("Evan, if you're not too busy, do you think I can ask you for a favor? I made some porridge with the #t4000009#s you brought me. Will you deliver it to #p1012101# before it gets cold?"); + if (!sm.askAccept("")) { + sm.sayOk("Hmm... Are you busy with something? But still, helping out a friend like #p1012101# would be nice..."); + return; + } + sm.sayOk("You do know where #b#p1012101##k is, right? The two of you are pretty close, right? She is at #b#m100000001##k. It's the house on the right side of town."); + if (!sm.addItem(4032458, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceStartQuest(22522); + } + + @Script("q22522e") + public static void q22522e(ScriptManager sm) { + // Delivering Maya's Porridge (22522 - end) + sm.sayNext("Cough, cough! Hi Evan, how are you? It's been a long time! I haven't seen you since my field trip to the Farm. Heh, I've been too sick to go outside... Is that lizard your pet? He's so cute..."); + sm.sayBoth("So what brings you to #m100000000#? An errand? Or are you here to hang out with #p1012108#? Huh? #p1010100# asked you to deliver this to me? Wow, its Blue Mushroom Porridge!"); + sm.sayBoth("Thank you so much for delivering it. Although the color looks weird, it really tastes good. Hehe, a lot of people hate it but it tastes great to me."); + sm.removeItem(4032458, 1); + sm.addExp(300); + sm.forceCompleteQuest(22522); + } + + @Script("q22523s") + public static void q22523s(ScriptManager sm) { + // Investigating Strange Mushrooms (22523 - start) + sm.sayNext("Hey, are you Evan? I'm #p1040000#, a Guard in charge of the #m100000000# Dungeon Entrance. I heard a lot about you from #p1010100#. She told me that you're strong for your age... Is that true? If so, I have a favor I'd like to ask."); + sm.sayBoth("You may have heard from #p1010100# already, but the #o2220100#s near the #m106010100# area have become very strange. Some #o2220100#s appear to be crying. They also attack people who pass by. I think a detailed investigation is needed. Can you help?"); + if (!sm.askAccept("")) { + sm.sayOk("Oh...is it too difficult? I was hoping you could help because I was told that you're strong enough to defeat #o2220100#s... I guess I'll have to find someone else..."); + return; + } + sm.sayNext("Luckly, there is a scholar named #p1012111# in #m100000000# who studies Mushrooms. I think you may be able to ask him for help. Please bring him #b40#k #b#t4000009#s#k that he can use as samples in his study."); + sm.sayBoth("Oh...and are you sure that the lizard next to you isn't dangerous? Maybe you should leash him so he can't attack or bite people. What? Hey, don't take offense. It's always smart to restrain your pets."); + sm.forceStartQuest(22523); + } + + @Script("q22523e") + public static void q22523e(ScriptManager sm) { + // Investigating Strange Mushrooms (22523 - end) + sm.sayNext("Oh? Aren't you Evan? Is there something I can do for you? Huh? These are #t4000009#s. #p1040000# wants me to investigate the #o2220100#s?"); + sm.sayBoth("The #o2220100#s are becoming violent? Could it be related to that other incident...?"); + sm.removeItem(4000009, 40); + sm.addExp(2500); + sm.forceCompleteQuest(22523); + } + + @Script("q22524s") + public static void q22524s(ScriptManager sm) { + // Strange Puppet (22524 - start) + sm.sayNext("I'm sorry. I was thinking about something else. Similar things have been happening around #m100000000# lately. Have you heard of the #o9300274#s? "); + sm.sayBoth("They used to be normal mushrooms but they turned strange. Your situation sounds similar. Luckily we know the solution to this problem thanks to someone who helped us before."); + sm.sayBoth("It you hunt about #r100 #o2220100#s#k and recover the #b#t4032459##k that they drop, it cools down the situation. Can you recover the #t4032459# and deliver it to #p1040000#?"); + if (!sm.askAccept("")) { + sm.sayOk("Is it too hard? I heard rumors that you eliminated #o2220100#s... Hmm. Maybe #p1010100# was mistaken."); + return; + } + sm.sayOk("Okay, please do your best. #o2220100#'s puppet probably won't drop that easily. You're going to need patience."); + sm.forceStartQuest(22524); + } + + @Script("q22524e") + public static void q22524e(ScriptManager sm) { + // Strange Puppet (22524 - end) + sm.sayNext("Oh, you're here. So what did #p1012111# say? Why are the #o2220100#s acting so weird? Huh? This is a...puppet?"); + sm.sayBoth("This puppet is what's causing the #o2220100# to change? That's difficult to believe, but since an expert is saying it...I guess I'll have to believe it. In any case, thanks."); + sm.removeItem(4032459, 1); + sm.addExp(2600); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2210), 1); + sm.forceCompleteQuest(22524); + sm.forceStartQuest(22525); + } + + @Script("q22525s") + public static void q22525s(ScriptManager sm) { + // Mike at the Perion Dungeon Entrance (22525 - start) + sm.sayNext("Hmm... This seems doubtful but a Guard should always be prepared for even the smallest chance of danger. I better warn the other towns too. Evan, if you're not too busy, do you think you could help me out?"); + if (!sm.askAccept("")) { + sm.sayOk("Oh, are you busy? All right then. I wonder if there is anyone that is headed to #m102000000#... Let me know if you change your mind."); + return; + } + sm.sayOk("Gee, thanks! Please go to #m102000000# and tell #p1040001#, the Guard in charge of the #b#m106000300##k, this official notice. #bHigh level of possibilities for unprecedented phenomenon occuring in monster forces near towns. Observation requested!#k"); + sm.forceStartQuest(22525); + } + + @Script("q22525e") + public static void q22525e(ScriptManager sm) { + // Mike at the Perion Dungeon Entrance (22525 - end - complex menu-based dialog) + final int answer1 = sm.askMenu("Yawn...snore...wha..what? Yes sir! Atten-hut! No problems to report sir! Wait... I mean... You're just an adventurer. Huh? #p1040000# asked you to come here? What is it?", java.util.Map.of( + 0, "High rate of people for...", + 1, "High level of possibilites for...", + 2, "High chances of feasibility for..." + )); + if (answer1 == 0 || answer1 == 2) { + if (answer1 == 0) { + sm.sayOk("High rate of people for what? Are a lot of people getting sick? What? No?"); + } else { + sm.sayOk("Chance of feasibility? What are you talking about? Can you just cut to the chase and tell me what #p1040000# wants to tell me?"); + } + return; + } + final int answer2 = sm.askMenu("High level of possibilities for? For what? Is there a problem?", java.util.Map.of( + 0, "unprecedented phenomon occuring in monster forces near town", + 1, "unprecedented dancing of monster forces near town", + 2, "unprecedented lullabies being sung by monsters near town" + )); + if (answer2 == 1 || answer2 == 2) { + if (answer2 == 1) { + sm.sayOk("The monsters are dancing? Are they bored?"); + } else { + sm.sayOk("If the monsters are singing lullabies, just sing along with them... Stop talking nonsense and tell me what's going on."); + } + return; + } + final int answer3 = sm.askMenu("Unprecedented phenomenon? So... What am I supposed to do?", java.util.Map.of( + 0, "Dance lessons requested!", + 1, "Singing lessons requested!", + 2, "Observation requested!" + )); + if (answer3 == 0 || answer3 == 1) { + if (answer3 == 0) { + sm.sayOk("You want me to teach the monsters how to dance? What the heck are you talking about?"); + } else { + sm.sayOk("You want me to teach the monsters how to sing? Oh, my. I'm a terrible singer..."); + } + return; + } + sm.sayNext("I see. Tell me the details. What's been happening in #m100000000#?"); + sm.sayBoth("Huh? Puppets are influencing monster behavior? Strange. Okay. There are no problems now, but I will let you know if something happens."); + sm.addExp(1700); + sm.forceCompleteQuest(22525); + } + + @Script("q22526s") + public static void q22526s(ScriptManager sm) { + // Mike's Request (22526 - start) + sm.sayNext("Hello, you're Evan right? #p1040000# tells me that you're pretty strong. Are you busy? No no, this has nothing to do with the puppets."); + sm.sayBoth("The #m102000000# Guards are always short-handed. We would love to have you join the #b#m102000000# Guards so that you can help us maintain security#k "); + sm.sayBoth("The work isn't too difficult. It's just clearing up the area near #m102000000# so that beginner adventurers don't get hurt. What do you think?"); + if (!sm.askAccept("")) { + sm.sayOk("Eh? You're going to refuse without even thinking about it? The pay isn't much but there is a lot of pride that comes with it..."); + return; + } + sm.setPlayerAsSpeaker(true); + sm.sayOk("#b(This is sure to interfere with your training. Will #p1013000# agree to do it? Talk to #p1013000#.)#k"); + sm.forceStartQuest(22526); + } + + @Script("q22526e") + public static void q22526e(ScriptManager sm) { + // Mike's Request (22526 - end) + final int answer1 = sm.askMenu("What is it, master? What?! You're thinking about joining the #m102000000# Guards? What do the Guards to anyway?", java.util.Map.of( + 0, "(You explain the Guards' job briefly.)" + )); + sm.sayOk("Oh! Helping people is a good thing! Heroes shouldn't refuse such a request! Training is important but that doesn't mean we should ignore people in need! Great idea! Let's join the Guards!"); + sm.addExp(1200); + sm.forceCompleteQuest(22526); + } + + @Script("q22527s") + public static void q22527s(ScriptManager sm) { + // A Guard's First Assignment: Cleaning up Around the Dungeon (22527 - start) + sm.sayNext("Are you going to join the #m102000000# Guards? You are?! This is great! I will now appoint you as a #m102000000# Guard! Since you're a little young, you can't be a full Guard, but I'll treat you the same as an official Guard."); + sm.sayBoth("Now onto your first assignment! It's pretty simple. Clean up the Dungeon Entrance area by eliminating the #o2110200#s surrounding #m106000300#. If it's too difficult, just tell me. Want to do it?"); + if (!sm.askAccept("")) { + sm.sayOk("Well, it's okay if it's too hard. Let me think of something easier. Hmm... But this is a basic job that all Guards should be able to do..."); + return; + } + sm.sayOk("Great! Go on and eliminate #r100 #o2110200#s#k!"); + sm.forceStartQuest(22527); + } + + @Script("q22527e") + public static void q22527e(ScriptManager sm) { + // A Guard's First Assignment: Cleaning up Around the Dungeon (22527 - end) + sm.sayNext("Wow, you eliminated 100 #o2110200#s already? I was right about you. I thought you would do a good job. And oh, there is something I forgot to give you earlier."); + sm.sayBoth("Here, this is the Honorary #m102000000# Guard Medal. It's not much but it should come in useful. Now please continue your good work. I'll call when I have a new assignment for you."); + sm.addExp(2900); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2210), 1); + if (!sm.addItem(1142153, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22527); + } + + @Script("q22528s") + public static void q22528s(ScriptManager sm) { + // A Guard's Second Assignment: Helping Beginner Adventurers (22528 - start) + sm.sayNext("Oh, you're here! I have your second assignment. This time, it's even easier. Once in a while, beginner adventurers accidentally enter the Deep Valleys, which are the Warning Streets in #m102000000#. Your job is to help them. Do you think you can do it?"); + if (!sm.askAccept("")) { + sm.sayOk("Hmm. Let me know if you get more confidence."); + return; + } + sm.sayOk("Okay, go patrol #bDeep Valley 1, 2, and 3 and help any lost Adventurers you find#k. Good luck."); + sm.forceStartQuest(22528); + } + + @Script("q22528e") + public static void q22528e(ScriptManager sm) { + // A Guard's Second Assignment: Helping Beginner Adventurers (22528 - end) + final int answer1 = sm.askMenu("You helped a beginner adventurer? Wow... You really are great! I doubt even #p1040000# could do better.", java.util.Map.of( + 0, "Are you and #p1040000# brothers?" + )); + final int answer2 = sm.askMenu("Nah, but we get that all the time, just because we have the same job and our names end in the same letters. Since our faces are covered, some people think we're twins. Actually, I'm a lot better looking than #p1040000#.", java.util.Map.of( + 0, "(You want to check but you can't see through his helmet.)" + )); + sm.sayNext("Don't compare me to a rookie like #p1040000#. I'm much more experienced. That's why I'm stationed in #m102000000#, which is way more dangerous than #m100000000#. Hehehe. There was a time when I used to compete with Manji... Wait, nevermind."); + sm.sayBoth("In any case, good job. I'll call you again if something else comes up."); + sm.addExp(3400); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2210), 1); + if (!sm.addItem(1942000, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22528); + } + + @Script("q22529s") + public static void q22529s(ScriptManager sm) { + // Helping Beginner Adventurer Christopher (22529 - start) + final int answer1 = sm.askMenu("So thirsty... Is this the end for me? All of my dreams of discovering relics... All of my hopes...were they all for naught? Am I hallucinating? I see someone...", java.util.Map.of( + 0, "Are you okay? (Did he hurt his head?)" + )); + final int answer2 = sm.askMenu("Whoa! A real person! I'm saved! I knew it! The heavens would not be so cruel as to abandon a genius like me! Did Shuang send you here to look for me? Of course she did!", java.util.Map.of( + 0, "Um, no... I'm a #m102000000# Guard... Do you need help?" + )); + final int answer3 = sm.askMenu("My name is #p1022106#. I'm a member of the Relic Excavation Team. Just to let you know, I'm not lost. I just can't move! I'm too thirsty! I dropped my water bottle and spilled all my water.", java.util.Map.of( + 0, "Sure... (He must be lost.)" + )); + sm.sayNext("Like I told you, I can find my way! Bu...but if you can just bring me some water...no not water...um...#bjust 3 #t4032460#s#k, I would really appreciate it!"); + if (!sm.askAccept("")) { + sm.sayOk("How can you refuse? Don't you feel sorry for me? If you just leave, it will be demise of one of the most brilliant geniuses of this century!"); + return; + } + sm.sayOk("You can get the #t4032460# from #rany stump#k around here. Of course it's not a good idea to attack dangerous ones like the #o2130100#s. Not that I can't defeat a #o2130100#... I'm just too tired at the moment. Really!"); + sm.forceStartQuest(22529); + } + + @Script("q22529e") + public static void q22529e(ScriptManager sm) { + // Helping Beginner Adventurer Christopher (22529 - end) + sm.sayNext("The saa....aaap of a #o0130100#! Ca...can I drink this?"); + sm.sayBoth("Gulp gulp gulp!"); + sm.sayBoth("Aaah, that hit the spot!"); + sm.sayBoth("Thank you! I'm full of energy now! I can find my own way to the Excavation Site! Since I'm not lost, I will go as soon as my thirst is quenched! I'll be going really soon... Can you just tell me which way is North?"); + sm.removeItem(4032460, 3); + sm.addExp(3100); + sm.forceCompleteQuest(22529); + } + + @Script("q22530s") + public static void q22530s(ScriptManager sm) { + // A Guard's Third Assignment: Maintaining Warning Signs (22530 - start) + sm.sayNext("Evan, do you know what a Guard's most important job is? It's to prevent incidents before they happen. To do that, we've posted warning signs throughout #m102000000#."); + sm.sayBoth("But we have to keep them up to date. Monsters often change their locations, so we have to continually update the signs. I'd like to check the information on the signs posted between #bEast Rocky Montain 1#k and #bEast Domain of Perion#k. Got it?"); + if (!sm.askAccept("")) { + sm.sayOk("How could you refuse? It's really quite simple... Please rethink your decision."); + return; + } + sm.sayOk("It seems complicated but it's not. Simply go find the five Warning Signs located in the section I mentioned and click on them to read them. All you have to do is to fix the mistakes."); + sm.forceStartQuest(22530); + } + + @Script("q22530e") + public static void q22530e(ScriptManager sm) { + // A Guard's Third Assignment: Maintaining Warning Signs (22530 - end) + sm.sayNext("Wow, have you finished updating the Warning Signs? Great job! You probably now totally know your way around #m102000000#. #p1040000# still gets lost in #m100000000#..."); + sm.sayBoth("Thanks. I'll call you if there is something else to do. I'll also let you know if anything strange happens, like #p1040000# mentioned."); + sm.addExp(3900); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2210), 1); + if (!sm.addItem(1952000, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22530); + } + + @Script("q22531s") + public static void q22531s(ScriptManager sm) { + // A Guard's Fourth Assignment: Discovery of Strange Mushrooms (22531 - start) + sm.sayNext("Evan, a phenomenon similar to what #p1040000# described has occurred in #m102000000#. To be specific, it's happening inside #m105040300# Dungeon."); + sm.sayBoth("According to some adventurers who were exploring, there is something strange going on with the #r#o2230101#s#k in #b#m105050300##k. I think we're going to have to investigate this. Can you handle it?"); + if (!sm.askAccept("")) { + sm.sayOk("Huh? This is a part of what a Gaurd must do. Will you still refuse? Hmm...this won't do. This just won't do."); + return; + } + sm.sayOk("Then go into the Sleepywood Dungeon and eliminate about #r100 Annoyed #o2230101#s#k. If this is the same phenomenon that #p1040000# described, you should be able to discover a #bpuppet#k. Please bring it to me."); + sm.forceStartQuest(22531); + } + + @Script("q22531e") + public static void q22531e(ScriptManager sm) { + // A Guard's Fourth Assignment: Discovery of Strange Mushrooms (22531 - end) + sm.sayNext("How did things go? Whoa, is that the puppet? It really looks strange...Let me take a closer look."); + sm.sayBoth("How could a puppet cause all this...? I guess I should strengthen security around #m102000000#. I'll let you know if anything else happens."); + sm.removeItem(4032461, 1); + sm.addExp(5100); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2210), 1); + if (!sm.addItem(1962000, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22531); + } + + @Script("q22532s") + public static void q22532s(ScriptManager sm) { + // A Guard's Fifth Assignment: Strange Wild Boars (22532 - start) + sm.sayNext("Evan, there is another case of monsters acting strange. I thought it only happened to mushroom monsters, but this time it's the #o2230102#s! "); + sm.sayBoth("I thought at first that the incidents weren't related, but there are too many similarities. Why don't you take care of this as well."); + if (!sm.askAccept("")) { + sm.sayOk("You are the only Guard who can take care of this! Please think about it!"); + return; + } + sm.sayOk("Good, I'm glad I can count on you. Go to #b#m101030001##k and eliminate about #r100 #o2230112#s#k. If you find #bpuppet#k, bring it to me."); + sm.forceStartQuest(22532); + } + + @Script("q22532e") + public static void q22532e(ScriptManager sm) { + // A Guard's Fifth Assignment: Strange Wild Boars (22532 - end) + sm.sayNext("So what was the result of your investigation? Was it another puppet? Who is responsible for this?"); + sm.sayBoth("Because of the strange incidents lately, I've requested additional Guards. I probably won't have to keep pestering you to do things for us any more."); + sm.sayBoth("But that doesn't mean that you are no longer an Honorary #m102000000# Guard. If ever we need your help, we'll be sure to call on you. Until then, please take good care of yourself."); + sm.sayBoth("By the way, now that your deeds as a Guard are famous, there will probably be more people that recognize you. If someone asks for help, please try to help."); + sm.removeItem(4032462, 1); + sm.addExp(5750); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2210), 1); + if (!sm.addItem(1972000, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22532); + } + + @Script("q22533s") + public static void q22533s(ScriptManager sm) { + // Please Catch the Thief (22533 - start) + sm.sayNext("Oh, are you Evan? I've heard so much about you from #p1040001#. He told me what a great Guard you are. That's why I thought you could help me."); + sm.sayBoth("My job is to collect herbs and make them into medicine. But recently, someone stole one of my herbs! It was a very rare and expensive herb! Do you think you can catch the thief for me?"); + if (!sm.askAccept("")) { + sm.sayOk("I didn't think you would refuse. #p1040001# told me what a great person you are but I guess he was wrong."); + return; + } + sm.sayNext("It's doubtful that there are thieves in #m105040300# so I'm certain that a thief from #m103000000# stole it! Can you go to #m103000000# to investigate and bring back my herb?"); + sm.sayBoth("How should go about the investigation? Er, shouldn't you know that? Maybe look around and search for a witness? Try talking to someone you know in #b#m103000000#... I don't know. That's YOUR job!"); + sm.forceStartQuest(22533); + } + + @Script("q22533e") + public static void q22533e(ScriptManager sm) { + // Please Catch the Thief (22533 - end) + sm.sayNext("You've returned! So who was the thief? What? You haven't caught the thief yet?"); + sm.sayBoth("It's hard for me to believe that the Thieves in #m103000000# aren't responsible... But since they are willing to put their honor on the line to help find the culprit, I will trust them for now. Now, please find the thief."); + sm.addExp(7250); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2210), 1); + sm.forceCompleteQuest(22533); + } + + @Script("q22534s") + public static void q22534s(ScriptManager sm) { + // Kerning City Investigation: Alex (22534 - start - very complex with multiple quiz questions) + final int answer1 = sm.askMenu("Hey, Evan, how are ya? Long time no see. What are you doing here? Have you run away from home, too? Or are you on an adventure?", java.util.Map.of( + 0, "(You tell him about becoming a Guard.)" + )); + final int answer2 = sm.askMenu("Wow, a Guard? That's pretty cool. You used to be so clumsy, so I'm kind of shocked. But what brings you to #m103000000#?", java.util.Map.of( + 0, "(You tell him about #p1061005#'s stolen herb.)" + )); + sm.sayNext("So you're looking for the herb thief in #m103000000#? Well, can I help? Actually, if you can give me some news about #b#m100000000##k, I'll tell you about someone who might know what's going on."); + if (!sm.askAccept("")) { + sm.sayOk("You don't want to? And I was going to help you since you are an old friend."); + return; + } + sm.sayOk("News about #m100000000#... What should I ask you about first. It's been so long. Give me a second. Let me think."); + sm.forceStartQuest(22534); + } + + @Script("q22534e") + public static void q22534e(ScriptManager sm) { + // Kerning City Investigation: Alex (22534 - end - multiple quiz answers) + sm.sayOk("Try talking to #b#p1052002##k down there. He know about all the back alley transactions. If the herb or whatever was being traded, he would know."); + sm.addExp(3250); + sm.forceCompleteQuest(22534); + } + + @Script("q22535s") + public static void q22535s(ScriptManager sm) { + // Kerning City Investigation: JM (22535 - start) + sm.sayNext("Do you have business with me? If you want something, trade something first. If you pay the price, I'll give you whatever information you want. I know everything about #m103000000#."); + if (!sm.askAccept("")) { + sm.sayOk("Are you refusing? Okay, then no trade. I doubt you can find anyone that knows as much as I do about #m103000000#."); + return; + } + sm.sayOk("Oh, so a trade then, eh? Okay. Get me #b50 #t4000042#s#k. Then I'll give you some information. Ah, I will give you a freebie. #o2300100#s can be found in the subway, so go #bdeep inside the subway#k."); + sm.forceStartQuest(22535); + } + + @Script("q22535e") + public static void q22535e(ScriptManager sm) { + // Kerning City Investigation: JM (22535 - end) + final int answer = sm.askMenu("Ah, you brought the #t4000042#s. Good. So what do you want to know? Huh? Herb dealing? Start from the beginning...", java.util.Map.of( + 0, "(You tell him about #p1061005#'s situation.)" + )); + sm.sayNext("Hmm... But there hasn't been a single person in #m103000000# who has been trading herbs. Potions are way more convenient than herbs these days."); + sm.sayBoth("I'm sorry that the information you got isn't as great as what you traded me for it. To show you how sorry I am, let me tell you this. The person who stole the herb may be want to use it rather than sell it. That would make it tough for me to find him or her."); + sm.sayBoth("But there is one person who might be able to find the culprit. #p1052103#. She has a fabulous nose. She would have noticed someone who walked by smelling like herbs. Try asking #b#p1052103##k."); + sm.removeItem(4000042, 50); + sm.addExp(7250); + sm.forceCompleteQuest(22535); + } + + @Script("q22536s") + public static void q22536s(ScriptManager sm) { + // Kerning City Investigation: Nella (22536 - start) + sm.sayNext("Hmm? I don't recall seeing you around here before. What brings you to #b#m103000000##k? Are you here to become a Thief?"); + sm.setPlayerAsSpeaker(true); + sm.sayBoth("#bHave you caught a whiff of anyone smelling of herbs?"); + sm.setPlayerAsSpeaker(false); + sm.sayNext("Smelling of herbs? I'm not sure... I thought everybody used potions these days?! Why are you asking about herbs? Are you looking to buy some?"); + sm.setPlayerAsSpeaker(true); + sm.sayBoth("#bWell, you see...\r\n\r\n(You explain what happened to #p1061005#.)"); + sm.setPlayerAsSpeaker(false); + sm.sayNext("Huh, an herb thief, huh? I see... Wait! What? Wait, wait, wait just a minute!! Are you suggesting that the thief is from #b#m103000000##k?!"); + sm.setPlayerAsSpeaker(true); + sm.sayBoth("#bThis IS a thief town, afterall, isn't it?"); + sm.setPlayerAsSpeaker(false); + sm.sayNext("Yes, but we're not burglars! This is a THIEF town, NOT a burglar town. UGH! It drives me absolutely crazy when- Geez! The things you're implying about us Thieves here in #b#m103000000##k! Sure, it's true that we can be a bit sneaky and petty, a little under-handed and cunning, yeah... But we DON'T threaten the livelihoods of others just to get what we want!"); + sm.setPlayerAsSpeaker(true); + sm.sayBoth("#bReally? Wow..."); + sm.setPlayerAsSpeaker(false); + sm.sayNext("Yes! Really! I know people get the wrong idea about us, but as someone who was born and raised in #b#m103000000##k, I'm DEEPLY offended! I swear on my MOTHER that the burglar you're looking for is not from here!"); + sm.setPlayerAsSpeaker(true); + sm.sayBoth("#bOh? Well, where is the burglar from, you think?"); + sm.setPlayerAsSpeaker(false); + if (!sm.askAccept("I haven't got a clue! I'm still upset about your implications and accusations, but I WILL find the thief who stole #b#p1061005##k's herbs myself! Then I will take #b#m103000000##k's honor back! Did you get that?! I will find it by MYSELF!\r\n\r\n#fUI/UIWindow2.img/QuestIcon/4/0#\r\n#fUI/UIWindow2.img/QuestIcon/8/0#\r\n8000 exp")) { + sm.sayOk("You come here implying such things and have the audacity to just walk away?! HMPH!"); + return; + } + sm.forceCompleteQuest(22536); + sm.addExp(8000); + sm.sayNext("I'll investigate the burglar you're looking for, so keep yourself available as much as possible! I'll reach out when I get to the bottom of this!"); + sm.sayBoth("Just keep training or whatever it is you do until I contact you, okay?"); + } + + @Script("q22537s") + public static void q22537s(ScriptManager sm) { + // Investigating the Biology of Dragons (22537 - start) + final int answer1 = sm.askMenu("Ma...master...", java.util.Map.of( + 0, "Whoa! #p1013000#, what happened to you? You're so much bigger! Your horns look sharper too. You look great, but what happened? Do Dragons always change so suddenly?" + )); + final int answer2 = sm.askMenu("I don't know master! Like I told you before, the only thing I know is that I am a Dragon and that we have a pact! Wha...what happened to me? My cuteness has decreased...", java.util.Map.of( + 0, "Come to think of it, we really don't know very much about Dragons. This just won't do! Let's find out more about Dragons!" + )); + final int answer3 = sm.askMenu("Master? Are you worried about me?", java.util.Map.of( + 0, "Yes! What if you suddenly fell asleep in the air and broke someone's roof or something? You know how much trouble I'd be in?" + )); + final int answer4 = sm.askMenu("Wait, you're worried about the roof, and not me?!", java.util.Map.of( + 0, "Hehehe. I wonder how we can find out more about Dragons? I've heard there are other Dragons in Maple World but I've never seen one. Who would know about Dragons?" + )); + final int answer5 = sm.askMenu("Way to change the topic! Well, whatever. There must be someone who knows... I mean, you knew about Dragons before you ever saw one, right?", java.util.Map.of( + 0, " I knew about Dragons before seeing one... That's it! Books! Reading about Dragons in books will let me learn about Dragons without having to find one in person!" + )); + final int answer6 = sm.askMenu("Wow! Master! You're so smart! But do you have any books about Dragons?", java.util.Map.of( + 0, "I don't, but someone must. The person with the most books in our town... Yes, #p1012109#! #p1012109# reads lots of books so he must have a book about Dragons!" + )); + sm.sayNext("All right! Let's hurry to that human named #p1012109#!"); + if (!sm.askAccept("")) { + sm.sayOk("Huh? Why? Do you not get along with that human named #p1012109#? But I really want to know more about my race..."); + return; + } + sm.sayOk("Let's hurry back to #b#m100000000##k where you used to live!"); + sm.forceStartQuest(22537); + } + + @Script("q22537e") + public static void q22537e(ScriptManager sm) { + // Investigating the Biology of Dragons (22537 - end) + sm.sayOk("Huh? Evan? Hey, what are you doing here? I heard that you've been helping a lot of people. Hehe, are you here to help me too?"); + sm.addExp(2000); + sm.forceCompleteQuest(22537); + } + + @Script("q22538s") + public static void q22538s(ScriptManager sm) { + // Dragon Types and Characteristics (Vol. I) (22538 - start) + sm.sayNext("Yeah, I do have a book about Dragons. It's called #t4161049#. Why? Are you interested in Dragons, too? Hmm, do you want to borrow it?"); + if (!sm.askAccept("")) { + sm.sayOk("Hmm... So you weren't very interested? Then nevermind. Let me know if you change your mind."); + return; + } + sm.sayNext("Wow, you really do have a lot of interests in dragons. You're even raising a lizard that looks like a Dragon... Here, did you get the book? I haven't been able to read all of it yet so please #bread it quickly and give it back to me#k. Okay?"); + if (!sm.addItem(4161049, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceStartQuest(22538); + } + + @Script("q22538e") + public static void q22538e(ScriptManager sm) { + // Dragon Types and Characteristics (Vol. I) (22538 - end) + final int answer = sm.askMenu("You finished the book already? Wow, you're a fast reader. Did the book contain the information you needed?", java.util.Map.of( + 0, "No, it wasn't in there. Do you have any other books?" + )); + sm.sayNext("I see, the book I let you borrow is just Volume 1. Volume 2 might contain more information about Dragon types, but I don't have that book..."); + sm.removeItem(4161049); + sm.addExp(1000); + sm.forceCompleteQuest(22538); + } + + @Script("q22539s") + public static void q22539s(ScriptManager sm) { + // Knowledge About Dragons 1 (22539 - start) + sm.sayNext("Master, master! You just got a book about Dragons, didn't you? Let me see it! Ugh... I can't read human letters. Please read it and tell me what it says!"); + if (!sm.askAccept("")) { + sm.sayOk("Huh? Don't you want to explain it to me? Fine, then can you teach me how to read human letters? This could take a while..."); + return; + } + sm.sayOk("I wonder what is written in it. I can't wait to hear about my race!"); + } + + @Script("q22539e") + public static void q22539e(ScriptManager sm) { + // Knowledge About Dragons 1 (22539 - end - multi-choice quiz) + final int answer1 = sm.askMenu("Master, master! Did you finish reading the book? Please tell me! What does the book talk about?", java.util.Map.of( + 0, "Dragon Culture", + 1, "Dragon Attack and Defense", + 2, "Dragon History and Traditions", + 3, "Dragon Types and Characteristics" + )); + if (answer1 != 3) { + String response = switch (answer1) { + case 0 -> "Dragon culture? That does sound pretty interesting, but I'm more interested about knowing what kind of Dragon I am..."; + case 1 -> "Attack and defense? I know that stuff without having to read about it. I think it must be innate."; + case 2 -> "Dragon history and traditions... That sounds so boring... Yawn."; + default -> ""; + }; + sm.sayOk(response); + return; + } + final int answer2 = sm.askMenu("Oh, Dragon Types and Characteristics? That's perfect! It'll tell me what kind of Dragon I am! Since you've read it, tell me what kind of Dragon I am master!", java.util.Map.of( + 0, "Blue Dragon", + 1, "Gold Dragon", + 2, "Black Dragon", + 3, "Can't Be Known" + )); + if (answer2 != 3) { + String response = switch (answer2) { + case 0 -> "Blue Dragon? Hmm... I guess my scales are kind of bluish...but I don't have any ice attributes. Read the book again."; + case 1 -> "Gold Dragon? Only a really small part of me is gold. I don't think that's it, master. Read the book more closely."; + case 2 -> "Black Dragon! Is that what I am? Hmm... But there is a small part of me that is gold colored. That can't be it. Please read it more closely, master."; + default -> ""; + }; + sm.sayOk(response); + return; + } + final int answer3 = sm.askMenu("Huh? What do you mean can't be known... Why? Am I not a Dragon? What am I then, master?", java.util.Map.of( + 0, "Special Dragon", + 1, "Wyvern", + 2, "Drake", + 3, "Alien Being" + )); + if (answer3 != 0) { + String response = switch (answer3) { + case 1 -> "Wyvern? But Wyverns don't have any front feet. Am I a mutant Wyvern? Read it more closely master!"; + case 2 -> "Drake? I don't think I look as dumb as a Drake... Really? Please tell me it's not true!"; + case 3 -> "Ahhh, so was I an alien being all this time!"; + default -> ""; + }; + sm.sayOk(response); + return; + } + sm.sayNext("Special Dragon? What's a special Dragon? Tell me more master!"); + sm.sayOk("Huh? This book doesn't contain information about special Dragons? It's in another book called Dragon Types and Characterics (Vol. II)? Master, let's go look for it! That book must contain information about me!"); + sm.addExp(5900); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2211), 1); + sm.forceCompleteQuest(22539); + } + + @Script("q22540s") + public static void q22540s(ScriptManager sm) { + // Ellinia Magic Library (22540 - start) + sm.sayNext("To tell you the truth, this #t4161049# isn't mine. It's a book I borrowed from #m101000003# in #m101000000#. I wanted to buy it, but I couldn't find it because it was so rare."); + sm.sayBoth("So if you go to #b#m101000000##k, you'll probably find #t4161050#, the sequel to this one. If there is anything you'd like to know about Dragons, borrow the book in #b#m101000003##k."); + if (!sm.askAccept("")) { + sm.sayOk("Hm, I suppose it's a bit of a hassle."); + return; + } + sm.sayOk("A lot of people go to #m101000003# in #m101000000# to find that book, so you'll have to hurry if you don't want someone else borrows it first. Rush there! I'll see you later then, Evan."); + sm.forceStartQuest(22540); + } + + @Script("q22540e") + public static void q22540e(ScriptManager sm) { + // Ellinia Magic Library (22540 - end) + sm.sayOk("Welcome to the #m101000000# #m101000003#. Hm, aren't you a strange one... You have a lot of MP but no Magic ATT. I've never met anyone like you."); + sm.addExp(3000); + sm.forceCompleteQuest(22540); + sm.forceStartQuest(22541); + } + + @Script("q22541s") + public static void q22541s(ScriptManager sm) { + // Where's the Book? 1 (22541 - start) + sm.sayNext("Do you come seeking knowledge? Remember that a constant thirst for knowledge never leads to any good. Continue growing your willpower and you will find boundless power within yourself. Excuse me, are you here for a book?"); + sm.setPlayerAsSpeaker(true); + sm.sayBoth("#bI'm looking for #t4161050#."); + sm.setPlayerAsSpeaker(false); sm.sayBoth("Ahh, yes, the infamous book published in #b#m240000000##k. I believe the second volume is- Oh my... It appears someone's already borrowed this book."); sm.setPlayerAsSpeaker(true); sm.sayBoth("#bWhat? Somebody already borrowed it out? Who?!"); @@ -1140,6 +2503,128 @@ public static void q22541s(ScriptManager sm) { sm.warp(103000000); } + @Script("q22541e") + public static void q22541e(ScriptManager sm) { + // Where's the Book? 1 (22541 - end) + final int answer1 = sm.askMenu("Ah, I've finished reading and now I'm bored. Hm? A book? Ah, you mean #t4161050#, yes? I thought I'd be able to fly with wings like a Dragon if I read that book, but I was wrong, so I stopped reading. You need it?", java.util.Map.of( + 0, "Yes. I'll return it for you so hand it over if you're done with the book." + )); + final int answer2 = sm.askMenu("#b#p1002100##k in #b#m104000000##k needed the book so I gave it to her. She said she'd return it for me. So go see #m104000000# because I don't have it anymore.", java.util.Map.of( + 0, "(Ugh, it's more work than you thought it'd be.)" + )); + final int answer3 = sm.askMenu("Wait, what is that thing flying next to you? That's a strange-looking lizard. Whoa, it flies too? Impressive. Can you let me borrow that lizard? I want to study it... Just for a little while...", java.util.Map.of( + 0, "Absolutely not!" + )); + sm.sayOk("Fine, you meanie... Fine, fine. I think flying with small wings looks silly anyway."); + sm.addExp(2000); + sm.forceCompleteQuest(22541); + } + + @Script("q22542s") + public static void q22542s(ScriptManager sm) { + // Where's the Book? 2 (22542 - start) + sm.sayNext("Are you here to ask me about my wonderful and impressive concoction? No? A book? Oh, #t4161050#? I thought, with that book, I'd be able to concoct something that would give me a Dragon's power, but I was wrong."); + sm.sayBoth("I wasn't going to read it anyway, so I gave it to #p1002001# since he wanted it. He even told me he'd return the book himself, so if anything, #p1002001# would have it. Ask #b#p1002001##k."); + if (!sm.askAccept("")) { + sm.sayOk("Why are you asking me so many questions when you're not even that curious? "); + return; + } + sm.sayOk("Oh, you have a strange lizard as a pet. Could I pet it? Oh my, it looks scary. I think I'll just stand back and observe."); + sm.forceStartQuest(22542); + } + + @Script("q22542e") + public static void q22542e(ScriptManager sm) { + // Where's the Book? 2 (22542 - end) + sm.sayNext("Ah, my face itches. Hm? I haven't seen you here before. What can I do for you? I wasn't planning on going out to the sea for a while... Hm? A book? Are you talking about #t4161050# by any chance? If you are, I don't have it."); + sm.sayBoth("A man of the sea like me has nothing to do with Dragons. I didn't borrow it for me. I borrowed it for #p1012101# since she's too weak to go anywhere. I already sent it to #b#m100000000##k, so if you want it, talk to #b#p1012101##k."); + sm.sayOk("Anyway, what is that weird pet you've got there? Not too long ago, I saw an adventurer with a Jr. Balrog! People are starting to travel with some strange creatures..."); + sm.addExp(1500); + sm.forceCompleteQuest(22542); + } + + @Script("q22543s") + public static void q22543s(ScriptManager sm) { + // Where's the Book? 3 (22543 - start) + sm.sayNext("Evan, are you here on an adventure? Huh? A book? Oh, you mean #t4161050#. I'm sorry but I've given it to someone else because it was too hard for me to read. I feel bad because #p1002001# went out of his way to send it to me. Who has the book now, you ask?"); + sm.sayBoth("I gave it to #p1012110#. I don't know if a kid like #p1012110# can understand anything written in that book, but oh well... #p1012110# should have it, so go see #b#p1012110##k in #b#m100010000##k if you really need that book."); + if (!sm.askAccept("")) { + sm.sayOk("Hm? It isn't all that important that you find the book? Then why don't you hang out with me for a bit?"); + return; + } + sm.sayOk("In any case, I think you've changed a lot, Evan. I don't mean that in a bad way. It's a compliment. You seem more confident and mature... You were always a bright one, but more so now."); + sm.forceStartQuest(22543); + } + + @Script("q22543e") + public static void q22543e(ScriptManager sm) { + // Where's the Book? 3 (22543 - end) + sm.sayNext("Who are you? I may be bored, but I won't play with a stranger. What? A book? Hm... #t4161050#? I don't have it. I gave it to my mom because I thought she'd need it."); + sm.sayBoth("My mom? Do you know Dr. #p1032104# in #m101000000#? She conducts research on organisms and she's interested in Dragons. If you really want to find the book, go to #b#m101000000##k and talk to Dr. #b#p1032104##k."); + sm.sayOk("Hm... Haven't you gone to #b#p1012110##k yet? Ah, you probably don't know #p1012110# very well. She's the kid that was brought to #m100000000# not too long ago. Maybe it's because she doesn't have any friends. Whatever the reason, she's always sitting alone in #b#m100010000##k."); + sm.addExp(1500); + sm.forceCompleteQuest(22543); + } + + @Script("q22544s") + public static void q22544s(ScriptManager sm) { + // Where's the Book? 4 (22544 - start) + sm.sayNext("Is there something I can do for you? Hm? #p1012110# sent you? Has my kid done something bad? Oh, then what brings you here? A book? Oh, you mean #t4161050#, correct? "); + sm.sayBoth("I sent it to my scholarly master who is conducting research on fossils. He's been trying to idenitify a fossil he recently discovered and thought it might be a Dragon fossil. If you want the book, please visit my master."); + if (!sm.askAccept("")) { + sm.sayOk("I thought you needed that book. I guess not."); + return; + } + sm.sayNext("My master is a renowned scholar in fossilology. His name is Dr. #b#p1022006##k. He is always roaming outside. He should be near the #bEast Rocky Mountain in #m102000000##k right about now. "); + sm.sayOk("That's one unique creature you've got there. It looks like a lizard but looking at its bone structure, it's obvious that it isn't a lizard. Could I just... Oh, I'm sorry. I got carried away. Anyway, good luck."); + sm.forceStartQuest(22544); + } + + @Script("q22544e") + public static void q22544e(ScriptManager sm) { + // Where's the Book? 4 (22544 - end) + sm.sayOk("Whoa! Wha... What is that? What is that strange creature? A lizard? I don't care if it's a lizard or a salamander! Get it out of here. It's gross! Just tell me why you are here! "); + sm.addExp(3000); + sm.forceCompleteQuest(22544); + } + + @Script("q22545s") + public static void q22545s(ScriptManager sm) { + // Where's the Book? 5 (22545 - start) + sm.sayNext("I have the book you're looking for, but I don't feel comfortable taking it out. Just a while ago, I was attacked by a #o3210100# while reading the book #p1032104# sent."); + sm.sayBoth("Running away is easy to do, but if the #o3210100# sets the book on fire, then what do you do? Could you please eliminate the #o3210100#s first before I take out the book?"); + if (!sm.askAccept("")) { + sm.sayOk("Hm... I can't take the book out if you don't eliminate the #o3210100#s. I don't feel safe."); + return; + } + sm.sayOk("Those dangerous #r#o3210100##ks will be in #bThe Burnt Land#k. You must go even deeper than #b#m106000100##k. Eliminate #r120 of them#k but be careful. I'll wait here."); + sm.forceStartQuest(22545); + } + + @Script("q22545e") + public static void q22545e(ScriptManager sm) { + // Where's the Book? 5 (22545 - end) + sm.sayOk("Wow, did you defeat all the #o3210100#s? Hold on a minute. I hid the book so it wouldn't get burned... But where did I hide it?"); + sm.addExp(8900); + sm.forceCompleteQuest(22545); + } + + @Script("q22546s") + public static void q22546s(ScriptManager sm) { + // Dragon Types and Characteristics (Vol. II) (22546 - start) + sm.sayNext("Thank you for getting rid of those #o3210100#s. I don't need this #t4161050# anymore so #byou take the book and read it, and then return it to the #m101000003# in #m101000000##k when you're done. "); + if (!sm.askAccept("")) { + sm.sayOk("Hm? Didn't you defeat the #o3210100#s because you needed the book? If you don't want it, I'll hold on to it a little longer and read a bit more. Let me know if you change your mind."); + return; + } + sm.sayNext("I hope there isn't a late fee or anything like that... No way, I guarded this book with my life! They couldn't possibly ask for more."); + if (!sm.addItem(4161050, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceStartQuest(22546); + } + @Script("q22546e") public static void q22546e(ScriptManager sm) { // Dragon Types and Characteristics (Vol. II) (22546 - end) @@ -1151,16 +2636,424 @@ public static void q22546e(ScriptManager sm) { sm.sayOk("Hmm... No matter. If you ever do need help, please come back!"); return; } - sm.forceCompleteQuest(22546); - sm.removeItem(4161050); - sm.addExp(12000); - sm.sayNext("There are lots of books about dragons here in our #b#m101000003##k but there aren't any other books regarding Onyx Dragons in particular. If a new book about the dragons ever crosses our library, I'll let you know immediately."); - sm.sayBoth("Oh, by the way, I have a friend in #b#m240000000##k named #b#p2081000##k of the Halflingers. I'll ask and see if he knows anything about Onyx Dragons."); - sm.sayBoth("I hear Onyx Dragons are covered in dark, clear scales and have golden horns. Your little lizard has golden horns, but doesn't have the dark, clear scales."); - sm.setPlayerAsSpeaker(true); - sm.sayBoth("#b(He might try to take #p1013000# for research, or worse, if he finds out he's a true Onyx Dragon.)\r\n\r\nHe isn't a dragon, he's just a lizard!"); - sm.setPlayerAsSpeaker(false); - sm.sayBoth("Why of course... Did I imply otherwise? It's nothing more than a lizard."); + sm.forceCompleteQuest(22546); + sm.removeItem(4161050); + sm.addExp(12000); + sm.sayNext("There are lots of books about dragons here in our #b#m101000003##k but there aren't any other books regarding Onyx Dragons in particular. If a new book about the dragons ever crosses our library, I'll let you know immediately."); + sm.sayBoth("Oh, by the way, I have a friend in #b#m240000000##k named #b#p2081000##k of the Halflingers. I'll ask and see if he knows anything about Onyx Dragons."); + sm.sayBoth("I hear Onyx Dragons are covered in dark, clear scales and have golden horns. Your little lizard has golden horns, but doesn't have the dark, clear scales."); + sm.setPlayerAsSpeaker(true); + sm.sayBoth("#b(He might try to take #p1013000# for research, or worse, if he finds out he's a true Onyx Dragon.)\r\n\r\nHe isn't a dragon, he's just a lizard!"); + sm.setPlayerAsSpeaker(false); + sm.sayBoth("Why of course... Did I imply otherwise? It's nothing more than a lizard."); + } + + @Script("q22547s") + public static void q22547s(ScriptManager sm) { + // Knowledge about the Dragon 2 (22547 - start) + final int answer1 = sm.askMenu("Master, master! Did you find the book? Do you think the book has information on my race?", java.util.Map.of( + 0, "I'm sure of it!" + )); + final int answer2 = sm.askMenu("I... I agree... But why am I getting so nervous? I've never seen any other Dragons before. I wonder if there are pictures?", java.util.Map.of( + 0, "If we discover where your race lives, want to go visit?" + )); + final int answer3 = sm.askMenu("Huh? Really?", java.util.Map.of( + 0, "Sure! We should go visit if we find out where they live. After all, they are your kin!" + )); + final int answer4 = sm.askMenu("Really? You really promise to go with me, master? You promise? You can't take it back if you promise!", java.util.Map.of( + 0, "Yes, hehe, I promise." + )); + sm.sayOk("You promised! Yay, you are the best master ever! Now, open the book. See if you can find my race!"); + } + + @Script("q22547e") + public static void q22547e(ScriptManager sm) { + // Knowledge about the Dragon 2 (22547 - end - multi-quiz) + final int answer1 = sm.askMenu("Did you find it? Did the book have any information on my race? Huh? Huh? Tell me, master! What type of Dragon am I?", java.util.Map.of( + 0, "Serpent Dragon", + 1, "Onyx Dragon", + 2, "Mutant Dragon" + )); + if (answer1 != 1) { + String response = switch (answer1) { + case 0 -> "A Serpent Dragon? Hm, that doesn't sound familiar at all. Are you sure, master? Check again!"; + case 2 -> "Mu... Mutant?! I'm a Mutant? Am I really a Mutant Dragon?"; + default -> ""; + }; + sm.sayOk(response); + return; + } + final int answer2 = sm.askMenu("An Onyx Dragon? Wow, that sounds majestic! Haha, but of course! That's the race I belong to. So what are some characteristics of Onyx Dragons?", java.util.Map.of( + 0, "They become whole after they make a pact.", + 1, "They are extremely strong and intelligent.", + 2, "They have three heads." + )); + if (answer2 != 0) { + String response = switch (answer2) { + case 1 -> "Ah, I'm as strong and intelligent as can be because I'm an Onyx Dragon! Right?"; + case 2 -> "Three heads...? But I only have one head! Will I grow two more when I'm older? Ewww, that's gross."; + default -> ""; + }; + sm.sayOk(response); + return; + } + final int answer3 = sm.askMenu("Ah, that's why I was able to meet you and awaken. Tell me more about the pact.", java.util.Map.of( + 0, "It's a slave pact, where the Dragon exploits a Human.", + 1, "It's a marriage between a Dragon and a Human!", + 2, "It's a bonding of spirit between a Human and a Dragon destined to enter that pact." + )); + if (answer3 != 2) { + String response = switch (answer3) { + case 0 -> "Oh, is that so? But I've never thought of you as a slave, master. Please, master... Don't leave me!"; + case 1 -> "Marriage! Master! Are we in love? Shall I call you honey from now on?"; + default -> ""; + }; + sm.sayOk(response); + return; + } + final int answer4 = sm.askMenu("More importantly, where do the Onyx Dragons live now? Where should we travel to meet my family of Onxy Dragons?!", java.util.Map.of( + 0, "Well..." + )); + final int answer5 = sm.askMenu("Well what? Is that not in the book?", java.util.Map.of( + 0, "It's in the book, but..." + )); + final int answer6 = sm.askMenu("I don't get it. If it's in the book, how come you don't know where they are? Tell me, master!", java.util.Map.of( + 0, "The... The Onyx Dragons have gone extinct..." + )); + sm.sayNext("What?! Extinct? No way... I'm still alive! I can't believe it. Did all of my family die? Then who am I? I don't understand."); + sm.sayOk("Ugh, nothing is certain. It's just a book, right? It doesn't mean it has to be true! Let's keep searching for information on Dragons, master! I am determined to find my race!"); + sm.addExp(6900); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2211), 1); + sm.forceCompleteQuest(22547); + } + + @Script("q22548s") + public static void q22548s(ScriptManager sm) { + // Clue about the Thief (22548 - start - auto-start) + sm.sayNext("You've waited so long, Evan. I've finally found the clue about the culprit who stole the #t4032464#! Finally, #m103000000# will clear its name! Or soon...because there seems to be one more problem."); + sm.sayBoth("I had one of my underlings hide out #b#m105040300##k to find the clue. He's the go-to person for a stakeout but he's so weak... As he was returning to #m103000000# he was attacked by a #o3110100# and lost the document containing all the clues."); + sm.sayBoth("If you're not too busy, will you find that document for me? Just eliminate some #o3110100# in the swamp area to find it. Defeating a few #o3110100#s should be a breeze for you. What do you say?"); + sm.sayOk("Go to the swamp and eliminate some #r#o3110100##ks and find the #b#t4032463##k. That document will clear #m103000000# of this false charge!"); + sm.forceStartQuest(22548); + } + + @Script("q22548e") + public static void q22548e(ScriptManager sm) { + // Clue about the Thief (22548 - end) + sm.sayOk("Oh, you've brought the #t4032463#! You probably won't be able to read it since it's in a code that only Thieves can read. Here, let me see it. I'll be able to find out who is responsible for all of this!"); + sm.addExp(10900); + sm.removeItem(4032463); + sm.forceCompleteQuest(22548); + } + + @Script("q22549s") + public static void q22549s(ScriptManager sm) { + // The Culprit is in the Dungeon (22549 - start) + sm.sayNext("Hm, good. I found the suspect! The culprit is in the dungeon. According to the document, the culprit who stole the document was seen heading towards #m105070300#. There must be a hideout somewhere near there."); + sm.sayBoth("A puppet stole the herbs? Ha. That makes no sense! I suppose that's not important."); + sm.sayBoth("Anyway, the culprit's hideout must be in Sleepy Dungeon #b#m105070300##k! Go catch the culprit! Please recover the honorable name of #m103000000#!"); + if (!sm.askAccept("")) { + sm.sayOk("What? You've accused the people of #m103000000# and now you refuse to help? Where is your heart, huh?"); + return; + } + sm.sayOk("The culrpit is in the dungeon! That's something I've been wanting to say for a long time now, haha."); + sm.forceStartQuest(22549); + } + + @Script("q22549e") + public static void q22549e(ScriptManager sm) { + // The Culprit is in the Dungeon (22549 - end) + final int answer1 = sm.askMenu("...", java.util.Map.of( + 0, "(An ugly wooden puppet that's broken and worn out lies on the floor. There is nothing else here. Could the culprit have gone somewhere?)" + )); + final int answer2 = sm.askMenu("...", java.util.Map.of( + 0, "(Anyway, this is the ugliest puppet ever. Who could have brought this ugly thing here?)" + )); + final int answer3 = sm.askMenu("(Flinch)", java.util.Map.of( + 0, "(Hm? The puppet flinched. Was that your imagination? Go over and give it a nudge.)" + )); + final int answer4 = sm.askMenu("Yikes! Don't touch me!", java.util.Map.of( + 0, "(The puppet talks!)" + )); + sm.sayOk("Intruder! How dare you enter my master's cave and try to steal me! I will not forgive you for this! But don't hit me. I can't fight. I'm not a battle puppet."); + sm.addExp(2500); + sm.forceCompleteQuest(22549); + sm.forceStartQuest(22550); + } + + @Script("q22550s") + public static void q22550s(ScriptManager sm) { + // Puppet Caring for his Master 1 (22550 - start) + final int answer1 = sm.askMenu("Who, who are you? My job is to guard my master's cave, though there is nothing to steal here and I can't fight at all!", java.util.Map.of( + 0, "Did you steal #p1061005#'s Herb?" + )); + final int answer2 = sm.askMenu("Eeeek! Are you a policeman? I'm so sorry. I'll never do it again. I'm sorry I stole it! I knew I'd get caught someday, but I didn't expect it to be so soon. But I cannot return the Herb!", java.util.Map.of( + 0, "Why can't you return the Herb?" + )); + final int answer3 = sm.askMenu("My master who created me was injured while battling his enemies, and I needed the Herb to make medicine to heal his wound. Potions would have been best, but I didn't have any money to buy them. I'm so sorry. Please forgive me.", java.util.Map.of( + 0, "(How could you not forgive this poor, pitiful puppet...?)" + )); + sm.sayNext("Please? My master's wound has not healed yet... I'm so sorry. And I'm so ashamed to ask you, but could you donate some potions for my master if I return the Herb? Please? *sniff sniff* I beg you."); + if (!sm.askAccept("")) { + sm.sayOk("Wahhhh, the world is such a cruel place. I'm sorry, master. No one is willing to help us."); + return; + } + sm.sayOk("Thank you! You are a wonderful, wonderful person! Please find me #b20 #t2000002#s#k and #b30 #t2000003#s#k! *sniff sniff* I can finally heal my master!"); + sm.forceStartQuest(22550); + } + + @Script("q22550e") + public static void q22550e(ScriptManager sm) { + // Puppet Caring for his Master 1 (22550 - end) + sm.sayNext("You've brought the potions! I don't know how to thank you for bringing me these precious potions. I'm so touched. Thank you, on behalf of my master, for helping us! "); + sm.sayOk("Here, I'll return the Herb as I promised. Please hold on a minute."); + sm.removeItem(2000002, 20); + sm.removeItem(2000003, 30); + sm.addExp(5450); + sm.forceCompleteQuest(22550); + sm.forceStartQuest(22551); + } + + @Script("q22551s") + public static void q22551s(ScriptManager sm) { + // The Returned Herbs (22551 - start) + sm.sayNext("Here is the Herb I stole. I've already used a little of it, but no one will be able to tell. Please return #bthe Herb to its owner#k. I'm sorry for the inconvenience I've caused. Please apologize to the owner for me."); + if (!sm.askAccept("")) { + sm.sayOk("Hm? Don't you need the Herb back? If not, can I keep it?"); + return; + } + sm.sayNext("I'll never do anything like this ever again. You're a lifesaver. Thank you so much for your help!"); + if (!sm.addItem(4032464, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22551); + sm.forceStartQuest(22552); + } + + @Script("q22551e") + public static void q22551e(ScriptManager sm) { + // The Returned Herbs (22551 - end) + sm.sayNext("It's been a long time, Evan. Have you heard anything about my Herb? I don't want to rush you, but there isn't anything else for me to do but blame #m103000000# for my stolen Herb. What? You found it?"); + sm.sayOk("Oh, that's it! That's definitely my Herb! Wait, let me take a look!"); + sm.removeItem(4032464); + sm.addExp(5450); + } + + @Script("q22552s") + public static void q22552s(ScriptManager sm) { + // Kerning City's Honor Restored (22552 - start) + final int answer = sm.askMenu("A tiny bit of my Herb is missing, but I suppose I can live with that. I was ready to give up, but I'm so glad you found it! ", java.util.Map.of( + 0, "I only managed to find your Herb because #p1052103# in #m103000000# helped me." + )); + sm.sayNext("Is that right? I thought it was the Thieves in #m103000000# who stole it. I'm so embarrassed. I shouldn't have jumped to conclusion like that. I'm sorry. Will you tell that girl, #p1052103#, that I am truly sorry?"); + if (!sm.askAccept("")) { + sm.sayOk("Hmmm, if you don't tell her, #p1052103# in #m103000000# will still think that I am falsely accusing the Thieves. "); + return; + } + sm.sayOk("Thank you for your help. I know it isn't easy to help someone, especially when you don't really know that person... You're even more wonderful than #p1040001# said. Thank you again."); + sm.forceStartQuest(22552); + } + + @Script("q22552e") + public static void q22552e(ScriptManager sm) { + // Kerning City's Honor Restored (22552 - end) + final int answer = sm.askMenu("Oh, Evan! So what happened? Did you catch the culprit that stole the Herb? What a relief! ", java.util.Map.of( + 0, "#p1061005# asked me to tell you he's sorry." + )); + sm.sayNext("Hehehe. #p1061005# won't blame #m103000000# anymore, right? That's excellent! I'm so relieved that the honorable name of #m103000000# has been restored. Here. This is for you."); + sm.sayOk("I'm giving you this as a token of my appreciation for clearing the name of #m103000000#. Thank you again. You're strong and kind... You're simply the best!"); + sm.addExp(2500); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2211), 1); + if (!sm.addItem(1142154, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22552); + } + + @Script("q22553s") + public static void q22553s(ScriptManager sm) { + // Puppet Caring for his Master 2 (22553 - start - auto-start) + sm.sayNext("Oh, my lifesaver! Please help my master! I don't think the potion alone is enough to heal my master. Could you please help me find a bandage I can use to wrap my master's wound?"); + sm.sayBoth("You really are amazing, lifesaver. Please find me some #t4000035#es so I can make a bandage. If I wash the #t4000035#es and cut them, I can use them as a bandage."); + sm.sayOk("You can find #t4000035#es in... Where was that... Oh, right! I heard creatures called #r#o3230101#s#k in #b#m103000000# Subway#k wear #t4000035#es over their heads. Can you eliminate those creatures and bring me #b60 #t4000035#es#k? I'm counting on you."); + sm.forceStartQuest(22553); + } + + @Script("q22553e") + public static void q22553e(ScriptManager sm) { + // Puppet Caring for his Master 2 (22553 - end) + final int answer1 = sm.askMenu("It's my lifesaver! Did you bring me the #t4000035#es? Wow, that's a lot! I think you have enough for me to make a bandage. You're such a lovely person, lifesaver. You deserve to be a part of my master's organization!", java.util.Map.of( + 0, "Your master's organization? What organization?" + )); + final int answer2 = sm.askMenu("I don't know what it's called, but it's an organization of people in Maple World that secretly do good deeds! A lot of people want to join! I can pull some strings and get you into the organization!", java.util.Map.of( + 0, "(As a Dragon Master, you should join an organization of people who secretly do good deeds, right?) Sounds good!" + )); + sm.sayOk("Alright! Just leave it to me!"); + sm.removeItem(4000035, 60); + sm.addExp(12100); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2211), 1); + sm.forceCompleteQuest(22553); + } + + @Script("q22554s") + public static void q22554s(ScriptManager sm) { + // Nella's Introductions (22554 - start - auto-start) + final int answer = sm.askMenu("Hey, Evan. Are you busy? I have something to tell you... I told someone about how you helped me before, and now that person wants to meet you. I'm not bothering you, am I?", java.util.Map.of( + 0, "No, you're not bothering me. But who wants to meet me?" + )); + sm.sayNext("It's Chief Stan of #m100000000#. Something happened and he's looking for someone strong. Won't you go over to #b#m100000000# Town#k and help #bChief Stan#k? I'll write you a Letter of Introduction."); + if (!sm.askAccept("")) { + sm.sayOk("Hmph, fine. Something happened in #m100000000#, but I'm sure Chief Stan will find a capable person to help him."); + return; + } + sm.sayNext("Thanks for accepting my request without giving me grief. I was scared that you might snap at me."); + if (!sm.addItem(4032465, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceStartQuest(22554); + } + + @Script("q22554e") + public static void q22554e(ScriptManager sm) { + // Nella's Introductions (22554 - end) + sm.sayOk("Is that you, Evan? Shouldn't you be helping out at the farm? Hm? Isn't this #t4032465#? Hm, I asked her to introduce a capable adventurer to me... Why did she send this with you? Let me give it a read."); + sm.removeItem(4032465); + sm.addExp(1500); + sm.forceCompleteQuest(22554); + } + + @Script("q22555s") + public static void q22555s(ScriptManager sm) { + // Chief Stan's Test (22555 - start) + final int answer = sm.askMenu("According to this Letter of Introduction, you are the strong adventurer #p1052103# has chosen to send me. But you're #p1013103#'s second child, not a strong adventurer! I thought I could count on #p1052103#, but I guess I was wrong.", java.util.Map.of( + 0, "I'm actually really strong. Believe me." + )); + sm.sayNext("You may have spent your time chasing a few monsters instead of helping out at the farm, but I need a competent adventurer. If you really are capable, prove your strength to me. Why don't I give you a little test?"); + if (!sm.askAccept("")) { + sm.sayOk("Stop wasting time trying to be someone you're not and go help your father at the farm. You...? An adventurer...? HA!"); + return; + } + sm.sayOk("Do you know a monster called #o3000001#? It's small but quite powerful. If you enter from the Warning Post at #m106010100#, you'll find a Hidden Street leading to Golem's Temple. Defeat the #r#o3000001##k in #b#m106010102##k and bring me a #b#t4000068##k. Then I will recognize you as a true adventurer."); + sm.forceStartQuest(22555); + } + + @Script("q22555e") + public static void q22555e(ScriptManager sm) { + // Chief Stan's Test (22555 - end) + final int answer = sm.askMenu("Hm, I don't believe this. You've really brought me a #t4000068#. You're stronger than I thought. But don't get excited. The monster you have to battle is much more powerful than #o3000001#. ", java.util.Map.of( + 0, "I'm much more powerful than #o3000001# as well. Stop worrying and let me help you." + )); + sm.sayOk("Let me think about it a little longer."); + sm.removeItem(4000068, 2); + sm.addExp(3200); + sm.forceCompleteQuest(22555); + } + + @Script("q22556s") + public static void q22556s(ScriptManager sm) { + // Chief Stan's Request (22556 - start) + sm.sayNext("I'd look for another adventurer, if I could, but I have no choice... You must promise me that you will run away if you find yourself in danger. Don't be foolish. Do you understand?"); + if (!sm.askAccept("")) { + sm.sayOk("Don't overestimate your strength. Many adventurers hurt themselves being foolish like that. I won't let you go anywhere until you promise me that you'll put your safety before anything else."); + return; + } + sm.sayNext("Okay then. #m100000000# is a peaceful town, but there is a dangerous area nearby known as the Golem's Temple. So far, it hasn't been a problem since the Golems are so slow and they don't leave their area."); + sm.sayBoth("But I've been hearing strange noises coming from the Golem's Temple lately. Thumping and banging... They're up to something, and we must find out what. That's where you come in."); + sm.sayOk("It might be dangerous, so don't go in too deep. Yes, go no further than #b#m106010102##k and see if you can find out anything. Don't get yourself into trouble now, you hear?"); + sm.forceStartQuest(22556); + } + + @Script("q22556e") + public static void q22556e(ScriptManager sm) { + // Chief Stan's Request (22556 - end) + final int answer = sm.askMenu("Did you visit the Golem's Temple? Was there anything out of the ordinary? Tell me.", java.util.Map.of( + 0, "There was a door with a strange puppet sitting on top. " + )); + sm.sayNext("A door with a strange puppet sitting on top... What could it be? Is someone playing a joke on us?"); + sm.sayOk("Monsters in various towns have started acting strange. It would be awful if those Golems became more violent than they already are. We can stop Mushrooms or #o1210100#s, but not Golems.. I'll ask for your help if something happens."); + sm.addExp(6600); + sm.forceCompleteQuest(22556); + } + + @Script("q22557s") + public static void q22557s(ScriptManager sm) { + // Kidnapping of Camila (22557 - start - auto-start) + sm.sayNext("Oh no! #p1012108# has been kidnapped by the Golems! I warned her to stay home, but she went out to pick strawberries! One of the Golems grabbed #p1012108# and disappeared into the Golem's Temple!"); + sm.sayBoth("We don't have much time! Please, hurry over to the #bGolem's Temple#k and rescue #b#p1012108##k!"); + if (!sm.askAccept("")) { + sm.sayOk("This is no time for jokes! Quit stalling and accept my request!"); + return; + } + sm.sayOk("I don't know how far the Golem went! You must hurry! Please, bring back #p1012108#!"); + sm.forceStartQuest(22557); + } + + @Script("q22557e") + public static void q22557e(ScriptManager sm) { + // Kidnapping of Camila (22557 - end) + sm.sayNext("You've brought #p1012108# back! Phew, what a relief. I never thought something so scary would happen."); + sm.sayOk("I must come up with a plan. Just hold on a minute. I have to concentrate."); + sm.addExp(14500); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2211), 1); + sm.forceCompleteQuest(22557); + } + + @Script("q22558s") + public static void q22558s(ScriptManager sm) { + // Reason for the Golem's Change (22558 - start) + sm.sayNext("I just can't figure out why the Golems are behaing like this. I wonder if it has to do with a puppet, like the other incidents that have occurred... I guess we have no other option than to ask #p1012108#. "); + sm.sayBoth("Please go talk to #b#p1012108##k and ask if she remembers anything... Anything at all. She's probably in shock right now, so comfort her a little while you're at it, would you?"); + if (!sm.askAccept("")) { + sm.sayOk("Hmph, don't you want to ask her? You two know each other, so #p1012108# might be more willing to share what she saw with you."); + return; + } + sm.sayOk("Please be nice to her. She's very fragile."); + sm.forceStartQuest(22558); + } + + @Script("q22558e") + public static void q22558e(ScriptManager sm) { + // Reason for the Golem's Change (22558 - end) + final int answer1 = sm.askMenu("Oh, Evan! I was so out of it that I forgot to thank you. Am I okay? Of course. I'm a little shaken up but I'm fine. What brings you here?", java.util.Map.of( + 0, "Hm, I wanted to ask you something. Did you see anything when you were in Golem's Temple? Anything that seemed out of place or strange?" + )); + final int answer2 = sm.askMenu("Well, I don't know if it's out of the ordinary or not, but I did see a door with a puppet sitting on top. I saw a lot of Golems jumping out of that door. Is that strange?", java.util.Map.of( + 0, "Golems from that door? Were they average Golems? Or were they reddish Golems?" + )); + sm.sayNext("All the Golems I saw were reddish. Are there other types? Anyway, that's all I can tell you. I hope it helps."); + sm.sayOk("By the way, how did you get so strong? You weren't this strong when you, me, and #p1013101# used to play together. What's your secret? Okay, okay, I'll stop prying."); + sm.addExp(1000); + sm.forceCompleteQuest(22558); + } + + @Script("q22559s") + public static void q22559s(ScriptManager sm) { + // Eliminate the Golems (22559 - start) + sm.sayNext("What did #p1012108# say? Hm, so that #rsuspicious door with the strange puppet sitting on top#k has something to do with this. I have a feeling there is an object of some sort that changes the Golems behind that door. It could very well be a puppet."); + sm.sayBoth("There have been many incidents involving puppets near #m100000000# lately. But it was mostly the Mushrooms causing a ruckus."); + sm.sayBoth("Golems are much scarier. But the cause of their change appears to be linked to the other incidents that have happened in #m100000000# and other towns."); + sm.sayBoth("But we can't assume anything until we see what's behind that door with the puppet ourselves. Could you look into this?"); + if (!sm.askAccept("")) { + sm.sayOk("You don't think you can do it? I thought you'd be confident in yourself. Must I find a stronger adventurer?"); + return; + } + sm.sayNext("Go to #m106010102# and enter through the #bdoor with strange puppet sitting on top#k. When you see #rEnranged Golems#k, defeat them and bring me a #bPuppet#k if you happen to find one. Good luck."); + sm.sayOk("I never thought I'd depend on #p1013103#'s second child like this. Ha, life really is full of surprises."); + sm.forceStartQuest(22559); + } + + @Script("q22559e") + public static void q22559e(ScriptManager sm) { + // Eliminate the Golems (22559 - end) + sm.sayNext("So? What did you find behind that door? Isn't this a #t4032466#? Who could be behind all of this?"); + sm.sayOk("If we had left the doll, more Golems would have turned violent and attacked #m100000000#. You have saved us, Evan. Thank you. You are a hero that has saved #m100000000#."); + sm.removeItem(4032466); + sm.addExp(14200); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2211), 1); + sm.forceCompleteQuest(22559); } @Script("q22560s") @@ -1177,6 +3070,104 @@ public static void q22560s(ScriptManager sm) { sm.sayBoth("I don't know why he doesn't just build the base somewhere else. He apparently tried building it in some garden, but had to halt the project because of some monsters that kept attacking. I guess that's why he's being more cautious this time."); } + @Script("q22560e") + public static void q22560e(ScriptManager sm) { + // Condition for Joining the Secret Organization 1 (22560 - end) + sm.sayOk("Wow, you've defeated all 150 #o3230100#s! My master will be very happy! Let me go ask my master if you can join the secret organization."); + sm.addExp(15800); + sm.forceCompleteQuest(22560); + sm.forceStartQuest(22561); + } + + @Script("q22561s") + public static void q22561s(ScriptManager sm) { + // Condition for Joining the Secret Organization 2 (22561 - start - auto-start) + sm.sayNext("I don't think that was enough to get you into the secret organization, lifesaver. My master says he'll let you join if you fulfill one more request."); + sm.sayBoth("This is like a practice round before you join the organization. I'm sure it'll be simple for you. Now, let me tell you about this task."); + sm.sayOk("The task requires you to find #b20 #t4000031#s#k. You'll find a lot of #r#o4230101#s#k in #b#m100040103##k, and all you have to do is acquire #t4000031#s from these monsters. I'll be waiting!"); + sm.forceStartQuest(22561); + } + + @Script("q22561e") + public static void q22561e(ScriptManager sm) { + // Condition for Joining the Secret Organization 2 (22561 - end) + sm.sayNext("Oh, you've brought all the #t4000031#s! That was quite impressive! Let me go ask my master if you can now join the organization!"); + sm.sayOk("My master was too busy, so I couldn't ask. I'll ask him a little later so please wait a few moments."); + sm.removeItem(4000031, 20); + sm.addExp(15800); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2211), 1); + sm.forceCompleteQuest(22561); + } + + @Script("q22562s") + public static void q22562s(ScriptManager sm) { + // Onyx Dragon Study (22562 - start - auto-start) + final int answer1 = sm.askMenu("It's been a long time. I had some news I wanted to share with you since you are conducting a research on Onyx Dragons. My friend, halflinger #p2081000#, who nurtures baby Dragons in #m240000000#, says he knows something about Onyx Dragons. ", java.util.Map.of( + 0, "Will he be able to distinguish whether a Dragon is an Onyx Dragon just by looking?" + )); + final int answer2 = sm.askMenu("Of course! He's a halflinger, so he can tell just by looking at the Dragon's scale. If you have an Onyx Dragon's scale, he'll be able to confirm it in a split second. That's how well he knows Dragons.", java.util.Map.of( + 0, "(Chief Tatamo probably has the answer to why the Onyx Dragons have become extinct, too...)" + )); + final int answer3 = sm.askMenu("But Tatamo may not tell you much about Onyx Dragons. He doesn't even tell me, his good friend. It's as if he feels guilty about something. ", java.util.Map.of( + 0, "What can I do to have Chief Tatamo share what he knows about Onyx Dragons?" + )); + sm.sayNext("Well, maybe if you showed him an Onyx Dragon's Scale... He might tell you what he knows and ask you to tell him how you got the scale in return."); + if (!sm.askAccept("")) { + sm.sayOk("Hm, of course. Even you wouldn't happen to have an Onyx Dragon's Scale."); + return; + } + sm.sayOk("It would be easy to find a scale if you were near an Onyx Dragon...."); + sm.forceStartQuest(22562); + } + + @Script("q22562e") + public static void q22562e(ScriptManager sm) { + // Onyx Dragon Study (22562 - end) + sm.sayNext("What is it? Huh? This is an Onyx Dragon's Scale? It has a mysterious glow. But I can't tell if this is real or fake. Let me ask Tatamo to appraise it."); + sm.sayOk("I'll send this over to #m240000000#. It'll take a while since it takes some time to send, receive, then reply. I'll contact you as soon as I receive an answer from #m240000000#."); + sm.removeItem(4032467); + sm.addExp(23000); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2212), 1); + sm.forceCompleteQuest(22562); + } + + @Script("q22563s") + public static void q22563s(ScriptManager sm) { + // The Value of One Scale (22563 - start) + final int answer1 = sm.askMenu("Ma... Master, why are you looking at me like that?", java.util.Map.of( + 0, "You heard, didn't you? I need one of your scales. Give me one." + )); + final int answer2 = sm.askMenu("You want one of my scales? How could you say something so cruel with a straight face, master? Do you know how painful that would be?", java.util.Map.of( + 0, "Well, not really, but there is no other way for us to identify which family of Dragons you belong to. Come on now, hurry." + )); + final int answer3 = sm.askMenu("No way! I can't do that! How could you try to take one of my pretty and perfect scales? Think about it, master. How pathetic would you feel if you had a bald spot?", java.util.Map.of( + 0, "A scale isn't like hair. I can take one from your body where it wouldn't be so noticeable. " + )); + final int answer4 = sm.askMenu("Wahhh, don't say that with such a happy face! I'm scared! Can't I give you something else? ", java.util.Map.of( + 0, "Something else... Like your horn?" + )); + sm.sayNext("Yikes! My horn? Of course not! Fine, just take a scale! But I have to consume enough calcium for my DEF to compensate for the loss of my scale."); + if (!sm.askAccept("")) { + sm.sayOk("Absolutely not, then! I don't care if you're my master, I can't just give you a scale. No way!"); + return; + } + sm.sayOk("Oh, what about a bone? I am going to eat a bone! If you go near #bRemains #k, you will find #r#o4230125#s#k. Bring me #b50#k of their #bBones#k! Then I will give you one... I repeat, just ONE scale."); + sm.forceStartQuest(22563); + } + + @Script("q22563e") + public static void q22563e(ScriptManager sm) { + // The Value of One Scale (22563 - end) + sm.sayNext("Did you already find all the #t4000204#s I've asked you to bring? I've always been impressed with your strength and speed, but I'm not so happy this time. Hmph, fine. Give me a minute. Eeeeeeeeeeeeek!"); + sm.sayOk("*sniff* Here is my scale. My precious scale. Please be careful with that. Okay? I'm not giving you another one."); + sm.removeItem(4000204, 50); + if (!sm.addItem(4032467, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22563); + } + @Script("q22564e") public static void q22564e(ScriptManager sm) { // Knowledge about Dragons 3 (22564 - end) @@ -1244,6 +3235,54 @@ public static void q22565s(ScriptManager sm) { sm.sayOk("All right, then! We'll give that #b#p1032001##k, or whatever his name is, time to find out more. In the meantime, let's train and get even stronger! Let's become heroes! Let's go help people!"); } + @Script("q22566s") + public static void q22566s(ScriptManager sm) { + // Permission to Join the Secret Organization (22566 - start - auto-start) + sm.sayNext("Hello, lifesaver! Long time no see! I'm sorry I couldn't contact you earlier. My master has been very busy... To tell you the truth, my master was fighting against his enemy and got himself in trouble, so he's running away. I think he's safe for now, though."); + sm.sayBoth("Oh, but that's not why you're here. Congratulations! You have been approved to join the secret organization! More accurately, you're a temporary member of the organization, but you will soon be promoted and become an official member like my master!"); + sm.sayBoth("I don't know what kind of tasks you will be given, lifesaver. If you wish to receive a mission as a member of the secret organization, go to the #b#m200080601##k in #b#m200080600##k. If you look in the #b#p2012034##k, you'll be able to view the mission you have been given."); + if (!sm.askYesNo("It's really hidden, isn't it? That's how the organization delivers its missions. Once you get promoted, you'll be able to meet with other members and decide on the missions you want to accept, like my master.")) { + sm.sayOk("Huh? Don't you approve of the way you receive missions? But it's a secret organization and everything must be done discreetly..."); + return; + } + sm.forceStartQuest(22566); + } + + @Script("q22566e") + public static void q22566e(ScriptManager sm) { + // Permission to Join the Secret Organization (22566 - end) + sm.sayOk("#b(Could this be the #p2012034# the #p1063018# was talking about? This brick that's popping out appears to be movable. Try picking up the brick.)#k"); + sm.addExp(6100); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2212), 1); + if (!sm.addItem(1142155, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22566); + } + + @Script("q22567s") + public static void q22567s(ScriptManager sm) { + // Secret Organization's First Mission (22567 - start) + // NPC 2012034 - Loose Brick + sm.setPlayerAsSpeaker(true); + sm.sayNext("#b(You take the brick out and place your hand inside the empty gap. You find a piece of paper. Read what it says.)#k"); + sm.setPlayerAsSpeaker(false); + sm.sayBoth("Bring back a #bGrowth Accelerant#k made by #b#p2030012##k and place it behind the brick.\r\nThe ingredients are as follows.\n\n10 #t4000070#s\n10 #t4000071#s\n10 #t4000072#s\n10 #t4000068#s\n\nDo not throw away or take this paper. Place it back behind the brick."); + sm.setPlayerAsSpeaker(true); + sm.sayBoth("#b(You place the paper back behind the brick.)#k"); + + if (!sm.askYesNo("Will you accept this mission?")) { + sm.setPlayerAsSpeaker(true); + sm.sayOk("#b(Ugh, this mission seems tedious. Since you don't want this mission, put the paper back and pretend you didn't see it.)#k"); + return; + } + + sm.setPlayerAsSpeaker(true); + sm.sayOk("#b(The #p2012034# has been put back into place)#k"); + sm.forceStartQuest(22567); + } + @Script("q22567e") public static void q22567e(ScriptManager sm) { // Secret Organization's First Mission (22567 - end) @@ -1275,6 +3314,281 @@ public static void q22567e(ScriptManager sm) { sm.sayBoth("#bYeah, I think that makes sense!"); } + @Script("q22568s") + public static void q22568s(ScriptManager sm) { + // Making the Growth Accelerant (22568 - start - complex multi-choice quiz) + final int answer1 = sm.askMenu("The only people who come all the way here are adventurers or those who need my research results. Do you fall under the former or the latter? ", java.util.Map.of( + 0, "Former", + 1, "Latter" + )); + if (answer1 == 0) { + sm.sayOk("You must be on an adventure. Take some time to rest here if you wish."); + return; + } + final int answer2 = sm.askMenu("Ah, how nice to meet someone in need of my research results! It's been a while. So, what would you like to make?", java.util.Map.of( + 0, "Growth Accelerant", + 1, "Hair-Regrowth Medication", + 2, "Diet Pills" + )); + if (answer2 != 0) { + String response = switch (answer2) { + case 1 -> "I'm sorry, but I don't know how to make such thing since I show no signs of balding. Haha."; + case 2 -> "Hm, it doesn't seem like you need to go on a diet. Exercise is the healthiest way to keep your body in shape."; + default -> ""; + }; + sm.sayOk(response); + return; + } + final int answer3 = sm.askMenu("Growth Accelerant? I've completed my research pertaining to the Growth Accelerant ages ago, but I haven't really made it because the ingredients are so hard to find. Do you know what ingredients are required to make it?", java.util.Map.of( + 0, "10 Cellion Tails, 10 Lioner Tails, 10 Grupin Tails, and 10 Fierry's Wings", + 1, "10 Cellion Tails, 10 Lioner Tails, 10 Grupin Tails, and 100 Fierry's Tentacles", + 2, "10 Cellion Tails, 10 Lioner Tails, 10 Grupin Tails, and 10 Fierry's Tentacles" + )); + if (answer3 != 0) { + sm.sayOk("I have no idea what you can make with those ingredients."); + return; + } + sm.sayNext("I'm glad you know the ingredients. I will make you the Growth Accelerant as soon you bring me the required ingredients. "); + if (!sm.askAccept("")) { + sm.sayOk("I can't make anything without proper ingredients..."); + return; + } + sm.sayOk("I can make it in a second if you just bring me the ingredients. Go on and bring me the #bingredients for the Growth Accelerant#k."); + sm.forceStartQuest(22568); + } + + @Script("q22568e") + public static void q22568e(ScriptManager sm) { + // Making the Growth Accelerant (22568 - end) + sm.sayOk("You've found all the ingredients I need to make the Growth Accelerant. But why do you need the Growth Accelerant anyway? Well, I suppose that's none of my business. I'll make it for you in a few."); + sm.removeItem(4000070, 10); + sm.removeItem(4000071, 10); + sm.removeItem(4000072, 10); + sm.removeItem(4000068, 10); + if (!sm.addItem(4032468, 10)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22568); + } + + @Script("q22569s") + public static void q22569s(ScriptManager sm) { + // Another Clue about the Onyx Dragon (22569 - start - auto-start) + sm.sayNext("It's been a long time, researcher of Onyx Dragons. I have called you because I found a book you might be interested in. This book appears to be an ordinary diary, but it contains a line that might interest you."); + sm.sayBoth("I think it will help you with your research, so if you want to read it, come to #m101000003##k in #b#m101000000#. I'll hold on to the book and won't let anyone borrow it."); + if (!sm.askYesNo("It has an explanation that seems to indicate something about Onyx Dragons. No one knows whether what it says here is true or not, but that is why you must verify the facts. I'll be waiting.")) { + sm.sayOk("You can't come? That's too bad. I'll have to make the book available for anyone that wants to borrow it."); + return; + } + sm.forceStartQuest(22569); + } + + @Script("q22569e") + public static void q22569e(ScriptManager sm) { + // Another Clue about the Onyx Dragon (22569 - end) + sm.sayOk("Oh, I'm glad you came. Wait here for a second. I'll take out the book I told you about. Let me see where that Voyage Log is..."); + sm.addExp(3000); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2213), 1); + sm.forceCompleteQuest(22569); + sm.forceStartQuest(22570); + } + + @Script("q22570s") + public static void q22570s(ScriptManager sm) { + // Crew Member's Voyage Log (22570 - start) + sm.sayNext("This book was written by a crew member. It's a Voyage Log he wrote. It contains all the struggles he encountered, but towards the end, it mentions something rather interesting. I think that's the part that would help you. Here, take the book."); + if (!sm.askYesNo("")) { + sm.sayOk("Hm... What's wrong? Do you have too many things in your Inventory? I'll wait so talk to me again when you're ready."); + return; + } + sm.sayOk("I don't have to explain everything. It's really up to you to use it to your benefit. Go ahead and read it. It isn't too long, so #bread through it and return it to me soon#k."); + if (!sm.addItem(4161051, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22570); + sm.forceStartQuest(22571); + } + + @Script("q22570e") + public static void q22570e(ScriptManager sm) { + // Crew Member's Voyage Log (22570 - end - multi-quiz) + final int answer1 = sm.askMenu("Did you finish reading the #t4161051#? That was fast. You're a speed reader! Which page had information about Onyx Dragons?", java.util.Map.of( + 0, "Page 15", + 1, "Page 17", + 2, "Page 18", + 3, "Page 20" + )); + if (answer1 != 2) { + sm.sayOk("Was there something about a Dragon on that page? I don't think so..."); + return; + } + final int answer2 = sm.askMenu("Right, that was the page. And the date the crewmember wrote that was... Wait, when was it again?", java.util.Map.of( + 0, "June 29th", + 1, "July 8th", + 2, "July 13th", + 3, "August 2nd" + )); + if (answer2 != 2) { + sm.sayOk("I don't think that's the correct date. Read it again."); + return; + } + final int answer3 = sm.askMenu("Do you remember the name of the crew member's ship? It was really unique... ", java.util.Map.of( + 0, "Thunder Bolt", + 1, "Thunder Bird", + 2, "Under Bolt", + 3, "Usain Bolt" + )); + if (answer3 != 1) { + sm.sayOk("I don't think that was his name..."); + return; + } + final int answer4 = sm.askMenu("It says there was a giant Dragon in the island the crewmember arrived in. Do you remember how many horns the Dragon had?", java.util.Map.of( + 0, "None", + 1, "One", + 2, "Two", + 3, "Four" + )); + if (answer4 != 3) { + sm.sayOk("Hm, I don't think that's the correct number..."); + return; + } + final int answer5 = sm.askMenu("Oh, right. That's why I said the Dragon mentioned in the Voyage Log might be an Onyx Dragon. Most Dragons don't have that many horns. I'm sure the Dragon this crew member saw was an old Onyx Dragon. But for all I know, he could have just made it up.", java.util.Map.of( + 0, "What can I do to find the truth?" + )); + final int answer6 = sm.askMenu("That's simple. You can just go find the author and ask him yourself. If you read the book carefully, you should have caught the name of the author. Do you remember the name of the crew member who wrote the Voyage Log?", java.util.Map.of( + 0, "Retired Crewmember #p0020000#", + 1, "Retired Crewmember Teo", + 2, "Retired Crewmember Kyrin", + 3, "Retired Crewmember Gustav" + )); + if (answer6 != 0) { + String response = switch (answer6) { + case 1 -> "Hm... He hasn't retired yet."; + case 2 -> "Kyrin is a very active pirate. Haha."; + case 3 -> "Did you forget who your father is? Haha!"; + default -> ""; + }; + sm.sayOk(response); + return; + } + sm.sayOk("Right, that was his name. If you find #p0020000# and ask him, I'm sure you'll get information about the island where he saw the Onyx Dragon. Oh, you know know #p0020000#? He lives in #m104000000#. Go to #m104000000# if you want to find him."); + sm.removeItem(4161051); + sm.addExp(1000); + } + + @Script("q22571s") + public static void q22571s(ScriptManager sm) { + // John's Testimony (22571 - start) + sm.sayNext("Are you here to buy a fish? Huh? #t4161051#...? Well, of course I wrote it! But I've retired long ago... What? You want to know whether what I wrote is true?"); + sm.sayBoth("Do I look like I need embellish stories to make them interesting? Of course it's all true! Even the bit about the island I where found a sleeping dragon. But why do you ask? You're not trying to get to that island, are you?"); + sm.sayBoth("Haha, another youngling acting with reckless bravado... I'd urge you to play it safe, but I suppose you must learn that on your own. Alright, I'll give you a map to the island."); + if (!sm.askYesNo("")) { + sm.sayOk("Don't need this map? Smart choice. Don't put yourself in danger for some ridiculous ambition."); + return; + } + sm.sayOk("Since you're not a crew member, find #b#p1002001##k near the ticketing booth and ask him if you can go to this island. The sea route will be rough, so he may decline your request."); + if (!sm.addItem(4032469, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceStartQuest(22571); + } + + @Script("q22571e") + public static void q22571e(ScriptManager sm) { + // John's Testimony (22571 - end) + sm.sayOk("That whale there is getting on my nerves. I probably shouldn't catch it though, huh? What are you doing here? Hm? You want to know if you can get to the island on this map? Let me see..."); + sm.removeItem(4032469); + sm.addExp(2000); + sm.forceCompleteQuest(22571); + sm.forceStartQuest(22572); + } + + @Script("q22572s") + public static void q22572s(ScriptManager sm) { + // Teo's Advice (22572 - start) + sm.sayNext("Must you go to the island on this map? If so, I can't help you. But I know someone who might be able to... #b#p1002101##k. He's retired now, but #p1002101# is the best crew member I know."); + sm.sayBoth("Like I said, #p1002101# retired a long time ago. He doesn't miss it much, either... He may not want to help you, but I know something that might change #p1002101#'s mind."); + if (!sm.askYesNo("")) { + sm.sayOk("Hm, I guess you're not THAT interested."); + return; + } + sm.sayOk("If you bring #p1002101# some #t4032470#, he might change his mind. Go to #b#m110000000##k and see if you can find some #b#t4032470##k. They don't make it often, so it might be hard to get..."); + if (!sm.addItem(4032526, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceStartQuest(22572); + sm.forceStartQuest(22573); + } + + @Script("q22572e") + public static void q22572e(ScriptManager sm) { + // Teo's Advice (22572 - end) + sm.sayNext("Ah, I don't think we've met. But you seem quite competent. It's rare to find someone so strong that I haven't trained myself. But what brings you here? Eh? You want me to take you to the island on this map?"); + sm.sayBoth("Hahaha, someone must have told you that I was the best crew member in town! But I'm retired. I don't have any desire to get back on a ship. I'm just here to guide adventurers and offer advice."); + sm.sayBoth("Oh, isn't this...#t4032470#? Oh, boy! There's always a shortage of this in Florina Beach. It smells delicious... Are you giving it to me?"); + sm.sayOk("Haha, what a generous person you are! Alright, why not? I'll take you to the island on the map. It'll be a nice change of pace."); + sm.removeItem(4032526); + sm.removeItem(4032470); + sm.addExp(38900); + sm.forceCompleteQuest(22572); + } + + @Script("q22573s") + public static void q22573s(ScriptManager sm) { + // Tropical Fruit Punch (22573 - start) + sm.sayNext("Welcome to #m110000000#. I hope you enjoy your stay. Yes? Is there anything I can do for you? Oh, #t4032470#? I'm all sold out of that."); + sm.sayBoth("#t4032470# takes a lot of time and energy to make, so we don't always have enough."); + sm.sayBoth("But if you really, really want some #t4032470#, I have a special offer for you! If you pay a service fee and bring me the necessary ingredients to make #t4032470#, I'll be more than glad to make some for you. What do you say?"); + if (!sm.askYesNo("")) { + sm.sayOk("You don't want it that badly, huh? Yeah, not many people choose to go through all that for a drink."); + return; + } + sm.sayOk("Wow, I've never met anyone who wanted #t4032470# so badly! Now then, please find #b5 #t4000136#s#k, #b30 #t4000029#s#k, and #b30 #t4000044#s#k. Oh, and I almost forgot! The fee is #b60000 mesos#k."); + sm.forceCompleteQuest(22573); + } + + @Script("q22573e") + public static void q22573e(ScriptManager sm) { + // Tropical Fruit Punch (22573 - end) + sm.sayOk("Did you bring me the ingredients? Let me have them. I just have to peel the #t4000136#s, dice the fruit, ripen the #t4000029#s, and put a few #t4000044#s in for kick..."); + sm.addMoney(-60000); + sm.removeItem(4000136, 5); + sm.removeItem(4000029, 30); + sm.removeItem(4000044, 30); + if (!sm.addItem(4032470, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + } + + @Script("q22574s") + public static void q22574s(ScriptManager sm) { + // Strong Sail Needed (22574 - start) + sm.sayNext("Oh my, I didn't realize how tough it would be to get to the island. It's not even the reef or the rough waves I'm worried about. It'll be so windy out there that the sail on our boat will get ripped into pieces. We need a stronger sail."); + sm.sayBoth("Oh, right. Are you familiar with #t4000030#? Geez! Calm down! What made you jump like that? It's not really the skin of a Dragon. It's the skin of creatures that resemble Dragons...like Drakes. People just call it #t4000030#. "); + sm.sayBoth("Anyway! I think I could make a sturdier sail that could withstand those winds if I used Dragon Skin. Will you bring me some #t4000030#? You have to hunt Drakes inside the #m105040300# Dungeon. Think you can do it?"); + if (!sm.askYesNo("")) { + sm.sayOk("You look strong enough. Maybe you just need more confidence. Well, I understand. Drakes may not be real Dragons, but they're extremely strong. Alright then."); + return; + } + sm.sayOk("Ah, I'm impressed. You must be more powerful than I thought. Bring me #b2 #t4000030#s#k and we'll be on our way to the island before you know it!"); + sm.forceStartQuest(22574); + } + + @Script("q22574e") + public static void q22574e(ScriptManager sm) { + // Strong Sail Needed (22574 - end) + sm.sayOk("Did you bring the #t4000030# I requested? Marvelous! I can see now how you have the confidence to venture to such a dangerous island. Give me a minute."); + sm.removeItem(4000030, 2); + sm.addExp(38900); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2213), 2); + sm.forceCompleteQuest(22574); + } + @Script("q22575s") public static void q22575s(ScriptManager sm) { // Secret Organization's Second Mission (22575 - start) @@ -1311,6 +3625,53 @@ public static void q22575s(ScriptManager sm) { sm.warp(211000001, "out01"); } + @Script("q22576s") + public static void q22576s(ScriptManager sm) { + // Delivering the Black Key (22576 - start) + sm.sayNext("Looks like you've received something from #p2022003#. Thanks for your work. Now all you have to do is deliver that package to me. Oh, you don't need to come all the way to see me in person, though."); + if (!sm.askYesNo("Have you ever heard of the #b#m211040400##k in #m211000000#? There's a #b#p2030015##k at the #m211040400#. Just go ahead and put the item under it, and another member will come and retrieve it.")) { + sm.sayOk("Hmm. Alright, I'll assume you are very busy at this time. But don't make me wait for too long."); + return; + } + sm.forceStartQuest(22576); + } + + @Script("q22576e") + public static void q22576e(ScriptManager sm) { + // Delivering the Black Key (22576 - end) + sm.sayNext("#b(There is a tree stump that looks rather suspicious. When you reach into it, you notice that it is hollow, as if man-made.)#k"); + sm.sayBoth("#b(You push the key into the hollow space inside the tree stump.)#k"); + sm.removeItem(4032471); + sm.addExp(7400); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2213), 1); + sm.forceCompleteQuest(22576); + sm.sayOk("(The stump looks just like any other tree stump.)"); + } + + @Script("q22577s") + public static void q22577s(ScriptManager sm) { + // The Lost Black Key (22577 - start) + sm.sayNext("Oh no, it seems you've lost the item. This affects the entire organization! Alright, I'll let this one go, but you're going to have to collect another #t4000069# right away."); + if (!sm.askYesNo("Well, it's just like last time... No, actually, that's not going to be enough. This time, you're going to have to collect #b300 #t4000069#s#k and deliver them to #b#p2022003##k. Only then will #p2022003# give you the item again.")) { + sm.sayOk("Honestly, one mistake is enough. If you are making yet another mistake by refusing to take on this assignment, then I just don't know if you'll be excused again."); + return; + } + sm.forceStartQuest(22577); + } + + @Script("q22577e") + public static void q22577e(ScriptManager sm) { + // The Lost Black Key (22577 - end) + sm.sayOk("Hehe. So you want me to give you the item again? Alright. I've made like 10 copies of the key anyway. Kekeke!"); + sm.removeItem(4000069, 300); + if (!sm.addItem(4032471, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceCompleteQuest(22577); + sm.sayOk("Kekeke! Such a dark force..."); + } + @Script("q22578s") public static void q22578s(ScriptManager sm) { // Question about the Secret Organization (22578 - start) @@ -1342,6 +3703,54 @@ public static void q22578s(ScriptManager sm) { sm.addExp(30000); } + @Script("q22579s") + public static void q22579s(ScriptManager sm) { + // Completed Sail (22579 - start) + sm.sayNext("Look here, Evan! The sail made of #t4000030# is finally done. You can now sail to the island on your map without worrying about hard-hitting gale winds! Hahaha! If you want to go, come to #b#m104000000##k right away!"); + if (!sm.askYesNo("You'd better hurry before I change my mind! Hahahaha!")) { + sm.sayOk("Huh? You're not going to go to the island after all that? Could we use the sail for something else then?"); + return; + } + sm.forceStartQuest(22579); + } + + @Script("q22579e") + public static void q22579e(ScriptManager sm) { + // Completed Sail (22579 - end) + sm.sayOk("Oh, that was quick! Ready to set sail? Is there anything else you want to do before taking off? It's going to take at least 15 minutes to get to the island, so if there's anything you need to do before setting sail, take care of it now!"); + sm.addExp(5200); + sm.forceCompleteQuest(22579); + sm.sayOk("If you'd like to go to the island on the map, just come and let me know."); + } + + @Script("q22580s") + public static void q22580s(ScriptManager sm) { + // Slumbering Dragon Island (22580 - start) + sm.sayNext("Master! Master! Something's strange. I don't hear anything, not even birds chirping, squirrels running, or leaves rustling in the wind! Seriously, all I hear are the waves. I would think that such quiet would make me anxious, but I kind of feel rather relaxed."); + if (!sm.askYesNo("There's something in the middle of the island, I can feel it. Something familiar, yet new! Maybe there's another dragon! Master, let's go check it out! I wonder if it's something related to Onyx Dragons!")) { + sm.sayOk("It's no fun when your hands play butterfingers at a time like this. C'mon, master!"); + return; + } + sm.setQRValue(22599, "1"); + sm.forceStartQuest(22580); + sm.sayOk("Please hurry, master!"); + } + + @Script("q22580e") + public static void q22580e(ScriptManager sm) { + // Slumbering Dragon Island (22580 - end) + final int answer = sm.askMenu("Wait. What's going on? I think that #o9300391# might be made of magic. Another being of my race...right at my fingertips. So close and yet so far!", java.util.Map.of( + 0, "Perhaps give it one more try?" + )); + sm.sayNext("No, I don't think it'll be any use. You don't want to do anything that might hurt a fellow dragon on the other side."); + sm.sayBoth("Doesn't look like you can get to the other side unless you break the #o9300391#. It doesn't look like it will break easily, either. Sigh, let's just keep training. Perhaps a clue will come to us eventually."); + sm.addExp(55200); + sm.getUser().getCharacterStat().getSp().addSp(JobConstants.getJobLevel(2214), 1); + sm.setQRValue(22599, "2"); + sm.forceCompleteQuest(22580); + sm.sayOk("Yeah, let's get stronger and come back to this island later, master!"); + } + @Script("q22581s") public static void q22581s(ScriptManager sm) { // Before Receiving the Secret Organization's Third Mission (22581 - start) @@ -1416,6 +3825,30 @@ public static void q22583s(ScriptManager sm) { ), "out00", 220011000, 60 * 10); } + @Script("q22584s") + public static void q22584s(ScriptManager sm) { + // Eliminating Door Blocks (22584 - start) + sm.sayNext("I am going to give you one last mission. Thanks to all your great work, one of our members has unlocked the First Door of #m922030011#."); + if (!sm.askYesNo("You must now go back up to #m922030020# and re-enter the Safe. Go in there and eliminate #r#o9300390##k, the monster.")) { + sm.sayOk("What? What's the matter? Are you unhappy about something that I've discussed about our plans?"); + return; + } + sm.forceStartQuest(22584); + sm.sayOk("#o9300390# is a scary, scary monster. You're going to have to eliminate him as fast as you can or we'll all be in danger. Please hurry."); + } + + @Script("q22584e") + public static void q22584e(ScriptManager sm) { + // Eliminating Door Blocks (22584 - end) + final int answer = sm.askMenu("Thank you for getting rid of #o9300390#. You've done some great work. You must be tired now. Why don't you get some rest. Another member will take it from here.", java.util.Map.of( + 0, "#o9300390# said something strange." + )); + sm.sayOk("Really? Well now, I wouldn't worry about it. #o9300390# may seem stupid, but it's actually really conniving and tries to confuse people. Don't think too much about it."); + sm.addExp(64900); + sm.forceCompleteQuest(22584); + sm.sayOk("I will talk to you again when there's another mission for you."); + } + @Script("q22585s") public static void q22585s(ScriptManager sm) { // Suspicions about the Secret Organization (22585 - start) @@ -1434,6 +3867,29 @@ public static void q22585s(ScriptManager sm) { sm.sayOk("So, the Black Wings... I don't want to be suspicious of them, but I can't help it..."); } + @Script("q22586s") + public static void q22586s(ScriptManager sm) { + // Secret Organization's Fourth Mission (22586 - start) + sm.sayNext("I didn't think I'd be seeing you so soon. I've actually received intel a bit earlier than expected. So now I have your fourth mission."); + sm.sayBoth("The fourth mission is to retrieve the map of an island. Don't worry, it won't be too difficult to find. #b#p2092001##k in #b#m251000000##k has the map, so all you need to do is get it from him."); + sm.sayBoth("Just tell #p2092001# that you need the #b#t4032472##k and he'll know what you're talking about. I'll tell you what the map is for after you bring it to me."); + if (!sm.askYesNo("Hehe... What luck! I had no idea that I was standing next to #rsomeone with an Onyx Dragon#k. Hmm. No, it's nothing. Just go ahead and complete the mission.")) { + sm.sayOk("What, you're refusing? Oh no, oh no. Why is everyone being so defiant today?"); + return; + } + sm.forceStartQuest(22586); + } + + @Script("q22586e") + public static void q22586e(ScriptManager sm) { + // Secret Organization's Fourth Mission (22586 - end) + sm.sayOk("You've brought the #t4032472#? Show it to me. I want to make sure it is the one that we are looking for."); + sm.removeItem(4032472); + sm.addExp(68100); + sm.forceCompleteQuest(22586); + sm.sayOk("Haha. Looks like you've got the right one."); + } + @Script("q22587s") public static void q22587s(ScriptManager sm) { // Map of Turtle Island (22587 - start) @@ -1461,6 +3917,86 @@ public static void q22587s(ScriptManager sm) { sm.warpInstance(925110001, "out00", 251000000, 60 * 30); } + @Script("q22588s") + public static void q22588s(ScriptManager sm) { + // Secret Organization's Fifth Mission (22588 - start) + sm.sayNext("Ha! If this is the exact location, we can open a portal there with magic. Evan, I would like to give you your fifth mission now. This one should be easy."); + sm.sayBoth("All you have to do is go through the portal and you'll get to Turtle Island. You will find an altar there. #bPlace a certain item on the altar#k. Just #bdrop#k it onto it. Then, #rleave the cave quickly#k! I warn you. Don't linger behind..."); + sm.sayBoth("#rSome evil being has put a magic spell on the island, which keeps the members of our organization, including me, from entering#k. Since you are immune to that spell, we are relying on you to handle this mission."); + if (!sm.askYesNo("What do you say? If you accept this mission, I will give you the item right now.")) { + sm.sayOk("What? You're refusing to accept this mission? But we have never needed you more! Please, think about it!"); + return; + } + if (!sm.addItem(4032473, 1)) { + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceStartQuest(22588); + sm.sayOk("You received the item, right? Now, go through the portal. You will immediately be taken to Turtle Island."); + } + + @Script("q22588e") + public static void q22588e(ScriptManager sm) { + // Secret Organization's Fifth Mission (22588 - end - auto-complete) + sm.sayNext("You destroyed the #o9300391#! I knew it. Someone with an Onyx Dragon would of course be able to do it. Hehehe. Oh, no, don't mind me. Please leave the island at once. You will see a boat just outside."); + sm.setQRValue(22605, "1"); + sm.forceCompleteQuest(22588); + sm.sayOk("Huh? A Dragon sleeping on the island? That is not something you should concern yourself with! Do not let the Dragon see you. You must get off the island immediately!"); + } + + @Script("q22589s") + public static void q22589s(ScriptManager sm) { + // Dangerous Premonition (22589 - start) + sm.sayNext("Master! Something is seriously wrong! Why is #p1013203# interested in the island where my fellow creature sleeps and why does he want you to destroy the #o9300391#? How is all this supposed to help Maple World?"); + if (!sm.askYesNo("I have a bad feeling about this. Something is not right! We must not leave the island right now, master! #bLet's go back into the cave!#k! I need to find out what is going on!")) { + sm.sayOk("Master! Don't let your fingers slip at a time like this! Come on! This is important stuff!"); + return; + } + sm.setQRValue(22600, "1"); + sm.forceStartQuest(22589); + sm.sayOk("Let's go back to the cave!"); + } + + @Script("q22589e") + public static void q22589e(ScriptManager sm) { + // Dangerous Premonition (22589 - end) + sm.sayOk("I wonder what's happening. Master, why is #p1013203# trying to attack my fellow creature? And why is he saying that he doesn't need you anymore? I mean, why is he attacking us?"); + sm.addExp(68100); + sm.setQRValue(22604, "1"); + sm.forceCompleteQuest(22589); + sm.forceStartQuest(22590); + sm.sayOk("I have no idea."); + } + + @Script("q22590s") + public static void q22590s(ScriptManager sm) { + // Voice of the Sleeping Dragon (22590 - start) + sm.sayNext("Ma...master! Did you hear that? But you just called me! You didn't call me? Was that a Dragon...? Wha... Listen! There it is again! You don't hear that, master? Master! Try talking to that sleeping dragon over there!"); + if (!sm.askYesNo("")) { + sm.sayOk("Don't tell me you're scared of the Dragon? It may be big, but I can tell it's really sweet. I know the Dragon won't hurt you!"); + return; + } + sm.forceStartQuest(22590); + sm.sayOk("Maybe the Dragon's awake?! You should go talk to it!"); + } + + @Script("q22590e") + public static void q22590e(ScriptManager sm) { + // Voice of the Sleeping Dragon (22590 - end) + final int answer1 = sm.askMenu("Are you the child's master?", java.util.Map.of( + 0, "Uh, er, if you want to put it that way. Yes. Are you...an Onyx Dragon? What is your name?" + )); + final int answer2 = sm.askMenu("I am. My name is #p1205000#. I am the king of the Onyx Dragons of the past... The now extinct Onyx Dragons.", java.util.Map.of( + 0, "You are the king of the Onyx Dragons? But how did the Onyx Dragons become extinct? And why are you trapped in ice?" + )); + if (!sm.askYesNo("To tell you all that...I need to go back in time hundreds of years. It is a long and sad story.")) { + return; + } + sm.addExp(20000); + sm.forceCompleteQuest(22590); + sm.sayOk("Are you ready to hear the story?"); + } + @Script("q22591s") public static void q22591s(ScriptManager sm) { // The Past, Onyx Dragons, Black Mage (22591 - start) @@ -1475,6 +4011,42 @@ public static void q22591s(ScriptManager sm) { sm.warp(900030000, "sp"); } + @Script("q22592s") + public static void q22592s(ScriptManager sm) { + // Unavoidable Truth (22592 - start) + final int answer1 = sm.askMenu("Master, master! I don't understand! You and that Dragon...#p1205000# just stared at each other with blank looks and then started talking. He said something about sending you into his memory. Did you really see his past?", java.util.Map.of( + 0, "Yeah... I heard a conversation between #p1205000# and Freud from hundreds of years ago." + )); + final int answer2 = sm.askMenu("What happened and how is this related to the Black Mage? Isn't the Black Mage the great person that #p1013203# said needed to be revived for the good of Maple World? Tell me what happened!", java.util.Map.of( + 0, "The Black Mage was an evil being who tried to conquer Maple World hundreds of years ago. He realized the great power that the Onyx Dragons held and told them he would complete their incomplete spirits if they betrayed their masters and follow him." + )); + final int answer3 = sm.askMenu("What? That's impossible! A Dragon and his master are one! There is no way that they would betray their masters!", java.util.Map.of( + 0, "Yes, the Onyx Dragons did not betray their masters. They loved humans and hated the Black Mage, who was evil incarnate. They knew that a spirit completed by the Black Mage would also become evil. That is when the Black Mage...destroyed the Onyx Dragons." + )); + final int answer4 = sm.askMenu("How...how could that be? That's why my entire race is extinct?! That's why #p1205000# is trapped in ice...?", java.util.Map.of( + 0, "After the Onyx Dragons were annihilated, #p1205000# and his master, along with the other heroes, fought the Black Mage to the end. Ultimately, the Black Mage was sealed away and Maple World regained its former peace. However, #p1205000# was trapped in ice by the Black Mage's final curse." + )); + sm.sayNext("So everything was caused by the Black Mage? Then what about all the things that we've been doing? I thought they were all for the good of Maple World! Were we wrong? Master! Let's go find out!"); + if (!sm.askYesNo("")) { + return; + } + sm.sayNext("The things we did in #m200000000#, the things we did in #m211000000#, and the things we did in #m220000000#... All the missions the Black Wings gave us..."); + sm.forceStartQuest(22592); + sm.sayOk("Let's look at the consequences of what we've accomplished. #bLet's go to each town and ask#k. If we were tricked by the Black Wings... I won't forgive them!"); + } + + @Script("q22592e") + public static void q22592e(ScriptManager sm) { + // Unavoidable Truth (22592 - end) + sm.sayNext("The Growth Accelerant that we thought would make the crops grow caused problems for #m200000000# by making the #o4230105#s grow abnormally."); + sm.sayBoth("We thought we were helping people by eliminating Zombies, but we were only gathering the material that would be used to trade for a stolen #t4032471#."); + sm.sayBoth("We defeated a monster who was guarding an important treasure in #m220000000#, causing the treasure to get stolen."); + sm.addExp(10000); + sm.forceCompleteQuest(22592); + sm.forceStartQuest(22596); + sm.sayOk("This confirms it. The Black Wings were only using us. They made us perform evil tasks so that they could revive the Black Mage! I can't forgive them!"); + } + @Script("q22593s") public static void q22593s(ScriptManager sm) { // Result of the First Mission (22593 - start) @@ -1529,6 +4101,34 @@ public static void q22595s(ScriptManager sm) { sm.sayBoth("Uh... Why are you making such a scary face? Please remember, this issue must be kept a secret so please watch what you say!"); } + @Script("q22596s") + public static void q22596s(ScriptManager sm) { + // Rage (22596 - start) + sm.sayNext("All of the things that the Black Wings had us do...were for evil! We wanted to help people but instead, we ended up causing them more problems!"); + if (!sm.askYesNo("Master, let's go to the #b#m922030001##k in #m220000300#. The house where we met #p1013203# in person. Let's go there and teach #r#p1013203##k a lesson! How could he trick us like this?!")) { + sm.sayOk("Master, I think you are too cautious at times. We have every right to be angry!"); + return; + } + sm.forceStartQuest(22596); + } + + @Script("q22596e") + public static void q22596e(ScriptManager sm) { + // Rage (22596 - end) + final int answer1 = sm.askMenu("Darn it... We just about had him but he ran away. Then again, a guy who could trick us so many times is probably great at running away. He probably won't show up here again.", java.util.Map.of( + 0, "Yeah, you're right. Geez... He was such a suspicious guy. We were so foolish to have believed him!" + )); + final int answer2 = sm.askMenu("Well, we could've been more careful. But I still think the deceiver deserves more blame than the deceived!", java.util.Map.of( + 0, "Of course!" + )); + final int answer3 = sm.askMenu("They said that their ultimate goal was to revive the Black Mage, the being that destroyed my race and trapped #p1205000# in ice... Master, if I said I wanted to take revenge on the Black Mage, would you help me?", java.util.Map.of( + 0, "Of course!! Personal reasons aside, the Black Mage is an evil being who wants to destroy Maple World. It's only right that an Onyx Dragon and his master stop him!!" + )); + sm.addExp(68100); + sm.forceCompleteQuest(22596); + sm.sayOk("Yeah! Master, you and I are on the same page! We lost to #p1013203# this time but next time we'll get him. We won't let the Black Wings revive the Black Mage! For the Onyx Dragons and for Maple World!"); + } + @Script("q22602s") public static void q22602s(ScriptManager sm) { // After Shedding 1 (22602 - start) @@ -1592,4 +4192,68 @@ public static void q22603s(ScriptManager sm) { sm.forceCompleteQuest(22603); sm.sayOk("Master, you should use this scale to make something useful that would reduce the damage you take when hit by a monster. You'll get stronger, which means that I'll get stronger. Sounds good to me!"); } + + + // ADDITIONAL EVAN QUESTS (23900-23968) ------------------------------------------------------------------------------------------------------------ + // Placeholder scripts for quests without detailed XML information + + @Script("q23903e") + public static void q23903e(ScriptManager sm) { + // Quest 23903 - Evan Quest (END) + sm.forceCompleteQuest(23903); + } + + @Script("q23907s") + public static void q23907s(ScriptManager sm) { + // Quest 23907 - Evan Quest (START) + sm.forceStartQuest(23907); + } + + @Script("q23909s") + public static void q23909s(ScriptManager sm) { + // Quest 23909 - Evan Quest (START) + sm.forceStartQuest(23909); + } + + @Script("q23928s") + public static void q23928s(ScriptManager sm) { + // Quest 23928 - Evan Quest (START) + sm.forceStartQuest(23928); + } + + @Script("q23961e") + public static void q23961e(ScriptManager sm) { + // Quest 23961 - Evan Quest (END) + sm.forceCompleteQuest(23961); + } + + @Script("q23961s") + public static void q23961s(ScriptManager sm) { + // Quest 23961 - Evan Quest (START) + sm.forceStartQuest(23961); + } + + @Script("q23963e") + public static void q23963e(ScriptManager sm) { + // Quest 23963 - Evan Quest (END) + sm.forceCompleteQuest(23963); + } + + @Script("q23963s") + public static void q23963s(ScriptManager sm) { + // Quest 23963 - Evan Quest (START) + sm.forceStartQuest(23963); + } + + @Script("q23968e") + public static void q23968e(ScriptManager sm) { + // Quest 23968 - Evan Quest (END) + sm.forceCompleteQuest(23968); + } + + @Script("q23968s") + public static void q23968s(ScriptManager sm) { + // Quest 23968 - Evan Quest (START) + sm.forceStartQuest(23968); + } } diff --git a/src/main/java/kinoko/script/quest/EvanTutorial.java b/src/main/java/kinoko/script/quest/EvanTutorial.java index 73d52324..2b12c9df 100644 --- a/src/main/java/kinoko/script/quest/EvanTutorial.java +++ b/src/main/java/kinoko/script/quest/EvanTutorial.java @@ -7,6 +7,7 @@ import kinoko.world.quest.QuestRecordType; import java.util.List; +import java.util.Map; public final class EvanTutorial extends ScriptHandler { @Script("evanAlone") @@ -452,6 +453,18 @@ public static void q22001s(ScriptManager sm) { sm.sayNext("Hurry up and head #bleft#k to feed #b#p1013102##k. He's been barking to be fed all morning."); } + @Script("q22001e") + public static void q22001e(ScriptManager sm) { + // Feeding Bull Dog (22001 - end) + sm.setPlayerAsSpeaker(true); + sm.sayNext("#b(You place food in #p1013102#'s bowl.)"); + sm.sayBoth("#b(#p1013102# is totally sweet. #p1013101# is just a coward.)"); + sm.sayBoth("#b(Looks like #p1013102# has finished eating. Return to #p1013101# and let him know.)\r\n\r\n#fUI/UIWindow2.img/QuestIcon/4/0#\r\n#fUI/UIWindow2.img/QuestIcon/8/0# 35 exp"); + sm.removeItem(4032447, 1); + sm.addExp(35); + sm.forceCompleteQuest(22001); + } + @Script("q22002s") public static void q22002s(ScriptManager sm) { // Sandwich for Breakfast (22002 - start) @@ -504,6 +517,24 @@ public static void q22003s(ScriptManager sm) { sm.sayImage(List.of("UI/tutorial/evan/5/0")); } + @Script("q22003e") + public static void q22003e(ScriptManager sm) { + // Delivering the Lunch Box (22003 - end) + sm.sayNext("Oh, Evan! What are you doing here? Are you here to help your man? Hey, that's a Lunch Box you've got there!"); + sm.sayBoth("Ah, I knew I was missing something! I always am, it seems. Today it was my #t4032448#, yesterday it was my hat, and the day before it was my shoes. I'm getting so forgetful!"); + sm.sayBoth("In any case, since you're here, will you do me a favor?\r\n\r\n#fUI/UIWindow2.img/QuestIcon/4/0#\r\n#i2022621# 10 #t2022621#\r\n#i2022622# 10 #t2022622#\r\n#fUI/UIWindow2.img/QuestIcon/8/0# 175 exp"); + sm.removeItem(4032448, 1); + if (!sm.addItems(List.of( + Tuple.of(2022621, 10), // Tasty Milk + Tuple.of(2022622, 10) // Squeezed Juice + ))) { + sm.sayNext("Please check if your inventory is full or not."); + return; + } + sm.addExp(175); + sm.forceCompleteQuest(22003); + } + @Script("q22004s") public static void q22004s(ScriptManager sm) { // Fixing the Fence (22004 - start) @@ -536,6 +567,88 @@ public static void q22004e(ScriptManager sm) { sm.sayImage(List.of("UI/tutorial/evan/7/0")); } + @Script("q22005s") + public static void q22005s(ScriptManager sm) { + // Rescuing the Piglet (22005 - start) + sm.sayNext("Oh no! A #b#p1013200##k ran away while the fence was broken. He's too young to find his way home, so we'll have to go find him. Do you think you can help me?"); + if (!sm.askAccept("I think the #p1013200# ran towards the #b#m900020100##k. Please head there to look for the #p1013200#.")) { + sm.sayNext("Hmm. #p1013101# would have volunteered to do it even before I asked."); + return; + } + sm.forceStartQuest(22005); + sm.sayNext("The Lush Forest is towards the #bupper left#k. The recent flood washed away much of the path, so be careful."); + } + + @Script("q22005e") + public static void q22005e(ScriptManager sm) { + // Rescuing the Piglet (22005 - end) + final int answer = sm.askMenu("That took a while. The #p1013200# must ran pretty far.", Map.of( + 0, "Er, yeah. Sure... Dad, is there a strange foggy forest around here?" + )); + sm.sayNext("A foggy forest? I don't think so. It's always clear around Henesys."); + sm.setPlayerAsSpeaker(true); + sm.sayBoth("#b(Strange. Did you have another dream? What's going on... )"); + sm.setPlayerAsSpeaker(false); + sm.sayBoth("Anyway, thanks for your help. Er, Evan? What has you so lost in thought?"); + sm.setPlayerAsSpeaker(true); + sm.sayBoth("#b(Wait, what's this? This is the egg from earlier! Then...it wasn't a dream?!)"); + sm.setPlayerAsSpeaker(false); + sm.sayBoth("Evan?"); + sm.setPlayerAsSpeaker(true); + sm.sayBoth("#bDad! Quick! How do I get an egg to hatch?!"); + sm.setPlayerAsSpeaker(false); + sm.sayBoth("Whoa! You scared me! You want to know how to hatch an egg? Why are you asking such a stange question...?"); + sm.sayBoth("I don't know how to hatch an egg... Maybe your mother would know.\r\n\r\n#fUI/UIWindow2.img/QuestIcon/4/0#\r\n#fUI/UIWindow2.img/QuestIcon/8/0# 700 exp"); + sm.removeItem(4032449, 1); + sm.addExp(700); + sm.forceCompleteQuest(22005); + } + + @Script("q22006s") + public static void q22006s(ScriptManager sm) { + // Returning the Empty Lunch Box (22006 - start) + sm.sayNext("If you want to learn about hatching eggs, you should head #bhome#k and ask #bMom#k. She raises all our chickens, so she'd know. Also..."); + if (!sm.askAccept("Since you're going home, return this #b#t4032450##k to your mother. I have so much work to do, I may not get home until late tonight.")) { + sm.sayNext("Hmm. #p1013101# would have been more than willing..."); + return; + } + if (!sm.addItem(4032450, 1)) { // Empty Lunch Box + sm.sayOk("Please check if your inventory is full or not."); + return; + } + sm.forceStartQuest(22006); + sm.sayNext("Thanks. See you later, kiddo."); + } + + @Script("q22006e") + public static void q22006e(ScriptManager sm) { + // Returning the Empty Lunch Box (22006 - end) + sm.sayNext("Evan, you're back? Ah, you brought back the #t4032450#. You're such a good kid. Huh? How do you raise an egg?"); + sm.sayBoth("There are many ways, but the simplest is to use an Incubator. Come to think of it, I think I saw #b#p1013101##k with one. Why don't you ask #p1013101# to lend it to you?\r\n\r\n#fUI/UIWindow2.img/QuestIcon/4/0#\r\n#i2022621# 20 #t2022621#\r\n#i2022622# 20 #t2022622#\r\n#fUI/UIWindow2.img/QuestIcon/8/0# 270 exp"); + sm.removeItem(4032450, 1); + if (!sm.addItems(List.of( + Tuple.of(2022621, 20), // Tasty Milk + Tuple.of(2022622, 20) // Squeezed Juice + ))) { + sm.sayNext("Please check if your inventory is full or not."); + return; + } + sm.addExp(270); + sm.forceCompleteQuest(22006); + } + + @Script("q22007s") + public static void q22007s(ScriptManager sm) { + // Collecting Eggs (22007 - start) + sm.sayNext("An Incubator? Yeah, I have one. I picked it up after an adventurer tossed it a while ago. It should still work. Why? You need it?"); + if (!sm.askAccept("Okay, you can have the Incubator, but you have to do me a favor first. Mom wants me to collect some #t4032451#s, but it's such a bother. If you collect an #t4032451# for me, I'll give you the Incubator. Do we have a deal?")) { + sm.sayNext("Fine. I'll just keep the Incubator, then."); + return; + } + sm.forceStartQuest(22007); + sm.sayNext("Okay, then go to that #b#p1013104# to your right#k and bring back an #t4032451#. You can get an #t4032451# by clicking on the #p1013104#. You just need to get me #bone#k."); + } + @Script("q22007e") public static void q22007e(ScriptManager sm) { // Collecting Eggs (22007 - end) @@ -599,4 +712,52 @@ public static void q22008e(ScriptManager sm) { sm.sayNext("#bThis is a weapon that Magicians use. It's a Wand#k. You probably won't really need it, but it'll make you look important if you carry it around. Hahahahaha."); sm.sayPrev("Anyway, the Foxes have increased, right? How weird is that? Why are they growing day by day? We should really look into it and get to the bottom of this."); } + + @Script("q22009s") + public static void q22009s(ScriptManager sm) { + // Verifying the Farm Situation (22009 - start) + sm.sayNext("If the number of foxes has increased near the farm just like it has near our house, that'll interfere with Dad's farm work. We should investigate this. Don't you agree?"); + if (!sm.askAccept("Go to the #b#m100030300##k and ask #bDad#k about the situation. If the number of #o9300385#es haa increased there as well, we're going to have to conduct a major #o9300385# hunt.")) { + sm.sayNext("What? Think hard about this! If the farm fails, what are we going to survive on! Huh? Talk to me again and press ACCEPT this time!"); + return; + } + sm.forceStartQuest(22009); + } + + @Script("q22009e") + public static void q22009e(ScriptManager sm) { + // Verifying the Farm Situation (22009 - end) + sm.sayNext("What is it, Evan? I'm sure you're not here to deliver another #t4032448#, and I'm too busy to play with you... What? Have the number of foxes increased here?"); + sm.sayBoth("Well, I'm not sure. I've been too busy to notice. The #b#o1210100##ks have been acting crazy, jumping all over the place. Even the foxes seem to be running away from the #o1210100#s..."); + sm.sayBoth("Ah, maybe that is why the #o9300385# population near the house has increased. They ran there to escape from the #o1210100#s. Hmm...\r\n\r\n#fUI/UIWindow2.img/QuestIcon/4/0#\r\n#fUI/UIWindow2.img/QuestIcon/8/0# 260 exp"); + sm.addExp(260); + sm.forceCompleteQuest(22009); + } + + @Script("q22010s") + public static void q22010s(ScriptManager sm) { + // Strange Farm (22010 - start) + sm.sayNext("Forget about the #o9300385#es. Since you're here, want to help me out again? I think the only way to calm the #o1210100#s is by disciplining them. Why don't you go take care of a few of the #r#o1210100#s#k?"); + if (!sm.askAccept("The crazy pigs can be found starting at the #b#m100030310##k. Head over and take care of just #r20#k of them. Hey, kiddo, you've really become a huge help to me.")) { + sm.sayNext("Huh? Are you scared of the #o1210100#s? They are jumping around like crazy, but you shouldn't be scared of them..."); + return; + } + sm.forceStartQuest(22010); + } + + @Script("q22010e") + public static void q22010e(ScriptManager sm) { + // Strange Farm (22010 - end) + sm.sayNext("Oh, you disciplined the #o1210100#s. Good job! Thank you."); + sm.sayBoth("Now I'll just get back to work.\r\n\r\n#fUI/UIWindow2.img/QuestIcon/4/0#\r\n#i2022621# 30 #t2022621#\r\n#i2022622# 30 #t2022622#\r\n#fUI/UIWindow2.img/QuestIcon/8/0# 980 exp"); + if (!sm.addItems(List.of( + Tuple.of(2022621, 30), // Tasty Milk + Tuple.of(2022622, 30) // Squeezed Juice + ))) { + sm.sayNext("Please check if your inventory is full or not."); + return; + } + sm.addExp(980); + sm.forceCompleteQuest(22010); + } } diff --git a/src/main/java/kinoko/script/quest/EventQuest.java b/src/main/java/kinoko/script/quest/EventQuest.java new file mode 100644 index 00000000..126abc96 --- /dev/null +++ b/src/main/java/kinoko/script/quest/EventQuest.java @@ -0,0 +1,4476 @@ +package kinoko.script.quest; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +/** + * Event Quest System + * Covers quests 10000-10999 range for event quests + */ +public final class EventQuest extends ScriptHandler { + + + @Script("q10002e") + public static void q10002e(ScriptManager sm) { + // Quest 10002 - Number of Special Agent Badges (END) + // NPC: 9000034 + + final int QUEST_ITEM_4031988 = 4031988; + + if (!sm.hasItem(QUEST_ITEM_4031988, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031988, 1); + sm.forceCompleteQuest(10002); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10008s") + public static void q10008s(ScriptManager sm) { + // Quest 10008 - Information on Master M (START) + // NPC: 9000033 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10008); + sm.addItem(4001192, 5); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10010e") + public static void q10010e(ScriptManager sm) { + // Quest 10010 - Special Order: Find Master M's Orders! (END) + // NPC: 9000036 + + final int QUEST_ITEM_4031999 = 4031999; + + if (!sm.hasItem(QUEST_ITEM_4031999, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031999, 20); + sm.forceCompleteQuest(10010); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10011e") + public static void q10011e(ScriptManager sm) { + // Quest 10011 - Special Order: Find Master M's Orders! (END) + // NPC: 9000036 + + final int QUEST_ITEM_4032000 = 4032000; + + if (!sm.hasItem(QUEST_ITEM_4032000, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032000, 20); + sm.forceCompleteQuest(10011); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10012e") + public static void q10012e(ScriptManager sm) { + // Quest 10012 - Special Order: Find Master M's Orders! (END) + // NPC: 9000036 + + final int QUEST_ITEM_4032001 = 4032001; + + if (!sm.hasItem(QUEST_ITEM_4032001, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032001, 20); + sm.forceCompleteQuest(10012); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10014s") + public static void q10014s(ScriptManager sm) { + // Quest 10014 - Today's Mission! (START) + // NPC: 9000036 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10014); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10034s") + public static void q10034s(ScriptManager sm) { + // Quest 10034 - 추석맞이 달나라 여행 (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10034); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10036e") + public static void q10036e(ScriptManager sm) { + // Quest 10036 - 달은 어떻게 생겼나요 (END) + // NPC: 9001102 + + final int QUEST_ITEM_4220067 = 4220067; + + if (!sm.hasItem(QUEST_ITEM_4220067, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4220067, 1); + sm.forceCompleteQuest(10036); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10036s") + public static void q10036s(ScriptManager sm) { + // Quest 10036 - 달은 어떻게 생겼나요 (START) + // NPC: 9001102 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10036); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10037e") + public static void q10037e(ScriptManager sm) { + // Quest 10037 - 카산드라의 도움 (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10037); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10039e") + public static void q10039e(ScriptManager sm) { + // Quest 10039 - Surprise Event: Special Alphabet (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10039); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10043s") + public static void q10043s(ScriptManager sm) { + // Quest 10043 - 달꽃떡 받기 (START) + // NPC: 9001101 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10043); + sm.addItem(4032036, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10046s") + public static void q10046s(ScriptManager sm) { + // Quest 10046 - Taking Care of Baby Bird (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10046); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10050e") + public static void q10050e(ScriptManager sm) { + // Quest 10050 - Baby Bird Feather (END) + // NPC: 9000042 + + final int QUEST_ITEM_4032066 = 4032066; + + if (!sm.hasItem(QUEST_ITEM_4032066, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032066, 1); + sm.forceCompleteQuest(10050); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10052s") + public static void q10052s(ScriptManager sm) { + // Quest 10052 - 메이플 2000일의 이야기 (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10052); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10059s") + public static void q10059s(ScriptManager sm) { + // Quest 10059 - Gaga's Favorite Song (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10059); + sm.addItem(4001202, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10066e") + public static void q10066e(ScriptManager sm) { + // Quest 10066 - 과거의 기억 (END) + // NPC: 2120005 + + final int QUEST_ITEM_2022256 = 2022256; + + if (!sm.hasItem(QUEST_ITEM_2022256, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022256, 20); + sm.forceCompleteQuest(10066); + sm.addItem(2022256, -20); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10069e") + public static void q10069e(ScriptManager sm) { + // Quest 10069 - 유령 T는 누구일까? (END) + // NPC: 2120008 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10069); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10074s") + public static void q10074s(ScriptManager sm) { + // Quest 10074 - 페토 변신 (START) + // NPC: 2120000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10074); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10075e") + public static void q10075e(ScriptManager sm) { + // Quest 10075 - 조나스의 뉘우침 (END) + // NPC: 2120004 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10075); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10076e") + public static void q10076e(ScriptManager sm) { + // Quest 10076 - 소필리아의 뉘우침 (END) + // NPC: 2120005 + + final int QUEST_ITEM_4032084 = 4032084; + + if (!sm.hasItem(QUEST_ITEM_4032084, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032084, 1); + sm.forceCompleteQuest(10076); + sm.addItem(4032084, -1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10077e") + public static void q10077e(ScriptManager sm) { + // Quest 10077 - 루드밀라의 뉘우침 (END) + // NPC: 2120006 + + final int QUEST_ITEM_4032088 = 4032088; + + if (!sm.hasItem(QUEST_ITEM_4032088, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032088, 1); + sm.forceCompleteQuest(10077); + sm.addItem(4032088, -1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10078e") + public static void q10078e(ScriptManager sm) { + // Quest 10078 - 집사의 뉘우침 (END) + // NPC: 2120002 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10078); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10079e") + public static void q10079e(ScriptManager sm) { + // Quest 10079 - 조이의 뉘우침 (END) + // NPC: 2120007 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10079); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10082s") + public static void q10082s(ScriptManager sm) { + // Quest 10082 - 메이플스토리 그린 캠페인 (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10082); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10083e") + public static void q10083e(ScriptManager sm) { + // Quest 10083 - 장로스탄의 응원 (END) + // NPC: 1012003 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10083); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10084e") + public static void q10084e(ScriptManager sm) { + // Quest 10084 - 피아의 응원 (END) + // NPC: 1012102 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10084); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10085e") + public static void q10085e(ScriptManager sm) { + // Quest 10085 - 에스텔의 응원 (END) + // NPC: 1032105 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10085); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10086e") + public static void q10086e(ScriptManager sm) { + // Quest 10086 - 요정 윙의 응원 (END) + // NPC: 1032106 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10086); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10087e") + public static void q10087e(ScriptManager sm) { + // Quest 10087 - 천지의 응원 (END) + // NPC: 9000007 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10087); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10088e") + public static void q10088e(ScriptManager sm) { + // Quest 10088 - 이카루스의 응원 (END) + // NPC: 1052106 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10088); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10089e") + public static void q10089e(ScriptManager sm) { + // Quest 10089 - 만지의 응원 (END) + // NPC: 1022002 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10089); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10090e") + public static void q10090e(ScriptManager sm) { + // Quest 10090 - 돼지와 함께 춤을의 응원 (END) + // NPC: 1020000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10090); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10091e") + public static void q10091e(ScriptManager sm) { + // Quest 10091 - 리드의 응원 (END) + // NPC: 1092009 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10091); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10092e") + public static void q10092e(ScriptManager sm) { + // Quest 10092 - 베인의 응원 (END) + // NPC: 1092002 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10092); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10093e") + public static void q10093e(ScriptManager sm) { + // Quest 10093 - 기억하고 있는 자의 응원 (END) + // NPC: 1061011 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10093); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10094e") + public static void q10094e(ScriptManager sm) { + // Quest 10094 - 찰리중사의 응원 (END) + // NPC: 2010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10094); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10095e") + public static void q10095e(ScriptManager sm) { + // Quest 10095 - 스카두르의 응원 (END) + // NPC: 2020007 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10095); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10096e") + public static void q10096e(ScriptManager sm) { + // Quest 10096 - 티건의 응원 (END) + // NPC: 2101004 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10096); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10097e") + public static void q10097e(ScriptManager sm) { + // Quest 10097 - 세쟌의 응원 (END) + // NPC: 2101011 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10097); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10098e") + public static void q10098e(ScriptManager sm) { + // Quest 10098 - 필리아의 응원 (END) + // NPC: 2111004 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10098); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10099e") + public static void q10099e(ScriptManager sm) { + // Quest 10099 - 노공의 응원 (END) + // NPC: 2091000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10099); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10100e") + public static void q10100e(ScriptManager sm) { + // Quest 10100 - 도공의 응원 (END) + // NPC: 2091001 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10100); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10101e") + public static void q10101e(ScriptManager sm) { + // Quest 10101 - 구영감의 응원 (END) + // NPC: 2092000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10101); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10102e") + public static void q10102e(ScriptManager sm) { + // Quest 10102 - 촌장 타타모의 응원 (END) + // NPC: 2081000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10102); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10103e") + public static void q10103e(ScriptManager sm) { + // Quest 10103 - 지니의 응원 (END) + // NPC: 9000014 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10103); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10104e") + public static void q10104e(ScriptManager sm) { + // Quest 10104 - Book of Cygnus Vol. 1 (END) + // NPC: 9010010 + + final int QUEST_ITEM_2430000 = 2430000; + + if (!sm.hasItem(QUEST_ITEM_2430000, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2430000, 20); + sm.forceCompleteQuest(10104); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10105e") + public static void q10105e(ScriptManager sm) { + // Quest 10105 - Book of Cygnus Vol. 2 (END) + // NPC: 9010010 + + final int QUEST_ITEM_2430001 = 2430001; + + if (!sm.hasItem(QUEST_ITEM_2430001, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2430001, 20); + sm.forceCompleteQuest(10105); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10106e") + public static void q10106e(ScriptManager sm) { + // Quest 10106 - Book of Cygnus Vol. 3 (END) + // NPC: 9010010 + + final int QUEST_ITEM_2430002 = 2430002; + + if (!sm.hasItem(QUEST_ITEM_2430002, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2430002, 20); + sm.forceCompleteQuest(10106); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10108s") + public static void q10108s(ScriptManager sm) { + // Quest 10108 - 그린 캠페인 훈장 바꾸기 (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10108); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10110e") + public static void q10110e(ScriptManager sm) { + // Quest 10110 - 골드리치의 초대 (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10110); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10206e") + public static void q10206e(ScriptManager sm) { + // Quest 10206 - Remnants of Black Mage (END) + // NPC: 9010000 + + final int QUEST_ITEM_4001237 = 4001237; + + if (!sm.hasItem(QUEST_ITEM_4001237, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4001237, 20); + sm.forceCompleteQuest(10206); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10206s") + public static void q10206s(ScriptManager sm) { + // Quest 10206 - Remnants of Black Mage (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10206); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10207e") + public static void q10207e(ScriptManager sm) { + // Quest 10207 - Remnants of Black Mage (END) + // NPC: 9010000 + + final int QUEST_ITEM_4001238 = 4001238; + + if (!sm.hasItem(QUEST_ITEM_4001238, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4001238, 20); + sm.forceCompleteQuest(10207); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10207s") + public static void q10207s(ScriptManager sm) { + // Quest 10207 - Remnants of Black Mage (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10207); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10208e") + public static void q10208e(ScriptManager sm) { + // Quest 10208 - Remnants of Black Mage (END) + // NPC: 9010000 + + final int QUEST_ITEM_4001239 = 4001239; + + if (!sm.hasItem(QUEST_ITEM_4001239, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4001239, 20); + sm.forceCompleteQuest(10208); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10208s") + public static void q10208s(ScriptManager sm) { + // Quest 10208 - Remnants of Black Mage (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10208); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10209e") + public static void q10209e(ScriptManager sm) { + // Quest 10209 - Remnants of Black Mage (END) + // NPC: 9010000 + + final int QUEST_ITEM_4001240 = 4001240; + + if (!sm.hasItem(QUEST_ITEM_4001240, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4001240, 20); + sm.forceCompleteQuest(10209); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10209s") + public static void q10209s(ScriptManager sm) { + // Quest 10209 - Remnants of Black Mage (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10209); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10210s") + public static void q10210s(ScriptManager sm) { + // Quest 10210 - Gaga's Analysis (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10210); + sm.addItem(4001237, 20); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10211s") + public static void q10211s(ScriptManager sm) { + // Quest 10211 - Gaga's Analysis (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10211); + sm.addItem(4001238, 20); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10212s") + public static void q10212s(ScriptManager sm) { + // Quest 10212 - Gaga's Analysis (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10212); + sm.addItem(4001239, 20); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10213s") + public static void q10213s(ScriptManager sm) { + // Quest 10213 - Gaga's Analysis (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10213); + sm.addItem(4001240, 20); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10214s") + public static void q10214s(ScriptManager sm) { + // Quest 10214 - Cassandra's Analysis (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10214); + sm.addItem(4001237, 20); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10215s") + public static void q10215s(ScriptManager sm) { + // Quest 10215 - Gaga's Analysis (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10215); + sm.addItem(4001238, 20); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10216s") + public static void q10216s(ScriptManager sm) { + // Quest 10216 - Gaga's Analysis (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10216); + sm.addItem(4001239, 20); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10217s") + public static void q10217s(ScriptManager sm) { + // Quest 10217 - Gaga's Analysis (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10217); + sm.addItem(4001240, 20); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10218e") + public static void q10218e(ScriptManager sm) { + // Quest 10218 - 가가의 크리스마스 추억 (END) + // NPC: 9000021 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10218); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10218s") + public static void q10218s(ScriptManager sm) { + // Quest 10218 - 가가의 크리스마스 추억 (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10218); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10219s") + public static void q10219s(ScriptManager sm) { + // Quest 10219 - 카산드라의 두번째 새해선물 (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10219); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10220s") + public static void q10220s(ScriptManager sm) { + // Quest 10220 - 스피드 퀴즈 (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10220); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10222s") + public static void q10222s(ScriptManager sm) { + // Quest 10222 - 두근두근 메이플!두근두근 나의 펫! (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10222); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10224s") + public static void q10224s(ScriptManager sm) { + // Quest 10224 - Starlight Festival (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10224); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10231s") + public static void q10231s(ScriptManager sm) { + // Quest 10231 - Golden Pig's Egg (START) + // NPC: 2084002 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10231); + sm.addItem(4001255, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10240e") + public static void q10240e(ScriptManager sm) { + // Quest 10240 - 카산드라의 봄꽃축제 (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032264 = 4032264; + final int QUEST_ITEM_4032266 = 4032266; + + if (!sm.hasItem(QUEST_ITEM_4032264, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4032266, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032264, 10); + sm.removeItem(QUEST_ITEM_4032266, 10); + sm.forceCompleteQuest(10240); + sm.addItem(4032264, -10); // Reward item + sm.addItem(4032266, -10); // Reward item + sm.addItem(2022526, 1); // Reward item + sm.addItem(2022527, 1); // Reward item + sm.addItem(2022528, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10241e") + public static void q10241e(ScriptManager sm) { + // Quest 10241 - 카산드라의 봄꽃축제 (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032265 = 4032265; + final int QUEST_ITEM_4032270 = 4032270; + + if (!sm.hasItem(QUEST_ITEM_4032265, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4032270, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032265, 10); + sm.removeItem(QUEST_ITEM_4032270, 10); + sm.forceCompleteQuest(10241); + sm.addItem(4032265, -10); // Reward item + sm.addItem(4032270, -10); // Reward item + sm.addItem(2022526, 1); // Reward item + sm.addItem(2022527, 1); // Reward item + sm.addItem(2022528, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10242e") + public static void q10242e(ScriptManager sm) { + // Quest 10242 - 베티의 봄꽃연구1 (END) + // NPC: 1032104 + + final int QUEST_ITEM_2022526 = 2022526; + + if (!sm.hasItem(QUEST_ITEM_2022526, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022526, 3); + sm.forceCompleteQuest(10242); + sm.addItem(2022526, -3); // Reward item + sm.addItem(1012139, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10243e") + public static void q10243e(ScriptManager sm) { + // Quest 10243 - 베티의 봄꽃연구2 (END) + // NPC: 1032104 + + final int QUEST_ITEM_2022527 = 2022527; + + if (!sm.hasItem(QUEST_ITEM_2022527, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022527, 3); + sm.forceCompleteQuest(10243); + sm.addItem(2022527, -3); // Reward item + sm.addItem(1012140, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10244e") + public static void q10244e(ScriptManager sm) { + // Quest 10244 - 베티의 봄꽃연구3 (END) + // NPC: 1032104 + + final int QUEST_ITEM_2022528 = 2022528; + + if (!sm.hasItem(QUEST_ITEM_2022528, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022528, 3); + sm.forceCompleteQuest(10244); + sm.addItem(2022528, -3); // Reward item + sm.addItem(1012141, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10245e") + public static void q10245e(ScriptManager sm) { + // Quest 10245 - 리사의 봄꽃연구1 (END) + // NPC: 2012012 + + final int QUEST_ITEM_2022526 = 2022526; + + if (!sm.hasItem(QUEST_ITEM_2022526, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022526, 3); + sm.forceCompleteQuest(10245); + sm.addItem(2022526, -3); // Reward item + sm.addItem(1012139, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10246e") + public static void q10246e(ScriptManager sm) { + // Quest 10246 - 리사의 봄꽃연구2 (END) + // NPC: 2012012 + + final int QUEST_ITEM_2022527 = 2022527; + + if (!sm.hasItem(QUEST_ITEM_2022527, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022527, 3); + sm.forceCompleteQuest(10246); + sm.addItem(2022527, -3); // Reward item + sm.addItem(1012140, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10247e") + public static void q10247e(ScriptManager sm) { + // Quest 10247 - 리사의 봄꽃연구3 (END) + // NPC: 2012012 + + final int QUEST_ITEM_2022528 = 2022528; + + if (!sm.hasItem(QUEST_ITEM_2022528, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022528, 3); + sm.forceCompleteQuest(10247); + sm.addItem(2022528, -3); // Reward item + sm.addItem(1012141, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10248e") + public static void q10248e(ScriptManager sm) { + // Quest 10248 - 콩쥐의 봄꽃연구1 (END) + // NPC: 2071004 + + final int QUEST_ITEM_2022526 = 2022526; + + if (!sm.hasItem(QUEST_ITEM_2022526, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022526, 3); + sm.forceCompleteQuest(10248); + sm.addItem(2022526, -3); // Reward item + sm.addItem(1012139, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10249e") + public static void q10249e(ScriptManager sm) { + // Quest 10249 - 콩쥐의 봄꽃연구2 (END) + // NPC: 2071004 + + final int QUEST_ITEM_2022527 = 2022527; + + if (!sm.hasItem(QUEST_ITEM_2022527, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022527, 3); + sm.forceCompleteQuest(10249); + sm.addItem(2022527, -3); // Reward item + sm.addItem(1012140, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10250e") + public static void q10250e(ScriptManager sm) { + // Quest 10250 - 콩쥐의 봄꽃연구3 (END) + // NPC: 2071004 + + final int QUEST_ITEM_2022528 = 2022528; + + if (!sm.hasItem(QUEST_ITEM_2022528, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022528, 3); + sm.forceCompleteQuest(10250); + sm.addItem(2022528, -3); // Reward item + sm.addItem(1012141, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10251e") + public static void q10251e(ScriptManager sm) { + // Quest 10251 - 키니의 봄꽃연구1 (END) + // NPC: 2111005 + + final int QUEST_ITEM_2022526 = 2022526; + + if (!sm.hasItem(QUEST_ITEM_2022526, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022526, 3); + sm.forceCompleteQuest(10251); + sm.addItem(2022526, -3); // Reward item + sm.addItem(1012139, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10252e") + public static void q10252e(ScriptManager sm) { + // Quest 10252 - 키니의 봄꽃연구2 (END) + // NPC: 2111005 + + final int QUEST_ITEM_2022527 = 2022527; + + if (!sm.hasItem(QUEST_ITEM_2022527, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022527, 3); + sm.forceCompleteQuest(10252); + sm.addItem(2022527, -3); // Reward item + sm.addItem(1012140, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10253e") + public static void q10253e(ScriptManager sm) { + // Quest 10253 - 키니의 봄꽃연구3 (END) + // NPC: 2111005 + + final int QUEST_ITEM_2022528 = 2022528; + + if (!sm.hasItem(QUEST_ITEM_2022528, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022528, 3); + sm.forceCompleteQuest(10253); + sm.addItem(2022528, -3); // Reward item + sm.addItem(1012141, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10254e") + public static void q10254e(ScriptManager sm) { + // Quest 10254 - 촌장 타타모의 봄꽃연구1 (END) + // NPC: 2081000 + + final int QUEST_ITEM_2022526 = 2022526; + + if (!sm.hasItem(QUEST_ITEM_2022526, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022526, 3); + sm.forceCompleteQuest(10254); + sm.addItem(2022526, -3); // Reward item + sm.addItem(1012139, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10255e") + public static void q10255e(ScriptManager sm) { + // Quest 10255 - 촌장 타타모의 봄꽃연구2 (END) + // NPC: 2081000 + + final int QUEST_ITEM_2022527 = 2022527; + + if (!sm.hasItem(QUEST_ITEM_2022527, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022527, 3); + sm.forceCompleteQuest(10255); + sm.addItem(2022527, -3); // Reward item + sm.addItem(1012140, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10256e") + public static void q10256e(ScriptManager sm) { + // Quest 10256 - 촌장 타타모의 봄꽃연구3 (END) + // NPC: 2081000 + + final int QUEST_ITEM_2022528 = 2022528; + + if (!sm.hasItem(QUEST_ITEM_2022528, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022528, 3); + sm.forceCompleteQuest(10256); + sm.addItem(2022528, -3); // Reward item + sm.addItem(1012140, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10260e") + public static void q10260e(ScriptManager sm) { + // Quest 10260 - Making Pure Perfume 1 (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032271 = 4032271; + + if (!sm.hasItem(QUEST_ITEM_4032271, 30)) { + sm.sayOk("You need 30 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032271, 30); + sm.forceCompleteQuest(10260); + sm.addItem(2430009, 1); // Reward item + sm.addItem(4032271, -30); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10261e") + public static void q10261e(ScriptManager sm) { + // Quest 10261 - Making Pure Perfume 2 (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032275 = 4032275; + + if (!sm.hasItem(QUEST_ITEM_4032275, 30)) { + sm.sayOk("You need 30 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032275, 30); + sm.forceCompleteQuest(10261); + sm.addItem(4032275, -30); // Reward item + sm.addItem(2430009, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10262e") + public static void q10262e(ScriptManager sm) { + // Quest 10262 - Making Pure Perfume 3 (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032276 = 4032276; + + if (!sm.hasItem(QUEST_ITEM_4032276, 30)) { + sm.sayOk("You need 30 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032276, 30); + sm.forceCompleteQuest(10262); + sm.addItem(4032276, -30); // Reward item + sm.addItem(2430009, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10263e") + public static void q10263e(ScriptManager sm) { + // Quest 10263 - Making Pure Perfume 4 (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032277 = 4032277; + + if (!sm.hasItem(QUEST_ITEM_4032277, 30)) { + sm.sayOk("You need 30 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032277, 30); + sm.forceCompleteQuest(10263); + sm.addItem(4032277, -30); // Reward item + sm.addItem(2430009, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10264e") + public static void q10264e(ScriptManager sm) { + // Quest 10264 - Defeating the Hidden Monster (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032284 = 4032284; + + if (!sm.hasItem(QUEST_ITEM_4032284, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032284, 1); + sm.forceCompleteQuest(10264); + sm.addItem(1012146, 1); // Reward item + sm.addItem(4032284, -1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10264s") + public static void q10264s(ScriptManager sm) { + // Quest 10264 - Defeating the Hidden Monster (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10264); + sm.addItem(2430009, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10265e") + public static void q10265e(ScriptManager sm) { + // Quest 10265 - Defeating the Hidden Monster (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032285 = 4032285; + + if (!sm.hasItem(QUEST_ITEM_4032285, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032285, 1); + sm.forceCompleteQuest(10265); + sm.addItem(1012146, 1); // Reward item + sm.addItem(4032285, -1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10265s") + public static void q10265s(ScriptManager sm) { + // Quest 10265 - Defeating the Hidden Monster (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10265); + sm.addItem(2430009, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10266e") + public static void q10266e(ScriptManager sm) { + // Quest 10266 - Defeating the Hidden Monster (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032286 = 4032286; + + if (!sm.hasItem(QUEST_ITEM_4032286, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032286, 1); + sm.forceCompleteQuest(10266); + sm.addItem(1012146, 1); // Reward item + sm.addItem(4032286, -1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10266s") + public static void q10266s(ScriptManager sm) { + // Quest 10266 - Defeating the Hidden Monster (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10266); + sm.addItem(2430009, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10267e") + public static void q10267e(ScriptManager sm) { + // Quest 10267 - Defeating the Hidden Monster (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032287 = 4032287; + + if (!sm.hasItem(QUEST_ITEM_4032287, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032287, 1); + sm.forceCompleteQuest(10267); + sm.addItem(1012146, 1); // Reward item + sm.addItem(4032287, -1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10267s") + public static void q10267s(ScriptManager sm) { + // Quest 10267 - Defeating the Hidden Monster (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10267); + sm.addItem(2430009, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10268e") + public static void q10268e(ScriptManager sm) { + // Quest 10268 - Gaga's Love Letter 1 (END) + // NPC: 9000021 + + final int QUEST_ITEM_4032272 = 4032272; + final int QUEST_ITEM_4032273 = 4032273; + + if (!sm.hasItem(QUEST_ITEM_4032272, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4032273, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032272, 10); + sm.removeItem(QUEST_ITEM_4032273, 10); + sm.forceCompleteQuest(10268); + sm.addItem(4032272, -10); // Reward item + sm.addItem(4032273, -10); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10270e") + public static void q10270e(ScriptManager sm) { + // Quest 10270 - Gaga's Love Letter 2 (END) + // NPC: 9000021 + + final int QUEST_ITEM_4032278 = 4032278; + final int QUEST_ITEM_4032281 = 4032281; + + if (!sm.hasItem(QUEST_ITEM_4032278, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4032281, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032278, 10); + sm.removeItem(QUEST_ITEM_4032281, 10); + sm.forceCompleteQuest(10270); + sm.addItem(4032281, -10); // Reward item + sm.addItem(4032278, -10); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10271e") + public static void q10271e(ScriptManager sm) { + // Quest 10271 - Gaga's Love Letter 3 (END) + // NPC: 9000021 + + final int QUEST_ITEM_4032279 = 4032279; + final int QUEST_ITEM_4032282 = 4032282; + + if (!sm.hasItem(QUEST_ITEM_4032279, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4032282, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032279, 10); + sm.removeItem(QUEST_ITEM_4032282, 10); + sm.forceCompleteQuest(10271); + sm.addItem(4032282, -10); // Reward item + sm.addItem(4032279, -10); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10272e") + public static void q10272e(ScriptManager sm) { + // Quest 10272 - Gaga's Love Letter 4 (END) + // NPC: 9000021 + + final int QUEST_ITEM_4032280 = 4032280; + final int QUEST_ITEM_4032283 = 4032283; + + if (!sm.hasItem(QUEST_ITEM_4032280, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4032283, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032280, 10); + sm.removeItem(QUEST_ITEM_4032283, 10); + sm.forceCompleteQuest(10272); + sm.addItem(4032283, -10); // Reward item + sm.addItem(4032280, -10); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10274e") + public static void q10274e(ScriptManager sm) { + // Quest 10274 - MapleStory 5th Anniversary Party Preparation. (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032298 = 4032298; + + if (!sm.hasItem(QUEST_ITEM_4032298, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032298, 20); + sm.forceCompleteQuest(10274); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10275e") + public static void q10275e(ScriptManager sm) { + // Quest 10275 - MapleStory 5th Anniversary Party Preparation! (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032299 = 4032299; + + if (!sm.hasItem(QUEST_ITEM_4032299, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032299, 20); + sm.forceCompleteQuest(10275); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10276e") + public static void q10276e(ScriptManager sm) { + // Quest 10276 - MapleStory 5th Anniversary Party Preparation!! (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032300 = 4032300; + + if (!sm.hasItem(QUEST_ITEM_4032300, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032300, 20); + sm.forceCompleteQuest(10276); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10277e") + public static void q10277e(ScriptManager sm) { + // Quest 10277 - MapleStory 5th Anniversary Party Preparation!!! (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032301 = 4032301; + + if (!sm.hasItem(QUEST_ITEM_4032301, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032301, 20); + sm.forceCompleteQuest(10277); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10278e") + public static void q10278e(ScriptManager sm) { + // Quest 10278 - Happy 5th Anniversary (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10278); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10279e") + public static void q10279e(ScriptManager sm) { + // Quest 10279 - Happy 5th Anniversary! (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10279); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10280e") + public static void q10280e(ScriptManager sm) { + // Quest 10280 - Happy 5th Anniversary!! (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10280); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10281e") + public static void q10281e(ScriptManager sm) { + // Quest 10281 - Happy 5th Anniversary!!! (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10281); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10282e") + public static void q10282e(ScriptManager sm) { + // Quest 10282 - Portrait of a Popular Monster: Orange Mushroom (END) + // NPC: 9010010 + + final int QUEST_ITEM_4220148 = 4220148; + + if (!sm.hasItem(QUEST_ITEM_4220148, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4220148, 1); + sm.forceCompleteQuest(10282); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10282s") + public static void q10282s(ScriptManager sm) { + // Quest 10282 - Portrait of a Popular Monster: Orange Mushroom (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10282); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10283e") + public static void q10283e(ScriptManager sm) { + // Quest 10283 - Portrait of a Popular Monster: Octopus (END) + // NPC: 9010010 + + final int QUEST_ITEM_4220149 = 4220149; + + if (!sm.hasItem(QUEST_ITEM_4220149, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4220149, 1); + sm.forceCompleteQuest(10283); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10283s") + public static void q10283s(ScriptManager sm) { + // Quest 10283 - Portrait of a Popular Monster: Octopus (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10283); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10284e") + public static void q10284e(ScriptManager sm) { + // Quest 10284 - Portrait of a Popular Monster: Yeti (END) + // NPC: 9010010 + + final int QUEST_ITEM_4220150 = 4220150; + + if (!sm.hasItem(QUEST_ITEM_4220150, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4220150, 1); + sm.forceCompleteQuest(10284); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10284s") + public static void q10284s(ScriptManager sm) { + // Quest 10284 - Portrait of a Popular Monster: Yeti (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10284); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10287s") + public static void q10287s(ScriptManager sm) { + // Quest 10287 - Aramia's Golden Maple Leaf (START) + // NPC: 9000055 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10287); + sm.addItem(4001168, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10300e") + public static void q10300e(ScriptManager sm) { + // Quest 10300 - Making the Witch's Secure Broomstick (END) + // NPC: 9010020 + + final int QUEST_ITEM_4032348 = 4032348; + + if (!sm.hasItem(QUEST_ITEM_4032348, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032348, 20); + sm.forceCompleteQuest(10300); + sm.addItem(4032348, -20); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10301e") + public static void q10301e(ScriptManager sm) { + // Quest 10301 - Making the Witch's Solid Broomstick (END) + // NPC: 9010020 + + final int QUEST_ITEM_4032349 = 4032349; + + if (!sm.hasItem(QUEST_ITEM_4032349, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032349, 20); + sm.forceCompleteQuest(10301); + sm.addItem(4032349, -20); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10302e") + public static void q10302e(ScriptManager sm) { + // Quest 10302 - Making the Witch's Sturdy Broomstick (END) + // NPC: 9010020 + + final int QUEST_ITEM_4032350 = 4032350; + + if (!sm.hasItem(QUEST_ITEM_4032350, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032350, 20); + sm.forceCompleteQuest(10302); + sm.addItem(4032350, -20); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10311e") + public static void q10311e(ScriptManager sm) { + // Quest 10311 - A Petrified Mouse (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10311); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10312e") + public static void q10312e(ScriptManager sm) { + // Quest 10312 - Gaga's Glasses (END) + // NPC: 9000021 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10312); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10313e") + public static void q10313e(ScriptManager sm) { + // Quest 10313 - Cassandra's Crystal Ball (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10313); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10314e") + public static void q10314e(ScriptManager sm) { + // Quest 10314 - A Shiny Watch (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10314); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10315e") + public static void q10315e(ScriptManager sm) { + // Quest 10315 - A Nice Cleat (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10315); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10316e") + public static void q10316e(ScriptManager sm) { + // Quest 10316 - Quest Completion Book (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10316); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10317e") + public static void q10317e(ScriptManager sm) { + // Quest 10317 - A Pretty Hair-Tie (END) + // NPC: 1022002 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10317); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10318e") + public static void q10318e(ScriptManager sm) { + // Quest 10318 - An Old Shoe (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10318); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10319e") + public static void q10319e(ScriptManager sm) { + // Quest 10319 - Artifact Hunt 1000 points acquired! (END) + // NPC: Unknown + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10319); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10319s") + public static void q10319s(ScriptManager sm) { + // Quest 10319 - Artifact Hunt 1000 points acquired! (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10319); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10320e") + public static void q10320e(ScriptManager sm) { + // Quest 10320 - Artifact Hunt 2500 points acquired! (END) + // NPC: Unknown + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10320); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10320s") + public static void q10320s(ScriptManager sm) { + // Quest 10320 - Artifact Hunt 2500 points acquired! (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10320); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10321e") + public static void q10321e(ScriptManager sm) { + // Quest 10321 - Artifact Hunt 4000 points acquired! (END) + // NPC: Unknown + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10321); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10321s") + public static void q10321s(ScriptManager sm) { + // Quest 10321 - Artifact Hunt 4000 points acquired! (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10321); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10324e") + public static void q10324e(ScriptManager sm) { + // Quest 10324 - Vaughn Lee's Cleat (END) + // NPC: 2110003 + + final int QUEST_ITEM_4001307 = 4001307; + + if (!sm.hasItem(QUEST_ITEM_4001307, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4001307, 1); + sm.forceCompleteQuest(10324); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10325e") + public static void q10325e(ScriptManager sm) { + // Quest 10325 - Stan's Cleat (END) + // NPC: 2110004 + + final int QUEST_ITEM_4001307 = 4001307; + + if (!sm.hasItem(QUEST_ITEM_4001307, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4001307, 1); + sm.forceCompleteQuest(10325); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10326e") + public static void q10326e(ScriptManager sm) { + // Quest 10326 - Louie's Cleat (END) + // NPC: 2110002 + + final int QUEST_ITEM_4001307 = 4001307; + + if (!sm.hasItem(QUEST_ITEM_4001307, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4001307, 1); + sm.forceCompleteQuest(10326); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10327e") + public static void q10327e(ScriptManager sm) { + // Quest 10327 - Corba's Watch (END) + // NPC: 2082003 + + final int QUEST_ITEM_4001306 = 4001306; + + if (!sm.hasItem(QUEST_ITEM_4001306, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4001306, 1); + sm.forceCompleteQuest(10327); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10329s") + public static void q10329s(ScriptManager sm) { + // Quest 10329 - Start the Artifact Hunt (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10329); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10330e") + public static void q10330e(ScriptManager sm) { + // Quest 10330 - Challenge! Honorable Mesoranger (END) + // NPC: 9000062 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10330); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10331e") + public static void q10331e(ScriptManager sm) { + // Quest 10331 - Special Order! Find Agent E! (END) + // NPC: 9000063 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10331); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10331s") + public static void q10331s(ScriptManager sm) { + // Quest 10331 - Special Order! Find Agent E! (START) + // NPC: 9000063 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10331); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10332e") + public static void q10332e(ScriptManager sm) { + // Quest 10332 - Special Order! Find Agent S! (END) + // NPC: 9000064 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10332); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10332s") + public static void q10332s(ScriptManager sm) { + // Quest 10332 - Special Order! Find Agent S! (START) + // NPC: 9000064 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10332); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10333s") + public static void q10333s(ScriptManager sm) { + // Quest 10333 - Special Order! Find Agent O! (START) + // NPC: 9000065 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10333); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10340s") + public static void q10340s(ScriptManager sm) { + // Quest 10340 - They're Coming Back! (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10340); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10341s") + public static void q10341s(ScriptManager sm) { + // Quest 10341 - The Revival of the Arans (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10341); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10342e") + public static void q10342e(ScriptManager sm) { + // Quest 10342 - Vague Aran Memories (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032366 = 4032366; + + if (!sm.hasItem(QUEST_ITEM_4032366, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032366, 20); + sm.forceCompleteQuest(10342); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10344e") + public static void q10344e(ScriptManager sm) { + // Quest 10344 - Dim Aran Memories (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032367 = 4032367; + + if (!sm.hasItem(QUEST_ITEM_4032367, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032367, 20); + sm.forceCompleteQuest(10344); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10345e") + public static void q10345e(ScriptManager sm) { + // Quest 10345 - Signs of Their Revival (END) + // NPC: 9000068 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10345); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10346e") + public static void q10346e(ScriptManager sm) { + // Quest 10346 - Faint Aran Memories (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032368 = 4032368; + + if (!sm.hasItem(QUEST_ITEM_4032368, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032368, 20); + sm.forceCompleteQuest(10346); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10347e") + public static void q10347e(ScriptManager sm) { + // Quest 10347 - Wolves Waiting for their Masters (END) + // NPC: 9000067 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10347); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10348e") + public static void q10348e(ScriptManager sm) { + // Quest 10348 - Cloudy Aran Memories (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032369 = 4032369; + + if (!sm.hasItem(QUEST_ITEM_4032369, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032369, 20); + sm.forceCompleteQuest(10348); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10349e") + public static void q10349e(ScriptManager sm) { + // Quest 10349 - Preparing for Their Arrival (END) + // NPC: 9010010 + + final int QUEST_ITEM_1442000 = 1442000; + + if (!sm.hasItem(QUEST_ITEM_1442000, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_1442000, 1); + sm.forceCompleteQuest(10349); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10350e") + public static void q10350e(ScriptManager sm) { + // Quest 10350 - Lingering Aran Memories (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032370 = 4032370; + + if (!sm.hasItem(QUEST_ITEM_4032370, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032370, 20); + sm.forceCompleteQuest(10350); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10351s") + public static void q10351s(ScriptManager sm) { + // Quest 10351 - The Memory of the Heroes (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10351); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10352e") + public static void q10352e(ScriptManager sm) { + // Quest 10352 - Flickering Aran Memories (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032371 = 4032371; + + if (!sm.hasItem(QUEST_ITEM_4032371, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032371, 20); + sm.forceCompleteQuest(10352); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10353e") + public static void q10353e(ScriptManager sm) { + // Quest 10353 - The Hidden Meaning Behind the Memory Fragments (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10353); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10354s") + public static void q10354s(ScriptManager sm) { + // Quest 10354 - Who Deserves Cassandra's Album? (START) + // NPC: 9040000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10354); + sm.addItem(4001316, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10355e") + public static void q10355e(ScriptManager sm) { + // Quest 10355 - Delivering Cassandra's Album (END) + // NPC: 9000021 + + final int QUEST_ITEM_4001316 = 4001316; + + if (!sm.hasItem(QUEST_ITEM_4001316, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4001316, 1); + sm.forceCompleteQuest(10355); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10356e") + public static void q10356e(ScriptManager sm) { + // Quest 10356 - Delivering Cassandra's Album (END) + // NPC: 9010010 + + final int QUEST_ITEM_4001316 = 4001316; + + if (!sm.hasItem(QUEST_ITEM_4001316, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4001316, 1); + sm.forceCompleteQuest(10356); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10360e") + public static void q10360e(ScriptManager sm) { + // Quest 10360 - Aran Welcome Celebration (END) + // NPC: 9010010 + + final int QUEST_ITEM_3994139 = 3994139; + + if (!sm.hasItem(QUEST_ITEM_3994139, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_3994139, 1); + sm.forceCompleteQuest(10360); + sm.addItem(3994139, -1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10370e") + public static void q10370e(ScriptManager sm) { + // Quest 10370 - Find the Master of Combos (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10370); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10380s") + public static void q10380s(ScriptManager sm) { + // Quest 10380 - Aran's Return (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10380); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10394e") + public static void q10394e(ScriptManager sm) { + // Quest 10394 - Perfect Pitch - Level Up Event (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10394); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10395e") + public static void q10395e(ScriptManager sm) { + // Quest 10395 - 류호의 이주민 (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10395); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10395s") + public static void q10395s(ScriptManager sm) { + // Quest 10395 - 류호의 이주민 (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10395); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10396e") + public static void q10396e(ScriptManager sm) { + // Quest 10396 - 류호의 도전자 (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10396); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10396s") + public static void q10396s(ScriptManager sm) { + // Quest 10396 - 류호의 도전자 (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10396); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10397e") + public static void q10397e(ScriptManager sm) { + // Quest 10397 - 류호의 정착자 (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10397); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10397s") + public static void q10397s(ScriptManager sm) { + // Quest 10397 - 류호의 정착자 (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10397); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10398e") + public static void q10398e(ScriptManager sm) { + // Quest 10398 - 류호의 개척자 (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(10398); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10398s") + public static void q10398s(ScriptManager sm) { + // Quest 10398 - 류호의 개척자 (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10398); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10400s") + public static void q10400s(ScriptManager sm) { + // Quest 10400 - The 2010 Winter King / Winter Queen Event! (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10400); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10401s") + public static void q10401s(ScriptManager sm) { + // Quest 10401 - The True Winter King / Winter Queen Event of 2010! (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10401); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10415e") + public static void q10415e(ScriptManager sm) { + // Quest 10415 - Winter Bingo (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032418 = 4032418; + + if (!sm.hasItem(QUEST_ITEM_4032418, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032418, 20); + sm.forceCompleteQuest(10415); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10417e") + public static void q10417e(ScriptManager sm) { + // Quest 10417 - Winter Bingo (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032419 = 4032419; + + if (!sm.hasItem(QUEST_ITEM_4032419, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032419, 20); + sm.forceCompleteQuest(10417); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10418e") + public static void q10418e(ScriptManager sm) { + // Quest 10418 - Winter Bingo (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032420 = 4032420; + + if (!sm.hasItem(QUEST_ITEM_4032420, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032420, 20); + sm.forceCompleteQuest(10418); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10419e") + public static void q10419e(ScriptManager sm) { + // Quest 10419 - Winter Bingo (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032421 = 4032421; + + if (!sm.hasItem(QUEST_ITEM_4032421, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032421, 20); + sm.forceCompleteQuest(10419); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10420e") + public static void q10420e(ScriptManager sm) { + // Quest 10420 - Winter Bingo (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032422 = 4032422; + + if (!sm.hasItem(QUEST_ITEM_4032422, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032422, 20); + sm.forceCompleteQuest(10420); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10450s") + public static void q10450s(ScriptManager sm) { + // Quest 10450 - Rainbow Week: Red Monday Magic (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10450); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10451s") + public static void q10451s(ScriptManager sm) { + // Quest 10451 - Rainbow Week: Red Monday Magic (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10451); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10452e") + public static void q10452e(ScriptManager sm) { + // Quest 10452 - Rainbow Week: Yellow Wednesday Magic (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032434 = 4032434; + + if (!sm.hasItem(QUEST_ITEM_4032434, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032434, 10); + sm.forceCompleteQuest(10452); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10452s") + public static void q10452s(ScriptManager sm) { + // Quest 10452 - Rainbow Week: Yellow Wednesday Magic (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10452); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10453e") + public static void q10453e(ScriptManager sm) { + // Quest 10453 - Rainbow Week: Yellow Wednesday Magic (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032434 = 4032434; + + if (!sm.hasItem(QUEST_ITEM_4032434, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032434, 10); + sm.forceCompleteQuest(10453); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10454e") + public static void q10454e(ScriptManager sm) { + // Quest 10454 - Rainbow Week: Yellow Wednesday Magic (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032434 = 4032434; + + if (!sm.hasItem(QUEST_ITEM_4032434, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032434, 10); + sm.forceCompleteQuest(10454); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10455e") + public static void q10455e(ScriptManager sm) { + // Quest 10455 - Rainbow Week: Yellow Wednesday Magic (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032434 = 4032434; + + if (!sm.hasItem(QUEST_ITEM_4032434, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032434, 10); + sm.forceCompleteQuest(10455); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10456e") + public static void q10456e(ScriptManager sm) { + // Quest 10456 - Rainbow Week: Yellow Wednesday Magic (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032434 = 4032434; + + if (!sm.hasItem(QUEST_ITEM_4032434, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032434, 10); + sm.forceCompleteQuest(10456); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10457e") + public static void q10457e(ScriptManager sm) { + // Quest 10457 - Rainbow Week: Yellow Wednesday Magic (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032434 = 4032434; + + if (!sm.hasItem(QUEST_ITEM_4032434, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032434, 10); + sm.forceCompleteQuest(10457); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10470s") + public static void q10470s(ScriptManager sm) { + // Quest 10470 - 별별 페스티발 (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10470); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10480s") + public static void q10480s(ScriptManager sm) { + // Quest 10480 - The Birth of a New Hero (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10480); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10481s") + public static void q10481s(ScriptManager sm) { + // Quest 10481 - The Maple Administrator's Congratulations (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10481); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10490s") + public static void q10490s(ScriptManager sm) { + // Quest 10490 - 카산드라의 심술 (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10490); + sm.addItem(3994184, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10491e") + public static void q10491e(ScriptManager sm) { + // Quest 10491 - 가가 골탕 먹이기 (END) + // NPC: 9000021 + + final int QUEST_ITEM_3994185 = 3994185; + + if (!sm.hasItem(QUEST_ITEM_3994185, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_3994185, 1); + sm.forceCompleteQuest(10491); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10492e") + public static void q10492e(ScriptManager sm) { + // Quest 10492 - 메이플운영자 골탕먹이기 (END) + // NPC: 9010000 + + final int QUEST_ITEM_3994185 = 3994185; + + if (!sm.hasItem(QUEST_ITEM_3994185, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_3994185, 1); + sm.forceCompleteQuest(10492); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10493e") + public static void q10493e(ScriptManager sm) { + // Quest 10493 - 클리프 골탕먹이기 (END) + // NPC: 2001000 + + final int QUEST_ITEM_3994185 = 3994185; + + if (!sm.hasItem(QUEST_ITEM_3994185, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_3994185, 1); + sm.forceCompleteQuest(10493); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10494e") + public static void q10494e(ScriptManager sm) { + // Quest 10494 - 토르 골탕먹이기 (END) + // NPC: 2002002 + + final int QUEST_ITEM_3994185 = 3994185; + + if (!sm.hasItem(QUEST_ITEM_3994185, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_3994185, 1); + sm.forceCompleteQuest(10494); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10497s") + public static void q10497s(ScriptManager sm) { + // Quest 10497 - 이상한 물약을 다시 만들어 주세요. (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10497); + sm.addItem(3994184, 1); // Quest item + sm.addItem(3994185, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10500s") + public static void q10500s(ScriptManager sm) { + // Quest 10500 - A Sign of the Dragon Master's Return (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10500); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10510e") + public static void q10510e(ScriptManager sm) { + // Quest 10510 - Evan Everyday Event (END) + // NPC: 9010010 + + final int QUEST_ITEM_3994187 = 3994187; + + if (!sm.hasItem(QUEST_ITEM_3994187, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_3994187, 1); + sm.forceCompleteQuest(10510); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10510s") + public static void q10510s(ScriptManager sm) { + // Quest 10510 - Evan Everyday Event (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10510); + sm.addItem(3994187, 1); // Quest item + sm.addItem(3994186, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10514s") + public static void q10514s(ScriptManager sm) { + // Quest 10514 - Evan Launch Commemoration 2PM Event (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10514); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10516s") + public static void q10516s(ScriptManager sm) { + // Quest 10516 - Evan Launch Commemoration 2PM Event (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10516); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10575e") + public static void q10575e(ScriptManager sm) { + // Quest 10575 - Explorer Level +30 Challenge (END) + // NPC: 9010010 + + final int QUEST_ITEM_1112427 = 1112427; + final int QUEST_ITEM_1112428 = 1112428; + final int QUEST_ITEM_1112429 = 1112429; + + if (!sm.hasItem(QUEST_ITEM_1112427, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_1112428, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_1112429, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_1112427, 1); + sm.removeItem(QUEST_ITEM_1112428, 1); + sm.removeItem(QUEST_ITEM_1112429, 1); + sm.forceCompleteQuest(10575); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10575s") + public static void q10575s(ScriptManager sm) { + // Quest 10575 - Explorer Level +30 Challenge (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10575); + sm.addItem(1112427, 1); // Quest item + sm.addItem(1112428, 1); // Quest item + sm.addItem(1112429, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10579s") + public static void q10579s(ScriptManager sm) { + // Quest 10579 - New Function: Party Search (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10579); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10594e") + public static void q10594e(ScriptManager sm) { + // Quest 10594 - Secret Part-Time Job (END) + // NPC: 9010010 + + final int QUEST_ITEM_2430052 = 2430052; + + if (!sm.hasItem(QUEST_ITEM_2430052, 5)) { + sm.sayOk("You need 5 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2430052, 5); + sm.forceCompleteQuest(10594); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10610s") + public static void q10610s(ScriptManager sm) { + // Quest 10610 - Reach Dual Blade Lv. 20! (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10610); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10611s") + public static void q10611s(ScriptManager sm) { + // Quest 10611 - Reach Dual Blade Lv. 30! (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10611); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10612s") + public static void q10612s(ScriptManager sm) { + // Quest 10612 - Reach Dual Blade Lv. 40! (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10612); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10613s") + public static void q10613s(ScriptManager sm) { + // Quest 10613 - Reach Dual Blade Lv. 50! (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10613); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10614s") + public static void q10614s(ScriptManager sm) { + // Quest 10614 - Reach Dual Blade Lv. 60! (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10614); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10615s") + public static void q10615s(ScriptManager sm) { + // Quest 10615 - Reach Dual Blade Lv. 70! (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10615); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10616s") + public static void q10616s(ScriptManager sm) { + // Quest 10616 - Reach Dual Blade Lv. 80! (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10616); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10617s") + public static void q10617s(ScriptManager sm) { + // Quest 10617 - Reach Dual Blade Lv. 90! (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10617); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10618s") + public static void q10618s(ScriptManager sm) { + // Quest 10618 - Reach Dual Blade Lv. 100! (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(10618); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q10619e") + public static void q10619e(ScriptManager sm) { + // Quest 10619 - Dual Blade - Everyday 2X Buff!! (END) + // NPC: 9010000 + + final int QUEST_ITEM_3994193 = 3994193; + + if (!sm.hasItem(QUEST_ITEM_3994193, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_3994193, 1); + sm.forceCompleteQuest(10619); + sm.addItem(3994193, -1); // Reward item + sm.addItem(2022694, 1); // Reward item + sm.addItem(2450018, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10620e") + public static void q10620e(ScriptManager sm) { + // Quest 10620 - Dual Blade: Top Secret (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032630 = 4032630; + + if (!sm.hasItem(QUEST_ITEM_4032630, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032630, 20); + sm.forceCompleteQuest(10620); + sm.addItem(4032630, -20); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q10720s") + public static void q10720s(ScriptManager sm) { + // Quest 10720 - Event Quest (START) + sm.forceStartQuest(10720); + } + + + @Script("q10818s") + public static void q10818s(ScriptManager sm) { + // Quest 10818 - Event Quest (START) + sm.forceStartQuest(10818); + } + + + @Script("q10827e") + public static void q10827e(ScriptManager sm) { + // Quest 10827 - Event Quest (END) + sm.forceCompleteQuest(10827); + } + + + @Script("q10828e") + public static void q10828e(ScriptManager sm) { + // Quest 10828 - Event Quest (END) + sm.forceCompleteQuest(10828); + } + + + @Script("q10828s") + public static void q10828s(ScriptManager sm) { + // Quest 10828 - Event Quest (START) + sm.forceStartQuest(10828); + } + + + @Script("q10829e") + public static void q10829e(ScriptManager sm) { + // Quest 10829 - Event Quest (END) + sm.forceCompleteQuest(10829); + } + + + @Script("q10830e") + public static void q10830e(ScriptManager sm) { + // Quest 10830 - Event Quest (END) + sm.forceCompleteQuest(10830); + } + + + @Script("q10831e") + public static void q10831e(ScriptManager sm) { + // Quest 10831 - Event Quest (END) + sm.forceCompleteQuest(10831); + } + + + @Script("q10832e") + public static void q10832e(ScriptManager sm) { + // Quest 10832 - Event Quest (END) + sm.forceCompleteQuest(10832); + } + + + @Script("q10833e") + public static void q10833e(ScriptManager sm) { + // Quest 10833 - Event Quest (END) + sm.forceCompleteQuest(10833); + } + + + @Script("q10834e") + public static void q10834e(ScriptManager sm) { + // Quest 10834 - Event Quest (END) + sm.forceCompleteQuest(10834); + } + + + @Script("q10835e") + public static void q10835e(ScriptManager sm) { + // Quest 10835 - Event Quest (END) + sm.forceCompleteQuest(10835); + } + + + @Script("q10836e") + public static void q10836e(ScriptManager sm) { + // Quest 10836 - Event Quest (END) + sm.forceCompleteQuest(10836); + } + + + @Script("q10837e") + public static void q10837e(ScriptManager sm) { + // Quest 10837 - Event Quest (END) + sm.forceCompleteQuest(10837); + } + + + @Script("q10838e") + public static void q10838e(ScriptManager sm) { + // Quest 10838 - Event Quest (END) + sm.forceCompleteQuest(10838); + } + + + @Script("q10839e") + public static void q10839e(ScriptManager sm) { + // Quest 10839 - Event Quest (END) + sm.forceCompleteQuest(10839); + } + + + @Script("q10840e") + public static void q10840e(ScriptManager sm) { + // Quest 10840 - Event Quest (END) + sm.forceCompleteQuest(10840); + } + + + @Script("q10841e") + public static void q10841e(ScriptManager sm) { + // Quest 10841 - Event Quest (END) + sm.forceCompleteQuest(10841); + } + + + @Script("q10842e") + public static void q10842e(ScriptManager sm) { + // Quest 10842 - Event Quest (END) + sm.forceCompleteQuest(10842); + } + + + @Script("q10842s") + public static void q10842s(ScriptManager sm) { + // Quest 10842 - Event Quest (START) + sm.forceStartQuest(10842); + } + + + @Script("q10845e") + public static void q10845e(ScriptManager sm) { + // Quest 10845 - Event Quest (END) + sm.forceCompleteQuest(10845); + } + + + @Script("q10846e") + public static void q10846e(ScriptManager sm) { + // Quest 10846 - Event Quest (END) + sm.forceCompleteQuest(10846); + } + + + @Script("q10848e") + public static void q10848e(ScriptManager sm) { + // Quest 10848 - Event Quest (END) + sm.forceCompleteQuest(10848); + } + + + @Script("q10848s") + public static void q10848s(ScriptManager sm) { + // Quest 10848 - Event Quest (START) + sm.forceStartQuest(10848); + } + +} \ No newline at end of file diff --git a/src/main/java/kinoko/script/quest/EventScripts.java b/src/main/java/kinoko/script/quest/EventScripts.java new file mode 100644 index 00000000..4da032b8 --- /dev/null +++ b/src/main/java/kinoko/script/quest/EventScripts.java @@ -0,0 +1,187 @@ +package kinoko.script.quest; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +/** + * Special Event Scripts + * Contains special event scripts like huntUP, quiz, scrollchange, and selectedMob + */ +public final class EventScripts extends ScriptHandler { + + // HUNT UP EVENT SCRIPTS + @Script("huntUP1") + public static void huntUP1(ScriptManager sm) { + sm.sayOk("Hunt UP Event 1 is not yet implemented."); + } + + @Script("huntUP2") + public static void huntUP2(ScriptManager sm) { + sm.sayOk("Hunt UP Event 2 is not yet implemented."); + } + + @Script("huntUP3") + public static void huntUP3(ScriptManager sm) { + sm.sayOk("Hunt UP Event 3 is not yet implemented."); + } + + @Script("huntUP4") + public static void huntUP4(ScriptManager sm) { + sm.sayOk("Hunt UP Event 4 is not yet implemented."); + } + + @Script("huntUP5") + public static void huntUP5(ScriptManager sm) { + sm.sayOk("Hunt UP Event 5 is not yet implemented."); + } + + @Script("huntUP6") + public static void huntUP6(ScriptManager sm) { + sm.sayOk("Hunt UP Event 6 is not yet implemented."); + } + + @Script("huntUP7") + public static void huntUP7(ScriptManager sm) { + sm.sayOk("Hunt UP Event 7 is not yet implemented."); + } + + @Script("huntUP8") + public static void huntUP8(ScriptManager sm) { + sm.sayOk("Hunt UP Event 8 is not yet implemented."); + } + + @Script("huntUP9") + public static void huntUP9(ScriptManager sm) { + sm.sayOk("Hunt UP Event 9 is not yet implemented."); + } + + // QUIZ EVENT SCRIPTS + @Script("quiz1_1") + public static void quiz1_1(ScriptManager sm) { + sm.sayOk("Quiz 1-1 is not yet implemented."); + } + + @Script("quiz1_2") + public static void quiz1_2(ScriptManager sm) { + sm.sayOk("Quiz 1-2 is not yet implemented."); + } + + @Script("quiz1_3") + public static void quiz1_3(ScriptManager sm) { + sm.sayOk("Quiz 1-3 is not yet implemented."); + } + + @Script("quiz1_4") + public static void quiz1_4(ScriptManager sm) { + sm.sayOk("Quiz 1-4 is not yet implemented."); + } + + @Script("quiz1_5") + public static void quiz1_5(ScriptManager sm) { + sm.sayOk("Quiz 1-5 is not yet implemented."); + } + + @Script("quiz1_6") + public static void quiz1_6(ScriptManager sm) { + sm.sayOk("Quiz 1-6 is not yet implemented."); + } + + @Script("quiz1_7") + public static void quiz1_7(ScriptManager sm) { + sm.sayOk("Quiz 1-7 is not yet implemented."); + } + + @Script("quiz1_8") + public static void quiz1_8(ScriptManager sm) { + sm.sayOk("Quiz 1-8 is not yet implemented."); + } + + @Script("quiz1_9") + public static void quiz1_9(ScriptManager sm) { + sm.sayOk("Quiz 1-9 is not yet implemented."); + } + + @Script("quiz1_10") + public static void quiz1_10(ScriptManager sm) { + sm.sayOk("Quiz 1-10 is not yet implemented."); + } + + @Script("quiz1_11") + public static void quiz1_11(ScriptManager sm) { + sm.sayOk("Quiz 1-11 is not yet implemented."); + } + + @Script("quiz1_12") + public static void quiz1_12(ScriptManager sm) { + sm.sayOk("Quiz 1-12 is not yet implemented."); + } + + @Script("quiz1_13") + public static void quiz1_13(ScriptManager sm) { + sm.sayOk("Quiz 1-13 is not yet implemented."); + } + + @Script("quiz1_14") + public static void quiz1_14(ScriptManager sm) { + sm.sayOk("Quiz 1-14 is not yet implemented."); + } + + @Script("quiz2_start") + public static void quiz2_start(ScriptManager sm) { + sm.sayOk("Quiz 2 Start is not yet implemented."); + } + + // SCROLL CHANGE SCRIPTS + @Script("scrollchange1") + public static void scrollchange1(ScriptManager sm) { + sm.sayOk("Scroll Change 1 is not yet implemented."); + } + + @Script("scrollchange2") + public static void scrollchange2(ScriptManager sm) { + sm.sayOk("Scroll Change 2 is not yet implemented."); + } + + @Script("scrollchange3") + public static void scrollchange3(ScriptManager sm) { + sm.sayOk("Scroll Change 3 is not yet implemented."); + } + + @Script("scrollchange4") + public static void scrollchange4(ScriptManager sm) { + sm.sayOk("Scroll Change 4 is not yet implemented."); + } + + // SELECTED MOB SCRIPTS + @Script("selectedMob") + public static void selectedMob(ScriptManager sm) { + sm.sayOk("Selected Mob is not yet implemented."); + } + + @Script("selectedMob1") + public static void selectedMob1(ScriptManager sm) { + sm.sayOk("Selected Mob 1 is not yet implemented."); + } + + @Script("selectedMob2") + public static void selectedMob2(ScriptManager sm) { + sm.sayOk("Selected Mob 2 is not yet implemented."); + } + + @Script("selectedMob3") + public static void selectedMob3(ScriptManager sm) { + sm.sayOk("Selected Mob 3 is not yet implemented."); + } + + @Script("selectedMob4") + public static void selectedMob4(ScriptManager sm) { + sm.sayOk("Selected Mob 4 is not yet implemented."); + } + + // MISC EVENT SCRIPTS + @Script("q4676e") + public static void q4676e(ScriptManager sm) { + sm.sayOk("Quest 4676 (END) is not yet implemented."); + } +} diff --git a/src/main/java/kinoko/script/quest/Explorer4thJob.java b/src/main/java/kinoko/script/quest/Explorer4thJob.java new file mode 100644 index 00000000..c6f9fdb9 --- /dev/null +++ b/src/main/java/kinoko/script/quest/Explorer4thJob.java @@ -0,0 +1,773 @@ +package kinoko.script.quest; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +/** + * Explorer 4th Job Advancement Quest Scripts + * + * Quest Chain Pattern (Level 120): + * - 6900-6904: Warrior (Dark Knight, Hero, Paladin) + * - 6910-6914: Magician (Arch Mage F/P, Arch Mage I/L, Bishop) + * - 6920-6924: Bowman (Bow Master, Marksman) + * - 6930-6934: Thief (Night Lord, Shadower) + * - 6940-6944: Pirate (Buccaneer, Corsair) + */ +public final class Explorer4thJob extends ScriptHandler { + + // ======================================== + // WARRIOR 4TH JOB QUEST CHAIN (6900-6904) + // ======================================== + + @Script("q6900s") + public static void q6900s(ScriptManager sm) { + // Quest 6900 - Tylus' Introduction Letter (START) + // NPC: Tylus (2020008) in El Nath + final int LETTER_OF_INTRODUCTION = 4031342; + + sm.sayNext("It's been a quite a while since I've last seen you. I'm happy to see you improved so much. Do you realize the hidden strength within you? You must have some reason to see me. What can I do for you?"); + + final int answer = sm.askMenu("I knew that you would return to see me someday. But I have no power to make your wish come true. Go to #bMinar Forest#k. If you find #b#p2081100##k, she may be able help you. Would you like to meet her?", + java.util.Map.of(0, "I want the 4th job advancement.")); + + if (answer == 0) { + if (sm.askYesNo("I'll recommend you to him. Hope you get stronger!")) { + sm.forceStartQuest(6900); + sm.addItem(LETTER_OF_INTRODUCTION, 1); + sm.sayNext("Remember. Bishop of Minar forest, #b#p2081100##k. Please see him."); + } else { + sm.sayOk("Aren't you here to do the 4th job advancement? If not, that's fine."); + } + } + } + + @Script("q6900e") + public static void q6900e(ScriptManager sm) { + // Quest 6900 - Tylus' Introduction Letter (END) + // NPC: Harmonia (2081100) in Leafre + final int LETTER_OF_INTRODUCTION = 4031342; + + if (!sm.hasItem(LETTER_OF_INTRODUCTION, 1)) { + sm.sayOk("What are you doing here?"); + return; + } + + sm.sayNext("Why do you want to see me, young warrior.."); + sm.sayBoth("#b#p2020008##k?... Is he the one in El Nath? Then I can trust you."); + + if (sm.askYesNo("A young warrior who wants increase their power. I have to tell you something. Talk to me only if you're ready to hear the truth. Many secrets will be revealed...")) { + sm.removeItem(LETTER_OF_INTRODUCTION, 1); + sm.forceCompleteQuest(6900); + sm.addExp(20000); + sm.sayNext("Good. Talk to me when you're ready to hear the first story."); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q6901s") + public static void q6901s(ScriptManager sm) { + // Quest 6901 - Harmonia's First Story (START) + sm.sayNext("The first story is about the town vanished into lava volcano. Would you like to listen?"); + + if (sm.askYesNo("Good. You should listen to the story. Are you ready?")) { + sm.forceStartQuest(6901); + sm.sayNext("Have you ever been to the deep lava volcano in the El Nath mountains? There used to be a town there."); + sm.sayBoth("People in the town worshipped a human shaped stone statue and the volcano. They built an altar and stone statue under the tree at the basin of the deepest volcano and worshiped the altar to prove their faith."); + sm.sayBoth("Then disaster struck. It was the wicked Zakum's tree. Zakum's spirit didn't have a body but instead possessed the stone statue that people built. His evil rapidly spread through the town..."); + sm.sayBoth("After that, the town disappeared. You've heard about the fearsome power called #bZakum#k sleeping under the lava volcano, as you've traveled the Maple World for a long time. Now you know how he came to be. You can guess what happened to the townspeople..."); + } else { + sm.sayOk("Are you afraid? You've come this far..."); + } + } + + @Script("q6901e") + public static void q6901e(ScriptManager sm) { + // Quest 6901 - Harmonia's First Story (END) + sm.forceCompleteQuest(6901); + sm.sayOk("That's the end of the first story. Talk to me when you're ready to listen to the second story."); + } + + @Script("q6902s") + public static void q6902s(ScriptManager sm) { + // Quest 6902 - Harmonia's Second Truth (START) + sm.sayNext("The second story is about a growing stone and the Aquarium. Do you want to hear about it?"); + + if (sm.askYesNo("Good. You deserve to listen to the story. Are you ready?")) { + sm.forceStartQuest(6902); + sm.sayNext("Have you been to the Aquarium under the sea? It is a mysterious place, floating over the valley of the deep sea. Haven't you wondered how it floats?"); + sm.sayBoth("There is a stone called the #bHolychoras#k underneath the valley of the deep sea. Nobody knows how came to be there, but it has been there for ages."); + sm.sayBoth("#bHolychoras#k has a strange power that purifies the sea. That's how Aqua Road is purified and the Aquarium can float over the valley under the sea. If #bHolychoras#k disappears, the sea will grow dark..."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged."); + } + } + + @Script("q6902e") + public static void q6902e(ScriptManager sm) { + // Quest 6902 - Harmonia's Second Truth (END) + sm.forceCompleteQuest(6902); + sm.sayOk("This is the end of the second story. Talk to me when you're ready to listen to the third story."); + } + + @Script("q6903s") + public static void q6903s(ScriptManager sm) { + // Quest 6903 - Harmonia's Third Story (START) + sm.sayNext("The third is the story about the lake where time stopped. Do you wanna listen?"); + + if (sm.askYesNo("Yes, you do have the right to listen to this story. Are you ready?")) { + sm.forceStartQuest(6903); + sm.sayNext("When you go to the north, there's a big lake. Bigger than a forest! People call this lake Ludus or the lake where time stopped."); + sm.sayBoth("Near the Lake lies Ludibrium Castle, supported by huge towers. The Holy Power of the clock tower in the middle of Ludibrium Castle protects the castle by stopping the time there."); + sm.sayBoth("But the dimensional crack in Ludibrium castle is getting wider and wider. A wicked power has invaded through the crack and changed the castle. You could probably feel the power of beings from the other dimension. They are called Alishar and Papulatus, and both are getting stronger."); + sm.sayBoth("You know why I'm telling you this story. The 4th job advancement requires more responsibility. You must know what you'll be up against."); + sm.sayBoth("You have to understand the Maple World more and behave as a true hero. Now I'll give you the last task for the 4th job advancement."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged..."); + } + } + + @Script("q6903e") + public static void q6903e(ScriptManager sm) { + // Quest 6903 - Harmonia's Third Story (END) + sm.forceCompleteQuest(6903); + sm.sayOk("Talk to me when you're ready."); + } + + @Script("q6904s") + public static void q6904s(ScriptManager sm) { + // Quest 6904 - Hero's Quality (START) + sm.sayNext("Now I'll give you the last task to do the 4th job advancement."); + + if (sm.askYesNo("Get me two things: #b#t4031343##k and #b#t4031344##k. Are you ready?")) { + sm.forceStartQuest(6904); + sm.sayNext("Go and get #b#t4031343##k and #b#t4031344##k."); + sm.sayOk("It's up to you how you get it. If you want to use your power and courage, you can catch #bManon and Griffey#k. If you wanna use wisdom, you can get them through #b#p2081000##k in Leafre."); + } else { + sm.sayOk("What are you afraid of? Great power awaits you.."); + } + } + + @Script("q6904e") + public static void q6904e(ScriptManager sm) { + // Quest 6904 - Hero's Quality (END) + final int HEROIC_PENTAGON = 4031343; + final int HEROIC_STAR = 4031344; + + if (!sm.hasItem(HEROIC_PENTAGON, 1) || !sm.hasItem(HEROIC_STAR, 1)) { + sm.sayOk("You haven't gathered #b#t4031343##k and #b#t4031344##k. That's will prove your quality."); + return; + } + + sm.removeItem(HEROIC_PENTAGON, 1); + sm.removeItem(HEROIC_STAR, 1); + sm.forceCompleteQuest(6904); + sm.addExp(50000); + sm.sayNext("You proved your quality as a hero."); + sm.sayOk("Now, what lies before you is the Way of a #bWarrior#k. Talk to me again if you are ready for the 4th job Advancement."); + } + + // ======================================== + // THIEF 4TH JOB QUEST CHAIN (6930-6934) + // ======================================== + + @Script("q6930s") + public static void q6930s(ScriptManager sm) { + // Quest 6930 - Arec's Letter of Introduction (START) + // NPC: Arec (2020011) in El Nath + final int LETTER_OF_INTRODUCTION = 4031516; + + sm.sayNext("Long time no see. I heard about you. You seem to have had a hard time. Did you find darkness within you? Then you must have a reason for being here. What can I do for you?"); + + final int answer = sm.askMenu("I knew that you would return to see me someday. But I have no power to make your wish come true. Go to #bMinar Forest#k. If you find #b#p2081400##k, she may be able help you. Would you like to meet her?", + java.util.Map.of(0, "I want the 4th job advancement.")); + + if (answer == 0) { + if (sm.askYesNo("I'll write a recommendation letter for you. Hope you get a new power.")) { + sm.forceStartQuest(6930); + sm.addItem(LETTER_OF_INTRODUCTION, 1); + sm.sayNext("Take this letter to #b#p2081400##k in Leafre. She will guide you on the path to ultimate power."); + } else { + sm.sayOk("Aren't you here for the 4th job advancement? If you don't want to that's fine."); + } + } + } + + @Script("q6930e") + public static void q6930e(ScriptManager sm) { + // Quest 6930 - Arec's Letter of Introduction (END) + // NPC: Hellin (2081400) in Leafre + final int LETTER_OF_INTRODUCTION = 4031516; + + if (!sm.hasItem(LETTER_OF_INTRODUCTION, 1)) { + sm.sayOk("What are you doing here?"); + return; + } + + sm.sayNext("What are you doing here, young Thief?"); + sm.sayBoth("#b#p2020011##k?... The one in El Nath? Then I can trust you."); + + if (sm.askYesNo("A young Thief dreaming of being a Nightlord or Shadower. I have a few stories to tell you, my stealthy friend. Are you ready?")) { + sm.removeItem(LETTER_OF_INTRODUCTION, 1); + sm.forceCompleteQuest(6930); + sm.sayNext("Good. Talk to me when you're ready to hear the first story."); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q6931s") + public static void q6931s(ScriptManager sm) { + // Quest 6931 - Hellin's First Story (START) + sm.sayNext("The first story is about the town vanished into lava volcano. Would you like to listen?"); + + if (sm.askYesNo("Good. You have the right to listen to the story. Are you ready?")) { + sm.forceStartQuest(6931); + sm.sayNext("Have you ever been to the deep lava volcano in the El Nath mountains? There used to be a town there."); + sm.sayBoth("People in the town worshipped a human shaped stone statue and the volcano. They built an altar and stone statue under the tree at the basin of the deepest volcano and worshiped the altar to prove their faith."); + sm.sayBoth("Then disaster struck. It was the wicked Zakum's tree. Zakum's spirit didn't have a body but instead possessed the stone statue that people built. His evil rapidly spread through the town..."); + sm.sayBoth("After that, the town disappeared. You've heard about the fearsome power called #bZakum#k sleeping under the lava volcano, as you've traveled the Maple World for a long time. Now you know how he came to be. You can guess what happened to the townspeople.."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged."); + } + } + + @Script("q6931e") + public static void q6931e(ScriptManager sm) { + // Quest 6931 - Hellin's First Story (END) + sm.forceCompleteQuest(6931); + sm.sayOk("That's the end of the first story. Talk to me when you're ready to listen to the second story."); + } + + @Script("q6932s") + public static void q6932s(ScriptManager sm) { + // Quest 6932 - Hellin's Second Truth (START) + sm.sayNext("The second story is about a growing stone and the Aquarium. Do you want to hear about it?"); + + if (sm.askYesNo("Good. You deserve to listen to the story. Are you ready?")) { + sm.forceStartQuest(6932); + sm.sayNext("Have you been to the Aquarium under the sea? It is a mysterious place, floating over the valley of the deep sea. Haven't you wondered how it floats?"); + sm.sayBoth("There is a stone called the #bHolychoras#k underneath the valley of the deep sea. Nobody knows how came to be there, but it has been there for ages."); + sm.sayBoth("#bHolychoras#k has a strange power that purifies the sea. That's how Aqua Road is purified and the Aquarium can float over the valley under the sea. If #bHolychoras#k disappears, the sea will grow dark..."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged."); + } + } + + @Script("q6932e") + public static void q6932e(ScriptManager sm) { + // Quest 6932 - Hellin's Second Truth (END) + sm.forceCompleteQuest(6932); + sm.sayOk("This is the end of the second story. Talk to me when you're ready to listen to the third story."); + } + + @Script("q6933s") + public static void q6933s(ScriptManager sm) { + // Quest 6933 - Hellin's Third Story (START) + sm.sayNext("The third is the story about the lake where time stopped. Do you wanna listen?"); + + if (sm.askYesNo("Yes, you do have the right to listen to the stories. Are you ready?")) { + sm.forceStartQuest(6933); + sm.sayNext("When you go to the north, there's a big lake. Bigger than a forest! People call this lake Ludus or the lake where the time stopped."); + sm.sayBoth("Near the Lake lies Ludibrium Castle, supported by huge towers. The Holy Power of the clock tower in the middle of Ludibrium Castle protects the castle by stopping the time there."); + sm.sayBoth("But the dimensional crack in Ludibrium castle is getting wider and wider. A wicked power has invaded through the crack and changed the castle. You could probably feel the power of beings from the other dimension. They are called Alishar and Papulatus, and both are getting stronger."); + sm.sayBoth("You know why I'm telling you this story. The 4th job advancement requires more responsibility. You must know what you'll be up against."); + sm.sayBoth("You have to understand the Maple World more and behave as a true hero. Now I'll give you the last task for the 4th job advancement."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged."); + } + } + + @Script("q6933e") + public static void q6933e(ScriptManager sm) { + // Quest 6933 - Hellin's Third Story (END) + sm.forceCompleteQuest(6933); + sm.sayOk("Talk to me when you're ready for the final trial."); + } + + @Script("q6934s") + public static void q6934s(ScriptManager sm) { + // Quest 6934 - Hero's Quality (START) + sm.sayNext("Now I'll give you the last task to do the 4th job advancement."); + + if (sm.askYesNo("Get me two things: #b#t4031517##k and #b#t4031518##k. Are you ready?")) { + sm.forceStartQuest(6934); + sm.sayNext("It's up to you how you get it. If you wanna use your power and courage, you can catch #bManon and Griffey#k. If you wanna use wisdom and warm heart, you can get them through #b#p2081000##k in Leafre."); + } else { + sm.sayOk("What are you afraid of? Great power awaits you..."); + } + } + + @Script("q6934e") + public static void q6934e(ScriptManager sm) { + // Quest 6934 - Hero's Quality (END) + final int HEROIC_STAR = 4031517; // Heroic Star + final int HEROIC_PENTAGON = 4031518; // Heroic Pentagon + + if (!sm.hasItem(HEROIC_STAR, 1) || !sm.hasItem(HEROIC_PENTAGON, 1)) { + sm.sayOk("Haven't you got #b#t4031517##k and #b#t4031518##k?"); + return; + } + + sm.removeItem(HEROIC_STAR, 1); + sm.removeItem(HEROIC_PENTAGON, 1); + sm.forceCompleteQuest(6934); + sm.sayNext("You proved your quality as a hero."); + sm.sayOk("Now you only have to go to the way of a Shadower or Nightlord. Talk to me if you're ready for the 4th job advancement."); + } + + // ======================================== + // MAGICIAN 4TH JOB QUEST CHAIN (6910-6914) + // ======================================== + + @Script("q6910s") + public static void q6910s(ScriptManager sm) { + // Quest 6910 - Robeira's Introduction Letter (START) + // NPC: Robeira (2020009) in El Nath + final int LETTER_OF_INTRODUCTION = 4031510; + + sm.sayNext("Long time no see. I'm happy to see you improved. Did you find the truth in your mind? Then you must have some reason to be here. What can I do for you?"); + + final int answer = sm.askMenu("Yes. I was expecting you. But I don't have enough power to help you. Go to #bMinar Forest#k. #b#p2081200##k will help make your dream come true. Do you want to see him?", + java.util.Map.of(0, "I want the 4th job advancement.")); + + if (answer == 0) { + if (sm.askYesNo("Then I'll recommend you to him. Don't be rude to him. May you find the power you seek!")) { + sm.forceStartQuest(6910); + sm.addItem(LETTER_OF_INTRODUCTION, 1); + sm.sayNext("Remember. The bishop of Minar Forest, #b#p2081200##k. Please see him."); + } else { + sm.sayOk("Aren't you here for the 4th job advancement? If you don't want it, that's fine."); + } + } + } + + @Script("q6910e") + public static void q6910e(ScriptManager sm) { + // Quest 6910 - Robeira's Introduction Letter (END) + // NPC: Bishop (2081200) in Leafre + final int LETTER_OF_INTRODUCTION = 4031510; + + if (!sm.hasItem(LETTER_OF_INTRODUCTION, 1)) { + sm.sayOk("What are you doing here?"); + return; + } + + sm.sayNext("What are you doing here young magician?"); + sm.sayBoth("#b#p2020009##k?... That's the one who lives in El Nath. If she recommended you, I can trust you."); + + if (sm.askYesNo("A young magician dreaming of being an Arch Mage. I have to tell you a few stories. Talk to me when you're ready.")) { + sm.removeItem(LETTER_OF_INTRODUCTION, 1); + sm.forceCompleteQuest(6910); + sm.addExp(20000); + sm.sayNext("Good. Talk to me when you're ready to hear the first story."); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q6911s") + public static void q6911s(ScriptManager sm) { + // Quest 6911 - Bishop's First Story (START) + sm.sayNext("The first story is about the town vanished into lava volcano. Would you like to listen?"); + + if (sm.askYesNo("Good. You should listen to the story. Are you ready?")) { + sm.forceStartQuest(6911); + sm.sayNext("Have you ever been to the deep lava volcano in the El Nath mountains? There used to be a town there."); + sm.sayBoth("People in the town worshipped a human shaped stone statue and the volcano. They built an altar and stone statue under the tree at the basin of the deepest volcano and worshiped the altar to prove their faith."); + sm.sayBoth("Then disaster struck. It was the wicked Zakum's tree. Zakum's spirit didn't have a body but instead possessed the stone statue that people built. His evil rapidly spread through the town..."); + sm.sayBoth("After that, the town disappeared. You've heard about the fearsome power called #bZakum#k sleeping under the lava volcano, as you've traveled the Maple World for a long time. Now you know how he came to be. You can guess what happened to the townspeople..."); + } else { + sm.sayOk("Are you afraid? You've come this far..."); + } + } + + @Script("q6911e") + public static void q6911e(ScriptManager sm) { + // Quest 6911 - Bishop's First Story (END) + sm.forceCompleteQuest(6911); + sm.sayOk("That's the end of the first story. Talk to me when you're ready to listen to the second story."); + } + + @Script("q6912s") + public static void q6912s(ScriptManager sm) { + // Quest 6912 - Bishop's Second Truth (START) + sm.sayNext("The second story is about a growing stone and the Aquarium. Do you want to hear about it?"); + + if (sm.askYesNo("Good. You deserve to listen to the story. Are you ready?")) { + sm.forceStartQuest(6912); + sm.sayNext("Have you been to the Aquarium under the sea? It is a mysterious place, floating over the valley of the deep sea. Haven't you wondered how it floats?"); + sm.sayBoth("There is a stone called the #bHolychoras#k underneath the valley of the deep sea. Nobody knows how came to be there, but it has been there for ages."); + sm.sayBoth("#bHolychoras#k has a strange power that purifies the sea. That's how Aqua Road is purified and the Aquarium can float over the valley under the sea. If #bHolychoras#k disappears, the sea will grow dark..."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged."); + } + } + + @Script("q6912e") + public static void q6912e(ScriptManager sm) { + // Quest 6912 - Bishop's Second Truth (END) + sm.forceCompleteQuest(6912); + sm.sayOk("This is the end of the second story. Talk to me when you're ready to listen to the third story."); + } + + @Script("q6913s") + public static void q6913s(ScriptManager sm) { + // Quest 6913 - Bishop's Third Story (START) + sm.sayNext("The third is the story about the lake where time stopped. Do you wanna listen?"); + + if (sm.askYesNo("Yes, you do have the right to listen to this story. Are you ready?")) { + sm.forceStartQuest(6913); + sm.sayNext("When you go to the north, there's a big lake. Bigger than a forest! People call this lake Ludus or the lake where time stopped."); + sm.sayBoth("Near the Lake lies Ludibrium Castle, supported by huge towers. The Holy Power of the clock tower in the middle of Ludibrium Castle protects the castle by stopping the time there."); + sm.sayBoth("But the dimensional crack in Ludibrium castle is getting wider and wider. A wicked power has invaded through the crack and changed the castle. You could probably feel the power of beings from the other dimension. They are called Alishar and Papulatus, and both are getting stronger."); + sm.sayBoth("You know why I'm telling you this story. The 4th job advancement requires more responsibility. You must know what you'll be up against."); + sm.sayBoth("You have to understand the Maple World more and behave as a true hero. Now I'll give you the last task for the 4th job advancement."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged..."); + } + } + + @Script("q6913e") + public static void q6913e(ScriptManager sm) { + // Quest 6913 - Bishop's Third Story (END) + sm.forceCompleteQuest(6913); + sm.sayOk("Talk to me when you're ready."); + } + + @Script("q6914s") + public static void q6914s(ScriptManager sm) { + // Quest 6914 - A Hero's Quality (START) + sm.sayNext("Now I'll give you the last task to do the 4th job advancement."); + + if (sm.askYesNo("Get me two things: #b#t4031511##k and #b#t4031512##k. Are you ready?")) { + sm.forceStartQuest(6914); + sm.sayNext("Get me #b#t4031511##k and #b#t4031512##k..."); + sm.sayOk("It's up to you how you get it. If you wanna use your power and courage, you can catch #bManon and Griffey#k. If you wanna use wisdom and warm heart, you can get them through #b#p2081000##k in Leafre."); + } else { + sm.sayOk("What are you afraid of? Great power awaits you..."); + } + } + + @Script("q6914e") + public static void q6914e(ScriptManager sm) { + // Quest 6914 - A Hero's Quality (END) + final int HEROIC_PENTAGON = 4031511; + final int HEROIC_STAR = 4031512; + + if (!sm.hasItem(HEROIC_PENTAGON, 1) || !sm.hasItem(HEROIC_STAR, 1)) { + sm.sayOk("You haven't found #b#t4031511##k and #b#t4031512##k."); + return; + } + + sm.removeItem(HEROIC_PENTAGON, 1); + sm.removeItem(HEROIC_STAR, 1); + sm.forceCompleteQuest(6914); + sm.addExp(50000); + sm.sayNext("You proved your quality as a hero."); + sm.sayOk("Now you only have to go to the way of an Arch Mage. Talk to me if you're ready for the 4th job advancement."); + } + + // ======================================== + // BOWMAN 4TH JOB QUEST CHAIN (6920-6924) + // ======================================== + + @Script("q6920s") + public static void q6920s(ScriptManager sm) { + // Quest 6920 - Rene's Introduction Letter (START) + // NPC: Rene (2020010) in El Nath + final int LETTER_OF_INTRODUCTION = 4031513; + + sm.sayNext("Long time no see. You remind me of the time when you came to me for the third advancement. Did you find the truth in your mind? You must have some reason to come to see me. What can I do for you?"); + + final int answer = sm.askMenu("I knew that you would return to see me someday. But I have no power to make your wish come true. Go to #bMinar Forest#k. If you find #b#p2081300##k, he may be able help you. Would you like to meet him?", + java.util.Map.of(0, "I want the 4th job advancement.")); + + if (answer == 0) { + if (sm.askYesNo("I'll write a recommendation letter for you. Hope you get a new power.")) { + sm.forceStartQuest(6920); + sm.addItem(LETTER_OF_INTRODUCTION, 1); + sm.sayNext("Remember. The bishop of Minar Forest, #b#p2081200##k. Please see him."); + } else { + sm.sayOk("Aren't you here for the 4th job advancement? If you don't want it, that's fine."); + } + } + } + + @Script("q6920e") + public static void q6920e(ScriptManager sm) { + // Quest 6920 - Rene's Introduction Letter (END) + // NPC: Bishop (2081300) in Leafre + final int LETTER_OF_INTRODUCTION = 4031513; + + if (!sm.hasItem(LETTER_OF_INTRODUCTION, 1)) { + sm.sayOk("What are you doing here?"); + return; + } + + sm.sayNext("What are you doing here, young Bowman?"); + sm.sayBoth("#b#p2020010##k?... The one in El Nath? Then I can trust you."); + + if (sm.askYesNo("A young Bowman dreaming of being a Bowmaster or Marksman. I have to tell you a few stories first. Talk to me when you're ready.")) { + sm.removeItem(LETTER_OF_INTRODUCTION, 1); + sm.forceCompleteQuest(6920); + sm.addExp(20000); + sm.sayNext("Good. Talk to me when you're ready to hear the first story."); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q6921s") + public static void q6921s(ScriptManager sm) { + // Quest 6921 - Bishop's First Story (START) + sm.sayNext("The first story is about the town vanished into lava volcano. Would you like to listen?"); + + if (sm.askYesNo("Good. You should listen to the story. Are you ready?")) { + sm.forceStartQuest(6921); + sm.sayNext("Have you ever been to the deep lava volcano in the El Nath mountains? There used to be a town there."); + sm.sayBoth("People in the town worshipped a human shaped stone statue and the volcano. They built an altar and stone statue under the tree at the basin of the deepest volcano and worshiped the altar to prove their faith."); + sm.sayBoth("Then disaster struck. It was the wicked Zakum's tree. Zakum's spirit didn't have a body but instead possessed the stone statue that people built. His evil rapidly spread through the town..."); + sm.sayBoth("After that, the town disappeared. You've heard about the fearsome power called #bZakum#k sleeping under the lava volcano, as you've traveled the Maple World for a long time. Now you know how he came to be. You can guess what happened to the townspeople..."); + } else { + sm.sayOk("Are you afraid? You've come this far..."); + } + } + + @Script("q6921e") + public static void q6921e(ScriptManager sm) { + // Quest 6921 - Bishop's First Story (END) + sm.forceCompleteQuest(6921); + sm.sayOk("That's the end of the first story. Talk to me when you're ready to listen to the second story."); + } + + @Script("q6922s") + public static void q6922s(ScriptManager sm) { + // Quest 6922 - Bishop's Second Truth (START) + sm.sayNext("The second story is about a growing stone and the Aquarium. Do you want to hear about it?"); + + if (sm.askYesNo("Good. You deserve to listen to the story. Are you ready?")) { + sm.forceStartQuest(6922); + sm.sayNext("Have you been to the Aquarium under the sea? It is a mysterious place, floating over the valley of the deep sea. Haven't you wondered how it floats?"); + sm.sayBoth("There is a stone called the #bHolychoras#k underneath the valley of the deep sea. Nobody knows how came to be there, but it has been there for ages."); + sm.sayBoth("#bHolychoras#k has a strange power that purifies the sea. That's how Aqua Road is purified and the Aquarium can float over the valley under the sea. If #bHolychoras#k disappears, the sea will grow dark..."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged."); + } + } + + @Script("q6922e") + public static void q6922e(ScriptManager sm) { + // Quest 6922 - Bishop's Second Truth (END) + sm.forceCompleteQuest(6922); + sm.sayOk("This is the end of the second story. Talk to me when you're ready to listen to the third story."); + } + + @Script("q6923s") + public static void q6923s(ScriptManager sm) { + // Quest 6923 - Bishop's Third Story (START) + sm.sayNext("The third is the story about the lake where time stopped. Do you wanna listen?"); + + if (sm.askYesNo("Yes, you do have the right to listen to this story. Are you ready?")) { + sm.forceStartQuest(6923); + sm.sayNext("When you go to the north, there's a big lake. Bigger than a forest! People call this lake Ludus or the lake where time stopped."); + sm.sayBoth("Near the Lake lies Ludibrium Castle, supported by huge towers. The Holy Power of the clock tower in the middle of Ludibrium Castle protects the castle by stopping the time there."); + sm.sayBoth("But the dimensional crack in Ludibrium castle is getting wider and wider. A wicked power has invaded through the crack and changed the castle. You could probably feel the power of beings from the other dimension. They are called Alishar and Papulatus, and both are getting stronger."); + sm.sayBoth("You know why I'm telling you this story. The 4th job advancement requires more responsibility. You must know what you'll be up against."); + sm.sayBoth("You have to understand the Maple World more and behave as a true hero. Now I'll give you the last task for the 4th job advancement."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged..."); + } + } + + @Script("q6923e") + public static void q6923e(ScriptManager sm) { + // Quest 6923 - Bishop's Third Story (END) + sm.forceCompleteQuest(6923); + sm.sayOk("Talk to me when you're ready."); + } + + @Script("q6924s") + public static void q6924s(ScriptManager sm) { + // Quest 6924 - Hero's Quality (START) + sm.sayNext("Now I'll give you the last task to do the 4th job advancement."); + + if (sm.askYesNo("Get me two things. Nothing too hard. You have to bring me #b#t4031514##k and #b#t4031515##k.")) { + sm.forceStartQuest(6924); + sm.sayOk("It's up to you how you get it. If you wanna use your power and courage, you can catch #bManon and Griffey#k. If you wanna use wisdom and warm heart, you can get them through #b#p2081000##k in Leafre."); + } else { + sm.sayOk("What are you afraid of? Great power awaits you..."); + } + } + + @Script("q6924e") + public static void q6924e(ScriptManager sm) { + // Quest 6924 - Hero's Quality (END) + final int HEROIC_PENTAGON = 4031514; + final int HEROIC_STAR = 4031515; + + if (!sm.hasItem(HEROIC_PENTAGON, 1) || !sm.hasItem(HEROIC_STAR, 1)) { + sm.sayOk("You haven't got #b#t4031514##k and #b#t4031515##k..."); + return; + } + + sm.removeItem(HEROIC_PENTAGON, 1); + sm.removeItem(HEROIC_STAR, 1); + sm.forceCompleteQuest(6924); + sm.addExp(50000); + sm.sayNext("You proved your quality as a hero."); + sm.sayOk("Now you only have to go to the way of a Bowmaster or Marksman. Talk to me if you're ready for the 4th job advancement."); + } + + // ======================================== + // PIRATE 4TH JOB QUEST CHAIN (6940-6944) + // ======================================== + + @Script("q6940s") + public static void q6940s(ScriptManager sm) { + // Quest 6940 - Pedro's Introduction (START) + // NPC: Pedro (2020013) in El Nath + final int LETTER_OF_INTRODUCTION = 4031859; + + sm.sayNext("It has been a long time. I have kept tabs on your steady progression. Seeing you standing before me, healthy and strong, I can sense that a lot has happened since our last encounter. Have you finally uncovered the freedom within you all this time? If so, then there must be a reason why you came all the way to see me. What is it?"); + + final int answer = sm.askMenu("I see... I have known that this day will someday come. Unfortunately, I do not have the powers to fulfill your wish. In order for you to complete this process, you'll have to head over to the #bMinar Forst#k and meet #b#p2081500##k, who should be meditating as you walk in. He may be enough to fulfill your wish. Would you like to pay a visit?", + java.util.Map.of(0, "I'd like to make the 4th job advancement.")); + + if (answer == 0) { + if (sm.askYesNo("I will write up a recommendation letter for you right now. I hope you come out of this with a wealth of new power at your disposal.")) { + sm.forceStartQuest(6940); + sm.addItem(LETTER_OF_INTRODUCTION, 1); + sm.sayNext("Remember the name. The priest of Minar Forest, #b#p2081500##k. Visit him."); + } else { + sm.sayOk("Aren't you here to see me to make the 4th job advancement? If not, then don't mind me."); + } + } + } + + @Script("q6940e") + public static void q6940e(ScriptManager sm) { + // Quest 6940 - Pedro's Introduction (END) + // NPC: Priest (2081500) in Leafre + final int LETTER_OF_INTRODUCTION = 4031859; + + if (!sm.hasItem(LETTER_OF_INTRODUCTION, 1)) { + sm.sayOk("What are you doing here?"); + return; + } + + sm.sayNext("What made you come all the way here to see me, young Pirate?"); + sm.sayBoth("#b#p2020013##k... Are you talking about the one in El Nath? If he's the one that recommended you, then you must be legit."); + + if (sm.askYesNo("Hello young Pirate, the one who strives to walk the path of the ultimate. I have a story I must share with you. When you are ready to see the truth, talk to me.")) { + sm.removeItem(LETTER_OF_INTRODUCTION, 1); + sm.forceCompleteQuest(6940); + sm.addExp(20000); + sm.sayNext("Good. Talk to me when you're ready to hear the first story."); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q6941s") + public static void q6941s(ScriptManager sm) { + // Quest 6941 - Priest's First Story (START) + sm.sayNext("The first story is about the town vanished into lava volcano. Would you like to listen?"); + + if (sm.askYesNo("Good. You should listen to the story. Are you ready?")) { + sm.forceStartQuest(6941); + sm.sayNext("Have you ever been to the deep lava volcano in the El Nath mountains? There used to be a town there."); + sm.sayBoth("People in the town worshipped a human shaped stone statue and the volcano. They built an altar and stone statue under the tree at the basin of the deepest volcano and worshiped the altar to prove their faith."); + sm.sayBoth("Then disaster struck. It was the wicked Zakum's tree. Zakum's spirit didn't have a body but instead possessed the stone statue that people built. His evil rapidly spread through the town..."); + sm.sayBoth("After that, the town disappeared. You've heard about the fearsome power called #bZakum#k sleeping under the lava volcano, as you've traveled the Maple World for a long time. Now you know how he came to be. You can guess what happened to the townspeople..."); + } else { + sm.sayOk("Are you afraid? You've come this far..."); + } + } + + @Script("q6941e") + public static void q6941e(ScriptManager sm) { + // Quest 6941 - Priest's First Story (END) + sm.forceCompleteQuest(6941); + sm.sayOk("That's the end of the first story. Talk to me when you're ready to listen to the second story."); + } + + @Script("q6942s") + public static void q6942s(ScriptManager sm) { + // Quest 6942 - Priest's Second Truth (START) + sm.sayNext("The second story is about a growing stone and the Aquarium. Do you want to hear about it?"); + + if (sm.askYesNo("Good. You deserve to listen to the story. Are you ready?")) { + sm.forceStartQuest(6942); + sm.sayNext("Have you been to the Aquarium under the sea? It is a mysterious place, floating over the valley of the deep sea. Haven't you wondered how it floats?"); + sm.sayBoth("There is a stone called the #bHolychoras#k underneath the valley of the deep sea. Nobody knows how came to be there, but it has been there for ages."); + sm.sayBoth("#bHolychoras#k has a strange power that purifies the sea. That's how Aqua Road is purified and the Aquarium can float over the valley under the sea. If #bHolychoras#k disappears, the sea will grow dark..."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged."); + } + } + + @Script("q6942e") + public static void q6942e(ScriptManager sm) { + // Quest 6942 - Priest's Second Truth (END) + sm.forceCompleteQuest(6942); + sm.sayOk("This is the end of the second story. Talk to me when you're ready to listen to the third story."); + } + + @Script("q6943s") + public static void q6943s(ScriptManager sm) { + // Quest 6943 - Priest's Third Story (START) + sm.sayNext("The third is the story about the lake where time stopped. Do you wanna listen?"); + + if (sm.askYesNo("Yes, you do have the right to listen to this story. Are you ready?")) { + sm.forceStartQuest(6943); + sm.sayNext("When you go to the north, there's a big lake. Bigger than a forest! People call this lake Ludus or the lake where time stopped."); + sm.sayBoth("Near the Lake lies Ludibrium Castle, supported by huge towers. The Holy Power of the clock tower in the middle of Ludibrium Castle protects the castle by stopping the time there."); + sm.sayBoth("But the dimensional crack in Ludibrium castle is getting wider and wider. A wicked power has invaded through the crack and changed the castle. You could probably feel the power of beings from the other dimension. They are called Alishar and Papulatus, and both are getting stronger."); + sm.sayBoth("You know why I'm telling you this story. The 4th job advancement requires more responsibility. You must know what you'll be up against."); + sm.sayBoth("You have to understand the Maple World more and behave as a true hero. Now I'll give you the last task for the 4th job advancement."); + } else { + sm.sayOk("Are you afraid? You came this far. Don't be discouraged..."); + } + } + + @Script("q6943e") + public static void q6943e(ScriptManager sm) { + // Quest 6943 - Priest's Third Story (END) + sm.forceCompleteQuest(6943); + sm.sayOk("Talk to me when you're ready."); + } + + @Script("q6944s") + public static void q6944s(ScriptManager sm) { + // Quest 6944 - Attributes of a Hero (START) + sm.sayNext("I will now give you the last task required to complete the 4th job advancement."); + + if (sm.askYesNo("It is your mission to acquire two items that I assign to you. I want #b#t4031517##k and #b#t4031518##k.")) { + sm.forceStartQuest(6944); + sm.sayNext("How you will acquire these items, I'll leave that up to you. If you want to acquire by fully utilizing your courage and physical capabilities, then you should get them through #bManon and Griffey#k. If you want to acquire them using brains and wisdom, then head to Leafre and see #b#p2081000##k."); + } else { + sm.sayOk("What is there for you to fear? You are walking the path of Pirate greatness, and you don't wish to encounter hardship?"); + } + } + + @Script("q6944e") + public static void q6944e(ScriptManager sm) { + // Quest 6944 - Attributes of a Hero (END) + final int HEROIC_PENTAGON = 4031860; + final int HEROIC_STAR = 4031861; + + if (!sm.hasItem(HEROIC_PENTAGON, 1) || !sm.hasItem(HEROIC_STAR, 1)) { + sm.sayOk("I don't think you have acquired #b#t4031517##k and #b#t4031518##k, yet."); + return; + } + + sm.removeItem(HEROIC_PENTAGON, 1); + sm.removeItem(HEROIC_STAR, 1); + sm.forceCompleteQuest(6944); + sm.addExp(50000); + sm.sayNext("You have proven your worth as a person that can be called a hero."); + sm.sayOk("What you'll need to do now is to keep walking the path of great Pirates. Talk to me when you are ready to make the job advancement."); + } +} diff --git a/src/main/java/kinoko/script/quest/ExplorerQuest.java b/src/main/java/kinoko/script/quest/ExplorerQuest.java index 42fcd326..27dfd245 100644 --- a/src/main/java/kinoko/script/quest/ExplorerQuest.java +++ b/src/main/java/kinoko/script/quest/ExplorerQuest.java @@ -120,7 +120,7 @@ public static void magician(ScriptManager sm) { sm.sayBoth("One more warning, though it's kind of obvious. Once you have chosen your job, try your best to stay alive. Every death will cost you a certain amount of experience points, and you don't want to lose those, do you?"); sm.sayBoth("Okay! This is all I can teach you. Go explore, train and better yourself. Find me when you feel like you've done all you can. I'll be waiting for you."); sm.sayPrev("Oh, and if you have any questions about being a Magician, feel free to ask. I don't know EVERYTHING, per se, but I'll help you out with all that I know of. Until then, farewell..."); - } else if (sm.getJob() == 200) { + } else if (sm.getJob() == 200 && sm.getLevel() < 30) { final Map options = Map.of( 0, "What are the basic characteristics of being a Magician?", 1, "What sort of weapons does a Magician use?", @@ -137,9 +137,1678 @@ public static void magician(ScriptManager sm) { } else if (answer == 3) { sm.sayOk("The skills available for Magicians use the high levels of intelligence and magic that they have. Also available are Magic Guard and Magic Armor, which help prevent Magicians with weak stamina from dying.\r\n\r\nTheir offensive skills are #bEnergy Bolt#k and #bMagic Claw#k. Firstly, Energy Bolt is a skill that applies a lot of damage to an opponent with minimal use of MP.\r\n\r\nMagic Claw, on the other hand, uses up a lot of MP to attack multiple opponents TWICE. But, you can only use it once Energy Bolt is at least Level 1, so keep that in mind. Whatever you choose to do, it's all up to you..."); } + } else if (sm.getJob() == 200) { + // 2nd Job Advancement: Magician → Wizard (F/P), Wizard (I/L), Cleric + final int jobChangeLevel = JobConstants.getJobChangeLevel(Job.WIZARD_FP.getJobId(), 1, 2); + if (sm.getLevel() < jobChangeLevel) { + sm.sayOk("You are still weak. Train more and grow stronger. Come back when you reach #bLevel " + jobChangeLevel + "#k and we can talk about your advancement."); + return; + } + sm.sayNext("You have grown much stronger since you first became a Magician. I can sense your magical power growing. You are ready to choose your path. There are three paths available to you."); + final Map jobOptions = new HashMap<>(); + jobOptions.put((int) Job.WIZARD_FP.getJobId(), "#bWizard (Fire/Poison)#k - Master of fire and poison magic. Devastating area-of-effect damage."); + jobOptions.put((int) Job.WIZARD_IL.getJobId(), "#bWizard (Ice/Lightning)#k - Master of ice and lightning magic. Powerful single-target and freezing abilities."); + jobOptions.put((int) Job.CLERIC.getJobId(), "#bCleric#k - Holy magic user with healing and support abilities. Essential for party play."); + final int selectedJob = sm.askMenu("Which path of magic calls to you?#b", jobOptions); + + String jobName; + String jobDescription; + if (selectedJob == Job.WIZARD_FP.getJobId()) { + jobName = "Wizard (Fire/Poison)"; + jobDescription = "Fire/Poison Wizards command the destructive forces of fire and poison. Your spells will burn and corrode your enemies, dealing massive area damage over time. This path is perfect for those who want to see their enemies engulfed in flames."; + } else if (selectedJob == Job.WIZARD_IL.getJobId()) { + jobName = "Wizard (Ice/Lightning)"; + jobDescription = "Ice/Lightning Wizards wield the power of ice and lightning. Your spells will freeze and shock your enemies, dealing high damage while controlling the battlefield. This path is perfect for those who want precision and control."; + } else { + jobName = "Cleric"; + jobDescription = "Clerics are blessed with holy magic. Your spells can heal allies and smite undead enemies with holy light. You will be invaluable in party situations. This path is perfect for those who want to support their allies while still dealing respectable damage."; + } + + if (!sm.askYesNo("You wish to walk the path of the #b" + jobName + "#k. " + jobDescription + "\r\n\r\nThis decision is permanent. Once chosen, you cannot change your path. Are you certain this is what you want?")) { + sm.sayOk("Take your time to consider. This is an important decision. Come back when you've made up your mind."); + return; + } + + sm.sayNext("Very well! You are now a #b" + jobName + "#k! Your magical abilities have increased dramatically. Use your new powers wisely!"); + sm.setJob(Job.getById(selectedJob)); + sm.sayBoth("You have gained access to new spells as a " + jobName + ". Open your skill window and study your new abilities carefully. Each spell has its purpose."); + sm.sayBoth("Continue to study and train. When you reach #bLevel 70#k, return to me for your 3rd job advancement. Greater power awaits you!"); + } else if (sm.getJob() == 210 || sm.getJob() == 220 || sm.getJob() == 230) { + // 3rd Job Advancement - Direct to El Nath + sm.sayOk("Your magical power has grown immensely! To advance to 3rd job, you must travel to #bEl Nath#k and seek out the magician instructor there."); + } else if (sm.getJob() == 211 || sm.getJob() == 221 || sm.getJob() == 231) { + // 4th Job Advancement - Direct to El Nath for quest + sm.sayOk("You have reached incredible magical mastery! To achieve your 4th job advancement, you must travel to #bEl Nath#k and speak with the instructor there. They will guide you on your final trial."); } else { sm.sayOk("Would you like to have the power of nature itself in your hands? It may be a long, hard road to be on, but you'll surely be rewarded in the end, reaching the very top of wizardry..."); - // TODO 2nd, 3rd job advancement handling } } + + @Script("warrior") + public static void warrior(ScriptManager sm) { + // Dances with Balrog : Warrior Instructor (1022000) + // Perion : Warrior Sanctuary (102000003) + if (sm.getJob() == 0) { + final int jobChangeLevel = JobConstants.getJobChangeLevel(Job.WARRIOR.getJobId(), 0, 1); + sm.sayNext("So you want to become the Warrior? You need to meet some requirements to become one. You better check and see if you meet them. At least #bLevel " + jobChangeLevel + "#k. Let's see...."); + if (sm.getLevel() < jobChangeLevel) { + sm.sayOk("You need more training to be a Warrior. Please come back when you are much stronger."); + return; + } + if (!sm.askYesNo("Oh, you look strong enough. Great! You definitely have the look of a Warrior. What do you think? Do you want to become a Warrior?")) { + sm.sayOk("Really? Have to give more thought to it, huh? Take your time. This is not something you should take lightly... come talk to me once you have made your decision."); + return; + } + sm.sayNext("Alright! You are the Warrior from here on out! It isn't much, but I'll give you a little bit of what I have..."); + if (!sm.addItem(1402001, 1)) { // Wooden Club + sm.sayOk("Please make sure that you have an empty slot in your #rEQP. inventory#k and then talk to me again."); + return; + } + sm.setJob(Job.WARRIOR); + if (sm.getLevel() > jobChangeLevel) { + sm.sayBoth("I think you are a bit late with making a job advancement. But don't worry, I have compensated you with additional Skill Points that you didn't receive by making the advancement so late."); + } + sm.sayBoth("I have just given you a book that gives you the list of skills you can acquire as a Warrior. In that book, you'll find a bunch of passive skills that will help you train. These skills are always in effect, whether you use them or not."); + sm.sayBoth("You have also gotten much stronger. You can view your status window to view your improvement. You'll gain more AP and SP as you level up, so use them wisely. A Warrior's most important skill is #bPower Strike#k, so remember that."); + sm.sayBoth("I just gave you a little bit of #bSP#k. When you open up the #bSkill menu#k on the lower right corner of the screen, there are skills you can learn by using your SP. One warning, though; you can't raise them all at once. There are also skills you can acquire only after having learned a couple of skills first."); + sm.sayBoth("Now go, live as a Warrior. After you have made the job advancement, you will have a low HP, since you have moved into a different job class. But as time passes and you continue to hunt and gather experience, you'll see that Warriors are stronger than anyone else. I'll be waiting for you for when you make the 2nd job advancement."); + sm.sayPrev("Oh, and if you have any questions about being a Warrior, feel free to ask. I don't know EVERYTHING, per se, but I'll help you out with all that I know of. Until then, farewell..."); + } else if (sm.getJob() == 100 && sm.getLevel() < 30) { + final Map options = Map.of( + 0, "What are the basic characteristics of being a Warrior?", + 1, "What sort of weapons does a Warrior use?", + 2, "What kind of armor can a Warrior wear?", + 3, "What types of skills does a Warrior have?" + ); + final int answer = sm.askMenu("Any questions about being a Warrior?#b", options); + if (answer == 0) { + sm.sayOk("Warriors possess an enormous power with stamina to back it up, and they shine the brightest in melee combat situations. Regular attacks are powerful to begin with, and armed with complex skills, the job is perfect for explosive attacks.\r\n\r\nWarriors are the strongest when it comes to attacking. They have high STR, which means their physical attacks are just incredible. Not only that, because of their high levels of HP and Weapon Defense, they make great people to brawl up front in the battles. Warriors are always known to be the leaders in any fight."); + } else if (answer == 1) { + sm.sayOk("Warriors are well trained in using weapons. Their specialty lies in pole-arm style weapons, one-handed and two-handed swords, axes, and blunt weapons. There's a lot of weapons to choose from.\r\n\r\nIf you make the 2nd job advancement, you can only use a specific weapon based on the job. For example, Fighters and Crusaders only use one-handed and two-handed swords and axes. Pages and White Knights can only use one-handed swords, one-handed axes, and blunt weapons. Spearmen and Dragon Knights can only use spears and pole-arms."); + } else if (answer == 2) { + sm.sayOk("Warriors boast strong stamina and strength, and they can wear pretty much anything. You really won't have to worry much about this. Of course, armors with high Defense also require high levels, so even if you are a Warrior, it's best to plan ahead on what to wear.\r\n\r\nIt's better to wear armors with high Defense and many options, rather than choosing ones because they look good. Warriors have many options as for armor, so look around for a powerful set. Armors with 2 slots can equip scrolls that are good, so make sure to purchase it with a few slots available."); + } else if (answer == 3) { + sm.sayOk("The skills for Warriors are mostly to help them attack more effectively and inflict bigger damage. That's why Power Strike and Slash Blast are so popular.\r\n\r\nBesides the attacking skills, Warriors also have defensive skills such as #bWeapon Mastery#k and #bWeapon Booster#k that allows Warriors to boost up their expertise on weapons, using it to its maximum potential, along with #bIron Body#k and #bPower Guard#k to help you defend against enemy attacks. Make sure you keep your eyes on them, because you never know when you might need them."); + } + } else if (sm.getJob() == 100) { + // 2nd Job Advancement: Warrior → Fighter/Page/Spearman + final int jobChangeLevel = JobConstants.getJobChangeLevel(Job.FIGHTER.getJobId(), 1, 2); + if (sm.getLevel() < jobChangeLevel) { + sm.sayOk("You're still weak. You need to be much stronger in order to advance to the 2nd job. Please train yourself and come back to me when you feel like you are much stronger than you are right now."); + return; + } + sm.sayNext("Hmmm... You have grown much stronger. I can see that you are ready to advance to the next level. There are three paths available to Warriors. Which path will you choose?"); + final Map jobOptions = new HashMap<>(); + jobOptions.put((int) Job.FIGHTER.getJobId(), "#bFighter#k - Master of swords and axes. Fighters are close-combat specialists who excel at dealing massive damage."); + jobOptions.put((int) Job.PAGE.getJobId(), "#bPage#k - Defender who uses swords, axes, and blunt weapons along with a shield. High defense and HP."); + jobOptions.put((int) Job.SPEARMAN.getJobId(), "#bSpearman#k - Long-range melee specialist using spears and polearms. Balanced offense and defense."); + final int selectedJob = sm.askMenu("Which job advancement would you like to make?#b", jobOptions); + + String jobName; + String jobDescription; + if (selectedJob == Job.FIGHTER.getJobId()) { + jobName = "Fighter"; + jobDescription = "Fighters are the most aggressive of all Warriors. They specialize in dealing massive damage using two-handed swords and axes. Their skills focus on pure offensive power, making them perfect for players who want to deal the highest damage possible."; + } else if (selectedJob == Job.PAGE.getJobId()) { + jobName = "Page"; + jobDescription = "Pages are defensive Warriors who use shields to protect themselves. They excel at tanking damage and protecting party members. Their skills focus on defense and survivability, making them perfect for players who want to be at the front lines."; + } else { + jobName = "Spearman"; + jobDescription = "Spearmen use long-range melee weapons to attack from a safer distance. They have a good balance of offense and defense. Their skills focus on crowd control and sustained damage, making them perfect for players who want versatility."; + } + + if (!sm.askYesNo("So you have chosen the path of the #b" + jobName + "#k. " + jobDescription + "\r\n\r\nOnce you have made your decision, you will not be able to turn back. Are you sure about this?")) { + sm.sayOk("You don't have to make your decision right now. Come back when you're ready."); + return; + } + + sm.sayNext("Alright! You are now a #b" + jobName + "#k! You have chosen wisely. From this point forward, your training will be much harder, but the rewards will be great. Train hard and you will become even stronger!"); + sm.setJob(Job.getById(selectedJob)); + sm.sayBoth("You have gained new skills as a " + jobName + ". Open your skill window and check out your new abilities. Make sure to use your SP wisely!"); + sm.sayBoth("I have also given you additional SP as a reward for your advancement. Continue training and when you reach #bLevel 70#k, come back to me for your 3rd job advancement."); + } else if (sm.getJob() == 110 || sm.getJob() == 120 || sm.getJob() == 130) { + // 3rd Job Advancement - Direct to El Nath + sm.sayOk("You have grown very strong! To advance to 3rd job, you must travel to #bEl Nath#k and seek out the warrior instructor there."); + } else if (sm.getJob() == 111 || sm.getJob() == 121 || sm.getJob() == 131) { + // 4th Job Advancement - Direct to El Nath for quest + sm.sayOk("You have reached an incredible level of power! To achieve your 4th job advancement, you must travel to #bEl Nath#k and speak with the instructor there. They will guide you on your final trial."); + } else { + sm.sayOk("You want more power? Then you will have to bring yourself more strength first. Come back when you have trained yourself harder."); + } + } + + @Script("bowman") + public static void bowman(ScriptManager sm) { + archer(sm); + } + + @Script("archer") + public static void archer(ScriptManager sm) { + // Athena Pierce : Bowman Instructor (1012100) + // Henesys : Bowman Instructional School (100000201) + if (sm.getJob() == 0) { + final int jobChangeLevel = JobConstants.getJobChangeLevel(Job.ARCHER.getJobId(), 0, 1); + sm.sayNext("Want to be a Bowman? Well... You need to meet some standards in order to be one. Let me see... Hmm... At least #bLevel " + jobChangeLevel + "#k. Alright, let me see how much you have been training..."); + if (sm.getLevel() < jobChangeLevel) { + sm.sayOk("Hmmm... You haven't trained enough to be a Bowman. Please train a little more until you reach Level " + jobChangeLevel + " and come see me."); + return; + } + if (!sm.askYesNo("You look like you can be a Bowman. Great! But I have to make sure, you know... So do you really want to become the Bowman?")) { + sm.sayOk("Really? Well, this is understandable. This is not an easy decision to make. But if you wish to become the Bowman, come back to me."); + return; + } + sm.sayNext("Nice! Your training and your reflexes are great! From here on out, you'll live as a Bowman! I'll make you stronger than you are right now. I'll make you a tough bowman. Hahaha!"); + if (!sm.addItem(1452002, 1)) { // Beginner Hunter's Bow + sm.sayOk("Please make sure that you have an empty slot in your #rEQP. inventory#k and then talk to me again."); + return; + } + sm.setJob(Job.ARCHER); + if (sm.getLevel() > jobChangeLevel) { + sm.sayBoth("I think you are a bit late with making a job advancement. But don't worry, I have compensated you with additional Skill Points that you didn't receive by making the advancement so late."); + } + sm.sayBoth("I just gave you a little bit of #bSP#k. When you open up the #bSkill menu#k on the lower right corner of the screen, there are skills you can learn by using your SP. One warning, though; you can't raise them all at once. There are also skills you can acquire only after having learned a couple of skills first."); + sm.sayBoth("Now... I'll explain to you the characteristics of a Bowman. First, you must know how to use a bow and a crossbow. Bows are better for long-distance attacks, whereas crossbows offer more explosive attacking power. It's up to you to pick a weapon that fits your style."); + sm.sayBoth("Unlike other warriors, Bowman possesses weak stamina and strength, but is blessed with dexterity and power that will help you attack enemies from afar. We provide support to those that are in the heat of the battle."); + sm.sayBoth("One more warning: Once you become a Bowman, you'll be a lot weaker than ever. But as you train and train, you'll see yourself getting stronger and stronger. Believe in yourself!"); + sm.sayPrev("Oh, and if you have any questions about being a Bowman, feel free to ask. I don't know EVERYTHING, per se, but I'll help you out with all that I know of. Until then, farewell..."); + } else if (sm.getJob() == 300 && sm.getLevel() < 30) { + final Map options = Map.of( + 0, "What are the basic characteristics of being a Bowman?", + 1, "What sort of weapons does a Bowman use?", + 2, "What kind of armor can a Bowman wear?", + 3, "What types of skills does a Bowman have?" + ); + final int answer = sm.askMenu("Any questions about being a Bowman?#b", options); + if (answer == 0) { + sm.sayOk("Bowmen are blessed with dexterity and power, taking charge of long-distance attacks, providing support for those at the front line of the battle. Very adept at using landscape as part of the arsenal.\r\n\r\nBowmen also possess high accuracy and avoidability. It doesn't have much health or defense, but its long-range attack power is better than anyone else, so you won't have to worry much about being in close combat. The higher the DEX, the higher your attack rate and accuracy will be."); + } else if (answer == 1) { + sm.sayOk("Bowmen can use bows and crossbows as weapons. One-handed or Two-handed weapons, it doesn't matter either way. Bows have long-distance attacks, whereas Crossbows offer more power. Whatever the choice, the skills you'll learn will be the same.\r\n\r\nPlease remember that the type of weapon WILL determine the job. If you like explosive attacking power, you can choose to become a Crossbowman, and if you prefer ranged attacks, you can choose to become a Hunter. This choice will have to be made once you reach Level 30 and do the 2nd job advancement."); + } else if (answer == 2) { + sm.sayOk("Unlike Warriors, Bowmen lack physical strength and stamina, so the only armors you can put on will have low defense. However, these items have a lot of DEX options, which will prove to be quite useful. Always look for armors that increases DEX. It'll help you a lot!"); + } else if (answer == 3) { + sm.sayOk("For Bowmen, skills that are available are the ones that allows you to use bows and crossbows more efficiently. It's the perfect arsenal of skills for Bowmen. You'll have #bArrow Blow#k and #bDouble Shot#k to attack enemies from a far, and you will also have #bFocus#k to boost up the weapon mastery and accuracy.\r\n\r\nIn addition, there are also #bCritical Shot#k, which allows you a certain probability of doing critical damage to the enemy, and #bThe Eye of Amazon#k, which increases the overall focus and accuracy of Bowmen. It's all very useful skills that can only be used by the Bowmen. If you get to learn a high level skill, you may also hit multiple enemies at once!"); + } + } else if (sm.getJob() == 300) { + // 2nd Job Advancement: Archer → Hunter/Crossbowman + final int jobChangeLevel = JobConstants.getJobChangeLevel(Job.HUNTER.getJobId(), 1, 2); + if (sm.getLevel() < jobChangeLevel) { + sm.sayOk("You're not ready yet. You need to train more and reach #bLevel " + jobChangeLevel + "#k before you can make the 2nd job advancement. Keep practicing!"); + return; + } + sm.sayNext("You have trained well as a Bowman. I can see your skills have improved greatly. Now it's time for you to choose your path. Will you specialize in the bow or the crossbow?"); + final Map jobOptions = new HashMap<>(); + jobOptions.put((int) Job.HUNTER.getJobId(), "#bHunter#k - Master of the bow. Specializes in rapid, long-range attacks with high mobility."); + jobOptions.put((int) Job.CROSSBOWMAN.getJobId(), "#bCrossbowman#k - Master of the crossbow. Specializes in powerful, piercing attacks with high accuracy."); + final int selectedJob = sm.askMenu("Choose your path carefully:#b", jobOptions); + + String jobName; + String jobDescription; + if (selectedJob == Job.HUNTER.getJobId()) { + jobName = "Hunter"; + jobDescription = "Hunters are masters of the bow, capable of firing arrows at incredible speeds. Your attacks will be swift and deadly, allowing you to take down enemies from a distance. This path is perfect for those who value speed and mobility."; + } else { + jobName = "Crossbowman"; + jobDescription = "Crossbowmen wield powerful crossbows that can pierce through even the toughest defenses. Your attacks pack more punch than a Hunter's, trading some speed for raw power. This path is perfect for those who value precision and stopping power."; + } + + if (!sm.askYesNo("You have chosen to become a #b" + jobName + "#k. " + jobDescription + "\r\n\r\nThis decision is final. Are you sure this is the path you want to follow?")) { + sm.sayOk("It's okay to take your time. This is an important choice. Come back when you've decided."); + return; + } + + sm.sayNext("Perfect! You are now a #b" + jobName + "#k! Your archery skills have reached a new level. Train hard and become even stronger!"); + sm.setJob(Job.getById(selectedJob)); + sm.sayBoth("As a " + jobName + ", you have gained powerful new abilities. Check your skill window to see what you can learn. Master these skills!"); + sm.sayBoth("Keep training diligently. When you reach #bLevel 70#k, come back to me for your 3rd job advancement!"); + } else if (sm.getJob() == 310 || sm.getJob() == 320) { + // 3rd Job Advancement - Direct to El Nath + sm.sayOk("Your archery skills are exceptional! To advance to 3rd job, you must travel to #bEl Nath#k and seek out the bowman instructor there."); + } else if (sm.getJob() == 311 || sm.getJob() == 321) { + // 4th Job Advancement - Direct to El Nath for quest + sm.sayOk("You have reached the pinnacle of archery! To achieve your 4th job advancement, you must travel to #bEl Nath#k and speak with the instructor there. They will guide you on your final trial."); + } else { + sm.sayOk("Would you like to be even stronger? Then you'd better train even more. I can always make you stronger once you meet my standards."); + } + } + + @Script("rogue") + public static void rogue(ScriptManager sm) { + // Dark Lord : Thief Instructor (1052001) + // Kerning City : Thieves' Hideout (103000003) + if (sm.getJob() == 0) { + final int jobChangeLevel = JobConstants.getJobChangeLevel(Job.ROGUE.getJobId(), 0, 1); + sm.sayNext("Do you want to become a Thief? You need to meet some criteria in order to do so. You need to be at least #bLevel " + jobChangeLevel + "#k. Let's see..."); + if (sm.getLevel() < jobChangeLevel) { + sm.sayOk("Hmmm, you are not strong enough. Come back to me when you are at least Level " + jobChangeLevel + "."); + return; + } + if (!sm.askYesNo("Oh, you look pretty strong. I can see that you have what it takes to be a Thief. What do you think, do you want to become a Thief?")) { + sm.sayOk("Really? Think about it carefully. If you decide to become one, come and find me."); + return; + } + sm.sayNext("Alright! I've made up my mind. You are ready to be a Thief from here on out! I'll make you stronger than you are right now. Hahaha!"); + if (!sm.addItem(1472061, 1)) { // Garnier + sm.sayOk("Please make sure that you have an empty slot in your #rEQP. inventory#k and then talk to me again."); + return; + } + sm.setJob(Job.ROGUE); + + // For Dual Blade players: Set quest info flag for quest 2351 completion + if (sm.getUser().getCharacterStat().getSubJob() == 1 && sm.hasQuestStarted(2351)) { + sm.setQRValue(7635, "1"); + } + + if (sm.getLevel() > jobChangeLevel) { + sm.sayBoth("I think you are a bit late with making a job advancement. But don't worry, I have compensated you with additional Skill Points that you didn't receive by making the advancement so late."); + } + sm.sayBoth("I have just given you a book that gives you the list of skills you can acquire as a Thief. Also, your Max HP and MP have increased, too. Check out your Stat Window now."); + sm.sayBoth("I just gave you a little bit of #bSP#k. When you open up the #bSkill menu#k on the lower right corner of the screen, there are skills you can learn by using your SP. One warning, though; you can't raise them all at once. There are also skills you can acquire only after having learned a couple of skills first."); + sm.sayBoth("Thieves have to be strong. But remember that you can't beat Warrior in physical strength; instead, you will have the highest dexterity of all. The combination of high dexterity and speed is one of the biggest assets of the Thieves."); + sm.sayBoth("If you are having trouble deciding whether to be a Thief or not, I have one word of advice for you - Thieves have many options to explore. Rather than being absolutely fixed in one form, you can experiment with your style and improve on those which suit you better. See the positive side of that flexibility."); + sm.sayPrev("Oh, and if you have any questions about being a Thief, feel free to ask. I don't know EVERYTHING, per se, but I'll help you out with all that I know of. Until then, farewell..."); + } else if (sm.getJob() == 400 && sm.getLevel() < 30) { + final Map options = Map.of( + 0, "What are the basic characteristics of being a Thief?", + 1, "What sort of weapons does a Thief use?", + 2, "What kind of armor can a Thief wear?", + 3, "What types of skills does a Thief have?" + ); + final int answer = sm.askMenu("Any questions about being a Thief?#b", options); + if (answer == 0) { + sm.sayOk("Thieves are a perfect blend of luck, dexterity, and power that are adept at surprise attacks against helpless enemies. A high level of avoidability and speed allows the thieves to attack enemies with various angles.\r\n\r\nApart from great attacking ability, Thieves also possess some skills that are simply impossible for other jobs. They can hide themselves from the radar through #bHide#k, use throwing stars through #bLucky Seven#k, summon other people to their side through #bTeleport#k, and even be immune to poison! There are so many things Thieves can do for themselves."); + } else if (answer == 1) { + sm.sayOk("Thieves have a wide variety of weapons to choose from. They can use both daggers and throwing stars. Daggers are used for close-combat situations, whereas stars can be thrown for long-distance attacks. Both weapons are quite powerful, so it will be your choice on which one to choose.\r\n\r\nThieves can't wear shields. Instead, they have access to Subani Pendant which can be used to raise all-around stat increases. Same goes with Maple Shield, which can raise weapon defense. That's not so bad, is it?"); + } else if (answer == 2) { + sm.sayOk("Thieves have high dexterity and high avoidability in general. Therefore, you don't really need an armor with high defense. What matters most is the stat increase and options on different pieces of clothing. With quickness and avoidability at hands, you don't really have to worry much about high defense."); + } else if (answer == 3) { + sm.sayOk("For Thieves, you'll find various kinds of attacking skills along with a number of supporting skills. The attacking skills are #bLucky Seven#k, a skill that allows you to throw a large number of stars at once, and #bDark Sight#k, a skill that allows you to hide yourself from the enemies and make an absolute escape.\r\n\r\nThieves are also able to boost their attacking ability through #bClaw Mastery#k, critical attack rate through #bCritical Throw#k, along with raising stats such as DEX and LUK through #bNimble Body#k. Please keep in mind that all these skills require mastery to use them, so make sure to keep training for the betterment of yourself."); + } + } else if (sm.getJob() == 400) { + // 2nd Job Advancement: Rogue → Assassin/Bandit (NO QUEST SYSTEM) + final int jobChangeLevel = JobConstants.getJobChangeLevel(Job.ASSASSIN.getJobId(), 1, 2); + + if (sm.getLevel() < jobChangeLevel) { + sm.sayOk("You're not quite strong enough yet. Train more and come back when you reach #bLevel " + jobChangeLevel + "#k."); + return; + } + + sm.sayNext("You have grown much stronger. You're ready to choose your path. Which style of thievery calls to you?"); + + final Map jobOptions = new HashMap<>(); + jobOptions.put((int) Job.ASSASSIN.getJobId(), "#bAssassin#k - Master of throwing stars and critical strikes. High burst damage."); + jobOptions.put((int) Job.BANDIT.getJobId(), "#bBandit#k - Master of daggers and melee combat. Versatile and powerful."); + final int selectedJob = sm.askMenu("Choose your path:#b", jobOptions); + + String jobName; + String jobDescription; + if (selectedJob == Job.ASSASSIN.getJobId()) { + jobName = "Assassin"; + jobDescription = "Assassins are masters of the shadows, specializing in throwing stars and deadly critical strikes. You excel at eliminating targets quickly. This path is perfect for those who value burst damage and precision."; + } else { + jobName = "Bandit"; + jobDescription = "Bandits are versatile thieves who use daggers for close combat. You have access to stealing abilities and powerful melee combos. This path is perfect for those who value flexibility."; + } + + if (!sm.askYesNo("You wish to become a #b" + jobName + "#k. " + jobDescription + "\r\n\r\nThis is a permanent choice. Are you certain?")) { + sm.sayOk("No rush. Take your time. Come back when you're ready."); + return; + } + + sm.sayNext("Perfect! You are now a #b" + jobName + "#k! Your thieving skills have evolved!"); + sm.setJob(Job.getById(selectedJob)); + sm.sayBoth("As a " + jobName + ", you've gained powerful new skills. Check your skill window!"); + sm.sayBoth("Continue training. When you reach #bLevel 70#k, return for your 3rd job advancement!"); + } else if (sm.getJob() == 410 || sm.getJob() == 420) { + // 3rd Job Advancement - Direct to El Nath + sm.sayOk("You have become a skilled thief! To advance to 3rd job, you must travel to #bEl Nath#k and seek out the thief instructor there."); + } else if (sm.getJob() == 411 || sm.getJob() == 421) { + // 4th Job Advancement - Direct to El Nath for quest + sm.sayOk("You have reached the peak of thievery! To achieve your 4th job advancement, you must travel to #bEl Nath#k and speak with the instructor there. They will guide you on your final trial."); + } else { + sm.sayOk("Hmm, do you want to be even stronger than you are now? Well, I can't do anything for you until you train more and improve yourself. Come back when you are stronger, and I'll see what I can do."); + } + } + + // Warrior 2nd job advancement NPC wrappers + @Script("fighter") + public static void fighter(ScriptManager sm) { + warrior(sm); + } + + @Script("page") + public static void page(ScriptManager sm) { + warrior(sm); + } + + @Script("spearman") + public static void spearman(ScriptManager sm) { + warrior(sm); + } + + // Magician 2nd job advancement NPC wrappers + @Script("FPwizard") + public static void FPwizard(ScriptManager sm) { + magician(sm); + } + + @Script("ILwizard") + public static void ILwizard(ScriptManager sm) { + magician(sm); + } + + @Script("cleric") + public static void cleric(ScriptManager sm) { + magician(sm); + } + + // Archer 2nd job advancement NPC wrappers + @Script("hunter") + public static void hunter(ScriptManager sm) { + archer(sm); + } + + @Script("crossbowman") + public static void crossbowman(ScriptManager sm) { + archer(sm); + } + + // Thief 2nd job advancement NPC wrappers + @Script("assassin") + public static void assassin(ScriptManager sm) { + rogue(sm); + } + + @Script("bandit") + public static void bandit(ScriptManager sm) { + rogue(sm); + } + + // Pirate 2nd job advancement NPC wrappers + @Script("brawler") + public static void brawler(ScriptManager sm) { + pirate(sm); + } + + @Script("gunslinger") + public static void gunslinger(ScriptManager sm) { + pirate(sm); + } + + // MUSHKING EMPIRE QUESTS (2300-2303) -------------------------------------------------------------------- + + @Script("q2300s") + public static void q2300s(ScriptManager sm) { + // Quest 2300 - Endangered Mushking Empire (START) - Warriors + // Dances with Balrog (1022000) - Perion + sm.sayNext("The Mushking Empire is in dire straits and in desperate need of help! As a strong Warrior, I believe you can make a difference there."); + + if (sm.askYesNo("I'd like to give you a #bletter of recommendation#k to take to #b#p1300005##k, the Head Security Officer of the Mushking Empire. Will you help them?")) { + sm.forceStartQuest(2300); + sm.addItem(4032375, 1); // Letter of Recommendation + sm.sayNext("Thank you! Please take this letter to #b#p1300005##k in the Mushking Empire. They need all the help they can get!"); + sm.sayOk("You can find the Mushking Empire by heading west from Henesys through Ghost Mushroom Forest. Good luck!"); + } else { + sm.sayOk("If you change your mind, come back and talk to me."); + } + } + + @Script("q2300e") + public static void q2300e(ScriptManager sm) { + // Quest 2300 - Endangered Mushking Empire (END) - Warriors + final int LETTER_OF_RECOMMENDATION = 4032375; + + if (!sm.hasItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("You need to bring the letter of recommendation from Dances with Balrog."); + return; + } + + sm.sayNext("Ah, you have a letter of recommendation from Dances with Balrog! Thank you for coming to help the Mushking Empire in our time of need."); + + if (sm.askYesNo("We are grateful for your assistance. Will you help us defend the Empire?")) { + sm.removeItem(LETTER_OF_RECOMMENDATION, 1); + sm.forceCompleteQuest(2300); + sm.sayOk("Thank you! The Mushking Empire is in your debt. There will be more challenges ahead, but with your help, we can overcome them!"); + } else { + sm.sayOk("Please reconsider. We really need your help."); + } + } + + @Script("q2301s") + public static void q2301s(ScriptManager sm) { + // Quest 2301 - Endangered Mushking Empire (START) - Magicians + // Grendel the Really Old (1032001) - Ellinia + sm.sayNext("The Mushking Empire is in dire straits and in desperate need of help! As a powerful Magician, I believe you can make a difference there."); + + if (sm.askYesNo("I'd like to give you a #bletter of recommendation#k to take to #b#p1300005##k, the Head Security Officer of the Mushking Empire. Will you help them?")) { + sm.forceStartQuest(2301); + sm.addItem(4032375, 1); // Letter of Recommendation + sm.sayNext("Thank you! Please take this letter to #b#p1300005##k in the Mushking Empire. They need all the help they can get!"); + sm.sayOk("You can find the Mushking Empire by heading west from Henesys through Ghost Mushroom Forest. Good luck!"); + } else { + sm.sayOk("If you change your mind, come back and talk to me."); + } + } + + @Script("q2301e") + public static void q2301e(ScriptManager sm) { + // Quest 2301 - Endangered Mushking Empire (END) - Magicians + final int LETTER_OF_RECOMMENDATION = 4032375; + + if (!sm.hasItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("You need to bring the letter of recommendation from Grendel the Really Old."); + return; + } + + sm.sayNext("Ah, you have a letter of recommendation from Grendel! Thank you for coming to help the Mushking Empire in our time of need."); + + if (sm.askYesNo("We are grateful for your assistance. Will you help us defend the Empire?")) { + sm.removeItem(LETTER_OF_RECOMMENDATION, 1); + sm.forceCompleteQuest(2301); + sm.sayOk("Thank you! The Mushking Empire is in your debt. There will be more challenges ahead, but with your help, we can overcome them!"); + } else { + sm.sayOk("Please reconsider. We really need your help."); + } + } + + @Script("q2302s") + public static void q2302s(ScriptManager sm) { + // Quest 2302 - Endangered Mushking Empire (START) - Thieves + // Dark Lord (1052001) - Kerning City + sm.sayNext("The Mushking Empire is in dire straits and in desperate need of help! As a skilled Thief, I believe you can make a difference there."); + + if (sm.askYesNo("I'd like to give you a #bletter of recommendation#k to take to #b#p1300005##k, the Head Security Officer of the Mushking Empire. Will you help them?")) { + sm.forceStartQuest(2302); + sm.addItem(4032375, 1); // Letter of Recommendation + sm.sayNext("Thank you! Please take this letter to #b#p1300005##k in the Mushking Empire. They need all the help they can get!"); + sm.sayOk("You can find the Mushking Empire by heading west from Henesys through Ghost Mushroom Forest. Good luck!"); + } else { + sm.sayOk("If you change your mind, come back and talk to me."); + } + } + + @Script("q2302e") + public static void q2302e(ScriptManager sm) { + // Quest 2302 - Endangered Mushking Empire (END) - Thieves + final int LETTER_OF_RECOMMENDATION = 4032375; + + if (!sm.hasItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("You need to bring the letter of recommendation from the Dark Lord."); + return; + } + + sm.sayNext("Ah, you have a letter of recommendation from the Dark Lord! Thank you for coming to help the Mushking Empire in our time of need."); + + if (sm.askYesNo("We are grateful for your assistance. Will you help us defend the Empire?")) { + sm.removeItem(LETTER_OF_RECOMMENDATION, 1); + sm.forceCompleteQuest(2302); + sm.sayOk("Thank you! The Mushking Empire is in your debt. There will be more challenges ahead, but with your help, we can overcome them!"); + } else { + sm.sayOk("Please reconsider. We really need your help."); + } + } + + @Script("q2303s") + public static void q2303s(ScriptManager sm) { + // Quest 2303 - Endangered Mushking Empire (START) - Bowmen + // Athena Pierce (1012100) - Henesys + sm.sayNext("The Mushking Empire is in dire straits and in desperate need of help! As a skilled Archer, I believe you can make a difference there."); + + if (sm.askYesNo("I'd like to give you a #bletter of recommendation#k to take to #b#p1300005##k, the Head Security Officer of the Mushking Empire. Will you help them?")) { + sm.forceStartQuest(2303); + sm.addItem(4032375, 1); // Letter of Recommendation + sm.sayNext("Thank you! Please take this letter to #b#p1300005##k in the Mushking Empire. They need all the help they can get!"); + sm.sayOk("You can find the Mushking Empire by heading west from Henesys through Ghost Mushroom Forest. Good luck!"); + } else { + sm.sayOk("If you change your mind, come back and talk to me."); + } + } + + @Script("q2303e") + public static void q2303e(ScriptManager sm) { + // Quest 2303 - Endangered Mushking Empire (END) - Bowmen + final int LETTER_OF_RECOMMENDATION = 4032375; + + if (!sm.hasItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("You need to bring the letter of recommendation from Athena Pierce."); + return; + } + + sm.sayNext("Ah, you have a letter of recommendation from Athena Pierce! Thank you for coming to help the Mushking Empire in our time of need."); + + if (sm.askYesNo("We are grateful for your assistance. Will you help us defend the Empire?")) { + sm.removeItem(LETTER_OF_RECOMMENDATION, 1); + sm.forceCompleteQuest(2303); + sm.sayOk("Thank you! The Mushking Empire is in your debt. There will be more challenges ahead, but with your help, we can overcome them!"); + } else { + sm.sayOk("Please reconsider. We really need your help."); + } + } + + @Script("pirate") + public static void pirate(ScriptManager sm) { + // Kyrin : Pirate Instructor (1090000) + // Nautilus : Navigation Room (120000101) + if (sm.getJob() == 0) { + final int jobChangeLevel = JobConstants.getJobChangeLevel(Job.PIRATE.getJobId(), 0, 1); + sm.sayNext("So you want to become a Pirate? Well, I have to see if you qualify to be one. You must be at least #bLevel " + jobChangeLevel + "#k. Let's see..."); + if (sm.getLevel() < jobChangeLevel) { + sm.sayOk("Hmm... You don't seem strong enough yet. Please get to Level " + jobChangeLevel + " first, then come back to me."); + return; + } + if (!sm.askYesNo("Oh, I see you meet the requirements! Would you like to become a Pirate?")) { + sm.sayOk("I see. Think carefully and see if you want to become one. If you do, come back to me."); + return; + } + sm.sayNext("Alright, from here on out you are a Pirate! Being flexible is the key to becoming a Pirate, and it will allow you to take on enemies in all situations. For now, I'll enhance your abilities a little bit and give you some SP."); + if (!sm.addItem(1482014, 1)) { // Knuckle Mace + sm.sayOk("Please make sure that you have an empty slot in your #rEQP. inventory#k and then talk to me again."); + return; + } + sm.setJob(Job.PIRATE); + if (sm.getLevel() > jobChangeLevel) { + sm.sayBoth("I think you are a bit late with making a job advancement. But don't worry, I have compensated you with additional Skill Points that you didn't receive by making the advancement so late."); + } + sm.sayBoth("I have just given you a book that gives you the list of skills you can acquire as a Pirate. Pirates are blessed with remarkable dexterity and power, using their guns for long-range attacks while using their power in melee combat situations."); + sm.sayBoth("I just gave you a little bit of #bSP#k. When you open up the #bSkill menu#k on the lower right corner of the screen, there are skills you can learn by using your SP. One warning, though; you can't raise them all at once. There are also skills you can acquire only after having learned a couple of skills first."); + sm.sayBoth("Unlike other jobs, Pirates are very versatile in combat. They can attack close-range with their fists or long-range with their guns. Gunslingers use elemental-based bullets for added damage, while Brawlers transform their energy into devastating melee attacks."); + sm.sayBoth("Remember that being a Pirate means quick reflexes and versatility in battle. Work hard and aim to be the best that you can be. I'll be here if you want to make the 2nd job advancement."); + sm.sayPrev("Oh, and if you have any questions about being a Pirate, feel free to ask. I don't know EVERYTHING, per se, but I'll help you out with all that I know of. Until then, farewell..."); + } else if (sm.getJob() == 500 && sm.getLevel() < 30) { + final Map options = Map.of( + 0, "What are the basic characteristics of being a Pirate?", + 1, "What sort of weapons does a Pirate use?", + 2, "What kind of armor can a Pirate wear?", + 3, "What types of skills does a Pirate have?" + ); + final int answer = sm.askMenu("Any questions about being a Pirate?#b", options); + if (answer == 0) { + sm.sayOk("Pirates are blessed with outstanding dexterity and power, utilizing their guns for long-range attacks while using their power in melee combat situations. Gunslingers use elemental-based bullets for added damage, while Brawlers transform their energy for maximum effect.\r\n\r\nPirates are pretty unique in that you can use both close-range and long-range attacks, depending on which path you choose. If you enjoy explosive attacks at close-range, choose to be an Brawler. If you enjoy sniping from afar, choose to be a Gunslinger."); + } else if (answer == 1) { + sm.sayOk("Pirates can use knuckles and guns as weapons. For those that enjoy pummeling their enemies up close, knuckles are the way to go. For those that enjoy shooting enemies from afar, guns are the perfect weapon. Unlike other weapons, guns require bullets to fire, so be sure to keep a good stock at all times.\r\n\r\nThe Brawler uses Knuckles to fight and the Gunslinger uses Guns to shoot. Those that choose to fight with knuckles will have some of the highest HP out of all the jobs, along with fast-paced, combo-based attacking abilities."); + } else if (answer == 2) { + sm.sayOk("Pirates possess high HP and avoidability, so the defensive ability of your armor isn't the most important factor to look for. It's more important to look for bonuses in STR and DEX when choosing armor. Of course, your armor must match your level to wear it."); + } else if (answer == 3) { + sm.sayOk("For Pirates, you have various attacking and supplemental skills at your disposal. Brawlers use #bSomersault Kick#k and #bDouble Shot#k for close combat, while Gunslingers use #bDouble Shot#k and #bInvisibility#k to shoot from afar.\r\n\r\nAll Pirates can use #bBullet Time#k, which allows you to temporarily avoid all attacks. Pirates also have #bDash#k, which allows you to cover distances very quickly. There are so many useful skills, so definitely experiment and try them all."); + } + } else if (sm.getJob() == 500) { + // 2nd Job Advancement: Pirate → Brawler/Gunslinger + final int jobChangeLevel = JobConstants.getJobChangeLevel(Job.BRAWLER.getJobId(), 1, 2); + if (sm.getLevel() < jobChangeLevel) { + sm.sayOk("You're not ready yet. Train harder and reach #bLevel " + jobChangeLevel + "#k first!"); + return; + } + sm.sayNext("You've trained well as a Pirate. I can see your potential. Now it's time to specialize. Will you fight up close with your fists, or attack from range with your guns?"); + final Map jobOptions = new HashMap<>(); + jobOptions.put((int) Job.BRAWLER.getJobId(), "#bBrawler#k - Close-combat specialist using knuckles. High HP and devastating melee combos."); + jobOptions.put((int) Job.GUNSLINGER.getJobId(), "#bGunslinger#k - Ranged specialist using guns. Elemental bullets and high mobility."); + final int selectedJob = sm.askMenu("Choose your path:#b", jobOptions); + + String jobName; + String jobDescription; + if (selectedJob == Job.BRAWLER.getJobId()) { + jobName = "Brawler"; + jobDescription = "Brawlers are melee fighters who use their fists and knuckles to deliver devastating combos. You will have some of the highest HP in the game and powerful close-range attacks. This path is perfect for those who want to get up close and personal with enemies."; + } else { + jobName = "Gunslinger"; + jobDescription = "Gunslingers use guns to attack from a distance with elemental bullets. You have high mobility and can deal consistent ranged damage. This path is perfect for those who prefer to keep their distance while dealing steady damage."; + } + + if (!sm.askYesNo("You want to become a #b" + jobName + "#k. " + jobDescription + "\r\n\r\nThis is a permanent decision. Are you sure?")) { + sm.sayOk("Take your time. Come back when you've decided."); + return; + } + + sm.sayNext("Great! You are now a #b" + jobName + "#k! Your pirate abilities have evolved. Train hard!"); + sm.setJob(Job.getById(selectedJob)); + sm.sayBoth("As a " + jobName + ", you've gained new abilities. Check your skill window!"); + sm.sayBoth("Continue training. When you reach #bLevel 70#k, return for your 3rd job advancement!"); + } else if (sm.getJob() == 510 || sm.getJob() == 520) { + // 3rd Job Advancement - Direct to El Nath + sm.sayOk("You've become a formidable pirate! To advance to 3rd job, you must travel to #bEl Nath#k and seek out the pirate instructor there."); + } else if (sm.getJob() == 511 || sm.getJob() == 521) { + // 4th Job Advancement - Direct to El Nath for quest + sm.sayOk("You have reached the peak of piracy! To achieve your 4th job advancement, you must travel to #bEl Nath#k and speak with the instructor there. They will guide you on your final trial."); + } else { + sm.sayOk("Hmmm... I think you need to train a bit more before I can help you. Come back to me once you are much stronger."); + } + } + + // DRAGON RIDER QUESTS (6008-6013) - Level 200 Congratulations ----------------------------------------------- + + @Script("q6008s") + public static void q6008s(ScriptManager sm) { + // Quest 6008 - Dragon Rider (START) - Warriors + // Harmonia (2081100) - El Nath / Leafre + sm.sayNext("I know who you are. You were born a warrior a long time ago at a town deep in the mountains, established your presence and power at the land of the snow, and... I also bestowed you of a name worthy of a great warrior."); + + sm.sayBoth("Now that you have reached the apex of this profession, I'll introduce to you a new companion. Many warriors who have taken their mind and body to the very limit, strived for it, but couldn't get it, and now you have the opportunity to get it... What do you think... do you want #t1902002#?"); + + if (sm.askYesNo("It may not be a human, but #t1902002# is much more proud than the humans... but, if it's someone like you, who's reached the level only a select few can ever reach, then it should serve you with the utmost loyalty.\r\n\r\nIf you are ready to engage in a powerful friendship with an unknown being, then let me know.")) { + sm.forceStartQuest(6008); + sm.sayNext("You are ready to meet your new companion! However, you will need to first obtain a #b#t1902001##k. This mount shows your readiness to handle an even greater creature."); + sm.sayOk("Once you have obtained the #b#t1902001##k, return to me and we will complete the bonding ceremony with #t1902002#!"); + } else { + sm.sayOk("I don't think you are ready. No one in Maple can defeat you, so why fear the change? As a warrior whose inner strength knows no limits, this should seem enticing to you..."); + } + } + + @Script("q6008e") + public static void q6008e(ScriptManager sm) { + // Quest 6008 - Dragon Rider (END) - Warriors + final int SILVER_MANE = 1902001; + final int DRAGON_MOUNT = 1902002; + + if (!sm.hasItem(SILVER_MANE, 1)) { + sm.sayOk("I don't think you are ready to fully embrace #t1902002#, yet. In order for you to meet #t1902002#, you'll first have to have some experience with #t1902001#. Until you bring me #b#t1902001##k, I am afraid I won't be able to introduce you to #t1902002#."); + return; + } + + sm.sayNext("Once you are ready to accept your new companion, step up. You will now be encountering a mysterious being totally unlike anything you've ever faced."); + + if (sm.askYesNo("Are you ready to exchange your #b#t1902001##k for the legendary #b#t1902002##k?")) { + if (!sm.removeItem(SILVER_MANE, 1)) { + sm.sayOk("There seems to be an issue. Please make sure you have #b#t1902001##k in your inventory."); + return; + } + + if (!sm.addItem(DRAGON_MOUNT, 1)) { + sm.sayOk("Please make sure you have space in your inventory."); + sm.addItem(SILVER_MANE, 1); // Return the Silver Mane + return; + } + + sm.forceCompleteQuest(6008); + sm.sayOk("You are the one who has already exceeded the boundaries and the limits of a human being... the only time the proud #t1902002# bows its head is to you. Hopefully you'll experience new, amazing adventures that are surely to await you with #t1902002#..."); + } else { + sm.sayOk("Come back when you are ready to accept this power."); + } + } + + @Script("q6009s") + public static void q6009s(ScriptManager sm) { + // Quest 6009 - Dragon Rider (START) - Magicians + // Gritto (2081200) - El Nath / Leafre + sm.sayNext("I know who you are. You were born a magician a long time ago at a town deep in the forest where mana filled the air, established your presence and power at the land of the snow, and... I also bestowed you of a name worthy of a great magician."); + + sm.sayBoth("Now that you have reached the very top of the magicians, I'll introduce to you a new companion. Many magicians who have taken their mind and body to the very limit in search of mana, strived for it, but couldn't get it, and now you have the opportunity to get it... What do you think... do you want #t1902002#?"); + + if (sm.askYesNo("It may not be a human, but #t1902002# is much more proud than the humans... but, if it's someone like you, who's reached the level only a select few can dare reach, then it should serve you with the utmost loyalty.\r\n\r\nIf you are ready to engage in a powerful friendship with an unknown being, then let me know.")) { + sm.forceStartQuest(6009); + sm.sayNext("You are ready to meet your new companion! However, you will need to first obtain a #b#t1902001##k. This mount shows your readiness to handle an even greater creature."); + sm.sayOk("Once you have obtained the #b#t1902001##k, return to me and we will complete the bonding ceremony with #t1902002#!"); + } else { + sm.sayOk("I don't think you are ready. No one in Maple can defeat you, so why fear the change? As a mage who has explored the limits of supreme magical power, this should seem enticing to you..."); + } + } + + @Script("q6009e") + public static void q6009e(ScriptManager sm) { + // Quest 6009 - Dragon Rider (END) - Magicians + final int SILVER_MANE = 1902001; + final int DRAGON_MOUNT = 1902002; + + if (!sm.hasItem(SILVER_MANE, 1)) { + sm.sayOk("I don't think you are ready to fully embrace #t1902002#, yet. In order for you to meet #t1902002#, you'll first have to have some experience with #t1902001#. Until you bring me #b#t1902001##k, I am afraid I won't be able to introduce you to #t1902002#."); + return; + } + + sm.sayNext("Once you are ready to accept your new companion, step up. You will now be encountering a mysterious being totally unlike anything you've ever faced."); + + if (sm.askYesNo("Are you ready to exchange your #b#t1902001##k for the legendary #b#t1902002##k?")) { + if (!sm.removeItem(SILVER_MANE, 1)) { + sm.sayOk("There seems to be an issue. Please make sure you have #b#t1902001##k in your inventory."); + return; + } + + if (!sm.addItem(DRAGON_MOUNT, 1)) { + sm.sayOk("Please make sure you have space in your inventory."); + sm.addItem(SILVER_MANE, 1); // Return the Silver Mane + return; + } + + sm.forceCompleteQuest(6009); + sm.sayOk("You are the one who has already exceeded the boundaries and the limits of a human being... the only time the proud #t1902002# bows its head is to you. Hopefully you'll experience new, amazing adventures that are surely to await you with #t1902002#..."); + } else { + sm.sayOk("Come back when you are ready to accept this power."); + } + } + + @Script("q6010s") + public static void q6010s(ScriptManager sm) { + // Quest 6010 - Dragon Rider (START) - Bowmen + // Legor (2081300) - El Nath / Leafre + sm.sayNext("I know who you are. You were born a bowman a long time ago at a town fiercely protected by the Elves, established your presence and power at the land of the snow, and... I also bestowed you of a name worthy of a great bowman."); + + sm.sayBoth("Now that you have reached the highest you can go as a bowman, I'll introduce to you a new companion. Countless bowmen have used bows to pierce the darkness this world offers, strived for it, but couldn't get it, and now you have the opportunity to get it... What do you think... do you want #t1902002#?"); + + if (sm.askYesNo("It may not be a human, but #t1902002# is much more proud than the humans... but, if it's someone like you, who's reached the level only a select few can dare reach, then it should serve you with the utmost loyalty.\r\n\r\nIf you are ready to engage in a powerful friendship with an unknown being, then let me know.")) { + sm.forceStartQuest(6010); + sm.sayNext("You are ready to meet your new companion! However, you will need to first obtain a #b#t1902001##k. This mount shows your readiness to handle an even greater creature."); + sm.sayOk("Once you have obtained the #b#t1902001##k, return to me and we will complete the bonding ceremony with #t1902002#!"); + } else { + sm.sayOk("I don't think you are ready. No one in Maple can defeat you, so why fear the change? As one with superior accuracy and the keenest eyes in the Maple World, this should seem enticing to you..."); + } + } + + @Script("q6010e") + public static void q6010e(ScriptManager sm) { + // Quest 6010 - Dragon Rider (END) - Bowmen + final int SILVER_MANE = 1902001; + final int DRAGON_MOUNT = 1902002; + + if (!sm.hasItem(SILVER_MANE, 1)) { + sm.sayOk("I don't think you are ready to fully embrace #t1902002#, yet. In order for you to meet #t1902002#, you'll first have to have some experience with #t1902001#. Until you bring me #b#t1902001##k, I am afraid I won't be able to introduce you to #t1902002#."); + return; + } + + sm.sayNext("Once you are ready to accept your new companion, step up. You will now be encountering a mysterious being totally unlike anything you've ever faced."); + + if (sm.askYesNo("Are you ready to exchange your #b#t1902001##k for the legendary #b#t1902002##k?")) { + if (!sm.removeItem(SILVER_MANE, 1)) { + sm.sayOk("There seems to be an issue. Please make sure you have #b#t1902001##k in your inventory."); + return; + } + + if (!sm.addItem(DRAGON_MOUNT, 1)) { + sm.sayOk("Please make sure you have space in your inventory."); + sm.addItem(SILVER_MANE, 1); // Return the Silver Mane + return; + } + + sm.forceCompleteQuest(6010); + sm.sayOk("You are the one who has already exceeded the boundaries and the limits of a human being... the only time the proud #t1902002# bows its head is to you. Hopefully you'll experience new, amazing adventures that are surely to await you with #t1902002#..."); + } else { + sm.sayOk("Come back when you are ready to accept this power."); + } + } + + @Script("q6011s") + public static void q6011s(ScriptManager sm) { + // Quest 6011 - Dragon Rider (START) - Thieves + // Hellin (2081400) - El Nath / Leafre + sm.sayNext("I know who you are. You were born a thief a long time ago at a dark city late at night, established your presence and power at the land of the snow, and... I also bestowed you of a new name worthy of a great thief..."); + + sm.sayBoth("Now that you have reached the apex as a thief, I'll introduce to you a new companion. Countless thieves, considered the dominant forces of the dark, strived for it, but couldn't get it, and now you have the opportunity to get it... What do you think... do you want #t1902002#?"); + + if (sm.askYesNo("It may not be a human, but #t1902002# is much more proud than the humans... but, if it's someone like you, who's reached the level only a select few can dare reach, then it should serve you with the utmost loyalty.\r\n\r\nIf you are ready to engage in a powerful friendship with an unknown being, then let me know.")) { + sm.forceStartQuest(6011); + sm.sayNext("You are ready to meet your new companion! However, you will need to first obtain a #b#t1902001##k. This mount shows your readiness to handle an even greater creature."); + sm.sayOk("Once you have obtained the #b#t1902001##k, return to me and we will complete the bonding ceremony with #t1902002#!"); + } else { + sm.sayOk("I don't think you are ready. No one in Maple can defeat you, so why fear the change? As a cunning master of stealth tactics, one who feels at home amongst the shadows, this should seem enticing to you..."); + } + } + + @Script("q6011e") + public static void q6011e(ScriptManager sm) { + // Quest 6011 - Dragon Rider (END) - Thieves + final int SILVER_MANE = 1902001; + final int DRAGON_MOUNT = 1902002; + + if (!sm.hasItem(SILVER_MANE, 1)) { + sm.sayOk("I don't think you are ready to fully embrace #t1902002#, yet. In order for you to meet #t1902002#, you'll first have to have some experience with #t1902001#. Until you bring me #b#t1902001##k, I am afraid I won't be able to introduce you to #t1902002#."); + return; + } + + sm.sayNext("Once you are ready to accept your new companion, step up. You will now be encountering a mysterious being totally unlike anything you've ever faced."); + + if (sm.askYesNo("Are you ready to exchange your #b#t1902001##k for the legendary #b#t1902002##k?")) { + if (!sm.removeItem(SILVER_MANE, 1)) { + sm.sayOk("There seems to be an issue. Please make sure you have #b#t1902001##k in your inventory."); + return; + } + + if (!sm.addItem(DRAGON_MOUNT, 1)) { + sm.sayOk("Please make sure you have space in your inventory."); + sm.addItem(SILVER_MANE, 1); // Return the Silver Mane + return; + } + + sm.forceCompleteQuest(6011); + sm.sayOk("You are the one who has already exceeded the boundaries and the limits of a human being... the only time the proud #t1902002# bows its head is to you. Hopefully you'll experience new, amazing adventures that are surely to await you with #t1902002#..."); + } else { + sm.sayOk("Come back when you are ready to accept this power."); + } + } + + @Script("q6013s") + public static void q6013s(ScriptManager sm) { + // Quest 6013 - Dragon Rider (START) - Pirates + // Kydo (2081500) - El Nath / Leafre + sm.sayNext("I know who you are. You were born a Pirate a long time ago aboard the mighty vessel known as the Nautilus. You established your presence and power at the land of the snow, and...I also bestowed you of a new name worthy of a great Pirate..."); + + sm.sayBoth("Now that you have reached the apex as a Pirate, I'll introduce to you a new companion. Countless Pirates, from Ms.Behave to Loosetooth Pete, strived for it, but couldn't get it, and now you have the opportunity to get it... What do you think...do you want #t1902002#?"); + + if (sm.askYesNo("It may not be a human, but #t1902002# possesses a fierce spirit...but, for someone like you, who's reached the level only a select few can dare reach, then it should serve you with the utmost loyalty.\r\n\r\nIf you are ready to initiate a powerful friendship, then let me know.")) { + sm.forceStartQuest(6013); + sm.sayNext("You are ready to meet your new companion! However, you will need to first obtain a #b#t1902001##k. This mount shows your readiness to handle an even greater creature."); + sm.sayOk("Once you have obtained the #b#t1902001##k, return to me and we will complete the bonding ceremony with #t1902002#!"); + } else { + sm.sayOk("I don't think you are ready. No one in Maple can defeat you, so why fear the change? As the undisputed master of the sea, one who feels at home amongst the waves, this should seem enticing to you..."); + } + } + + @Script("q6013e") + public static void q6013e(ScriptManager sm) { + // Quest 6013 - Dragon Rider (END) - Pirates + final int SILVER_MANE = 1902001; + final int DRAGON_MOUNT = 1902002; + + if (!sm.hasItem(SILVER_MANE, 1)) { + sm.sayOk("I don't think you are ready to fully embrace #t1902002#, yet. In order for you to meet #t1902002#, you'll first have to have some experience with #t1902001#. Until you bring me #b#t1902001##k, I am afraid I won't be able to introduce you to your new friend. Return to me when you are ready, and I shall make the introduction."); + return; + } + + sm.sayNext("Once you are ready to accept your new companion, step up. You will encounter a mysterious being unlike anything you've ever faced."); + + if (sm.askYesNo("Are you ready to exchange your #b#t1902001##k for the legendary #b#t1902002##k?")) { + if (!sm.removeItem(SILVER_MANE, 1)) { + sm.sayOk("There seems to be an issue. Please make sure you have #b#t1902001##k in your inventory."); + return; + } + + if (!sm.addItem(DRAGON_MOUNT, 1)) { + sm.sayOk("Please make sure you have space in your inventory."); + sm.addItem(SILVER_MANE, 1); // Return the Silver Mane + return; + } + + sm.forceCompleteQuest(6013); + sm.sayOk("You are the one who has already exceeded the boundaries and the limits of a human being...the only time the proud #t1902002# bows its head is to you. Hopefully you'll experience new, amazing adventures that are surely to await you with #t1902002#..."); + } else { + sm.sayOk("Come back when you are ready to accept this power."); + } + } + + // ADDITIONAL EXPLORER QUESTS (2100-2400) -------------------------------------------------------------------- + + @Script("q2124e") + public static void q2124e(ScriptManager sm) { + // Quest 2124 - A Supply from the Sand Crew (END) + // NPC: 2101002 + final int QUEST_ITEM_4031619 = 4031619; + + if (!sm.hasItem(QUEST_ITEM_4031619, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031619, 1); + sm.forceCompleteQuest(2124); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2126e") + public static void q2126e(ScriptManager sm) { + // Quest 2126 - A Supply from the Sand Crew! (END) + // NPC: 2101002 + final int QUEST_ITEM_4031624 = 4031624; + + if (!sm.hasItem(QUEST_ITEM_4031624, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031624, 1); + sm.forceCompleteQuest(2126); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2127e") + public static void q2127e(ScriptManager sm) { + // Quest 2127 - To the Desert... (END) + // NPC: 1022002 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(2127); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2147e") + public static void q2147e(ScriptManager sm) { + // Quest 2147 - Stumpy's Seedling (END) + // NPC: 1022006 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(2147); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2147s") + public static void q2147s(ScriptManager sm) { + // Quest 2147 - Stumpy's Seedling (START) + // NPC: 1022006 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2147); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2148s") + public static void q2148s(ScriptManager sm) { + // Quest 2148 - Truth of the Rumor-Blackbull (START) + // NPC: 1020000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2148); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2149s") + public static void q2149s(ScriptManager sm) { + // Quest 2149 - Truth of the Rumor-Manji (START) + // NPC: 1022002 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2149); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2150s") + public static void q2150s(ScriptManager sm) { + // Quest 2150 - Truth of the Rumor-Ayan (START) + // NPC: 1022007 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2150); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2151s") + public static void q2151s(ScriptManager sm) { + // Quest 2151 - Truth of the Rumor- Dances with Balrog (START) + // NPC: 1022000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2151); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2152s") + public static void q2152s(ScriptManager sm) { + // Quest 2152 - Truth of the Rumor-Betty (START) + // NPC: 1032104 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2152); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2156e") + public static void q2156e(ScriptManager sm) { + // Quest 2156 - A rainbow snail shell that makes wishes come true!? (END) + // NPC: 1012102 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(2156); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2197e") + public static void q2197e(ScriptManager sm) { + // Quest 2197 - Tienk, the Monster Book Salesman (END) + // NPC: 2006 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(2197); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2214e") + public static void q2214e(ScriptManager sm) { + // Quest 2214 - The Run-down Huts in the Swamp (END) + // NPC: 1052108 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(2214); + sm.addItem(4031894, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2215e") + public static void q2215e(ScriptManager sm) { + // Quest 2215 - Find the Crumpled Piece of Paper Again (END) + // NPC: 1052108 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(2215); + sm.addMoney(-2000); // Mesos reward + sm.addItem(4031894, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2216s") + public static void q2216s(ScriptManager sm) { + // Quest 2216 - Information from Mr. Pickall (START) + // NPC: 9000008 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2216); + sm.addItem(4031894, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2217s") + public static void q2217s(ScriptManager sm) { + // Quest 2217 - Information from Shumi (START) + // NPC: 1052102 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2217); + sm.addItem(4031894, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2218s") + public static void q2218s(ScriptManager sm) { + // Quest 2218 - Information from Nella (START) + // NPC: 1052103 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2218); + sm.addItem(4031894, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2219s") + public static void q2219s(ScriptManager sm) { + // Quest 2219 - Information from Jake (START) + // NPC: 1052006 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2219); + sm.addItem(4031894, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2228s") + public static void q2228s(ScriptManager sm) { + // Quest 2228 - Reef's Gratitude (START) + // NPC: 1032108 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2228); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2232e") + public static void q2232e(ScriptManager sm) { + // Quest 2232 - Find a Junior! (END) + // NPC: Unknown + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(2232); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2233e") + public static void q2233e(ScriptManager sm) { + // Quest 2233 - Raise the Rep! (END) + // NPC: Unknown + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(2233); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2234e") + public static void q2234e(ScriptManager sm) { + // Quest 2234 - Enjoy the Entitlement! (END) + // NPC: Unknown + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(2234); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2238s") + public static void q2238s(ScriptManager sm) { + // Quest 2238 - Who is the Owner of the Mysterious Note? (START) + // NPC: 1061014 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2238); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2244e") + public static void q2244e(ScriptManager sm) { + // Quest 2244 - Tristan's Successor (END) + // NPC: 1061017 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(2244); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2245s") + public static void q2245s(ScriptManager sm) { + // Quest 2245 - To Tristan's Tomb (START) + // NPC: 1061014 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2245); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2251e") + public static void q2251e(ScriptManager sm) { + // Quest 2251 - Zombie Mushroom Signal 3 (END) + // NPC: 1061011 + final int QUEST_ITEM_4032399 = 4032399; + + if (!sm.hasItem(QUEST_ITEM_4032399, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032399, 20); + sm.forceCompleteQuest(2251); + sm.addExp(8000); // EXP reward + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2254s") + public static void q2254s(ScriptManager sm) { + // Quest 2254 - Karcasa of the Desert (START) + // NPC: 1061011 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2254); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2257e") + public static void q2257e(ScriptManager sm) { + // Quest 2257 - Karcasa Relents (END) + // NPC: 2110005 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(2257); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2258e") + public static void q2258e(ScriptManager sm) { + // Quest 2258 - Meerkats Listen During the Day (END) + // NPC: 2110005 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(2258); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2258s") + public static void q2258s(ScriptManager sm) { + // Quest 2258 - Meerkats Listen During the Day (START) + // NPC: 2110005 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2258); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2259e") + public static void q2259e(ScriptManager sm) { + // Quest 2259 - Scorpions Can't Listen at Night (END) + // NPC: 2110005 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(2259); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2259s") + public static void q2259s(ScriptManager sm) { + // Quest 2259 - Scorpions Can't Listen at Night (START) + // NPC: 2110005 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2259); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2260e") + public static void q2260e(ScriptManager sm) { + // Quest 2260 - To the Mushroom Castle! (END) + // NPC: 2110005 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(2260); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2260s") + public static void q2260s(ScriptManager sm) { + // Quest 2260 - To the Mushroom Castle! (START) + // NPC: 2110005 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2260); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2291e") + public static void q2291e(ScriptManager sm) { + // Quest 2291 - Admission to the VIP Zone (END) + // NPC: 1052125 + final int QUEST_ITEM_4032521 = 4032521; + + if (!sm.hasItem(QUEST_ITEM_4032521, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032521, 10); + sm.forceCompleteQuest(2291); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2293s") + public static void q2293s(ScriptManager sm) { + // Quest 2293 - The Last Song (START) + // NPC: 1052120 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2293); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2332e") + public static void q2332e(ScriptManager sm) { + // Quest 2332 - Where's Violetta? (END) + // NPC: 1300002 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(2332); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + @Script("q2342s") + public static void q2342s(ScriptManager sm) { + // Quest 2342 - The Recovered Royal Seal (START) + // NPC: 1300002 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(2342); + sm.addItem(4001318, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q2363e") + public static void q2363e(ScriptManager sm) { + // Quest 2363 - Dual Blade: Time for the Awakening (END) + // First Job Advancement: Rogue -> Blade Recruit (430) + // NPC: Lady Syl (1056000) + final int MIRROR_OF_INSIGHT = 4032616; + + // Check if player has the Mirror of Insight + if (!sm.hasItem(MIRROR_OF_INSIGHT, 1)) { + sm.sayOk("You must bring me the #bMirror of Insight#k to prove you are ready."); + return; + } + + // Check level requirement + if (sm.getLevel() < 20) { + sm.sayOk("You must be at least #bLevel 20#k to awaken as a Dual Blade."); + return; + } + + sm.sayNext("I can see it in your eyes... the Mirror of Insight has chosen you. You have proven yourself worthy to walk the path of the Dual Blade."); + sm.sayBoth("From this moment forward, you are a #bBlade Recruit#k. Your journey as a Dual Blade has truly begun!"); + + // Remove Mirror of Insight + sm.removeItem(MIRROR_OF_INSIGHT, 1); + + // Job advancement to Blade Recruit + sm.setJob(Job.BLADE_RECRUIT); // Job 430 + + // Give SP for job advancement (jobLevel 1, 1 SP) + sm.addSp(1, 1); + + // Complete the quest + sm.forceCompleteQuest(2363); + + sm.sayOk("Congratulations on awakening as a Dual Blade! You have gained #b1 SP#k. Continue your training and return to me when you reach Level 30 for your next advancement."); + } + + @Script("q2367e") + public static void q2367e(ScriptManager sm) { + // Quest 2367 - Seventh Mission: Eyewitness (END) + // NPC: Lady Syl (1056000) + // Requires quest 2368 to be completed + + // Check if quest 2368 is completed + if (!sm.hasQuestCompleted(2368)) { + sm.sayOk("You haven't completed the 'Eyewitness Holding the Key' quest yet. Please finish that first."); + return; + } + + // Multi-stage conversation with choices + final int answer1 = sm.askMenu("I heard that you were handling this mission. Were you able to find any eyewitnesses?", + Map.of(0, "I met and spoke with Manji in Perion since he knows Tristan, who was a friend of the former Dark Lord. I figured he might know something about the former Dark Lord.")); + + final int answer2 = sm.askMenu("Yes, I suppose that's reasonable. So, what did he say?", + Map.of(0, "He said that the former Dark Lord went to the Cursed Sanctuary upon Tristan's request to join in the battle against Balrog. Unfortunately, they were somehow separated, and by the time Tristan and Manji arrived, the former Dark Lord had already passed away.")); + + final int answer3 = sm.askMenu("So...did he see anything?", + Map.of(0, "Manji said that when he got there...he was there...covered in blood...")); + + final int answer4 = sm.askMenu("By 'he' you mean...", + Map.of(0, "A boy named Jin...")); + + sm.sayNext("Hmpf! It really was him! Jin was the culprit behind the death... Now, the time has come for vengeance!"); + + // Give rewards + sm.addExp(30000); + sm.forceCompleteQuest(2367); + + sm.sayOk("I need to be alone right now. Please leave."); + } + + @Script("q2369e") + public static void q2369e(ScriptManager sm) { + // Quest 2369 - Dual Blade: Time for the Awakening (END) + // Second Job Advancement: Blade Recruit → Blade Acolyte (431) + // NPC: Lady Syl (1056000) + final int FORMER_DARK_LORD_DIARY = 4032617; + + // Check if player has the diary + if (!sm.hasItem(FORMER_DARK_LORD_DIARY, 1)) { + sm.sayOk("You must bring me the #bFormer Dark Lord's Diary#k from the Jazz Bar secret room."); + return; + } + + // Check level requirement + if (sm.getLevel() < 30) { + sm.sayOk("You must be at least #bLevel 30#k to advance to Blade Acolyte."); + return; + } + + sm.sayNext("You've returned with the diary... I can feel my father's presence within these pages."); + sm.sayBoth("You have proven yourself worthy time and time again. It's time for you to take the next step on your path as a Dual Blade."); + + // Remove diary + sm.removeItem(FORMER_DARK_LORD_DIARY, 1); + + // Job advancement to Blade Acolyte + sm.setJob(Job.BLADE_ACOLYTE); // Job 431 + + // Give SP for 2nd job advancement + sm.addSp(2, 1); + + // Complete the quest + sm.forceCompleteQuest(2369); + + sm.sayOk("Congratulations! You are now a #bBlade Acolyte#k! You have gained #b1 SP#k. Continue your training and become even stronger!"); + } + + @Script("q2374e") + public static void q2374e(ScriptManager sm) { + // Quest 2374 - Arec's Secret Letter (END) + // NPC: Lady Syl (1056000) - Dual Blade 3rd Job Advancement (Level 55) + final int QUEST_ITEM_4032619 = 4032619; + final int RING_REWARD = 1132021; + + if (!sm.hasItem(QUEST_ITEM_4032619, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("I've been waiting for you. Do you have Arec's answer? Please give me his letter."); + sm.sayBoth("We have finally received Arec's official recognition. This is an important moment for us. It's also time that you experience a change."); + + // Check inventory space for ring reward + if (!sm.canAddItem(RING_REWARD, 1)) { + sm.sayOk("Please check and see if you have an empty slot available at your equip inventory."); + return; + } + + // Complete quest and give rewards + sm.removeItem(QUEST_ITEM_4032619, 1); + sm.forceCompleteQuest(2374); + sm.setJob(Job.BLADE_SPECIALIST); // Job advancement to 432 + sm.addItem(RING_REWARD, 1); // Give ring reward + sm.sayOk("Now that we have Arec's recognition, you can make a job advancement by going to see him when you reach Lv. 70. Finally, a new future has been opened for the Dual Blades."); + } + + @Script("q1447e") + public static void q1447e(ScriptManager sm) { + // Quest 1447 - Endurance (END) + // NPC: Lady Syl (1056000) - Dual Blade 3.5 Job Advancement (Level 70) + // Blade Specialist (432) → Blade Lord (433) + final int HOLY_STONE = 4031059; + final int MEDAL_REWARD = 1142109; + final int BLACK_CHARM = 2290156; + + // Check if player has the Holy Stone (proof of defeating Dark Lord's clone) + if (!sm.hasItem(HOLY_STONE, 1)) { + sm.sayOk("You must complete the trial before you can advance to Blade Lord."); + return; + } + + // Check level requirement + if (sm.getLevel() < 70) { + sm.sayOk("You must be at least #bLevel 70#k to advance to Blade Lord."); + return; + } + + sm.sayNext("You passed the test. Not bad. I should tell you, the Dark Lord from another dimension you fought was merely a clone, but I still didn't think you would win. I was surprised the Dark Lord went out of his way to summon his clone using the Holy Stone, but I guess it was worth it."); + + if (!sm.askYesNo("The fight with the master Thief, Dark Lord, has proven your own worth as a Thief. The only thing left is your Job Advancement. Are you prepared to become a Blade Lord, and bring your might to a new level?")) { + sm.sayOk("Come back when you are ready."); + return; + } + + // Check inventory space for rewards + if (!sm.canAddItem(MEDAL_REWARD, 1) || !sm.canAddItem(BLACK_CHARM, 1)) { + sm.sayNext("The Job Advancement cannot continue because you either have no room in your Equip or Use tab, or you do not have a Black Charm."); + return; + } + + // Job advancement + sm.removeItem(HOLY_STONE, 1); + sm.setJob(Job.BLADE_LORD); // Job 433 + sm.addItem(MEDAL_REWARD, 1); // Medal + sm.addItem(BLACK_CHARM, 1); // Black Charm + sm.forceCompleteQuest(1447); + + sm.sayOk("You are now a #bBlade Lord#k. As a true Blade Lord, use your strength to the fullest!"); + } + + @Script("q2351e") + public static void q2351e(ScriptManager sm) { + // Quest 2351 - First Mission: Infiltration (END) + // NPC: Ryden (1057001) in Kerning City + // Requirement: Player must have become a Rogue (job 400) + final int EARRING_REWARD = 1032076; + + // Check if quest is started + if (!sm.hasQuestStarted(2351)) { + sm.sayOk("Huh...is something wrong?"); + return; + } + + // Check if player has become a Rogue + if (sm.getJob() != 400) { + sm.sayOk("Ahh... #h0#, you found me. Now, you haven't forgotten what your mission is, right? In order to get close to the Dark Lord, you have to become his subordinate. Act like a casual beginner and request an advancement as a Rogue. Camouflage is the first step in infiltration. Understood?"); + return; + } + + sm.sayNext("I see that you have been successful. The plan went smoother than I expected. But hmm... It looks like he didn't teach you all of the skills. I guess the Dark Lord is smarter than I thought... "); + sm.sayBoth("But we'll just let it be. After all, the goal was for you to advance, and that you did even while being under his suspicious glare. Let me reward you for successfully completing your first mission."); + + // Check inventory space + if (!sm.canAddItem(EARRING_REWARD, 1)) { + sm.sayOk("Please make space in your equip inventory first."); + return; + } + + // Give rewards + sm.addExp(500); + sm.addItem(EARRING_REWARD, 1); + sm.forceCompleteQuest(2351); + + sm.sayOk("The real mission starts now. You are to act as a Rogue and collect information about the Dark Lord and other thieves. When the time comes, Lady Syl will call for you. Until then, you must be sure to hide your true identity and act like a Rogue. Is that clear?"); + } } diff --git a/src/main/java/kinoko/script/quest/HighLevelQuest.java b/src/main/java/kinoko/script/quest/HighLevelQuest.java new file mode 100644 index 00000000..a53c96bd --- /dev/null +++ b/src/main/java/kinoko/script/quest/HighLevelQuest.java @@ -0,0 +1,1369 @@ +package kinoko.script.quest; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +/** + * High-Level Quest System + * Covers quests 28000-28999 range for high-level quests + */ +public final class HighLevelQuest extends ScriptHandler { + + + @Script("q28004s") + public static void q28004s(ScriptManager sm) { + // Quest 28004 - Save the Snowman! (START) + // NPC: 9105003 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28004); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28117s") + public static void q28117s(ScriptManager sm) { + // Quest 28117 - 4 Candles (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28117); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28118s") + public static void q28118s(ScriptManager sm) { + // Quest 28118 - 4 Year Anniversary Cake (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28118); + sm.addItem(4220074, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28131e") + public static void q28131e(ScriptManager sm) { + // Quest 28131 - Soft and Cozy Chief's Chair (END) + // NPC: 9201116 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28131); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28137e") + public static void q28137e(ScriptManager sm) { + // Quest 28137 - Spirit Week Event - September 29th (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28137); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28138e") + public static void q28138e(ScriptManager sm) { + // Quest 28138 - Spirit Week Event - October 6th (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28138); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28139e") + public static void q28139e(ScriptManager sm) { + // Quest 28139 - Spirit Week Event - October 13th (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28139); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28140e") + public static void q28140e(ScriptManager sm) { + // Quest 28140 - Spirit Week Event - October 20th (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28140); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28141s") + public static void q28141s(ScriptManager sm) { + // Quest 28141 - Spirit Week Event - Cassandra's Candle (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28141); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28142s") + public static void q28142s(ScriptManager sm) { + // Quest 28142 - Spirit Week Event - Cassandra's Candle (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28142); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28143s") + public static void q28143s(ScriptManager sm) { + // Quest 28143 - Spirit Week Event - Cassandra's Candle (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28143); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28144s") + public static void q28144s(ScriptManager sm) { + // Quest 28144 - Spirit Week Event - Cassandra's Candle (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28144); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28145e") + public static void q28145e(ScriptManager sm) { + // Quest 28145 - Spirit Week Event - September 24th (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28145); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28146e") + public static void q28146e(ScriptManager sm) { + // Quest 28146 - Spirit Week Event - October 1st (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28146); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28147e") + public static void q28147e(ScriptManager sm) { + // Quest 28147 - Spirit Week Event - October 8th (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28147); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28148e") + public static void q28148e(ScriptManager sm) { + // Quest 28148 - Spirit Week Event - October 15th (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28148); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28149s") + public static void q28149s(ScriptManager sm) { + // Quest 28149 - Spirit Week Event - Cassandra's Candle (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28149); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28150s") + public static void q28150s(ScriptManager sm) { + // Quest 28150 - Spirit Week Event - Cassandra's Candle (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28150); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28151s") + public static void q28151s(ScriptManager sm) { + // Quest 28151 - Spirit Week Event - Cassandra's Candle (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28151); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28152s") + public static void q28152s(ScriptManager sm) { + // Quest 28152 - Spirit Week Event - Cassandra's Candle (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28152); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28153e") + public static void q28153e(ScriptManager sm) { + // Quest 28153 - Spirit Week Event - October 27th (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28153); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28154s") + public static void q28154s(ScriptManager sm) { + // Quest 28154 - Spirit Week Event - Cassandra's Candle (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28154); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28155e") + public static void q28155e(ScriptManager sm) { + // Quest 28155 - Spirit Week Event - October 22nd (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28155); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28156s") + public static void q28156s(ScriptManager sm) { + // Quest 28156 - Spirit Week Event - Cassandra's Candle (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28156); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28157e") + public static void q28157e(ScriptManager sm) { + // Quest 28157 - Spirit Week Event - September 28th (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28157); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28158e") + public static void q28158e(ScriptManager sm) { + // Quest 28158 - Spirit Week Event - October 5th (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28158); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28159e") + public static void q28159e(ScriptManager sm) { + // Quest 28159 - Spirit Week Event - October 12th (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28159); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28160e") + public static void q28160e(ScriptManager sm) { + // Quest 28160 - Spirit Week Event - October 19th (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28160); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28161e") + public static void q28161e(ScriptManager sm) { + // Quest 28161 - Spirit Week Event - October 26th (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28161); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28284e") + public static void q28284e(ScriptManager sm) { + // Quest 28284 - Cassandra's Trick or Treat (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032444 = 4032444; + + if (!sm.hasItem(QUEST_ITEM_4032444, 31)) { + sm.sayOk("You need 31 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032444, 31); + sm.forceCompleteQuest(28284); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28285e") + public static void q28285e(ScriptManager sm) { + // Quest 28285 - Cassandra's Trick or Treat (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032445 = 4032445; + + if (!sm.hasItem(QUEST_ITEM_4032445, 31)) { + sm.sayOk("You need 31 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032445, 31); + sm.forceCompleteQuest(28285); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28286e") + public static void q28286e(ScriptManager sm) { + // Quest 28286 - Cassandra's Trick or Treat (END) + // NPC: 9010010 + + final int QUEST_ITEM_4032446 = 4032446; + + if (!sm.hasItem(QUEST_ITEM_4032446, 31)) { + sm.sayOk("You need 31 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032446, 31); + sm.forceCompleteQuest(28286); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28304e") + public static void q28304e(ScriptManager sm) { + // Quest 28304 - Olivia: "Bring My Daddy" (END) + // NPC: 9201137 + + final int QUEST_ITEM_4032441 = 4032441; + + if (!sm.hasItem(QUEST_ITEM_4032441, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032441, 1); + sm.forceCompleteQuest(28304); + sm.addItem(4032441, -1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28311e") + public static void q28311e(ScriptManager sm) { + // Quest 28311 - Olivia: "Bring My Daddy" (END) + // NPC: 9201137 + + final int QUEST_ITEM_4032442 = 4032442; + + if (!sm.hasItem(QUEST_ITEM_4032442, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032442, 1); + sm.forceCompleteQuest(28311); + sm.addItem(4032442, -1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28318e") + public static void q28318e(ScriptManager sm) { + // Quest 28318 - Olivia: "Bring My Daddy" (END) + // NPC: 9201137 + + final int QUEST_ITEM_4032443 = 4032443; + + if (!sm.hasItem(QUEST_ITEM_4032443, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032443, 1); + sm.forceCompleteQuest(28318); + sm.addItem(4032443, -1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28326e") + public static void q28326e(ScriptManager sm) { + // Quest 28326 - Yellow Turkey Egg Hunt (END) + // NPC: 9200000 + + final int QUEST_ITEM_4032522 = 4032522; + + if (!sm.hasItem(QUEST_ITEM_4032522, 5)) { + sm.sayOk("You need 5 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032522, 5); + sm.forceCompleteQuest(28326); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28327e") + public static void q28327e(ScriptManager sm) { + // Quest 28327 - Green Turkey Egg Hunt (END) + // NPC: 9200000 + + final int QUEST_ITEM_4032523 = 4032523; + + if (!sm.hasItem(QUEST_ITEM_4032523, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032523, 10); + sm.forceCompleteQuest(28327); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28328e") + public static void q28328e(ScriptManager sm) { + // Quest 28328 - Blue Turkey Egg Hunt (END) + // NPC: 9200000 + + final int QUEST_ITEM_4032524 = 4032524; + + if (!sm.hasItem(QUEST_ITEM_4032524, 20)) { + sm.sayOk("You need 20 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032524, 20); + sm.forceCompleteQuest(28328); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28333s") + public static void q28333s(ScriptManager sm) { + // Quest 28333 - New Year's Presents from Cassandra (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28333); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28337e") + public static void q28337e(ScriptManager sm) { + // Quest 28337 - Cupid's Lost Arrows (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28337); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28353s") + public static void q28353s(ScriptManager sm) { + // Quest 28353 - The Shadow Knight in Dragon's Nest (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28353); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28354e") + public static void q28354e(ScriptManager sm) { + // Quest 28354 - The Shadow Knight's Request for Help (END) + // NPC: 9201144 + + final int QUEST_ITEM_4032639 = 4032639; + + if (!sm.hasItem(QUEST_ITEM_4032639, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032639, 1); + sm.forceCompleteQuest(28354); + sm.addItem(4032639, -1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28354s") + public static void q28354s(ScriptManager sm) { + // Quest 28354 - The Shadow Knight's Request for Help (START) + // NPC: 9201144 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28354); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28361e") + public static void q28361e(ScriptManager sm) { + // Quest 28361 - First Evan Launch Gift from the Admin (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28361); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28362e") + public static void q28362e(ScriptManager sm) { + // Quest 28362 - Second Evan Launch Gift from the Admin (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28362); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28363e") + public static void q28363e(ScriptManager sm) { + // Quest 28363 - Third Evan Launch Gift from the Admin (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28363); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28364e") + public static void q28364e(ScriptManager sm) { + // Quest 28364 - Fourth Evan Launch Gift from the Admin (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28364); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28365e") + public static void q28365e(ScriptManager sm) { + // Quest 28365 - Fifth Evan Launch Gift from the Admin (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28365); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28368e") + public static void q28368e(ScriptManager sm) { + // Quest 28368 - I Need to Prove Myself to My Daughter (END) + // NPC: 1012111 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28368); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28369e") + public static void q28369e(ScriptManager sm) { + // Quest 28369 - I Need to Prove Myself to My Daughter (END) + // NPC: 1012111 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28369); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28370e") + public static void q28370e(ScriptManager sm) { + // Quest 28370 - Welcome Back to New Leaf City Day 1 (END) + // NPC: 9201050 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28370); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28371e") + public static void q28371e(ScriptManager sm) { + // Quest 28371 - Welcome Back to New Leaf City Day 2 (END) + // NPC: 9201050 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28371); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28372e") + public static void q28372e(ScriptManager sm) { + // Quest 28372 - Welcome Back to New Leaf City Day 3 (END) + // NPC: 9201050 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28372); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28373e") + public static void q28373e(ScriptManager sm) { + // Quest 28373 - Welcome Back to New Leaf City Day 4 (END) + // NPC: 9201050 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28373); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28374e") + public static void q28374e(ScriptManager sm) { + // Quest 28374 - Welcome Back to New Leaf City Day 5 (END) + // NPC: 9201050 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28374); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28375e") + public static void q28375e(ScriptManager sm) { + // Quest 28375 - Welcome Back to New Leaf City Day 6 (END) + // NPC: 9201050 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28375); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28376e") + public static void q28376e(ScriptManager sm) { + // Quest 28376 - Nautilus' Secret Entree (END) + // NPC: 1092000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28376); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28377e") + public static void q28377e(ScriptManager sm) { + // Quest 28377 - Welcome Back to New Leaf City Day 7 (END) + // NPC: 9201050 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28377); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28378e") + public static void q28378e(ScriptManager sm) { + // Quest 28378 - Nautilus' Secret Entree (END) + // NPC: 1092000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28378); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28379e") + public static void q28379e(ScriptManager sm) { + // Quest 28379 - Path of a True Mapler (END) + // NPC: 1101002 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28379); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28380e") + public static void q28380e(ScriptManager sm) { + // Quest 28380 - Path of a True Mapler (END) + // NPC: 1101002 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28380); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28381e") + public static void q28381e(ScriptManager sm) { + // Quest 28381 - Path of a True Mapler (END) + // NPC: 1101002 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28381); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28382e") + public static void q28382e(ScriptManager sm) { + // Quest 28382 - Path of a True Mapler (END) + // NPC: 1101002 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28382); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28383e") + public static void q28383e(ScriptManager sm) { + // Quest 28383 - I Need to Prove Myself to My Daughter (END) + // NPC: 1012111 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28383); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28384e") + public static void q28384e(ScriptManager sm) { + // Quest 28384 - I Need to Prove Myself to My Daughter (END) + // NPC: 1012111 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28384); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28385e") + public static void q28385e(ScriptManager sm) { + // Quest 28385 - Welcome Back to New Leaf City Day 1 (END) + // NPC: 9201050 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28385); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28386e") + public static void q28386e(ScriptManager sm) { + // Quest 28386 - Welcome Back to New Leaf City Day 2 (END) + // NPC: 9201050 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28386); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28387e") + public static void q28387e(ScriptManager sm) { + // Quest 28387 - Welcome Back to New Leaf City Day 3 (END) + // NPC: 9201050 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28387); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28388e") + public static void q28388e(ScriptManager sm) { + // Quest 28388 - Welcome Back to New Leaf City Day 4 (END) + // NPC: 9201050 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28388); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28389e") + public static void q28389e(ScriptManager sm) { + // Quest 28389 - Welcome Back to New Leaf City Day 5 (END) + // NPC: 9201050 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28389); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28390e") + public static void q28390e(ScriptManager sm) { + // Quest 28390 - Welcome Back to New Leaf City Day 6 (END) + // NPC: 9201050 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28390); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28391e") + public static void q28391e(ScriptManager sm) { + // Quest 28391 - Nautilus' Secret Entree (END) + // NPC: 1092000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28391); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28392e") + public static void q28392e(ScriptManager sm) { + // Quest 28392 - Welcome Back to New Leaf City Day 7 (END) + // NPC: 9201050 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28392); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28393e") + public static void q28393e(ScriptManager sm) { + // Quest 28393 - Nautilus' Secret Entree (END) + // NPC: 1092000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28393); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28394e") + public static void q28394e(ScriptManager sm) { + // Quest 28394 - Path of a True Mapler (END) + // NPC: 1101002 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28394); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28395e") + public static void q28395e(ScriptManager sm) { + // Quest 28395 - Path of a True Mapler (END) + // NPC: 1101002 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28395); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28396e") + public static void q28396e(ScriptManager sm) { + // Quest 28396 - Path of a True Mapler (END) + // NPC: 1101002 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28396); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28397e") + public static void q28397e(ScriptManager sm) { + // Quest 28397 - Path of a True Mapler (END) + // NPC: 1101002 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28397); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28399e") + public static void q28399e(ScriptManager sm) { + // Quest 28399 - Cakes or Pies? (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28399); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28422s") + public static void q28422s(ScriptManager sm) { + // Quest 28422 - What day is today? (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28422); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28423s") + public static void q28423s(ScriptManager sm) { + // Quest 28423 - What day is today?? (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28423); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28431e") + public static void q28431e(ScriptManager sm) { + // Quest 28431 - Lucky Gift (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(28431); + sm.addItem(2450000, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q28433s") + public static void q28433s(ScriptManager sm) { + // Quest 28433 - How to find friends (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28433); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q28436s") + public static void q28436s(ScriptManager sm) { + // Quest 28436 - How to find friends (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(28436); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + +} diff --git a/src/main/java/kinoko/script/quest/LudibriumQuest.java b/src/main/java/kinoko/script/quest/LudibriumQuest.java new file mode 100644 index 00000000..6cc60030 --- /dev/null +++ b/src/main/java/kinoko/script/quest/LudibriumQuest.java @@ -0,0 +1,1435 @@ +package kinoko.script.quest; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +import java.util.Map; + +/** + * Ludibrium Region Quests + * Area 37 - Ludibrium, Eos Tower, Toy Factory, Clocktower + */ +public final class LudibriumQuest extends ScriptHandler { + // Item constants + public static final int SOUL_TEDDY_SPIRIT = 4000144; + public static final int TIMER_BABY_BIRD = 4220046; + public static final int SPRINGY_WORM = 4000460; + public static final int BLUEPRINT_MACHINE = 4031100; + public static final int WAVE_TRANSLATOR = 4031927; + public static final int SACK_OF_RICE = 4031229; + public static final int SACK_OF_RICE_2 = 4031246; + public static final int SPACE_FOOD = 4000117; + public static final int LASER_GUN = 4031101; + public static final int WORN_OUT_GOGGLE = 4000103; + public static final int PROPELLER = 4000123; + public static final int EOS_ROCK_SCROLL = 4001020; + public static final int LUNCHBOX = 2020021; + public static final int RATZ_CHEESE = 4031129; + public static final int SACK_OF_RICE_3 = 4031247; + public static final int BIRK_EGG = 4000116; + public static final int RAT_TRAP = 4000095; + public static final int SACK_OF_RICE_4 = 4031248; + public static final int BROTHERLY_LOVE_LETTER = 4031237; + public static final int LOST_SEED = 4031241; + public static final int SWALLOW_SEED = 4031225; + public static final int NOLBU_GOURD_SEED = 4031245; + public static final int NOLBU_GOURD = 4031224; + public static final int HONGBU_GOURD_SEED = 4031244; + public static final int HONGBU_GOURD = 4031223; + public static final int GOURD_TREASURE = 4031235; + + // ================================ + // Quest 3445 - Free Spirit + // ================================ + + @Script("q3445s") + public static void q3445s(ScriptManager sm) { + // Free Spirit (3445 - start) + // NPC 2041026 - Ghosthunter Bob (Ludibrium - Path of Time) + // Level 62+ requirement + sm.sayNext("Who am I? I'm Bob, Ghosthunter Bob. Have you seen any monsters that roam around this area?"); + sm.sayBoth("You might have noticed it, but... the Teddys are being controlled by the force of evil. Looking at the Teddys, knowing that their souls are being controlled by someone else..."); + sm.sayBoth("That's right. I really want to free up their souls from that force of evil, you know. So... can you help me out by killing those monsters, and gather up their souls for me?"); + + if (!sm.askYesNo("Will you help free the souls of Master Soul Teddy?")) { + sm.sayOk("What? I don't think it's a difficult task, though... please reconsider."); + return; + } + + sm.sayNext("The monster you're dealing with is Master Soul Teddy, and you'll notice that a huge evil ghost hovers around it, controlling its every move."); + sm.sayBoth("Go down below and fight the monster, and you may be able to gather up the freed-up souls of Master Soul Teddy. Bring those souls to me, and I'll do what I can for them to regain their freedom."); + sm.sayBoth("Oh, and I'd sure appreciate it if you can collect 80 of their souls. It's not gonna be too hard for you, is it? Thanks~~"); + + sm.forceStartQuest(3445); + } + + @Script("q3445e") + public static void q3445e(ScriptManager sm) { + // Free Spirit (3445 - end) + // NPC 2041026 - Ghosthunter Bob + // Requires: 80x Soul Teddy's Spirit (4000144) + if (!sm.hasItem(SOUL_TEDDY_SPIRIT, 80)) { + sm.sayOk("Hmmm ... the numbers don't match. I don't think you brought the number I was asking for."); + return; + } + + sm.sayNext("What, you freed up their souls? All of them, like you promised?"); + + if (!sm.askYesNo("Turn in 80 Soul Teddy's Spirits?")) { + sm.sayOk("Come back when you have all 80 souls."); + return; + } + + sm.sayOk("Yeah, those souls may be sent to a much nicer place now, and may they rest in peace. I'm sure they are thankful of your good deeds."); + + sm.removeItem(SOUL_TEDDY_SPIRIT, 80); + sm.addExp(35000); + sm.forceCompleteQuest(3445); + } + + // ================================ + // Quest 3250 - Raise the Timer + // ================================ + + @Script("q3250s") + public static void q3250s(ScriptManager sm) { + // Raise the Timer (3250 - start) + // NPC at Clocktower - Forgotten Path of Time <1> (220070000) + if (sm.hasItem(TIMER_BABY_BIRD)) { + sm.sayOk("Oh what...? You still have the Timer from long ago. You want another Timer when you can't even take care of the one you have? At least return the one you have before you take on a new one."); + return; + } + + sm.sayNext("Wow! I fed this one...did I feed this one? Hmmm...I don't think I've fed the third one yet! Here, have some food. Hehe, it fills my appetite to just see them eat... Oh, are you here?"); + sm.sayBoth("Sigh... I've been really busy, ever since that Timer changed due to the Papulatus's influence. Proper nurturing can rear natural birds. I cleared out the monsters... so, since I have the time to spare now, I decided to rear a bird myself."); + + if (!sm.askYesNo("But it's so much work to raise not just one, but ten birds at once. Not to mention how hard it is to find food to feed all of them. But they're very adorable. Would you like to raise one?")) { + sm.sayOk("Hmmm...I guess you're not a huge fan of animals. But they're so cute!"); + return; + } + + if (!sm.canAddItem(TIMER_BABY_BIRD, 1)) { + sm.sayOk("It seems like you don't have enough room for a baby bird..."); + return; + } + + sm.forceStartQuest(3250); + sm.addItem(TIMER_BABY_BIRD, 1); + sm.sayNext("Here, I'll give you one. Please take good care of it. You can feed them Springy Worms that drop from monsters at the Clocktower."); + sm.sayBoth("Timers must be returned to their original habitat when full-fledged, so bring the Timer back to me when it's full-grown. I'll be counting on you."); + } + + @Script("q3250e") + public static void q3250e(ScriptManager sm) { + // Raise the Timer (3250 - end) + // Return the fully grown Timer + sm.sayNext("So, how's it feel to be raising the Timer?"); + + if (!sm.hasItem(TIMER_BABY_BIRD)) { + sm.sayOk("Where's the Timer I gave you? You need to bring it back when it's fully grown!"); + return; + } + + sm.sayBoth("What? The Timer's already fully grown? Woah... Seems like you've been feeding the Springy Worms a whole bunch... Well then, I'll need that Timer back now. It's time for us to return it to its world..."); + sm.sayBoth("I hate to see them go, but they don't belong here...it's for their good."); + + sm.removeItem(TIMER_BABY_BIRD, 1); + sm.addExp(80000); + sm.forceCompleteQuest(3250); + } + + // ================================ + // Quest 3200 - Cleaning up Eos Tower + // ================================ + + @Script("q3200s") + public static void q3200s(ScriptManager sm) { + // Cleaning up Eos Tower (3200 - start) + // NPC 2041004 - Marcel (Eos Tower 101st Floor) + sm.sayNext("Hmmm ... you look like someone that likes adventure. Can I ask you for a few favors? It's not going to be easy, but if you get the job done, I'll reward you well for it!"); + + if (!sm.askYesNo("Are you willing to help clean up Eos Tower?")) { + sm.sayOk("Really. This task isn't that difficult, and I think you can do it. If you have any free time, then come talk to me."); + return; + } + + sm.sayNext("Cool! What I am going to ask from you is simple. Ludibrium is a floating castle supported by two huge towers, so to get down to the ground level, you'll need to go through Eos Tower, a tower of mind-numbing heights."); + sm.sayBoth("It's a very important tower, but the cleaning hasn't been done lately, and the tower is now infiltrated with filthy monsters. I can't move from my duties here as a guard, so I was hoping if you can go in the tower and take care of some monsters."); + sm.sayBoth("Please take care of 50 #o3110102#s that are roaming around in Eos Tower. They are a bunch of white rats with springs on the back, and they are a handful. I'll be here waiting until you take of all of them."); + + sm.forceStartQuest(3200); + } + + @Script("q3200e") + public static void q3200e(ScriptManager sm) { + // Cleaning up Eos Tower (3200 - end) + // NPC 2041004 - Marcel + sm.sayNext("Awesome! You took out all of them! There must have been quite a few #o3110102#s roaming around Eos Tower. Anyway, thank you for helping me out. I'll give you an item only available in Ludibrium that you'll need to use for hunting. Please take it!"); + + if (!sm.askYesNo("Accept the rewards?")) { + return; + } + + sm.sayOk("Do you like the 50 #t2000008#s and 50 #t2000010#s? Thank you so much for your help, but it seems like Eos Tower is still full of nasty monsters. I have a lot to ask of you, so if you have any free time, please come talk to me."); + + if (sm.canAddItem(2000008, 50) && sm.canAddItem(2000010, 50)) { + sm.addItem(2000008, 50); // Orange Potion + sm.addItem(2000010, 50); // White Potion + sm.addExp(1250); + sm.forceCompleteQuest(3200); + } else { + sm.sayOk("Please make sure you have enough inventory space."); + } + } + + // ================================ + // Quest 3401 - Dr. Kim's Comments: Blueprint for New Robot + // ================================ + + @Script("q3401s") + public static void q3401s(ScriptManager sm) { + // Dr. Kim's Comments: Blueprint for New Robot (3401 - start) + // NPC 2050001 - Dr. Kim (Omega Sector Command Center) + sm.sayNext("Hmmm ... done! The #t" + BLUEPRINT_MACHINE + "#, which contains the blueprint of the new robot, is now COMPLETE! Hahaha!! Once the making of the robot is complete, straight out of this blueprint, the aliens outside the sector will be of no factor whatsoever! Yes... hey you! You look like you don't have much to do. Can you do me a favor?"); + + if (!sm.askYesNo("Will you help Dr. Kim show the blueprint to Chury, Hoony, and Gunny?")) { + sm.sayOk("Did you feel irked when I said you look like you don't have much to do? I know a whole bunch of people just like you... hmmm... anyway, if you change your mind, talk to me. I am well aware that you really don't have much to do right now."); + return; + } + + if (!sm.canAddItem(BLUEPRINT_MACHINE, 1)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("Thank you! I'll give you this #t" + BLUEPRINT_MACHINE + "#. Your job is to show this to #p2050005#, #p2050006#, and #p2050007#. They should be all over Omega Sector. The first person you'll need to meet is #p2050005#. He should be resting somewhere around the Silo."); + + sm.addItem(BLUEPRINT_MACHINE, 1); + sm.forceStartQuest(3401); + } + + @Script("q3401e") + public static void q3401e(ScriptManager sm) { + // Dr. Kim's Comments: Blueprint for New Robot (3401 - end) + // NPC 2050005 - Chury (Omega Sector Silo) + if (!sm.hasItem(BLUEPRINT_MACHINE)) { + sm.sayOk("Hmmm... are you saying that #p2050001# has completed the #t" + BLUEPRINT_MACHINE + "# of the new robot? But where is the #t" + BLUEPRINT_MACHINE + "#? Maybe you lost it on the way here... if so, then please go back to #p2050001#. He put a security device on that baby just in case something like this happened."); + return; + } + + sm.sayNext("Hoh... so this is #t" + BLUEPRINT_MACHINE + "#, the blueprint for the new robot that #p2050001# had been diligently working on for the past few months. Hmmm... so when I put my eyes right there, I can see the contents inside, just for a security measure. Amazing, just the kind of stuff that he would make. Okay, now let's see what's inside this baby..."); + + sm.removeItem(BLUEPRINT_MACHINE, 1); + sm.addExp(2400); + sm.forceCompleteQuest(3401); + } + + // ================================ + // Quest 3455 - The Endangered Lives of Grays + // ================================ + + @Script("q3455s") + public static void q3455s(ScriptManager sm) { + // The Endangered Lives of Grays (3455 - start) + // NPC 2050002 - Alien Gray (Omega Sector) + // This quest requires the Wave Translator from quest 3457 + + int response = sm.askMenu("You... you're back. My human friend, it's nice to see you again but I really don't have time to chat. Perhaps next time?", + Map.of(0, "#b(Activate the Wave Translator.)#k")); + + if (response != 0) { + return; + } + + sm.sayNext("You can ask about Zeno all you want but I don't have anything to say. The seniors are in charge of Zeno, so I don't know anything.(Oh no, my palate is getting accustomed to human food. Everything about me is becoming more and more human.)"); + + sm.askMenu("", + Map.of(0, "#b(Wave Translator activated)#k")); + + sm.sayNext("More importantly, what happened to you, my human friend? You're not cooperating with the hypocrites in the Omega Sector, are you?(Even my standards for attractiveness is becoming human too. Agent Marco is probably the most handsome human ever.)"); + + sm.askMenu("", + Map.of(0, "#b(Wave Translator activated)#k")); + + sm.sayNext("The Grays do not want to hurt the humans. We want to get closer. Don't you know? (No, forget it! If Prince finds out, he'll be furious!)"); + + sm.askMenu("", + Map.of(0, "#b(Wave Translator activated)#k")); + + sm.sayNext("Don't be afraid of our guidance. Grays are on humans' side. We will lead the humans to their glory.(Let's think of more practical things... like washing my chute and drying food... I'm going to get alien's eczema soon.)"); + + sm.askMenu("", + Map.of(0, "#b(Wave Translator activated)#k")); + + if (!sm.askYesNo("So... would you like to cooperate with us now?")) { + sm.sayOk("Too bad. Well, I guess I'll see you around."); + return; + } + + sm.sayNext("You must have decided to side with the Grays! Here, sign this contract to pledge your loyalty!"); + sm.forceStartQuest(3455); + } + + @Script("q3455e") + public static void q3455e(ScriptManager sm) { + // The Endangered Lives of Grays (3455 - end) + // NPC 2050002 - Alien Gray + sm.sayNext("Seems like you've made up your mind. Haha! Are you truly THAT happy to have signed your life away to the Grays? ... What? Am I joking around?"); + sm.sayBoth("What, what are you talking about... You seem so serious... Hmmm... I guess I don't have what it takes to read human facial expressions yet..."); + + if (!sm.askYesNo("Complete the quest?")) { + return; + } + + sm.sayOk("#b(Alien Gray has nothing more to say... It seems you've collected a bunch of useless information... The Wave Translator must have malfunctioned. Let's ask Dr. Kim to build another Wave Translator.)#k"); + + sm.removeItem(WAVE_TRANSLATOR, 1); + sm.forceCompleteQuest(3455); + } + + // ================================ + // Quest 3601 - The Brothers' Stack of Rice 1 + // ================================ + + @Script("q3601s") + public static void q3601s(ScriptManager sm) { + // The Brothers' Stack of Rice 1 (3601 - start) + // NPC 2071005 - Chil Sung (Korean Folk Town) + sm.sayNext("Hello there, you must be new to this town; you don't look familiar. I live here in town with my younger brother, farming for a living. We harvested our crops a few days ago, but there's something that concerns me right now. I have split the Sack of Rice equally between my brother and me, but my brother raises more kids, and..."); + sm.sayBoth("I'd much prefer giving my brother a little more of #t" + SACK_OF_RICE + "#, but I know he won't accept it. That's why ... can you please secretly put one #t" + SACK_OF_RICE + "# on top of the stack of rice in front of my brother's house? My brother Chil Nam lives at the very east of this town."); + + if (!sm.askYesNo("Will you help Chil Sung secretly deliver the sack of rice?")) { + sm.sayOk("I'm sorry. I shouldn't have asked for such a huge favor to a stranger. I apologize for my mishap."); + return; + } + + if (!sm.canAddItem(SACK_OF_RICE, 1)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("I urge you, please make sure my brother doesn't know about this~"); + + sm.addItem(SACK_OF_RICE, 1); + sm.forceStartQuest(3601); + } + + @Script("q3601e") + public static void q3601e(ScriptManager sm) { + // The Brothers' Stack of Rice 1 (3601 - end) + // NPC 2072001 - Rice Stack (Korean Folk Town) + if (!sm.hasItem(SACK_OF_RICE)) { + sm.sayOk("This is Chil Nam's stack of rice."); + return; + } + + sm.sayOk("I see a stack of rice stacked at the garden of Chil Nam's house. Secretly, I brought out a sack of rice and laid it unnoticeably on top of the stack."); + + sm.removeItem(SACK_OF_RICE, 1); + sm.addExp(1000); + sm.forceCompleteQuest(3601); + } + + // ================================ + // Marcel's Eos Tower Cleaning Quest Chain + // ================================ + + @Script("q3201s") + public static void q3201s(ScriptManager sm) { + // Cleaning Up the Inner Parts of Eos Tower (3201 - start) + sm.sayNext("Wow, great timing! I actually have something else to ask you for a little help on. A few days ago, one of the guys from us reported that Eos Tower is now full of even nastier monsters than last time, and so I thought of you as the enforcer ... so can you do it instead of me??"); + + if (!sm.askYesNo("Will you help clean up the inner parts of Eos Tower?")) { + sm.sayOk("Really. This task isn't that difficult, and I think you can do it. If you have any free time, then come talk to me."); + return; + } + + sm.sayNext("Alright. It's similar to others last time. The tower that connects Ludibrium and ground, Eos Tower, is now infected with dirty spiders and black rats. So please go in and take care of some of them."); + sm.sayBoth("Please take care of 40 #o3210205#s and 40 #o2230103#s that are roaming around in Eos Tower. They are a bunch of huge black rats with springs on the back, and spiders making spider webs on the wall. I'll be here waiting until you take of all of them."); + + sm.forceStartQuest(3201); + } + + @Script("q3201e") + public static void q3201e(ScriptManager sm) { + // Cleaning Up the Inner Parts of Eos Tower (3201 - end) + sm.sayNext("Awesome! You took them all out again! There must have been quite a few #o2230103#s and #o3210205#s around Eos Tower. Anyway, thank you for helping me out. I'll give you an item that you'll need to use for hunting. Please take it!"); + + if (!sm.askYesNo("Accept the rewards?")) { + return; + } + + sm.sayOk("Here they are, 50 #t2000009#s and 50 #t2000011#s. Thank you so much for your help, but it seems like Eos Tower is still full of nasty monsters. I have a lot to ask of you, so if you have any free time, please come talk to me."); + + if (sm.canAddItem(2000009, 50) && sm.canAddItem(2000011, 50)) { + sm.addItem(2000009, 50); // Blue Potion + sm.addItem(2000011, 50); // Red Potion + sm.addExp(2500); + sm.forceCompleteQuest(3201); + } else { + sm.sayOk("Please make sure you have enough inventory space."); + } + } + + @Script("q3202s") + public static void q3202s(ScriptManager sm) { + // Cleaning Up the Outer Parts of Eos Tower (3202 - start) + sm.sayNext("Hey, you're back! Actually, I have another favor to ask you. This time, I've heard reports that the menacing toy monsters have been appearing around the outer walls of Eos Tower, and I'd really like for you to defeat them for me. Will you help me out?"); + + if (!sm.askYesNo("Will you help clean the outer walls?")) { + sm.sayOk("Really. This task isn't that difficult, and I think you can do it. If you have any free time, then come talk to me."); + return; + } + + sm.sayNext("Okay! It's similar to what I requested before. There have been reports that some of the toys made from the Toy Factory, located at the bottom of the Ludibrium Clocktower, have been attacking anyone walking around the outer walls of Eos Tower. I would like for you to go back into the tower and take care of them."); + sm.sayBoth("Please take out 25 #o3230303#s and 25 #o3230308#s. Both of them can be found at the outer wall of Eos Tower. One resemble a toy pink plane, while the other looks like a pink bird. I'll be waiting for you here."); + + sm.forceStartQuest(3202); + } + + @Script("q3202e") + public static void q3202e(ScriptManager sm) { + // Cleaning Up the Outer Parts of Eos Tower (3202 - end) + sm.sayNext("Awesome! You took them all out again! There must have been quite a few #o3230303#s and #o3230308#s around Eos Tower. Anyway, thank you for helping me out. I'll give you an item that you'll need to use for hunting. Please take it!"); + + if (!sm.askYesNo("Accept the rewards?")) { + return; + } + + sm.sayOk("Here they are, 50 #t2000004#s. Thank you so much for your help, but it seems like Eos Tower is still full of nasty, evil monsters. I have a lot to ask of you, so if you have any free time, please come talk to me."); + + if (sm.canAddItem(2000004, 50)) { + sm.addItem(2000004, 50); // Elixir + sm.addExp(4000); + sm.forceCompleteQuest(3202); + } else { + sm.sayOk("Please make sure you have enough inventory space."); + } + } + + @Script("q3203s") + public static void q3203s(ScriptManager sm) { + // Eos Tower Threatened! (3203 - start) + sm.sayNext("Oh, hello! I've been waiting for you, actually, because something serious has occurred here. A group of unruly monsters have come to threaten and actually destroy Eos Tower and Ludibrium. I was wondering if you can help us out here ..."); + + if (!sm.askYesNo("Will you help protect Eos Tower?")) { + sm.sayOk("Really. This task isn't that difficult, then come talk to me."); + return; + } + + sm.sayNext("That's good! There were some leftover blocks while building Eos Tower, and those blocks were exposed to dark forces, reinventing themselves as Golems. Those Golems are nasty, unruly, and most definitely destructive. I know it is going to be dangerous, but I really hope you can take some of them out from Eos Tower for us."); + sm.sayBoth("Please take out 20 #o4230109#s and 12 #o4230110#s. They are Golems made in toy blocks, and they are much more powerful than any you may have faced before. I'll be here wishing you a good luck."); + + sm.forceStartQuest(3203); + } + + @Script("q3203e") + public static void q3203e(ScriptManager sm) { + // Eos Tower Threatened! (3203 - end) + sm.sayNext("I can't believe you took care of all those unruly Golems! How was it? They didn't destroy Eos Tower to the point of no return, did they? Anyway, I can't thank you enough for this. I'll give you this item that's essential on hunting, so please accept it!"); + + if (!sm.askYesNo("Accept the reward?")) { + return; + } + + sm.sayOk("Did you like the reward? Thank you so much for your effort. Eos Tower will be safer than it was before you came here, but what is this weird feeling ... I don't know, but just to make sure, if you have any time available, please drop by."); + + // Random scroll reward (one of many weapon scrolls) + sm.addExp(7000); + sm.forceCompleteQuest(3203); + } + + @Script("q3204s") + public static void q3204s(ScriptManager sm) { + // Peace at Eos Tower (3204 - start) + sm.sayNext("Oh wow! Thank goodness you're here, because I've been waiting for you. First of all, I'd like to personally thank you for helping us protect Eos Tower from destruction. The problem is, unless we find the root of the problem, Eos Tower will be put to test again. I'd appreciate it if you find a way to take out this boss-life character in this game."); + + if (!sm.askYesNo("Will you defeat the leader of the Block Golems?")) { + sm.sayOk("Really? I understand, since this is much tougher than any of the other things people asked for, but if you have any time, then please come back and talk to me."); + return; + } + + sm.sayNext("Thank you! As luck would have it, a few days ago, one of the guys here spotted what appeared to be a head figure. The way I see it, as long as it is here, Eos Tower will suffer the consequences. I would like for you to find it and kill it for me..."); + sm.sayBoth("The monster you're facing is the leader of the Block Golems, #o4130103#. Astonishingly powerful, it is considered incomparable to other Golems. I strongly suggest you take on it with your party or guild. I'll be here waiting for you."); + + sm.forceStartQuest(3204); + } + + @Script("q3204e") + public static void q3204e(ScriptManager sm) { + // Peace at Eos Tower (3204 - end) + sm.sayNext("Unbelievable ... you really took care of #o4130103#? That's just un-be-lievable. You have done so much for me and other residents of Ludibrium, that I'll have to give you a handsome reward. The item I'm giving you shall help you out while hunting, so please accept it."); + + if (!sm.askYesNo("Accept the reward?")) { + return; + } + + sm.sayOk("Thank you so much for helping. It seems like Eos Tower AND Ludibrium are now in peace. I am so glad I ran into you in dire times like this. Thank you for all your hard work. Please drop by often!"); + + sm.addExp(10500); + sm.forceCompleteQuest(3204); + } + + @Script("q3205s") + public static void q3205s(ScriptManager sm) { + // The Lost Guard (3205 - start) + sm.sayNext("Hmmm ... where am I?? I'm in charge of security for the 59th floor of Eos Tower, but I have nooo idea where I am at right this minute. Dang ... this place is also full of monsters, which makes it impossible for me to do my duty here. Can you help me out, if it's okay with you?"); + + if (!sm.askYesNo("Will you help the lost guard?")) { + return; + } + + sm.sayNext("Alright! It's a simple request, actually. Please take out the monsters that are on my way to the 59th floor of Eos Tower, and hand me the leftovers as a proof. It'll be much easier for me to go to the 59th floor if the numbers of the monsters decrease on my way there."); + sm.sayBoth("The ones you'll need to take down are #o3230307# and #o3210206#. Take those down and hand me 15 #t4000123#s and 15 #t4000103#s. I'll be here waiting for you, my friend. Good luck!"); + + sm.forceStartQuest(3205); + } + + @Script("q3205e") + public static void q3205e(ScriptManager sm) { + // The Lost Guard (3205 - end) + if (!sm.hasItem(PROPELLER, 15) || !sm.hasItem(WORN_OUT_GOGGLE, 15)) { + sm.sayOk("I don't think you have collected all the items I asked of you. Please take down the monsters within Eos Tower and collect 15 #t4000123#s and 15 #t4000103#s for me. I can't get to the 59th floor where I am in charge of the security because of those darn monsters. Please help me!"); + return; + } + + sm.sayNext("Wow ... you really took them all down. I knew I had an eye for talent. Anyway, thank you for your hard work. As a sign of appreciation, I'll give you #t4001020#s, something I've cherished for a long time. I'll tell you how to use it after you receive it."); + + if (!sm.askYesNo("Accept the reward?")) { + return; + } + + if (!sm.canAddItem(EOS_ROCK_SCROLL, 20)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("The #t4001020# that I gave you is an item that is very essential in activating the four stones within Eos Tower. It'll allow you to use #p2040024# at the 100th floor, #p2040025# at the 71st floor, #p2040026# at the 41st floor, and #p2040027# at the 1st floor. Use those rocks to teleport to other rocks. Please drop by again ~"); + + sm.removeItem(PROPELLER, 15); + sm.removeItem(WORN_OUT_GOGGLE, 15); + sm.addItem(EOS_ROCK_SCROLL, 20); + sm.forceCompleteQuest(3205); + } + + // ================================ + // Dr. Kim's Blueprint Quest Chain + // ================================ + + @Script("q3402s") + public static void q3402s(ScriptManager sm) { + // Dr. Kim's Comments: A Meeting with Chury (3402 - start) + sm.sayNext("Ohhh... this is just incredible! I can't believe he drew up a robot as technologically advanced as this! I knew he was incredible to begin with, but... wow!!! He's much more than I thought! Anyway, please take this #t" + BLUEPRINT_MACHINE + "# and show it to #p2050006#, who should be at a field around Omega Sector. I bet you he'll be just as astounded looking at it as I am right now."); + sm.sayBoth("Oh, and... #p2050006# ... he's been out on a mission for a while now and I'm pretty sure he's run out of food as we speak. Please gather up 20 #t" + SPACE_FOOD + "#s, which can be found through #o4230116# the alien, and give them to him. Good luck!"); + + sm.forceStartQuest(3402); + } + + @Script("q3402e") + public static void q3402e(ScriptManager sm) { + // Dr. Kim's Comments: A Meeting with Chury (3402 - end) + if (!sm.hasItem(SPACE_FOOD, 20)) { + sm.sayOk("Hmmm... are you sure you have 20 #t" + SPACE_FOOD + "#s? Or is your etc. inventory full by any chance? Please check your item inventory one more time."); + return; + } + + sm.sayNext("Hoh... #t" + SPACE_FOOD + "#!!! Great timing, because I've just ran out of food, and I'm still in the middle of a mission. Can you give me the #t" + SPACE_FOOD + "#s? I'll need to fill my belly up first before I check out the #t" + BLUEPRINT_MACHINE + "# or anything else for that matter."); + + sm.removeItem(SPACE_FOOD, 20); + sm.addExp(2700); + sm.forceCompleteQuest(3402); + } + + @Script("q3403s") + public static void q3403s(ScriptManager sm) { + // Dr. Kim's Comments: A Meeting with Hoony (3403 - start) + sm.sayNext("Phew... I feel much better now. But #t" + BLUEPRINT_MACHINE + "# is really amazing! I really want to show #p2050007# this... please find him somewhere around the fields. Oh, and please give him the #t" + LASER_GUN + "# that I just gave you, too. He told me it was broken, so I fixed it for him."); + + if (!sm.canAddItem(LASER_GUN, 1)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.addItem(LASER_GUN, 1); + sm.forceStartQuest(3403); + } + + @Script("q3403e") + public static void q3403e(ScriptManager sm) { + // Dr. Kim's Comments: A Meeting with Hoony (3403 - end) + if (!sm.hasItem(LASER_GUN)) { + sm.sayOk("Well, I was sent here on a mission, and one day, my precious #t" + LASER_GUN + "# broke down on me. I gave it to #p2050006# so he can fix it up quickly, but I'm guessing the fixing has taken longer than expected, and thus, I do not have a weapon to defend myself with. It's quite dangerous around here without a trusty weapon... I hope I can get that really soon..."); + return; + } + + sm.sayNext("Oh wow, you have my #t" + LASER_GUN + "#! I am guessing you ran into #p2050006# first. Anyway, it's been tough for me to handle all these aliens by myself. Now, let's see if this gun is fixed right."); + + if (!sm.askYesNo("Give Gunny the Laser Gun?")) { + return; + } + + if (!sm.canAddItem(2030011, 7)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("Whoa... this #t" + LASER_GUN + "# ... it seems like it's actually working better than it did before it broke. And this... this must be the #t" + BLUEPRINT_MACHINE + "# of the new robot that #p2050001# had been working so hard on. This is incredible!! I truly believe with this robot, the aliens will be much easier to handle."); + + sm.removeItem(LASER_GUN, 1); + sm.addItem(2030011, 7); // Lemon + sm.addExp(3000); + sm.forceCompleteQuest(3403); + } + + // ================================ + // Brothers' Rice Quest Chain (continued) + // ================================ + + @Script("q3602s") + public static void q3602s(ScriptManager sm) { + // The Brothers' Stack of Rice 2 (3602 - start) + sm.sayNext("Umm, hi there. I don't think we've ever met. Someone told me a person from outside came by here, and I guess that must be you. This is great; I need to ask you for a small favor. My brother and I live here in this town, farming for a living. We harvested our crop a few days ago, but something's been nagging me ever since. I have equally split the #t" + SACK_OF_RICE_2 + "# between my brother and myself, but my brother takes care of my parents, and I seriously believe my brother deserves a little more of #t" + SACK_OF_RICE_2 + "#, but I know he won't accept it. That's why ... can you please secretly put one #t" + SACK_OF_RICE_2 + "# on top of the stack of rice in front of my brother's house?"); + + if (!sm.askYesNo("Will you help Chil Nam deliver rice to his brother?")) { + sm.sayOk("I understand. It's rude for me to ask for such a huge favor to a stranger. I'm sorry."); + return; + } + + if (!sm.canAddItem(SACK_OF_RICE_2, 1)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("Please, please make sure my brother does NOT find out about this."); + + sm.addItem(SACK_OF_RICE_2, 1); + sm.forceStartQuest(3602); + } + + @Script("q3602e") + public static void q3602e(ScriptManager sm) { + // The Brothers' Stack of Rice 2 (3602 - end) + if (!sm.hasItem(SACK_OF_RICE_2)) { + sm.sayOk("This is Chil Sung's stack of rice."); + return; + } + + sm.sayOk("I see a stack of rice stacked at the garden of Chil Sung's house. Secretly, I brought out a sack of rice and laid it unnoticeably on top of the stack."); + + sm.removeItem(SACK_OF_RICE_2, 1); + sm.addExp(1000); + sm.forceCompleteQuest(3602); + } + + @Script("q3603s") + public static void q3603s(ScriptManager sm) { + // The Brothers' Stack of Rice 3 (3603 - start) + sm.sayNext("Did you make sure my brother did not find out about this? Something's weird, though. It seems like my stack of rice didn't shrink one bit. Don't you think so, too? Well, that's why ... can you go back to Chil Nam's house once more?"); + + if (!sm.askYesNo("Will you deliver another sack of rice?")) { + sm.sayOk("I'm sorry. I should have known that I was pushing it by asking you to do this twice..."); + return; + } + + if (!sm.canAddItem(SACK_OF_RICE_3, 1)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("I urge you, please make sure my brother doesn't know about this~"); + + sm.addItem(SACK_OF_RICE_3, 1); + sm.forceStartQuest(3603); + } + + @Script("q3603e") + public static void q3603e(ScriptManager sm) { + // The Brothers' Stack of Rice 3 (3603 - end) + if (!sm.hasItem(SACK_OF_RICE_3)) { + sm.sayOk("This is Chil Nam's stack of rice."); + return; + } + + sm.sayOk("I see a stack of rice stacked at the garden of Chil Nam's house. Secretly, I brought out a sack of rice and laid it unnoticeably on top of the stack."); + + sm.removeItem(SACK_OF_RICE_3, 1); + sm.addExp(1000); + sm.forceCompleteQuest(3603); + } + + // ================================ + // Nemi's Quest Chain (Ludibrium) + // ================================ + + @Script("q3206s") + public static void q3206s(ScriptManager sm) { + // Nemi's Lunchbox Delivery (3206 - start) + sm.sayNext("My father may be waiting for me by now. I have so many chores to take care of here ... I have a favor to ask you regarding my father... will you help us out?"); + + if (!sm.askYesNo("Will you deliver Nemi's lunchbox to her father?")) { + sm.sayOk("I see ... you must be swamped with other things yourself. If you ever find some time, then please come talk to me. I can always use some help, you know~"); + return; + } + + if (!sm.canAddItem(LUNCHBOX, 1)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("Thank you so much~ My father is the manager of the Toy Factory inside the Ludibrium Clocktower, and I think he forgot to pack his lunch today. I am hoping you can deliver this to my father for me."); + + sm.addItem(LUNCHBOX, 1); + sm.forceStartQuest(3206); + } + + @Script("q3206e") + public static void q3206e(ScriptManager sm) { + // Nemi's Lunchbox Delivery (3206 - end) + if (!sm.hasItem(LUNCHBOX)) { + sm.sayOk("You must have met my daughter! Didn't she want you to deliver the lunchbox to me? If you lost it in the middle of the track or have eaten it, please go back to her immediately. If she feels good, she may just make you another one."); + return; + } + + sm.sayNext("Ohh, this is it! The lunchbox from heaven by none other than my daughter! I can't work without this. Great job bringing this here. Thanks to you, I'll be able to concentrate on my work now. Here are some mesos for you."); + + if (!sm.askYesNo("Accept the reward?")) { + return; + } + + sm.sayOk("Mmmhmmm, this is how it should be! #p2041005#'s the best, bar none! Oh, by the way, I have been really busy these days with work, and I was hoping someone help me out here. If you have any free time down the road, please drop by."); + + sm.removeItem(LUNCHBOX, 1); + sm.addMoney(4500); + sm.addExp(750); + sm.forceCompleteQuest(3206); + } + + @Script("q3207s") + public static void q3207s(ScriptManager sm) { + // Nemi's First Ingredient (3207 - start) + sm.sayNext("So much work to do at home... oh, hello there? What are you doing here? Oh, oh yeah! If it's all right with you, can you help me out a little? I have to do something for my father, but I have no time to do it with all these chores to take care of."); + + if (!sm.askYesNo("Will you help Nemi gather ingredients?")) { + sm.sayOk("I see ... you must be swamped with other things yourself. If you ever find some time, then please come talk to me. I can always use some help, you know~"); + return; + } + + sm.sayNext("Oh, thank you~! Actually it seems like my father has been working really hard lately, with an overwhelming number of new orders for toys. He's been leaving before dawn, and comes home before midnight, so I've decided to make him a special dinner, but I don't have anything to work with right now..."); + sm.sayBoth("Head over to Eos Tower and you'll see a monster called #o3110102#. Take them out and please gather up 10 #t4031129#s. Their cheese is supposed to be top-notch. I feel like I can make the best soup in the world with that chesse, you know? Well, good luck~~"); + + sm.forceStartQuest(3207); + } + + @Script("q3207e") + public static void q3207e(ScriptManager sm) { + // Nemi's First Ingredient (3207 - end) + if (!sm.hasItem(RATZ_CHEESE, 10)) { + sm.sayOk("Hmmm ... I don't think you have gathered up #t4031129#s yet. Please head over to Eos Tower and defeat the #o3110102#, then gather up 10 #t4031129#s for me. I feel like I'll be the best cook in the world with that cheese."); + return; + } + + sm.sayNext("Hey, you're back! Yes! That's the cheese I was talking about! With this, I think I can make a hearty bowl of soup that my father will really enjoy after a long day at work. Thank you so much for helping me out again. I'll have to reward you for this. I hope you like it..."); + + if (!sm.askYesNo("Accept the reward?")) { + return; + } + + if (!sm.canAddItem(2020004, 100)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("Do you like the 100 #t2020004#s that I gave you? Now that I've gotten the ingredients and all, I'll have to start working on this soup. My father is coming soon, and I'll need to start this now in order for him to enjoy it the moment he gets here. Thank you so much for your help! Now, if you'll excuse me..."); + + sm.removeItem(RATZ_CHEESE, 10); + sm.addItem(2020004, 100); // Salad + sm.addExp(4200); + sm.forceCompleteQuest(3207); + } + + // ================================ + // Dr. Kim's Blueprint Chain (Final) + // ================================ + + @Script("q3404s") + public static void q3404s(ScriptManager sm) { + // Dr. Kim's Comments: A Meeting with Gunny (3404 - start) + sm.sayNext("Must have been tough for you to show all 3 of us the #t" + BLUEPRINT_MACHINE + "#. All you need to do now is to report all this to #p2050001#. The #t2030011# that I just gave you is an item that allows you to teleport straight to the Command Center in Omega Sector. This will help you report this much easier."); + + sm.forceStartQuest(3404); + } + + @Script("q3404e") + public static void q3404e(ScriptManager sm) { + // Dr. Kim's Comments: A Meeting with Gunny (3404 - end) + sm.sayNext("Oh ho... you must have met #p2050005#, #p2050006#, and #p2050007# and showed them #t" + BLUEPRINT_MACHINE + "#. How did they react to it? Were they happy to see it? Hahaha... that was good to know. Now, since you helped us out a great deal, here's a small reward for your job well done. I know it isn't much, but please take it."); + + if (!sm.askYesNo("Accept the reward?")) { + return; + } + + sm.sayOk("Did you get the 12,000 Mesos? I also raised your fame level a little bit. It's only fair that your reputation improves after the great job you did. I may need your help again down the road, so please drop by from time to time again."); + + sm.removeItem(BLUEPRINT_MACHINE, 1); + sm.addMoney(12000); + sm.addExp(4500); + // Fame is handled by quest definition XML + sm.forceCompleteQuest(3404); + } + + // ================================ + // Nemi's Quest Chain (Continued) + // ================================ + + @Script("q3208s") + public static void q3208s(ScriptManager sm) { + // Nemi's Second Ingredient (3208 - start) + sm.sayNext("Hi~ It's sunny here today. I can't tell you how much I love the fact that it's sunny here 365 days a year. How's it going? Are you here just to say hi? Well, if that's so... then can you help me out one more time...?"); + + if (!sm.askYesNo("Will you help Nemi gather more ingredients?")) { + sm.sayOk("I see ... you must be swamped with other things yourself. If you ever find some time, then please come talk to me. I can always use some help, you know~"); + return; + } + + sm.sayNext("Thank you so much~! This one's also on the fact that I don't have anything to cook with at home. My dad works everyday, and I want to make something healthy as well as delicious for his lunch today, but I have nothing to work with at home. My dad seems to be losing his appetite, so I need something better than usual."); + sm.sayBoth("Head over to Eos Tower and you'll see a monster called #b#o3230308##k hanging around at the outer wall. They cough up #t4000116#, so please gather up #b15 #t4000116#s#k. They are full inside, and tasty like no other. It can be used on a variety of dishes, so ... good luck!"); + + sm.forceStartQuest(3208); + } + + @Script("q3208e") + public static void q3208e(ScriptManager sm) { + // Nemi's Second Ingredient (3208 - end) + if (!sm.hasItem(BIRK_EGG, 15)) { + sm.sayOk("I don't think you have the stuff I asked for, yet. Defeat the #b#o3230308#s#k, who are usually located at the outer wall of Eos Tower, and collect #b15 #t4000116#s#k in the process. That will make me look like a great cook in a hurry!"); + return; + } + + sm.sayNext("Hey, you're back! That's it! That's what I was looking for! My father will love it when he sees it in his lunchbox! Thank you so much, yet again. Here, this is an item that I truly cherish, and it's for you. Hope you like it."); + + if (!sm.askYesNo("Accept the reward?")) { + return; + } + + if (!sm.canAddItem(1032006, 1)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("Do you like the #b#t1032006##k? Now that I have the ingredients ready, I'll have to get back to cooking now. I can literally see my dad drooling over this as we speak. Thank you so much for you help. I'll have to go now. See you around!"); + + sm.removeItem(BIRK_EGG, 15); + sm.addItem(1032006, 1); // Silver Earring + sm.addExp(5000); + sm.forceCompleteQuest(3208); + } + + @Script("q3209s") + public static void q3209s(ScriptManager sm) { + // Nemi's Dilemma (3209 - start) + sm.sayNext("Oh no... what should I do... oh hi!! You came by again! I have run into yet another problem. I am so sorry that I keep asking you for favors, but I seriously can't find a way to wiggle out of this by myself. Can you help me out...?"); + + if (!sm.askYesNo("Will you help Nemi with her rat problem?")) { + sm.sayOk("I see ... you must be swamped with other things yourself. If you ever find some time, then please come talk to me. I can always use some help, you know~"); + return; + } + + sm.sayNext("Thank you. I am sure you're terribly busy but still. Well, here's a problem. It has happened before, but it has happened much more often lately. Every time I make food, it disappears. I am sure it's the rats, but I can't prove it."); + sm.sayBoth("I work hard at making the best food possible, and as soon as I leave, even for a few minutes, the food just disappears. It's so disheartening. The thing is ... this has happened before, and because of that, I got some #o3110102#s as a toy rat, and I set up rat traps on them to catch rats."); + sm.sayBoth("But as you can see, they have since turned into monsters and are attacking people now. It's all my fault... and because of that... can you go back to Eos Tower and defeat #b#o3110102##k? Please gather up #b45 #t4000095#s#k afterwards, and do it ASAP. Thank you."); + + sm.forceStartQuest(3209); + } + + @Script("q3209e") + public static void q3209e(ScriptManager sm) { + // Nemi's Dilemma (3209 - end) + if (!sm.hasItem(RAT_TRAP, 45)) { + sm.sayOk("Please head over to Eos Tower and collect #b45 #t4000095#s#k by defeating the #b#o3110102##k so I can chase off the rats from my food. I'll have to get the #t4000095#s back from the rats. Thanks again!"); + return; + } + + sm.sayNext("Oh no, those rats must have taken my lunch ... whoa, hello! You're back already! That's much faster than I thought! Sigh ... I'm just glad it's over now. With these traps, I can at least surround my food with them. This may not seem like much, but please accept it. Thanks."); + + if (!sm.askYesNo("Accept the reward?")) { + return; + } + + sm.sayOk("Do you like the hat that I gave you? I've been saving up the hat in case I venture outside Ludibrium, but it looks like you need it more than I do right now. Now I'll have to set these traps up before making the dinner. I'll see you around!"); + + // Job-specific hat reward - the system will handle job selection + sm.removeItem(RAT_TRAP, 45); + sm.addExp(6000); + sm.forceCompleteQuest(3209); + } + + // ================================ + // Brothers' Rice Quest Chain (Continued) + // ================================ + + @Script("q3604s") + public static void q3604s(ScriptManager sm) { + // The Brothers' Stack of Rice 4 (3604 - start) + sm.sayNext("Did you make sure my brother did not notice a change in the stack of rice? It's weird, though; my stack of rice hasn't changed one bit. Don't you think so, too? This is why... um... can you go back to my brother's house once more?"); + + if (!sm.askYesNo("Will you deliver another sack of rice?")) { + sm.sayOk("I'm sorry that I asked you to do the same thing twice."); + return; + } + + if (!sm.canAddItem(SACK_OF_RICE_4, 1)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("Please, please make sure my brother does NOT find out about this."); + + sm.addItem(SACK_OF_RICE_4, 1); + sm.forceStartQuest(3604); + } + + @Script("q3604e") + public static void q3604e(ScriptManager sm) { + // The Brothers' Stack of Rice 4 (3604 - end) + if (!sm.hasItem(SACK_OF_RICE_4)) { + sm.sayOk("This is Chil Sung's stack of rice."); + return; + } + + sm.sayOk("I see a stack of rice stacked at the garden of Chil Sung's house. Secretly, I brought out a sack of rice and laid it unnoticeably on top of the stack."); + + sm.removeItem(SACK_OF_RICE_4, 1); + sm.addExp(1000); + sm.forceCompleteQuest(3604); + } + + @Script("q3605s") + public static void q3605s(ScriptManager sm) { + // Brotherly Love (3605 - start) + sm.sayNext("Hey, what's that sack of rice you're carrying right now? I asked you to leave it at my brother's house, and ... what are you doing bring it back here? What? My brother was the one making the request? Ahhh ... that's my brother there. No wonder my stack of rice hasn't shrunk one bit..."); + sm.sayBoth("So you went through all this trouble thanks to us brothers... thankfully, this incident is bound to make our brotherly bond that much tighter. I think my brother may have noticed it by now, too. I know it's a lot of work, but please go visit my brother Chil Nam right now. I'm sure he wants to find a way to say thank you for your hard work."); + + sm.forceStartQuest(3605); + } + + @Script("q3605e") + public static void q3605e(ScriptManager sm) { + // Brotherly Love (3605 - end) + sm.sayNext("Hi, there. I've noticed it, too. I was wondering why my stack of rice didn't shrink at all, and I just got a message from my brother about what happened. I really don't know how to thank you for going through all this trouble..."); + + if (!sm.askYesNo("Accept the reward?")) { + return; + } + + if (!sm.canAddItem(BROTHERLY_LOVE_LETTER, 1)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("I will never forget your hard work in this. I can't give you anything right this minute, but if you ever feel like feasting on some pork and buckwheat paste, please come visit us. My brother and I will make the best possible food you'll find in this town, just for you."); + + sm.addItem(BROTHERLY_LOVE_LETTER, 1); + sm.addExp(4000); + sm.forceCompleteQuest(3605); + } + + // ================================ + // Hongbu and Nolbu Quest Chain (Korean Folk Town) + // ================================ + + @Script("q3606s") + public static void q3606s(ScriptManager sm) { + // The Lost Seed (3606 - start) + sm.sayNext("Oh no... My wing must be clipped. I need to find the seed, though... please help me."); + + if (!sm.askYesNo("Will you help the injured Swallow?")) { + sm.sayOk("Aww, you're not going to leave me here, are you? My wing just got clipped!"); + return; + } + + sm.sayNext("I'm the messenger from the God of Sky, and I am in a little bit of a tight spot right now. I was carrying a present from the God of Sky for this man that took care of my broken leg last spring, but while I was resting at the top of the mountain, I dropped it somewhere, and now I can't find it."); + sm.sayBoth("So I went into the woods looking for that lost seed, only to find a bunch of angry rabbits chasing me. That's how I clipped my wing, frantically trying to fly away from those rabbits. If the lord found out that I have lost the seed, then I'll be harshly reprimanded for this!! Pleeeeeease help me find the seed. I have a feeling that it's one of those rabbits that have the seed. I am pretty sure of that!"); + + sm.forceStartQuest(3606); + } + + @Script("q3606e") + public static void q3606e(ScriptManager sm) { + // The Lost Seed (3606 - end) + if (!sm.hasItem(LOST_SEED)) { + sm.sayOk("Did you find the seed? If I ever tell the lord up there I lost the seed, then I will be scolded by him... sigh..."); + return; + } + + sm.sayNext("Hey, that seed you have in your hand... that's it!!! You really did find it! That's incredible! Thank you thank you thank you! Wait, oh no... now I forgot who to give this seed too. I keep forgetting the most important things! Was it Hongbu or Nolbu?"); + + if (!sm.askYesNo("Accept the seed to deliver?")) { + return; + } + + if (!sm.canAddItem(SWALLOW_SEED, 1)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("I really cannot remember which one... I'm sure it's either Hongbu or Nolbu, but ... I can't even fly there right now because my wing is clipped... please take this seed to either one of the two. I'll trust you to make the correct call."); + + sm.removeItem(LOST_SEED, 1); + sm.addItem(SWALLOW_SEED, 1); + sm.addExp(10000); + sm.forceCompleteQuest(3606); + } + + @Script("q3607s") + public static void q3607s(ScriptManager sm) { + // Opening Nolbu's Gourd (3607 - start) + int choice = sm.askMenu("What? What do you want from me? If you want to ask me for a favor, then get lost! I don't have time to listen to your 'request'!", + Map.of( + 0, "#bFine! I don't want anything to do with you, either!#k", + 1, "#bDid you fix a swallow's leg by any chance? Someone told me to give you this seed.#k" + )); + + if (choice == 0) { + sm.sayOk("Get out! Leave!"); + return; + } + + sm.sayNext("A swallow? Right, right. I did fix its legs before. You're telling me this seed is for me? Haha, alright, then. Okay, while you're here with the seed, can you do me a favor and plant it on my roof? And once it yields the gourd, open it and bring me whatever that's inside, okay?"); + + if (!sm.askYesNo("Plant the seed on Nolbu's roof?")) { + sm.sayOk("You're telling me that's too much? This is why the outsiders cannot be trusted..."); + return; + } + + if (!sm.canAddItem(NOLBU_GOURD_SEED, 1)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("Haha... wonder what kind of expensive jewelry is inside the gourd ... hey, what are you doing standing there? Go up there and plant the seed right now! What's taking you so long?"); + + sm.removeItem(SWALLOW_SEED, 1); + sm.addItem(NOLBU_GOURD_SEED, 1); + sm.forceStartQuest(3607); + } + + @Script("q3607e") + public static void q3607e(ScriptManager sm) { + // Opening Nolbu's Gourd (3607 - end) + if (!sm.hasItem(NOLBU_GOURD)) { + sm.sayOk("I told you to open the gourd! Why are you empty-handed? Go open it right now!"); + return; + } + + int choice = sm.askMenu("Okay, so did you open the gourd? What came out of it? How big's the jewelry?", + Map.of(0, "#bJust this piece of paper.#k")); + + sm.sayNext("What? That can't be! Give me that paper! What's this? Warrant of attachment? I need to forfeit my possessions to whoever brought this document? THIS CANNOT BE!!!!"); + + choice = sm.askMenu("", + Map.of(0, "#bSo I get to take all your possessions? What should I take?#k")); + + sm.sayNext("NO! NO! This represents everything I have!"); + + if (!sm.askYesNo("Complete the quest?")) { + return; + } + + if (!sm.canAddItem(GOURD_TREASURE, 1)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("Oh my ... I am RUINED! I am BROKE! NOOOO!"); + + sm.removeItem(NOLBU_GOURD, 1); + sm.addItem(GOURD_TREASURE, 1); + sm.addExp(10000); + sm.forceCompleteQuest(3607); + } + + @Script("q3608s") + public static void q3608s(ScriptManager sm) { + // Opening Hongbu's Gourd (3608 - start) + int choice = sm.askMenu("How can I help you? As you can see, our family isn't doing too well here, so I am really sorry, but I can't serve anyone right now, even a stranger from out of town like you.", + Map.of( + 0, "#bWow, you really must be struggling here.#k", + 1, "#bDid you fix a swallow's leg by any chance? Someone told me to give you this seed.#k" + )); + + if (choice == 0) { + sm.sayOk("...yes, I know , but ... I still feel very rich inside."); + return; + } + + sm.sayNext("Well, I did find this swallow that broke a leg back in spring, and fixed its leg, but ... you're telling me that Swallow told you to give this seed to me? That's interesting. Say, if you aren't really busy and all, can you please do me a favor and plant the seed on the roof of our house? I am sorry, but I haven't had anything to eat for the past few days, and I am too tired to go up there. If the seed turns into a gourd later on, then can you do me another favor and open it and give me what's inside? Thank you."); + + if (!sm.askYesNo("Plant the seed on Hongbu's roof?")) { + sm.sayOk("I am sorry. I must have asked too much from a stranger like you. If you are busy with other things, then please go take care of your business."); + return; + } + + if (!sm.canAddItem(HONGBU_GOURD_SEED, 1)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("Thank you so much. Please be careful while climbing up there. The rope is really old and ragged, you know."); + + sm.removeItem(SWALLOW_SEED, 1); + sm.addItem(HONGBU_GOURD_SEED, 1); + sm.forceStartQuest(3608); + } + + @Script("q3608e") + public static void q3608e(ScriptManager sm) { + // Opening Hongbu's Gourd (3608 - end) + if (!sm.hasItem(HONGBU_GOURD)) { + sm.sayOk("Is it still a long time before the gourd opens? Was there anything inside?"); + return; + } + + int choice = sm.askMenu("Did you open the gourd? Is it full inside? If so, then I better make a huge bowl of porridge out of it for my family. They missed a meal today, you know...", + Map.of(0, "#bHey, inside the gourd is nothing but jewelry and gold!#k")); + + sm.sayNext("What?? All this?? Wow ... it's a relief; now I can definitely feed my family until they are too full to eat. Thank you so very much. As a sign of thank you, I want you to take one of these jewelries. Please choose one."); + + if (!sm.askYesNo("Accept the reward?")) { + return; + } + + if (!sm.canAddItem(GOURD_TREASURE, 1)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("I got lucky thanks to the seed you brought here. I can't thank you enough for this. Thank you so much. Have a safe trip~"); + + sm.removeItem(HONGBU_GOURD, 1); + sm.addItem(GOURD_TREASURE, 1); + sm.addExp(10000); + sm.forceCompleteQuest(3608); + } + + @Script("q3609s") + public static void q3609s(ScriptManager sm) { + // The Seed That Swallow Lost (3609 - start) + sm.sayNext("What's going on? What? You lost the seed?? How can you lose it? Okay, I'll give you another one right now. This time, PLEASE don't lose it, and give it to the rightful owner, okay?"); + + if (!sm.askYesNo("Get another seed from Swallow?")) { + sm.sayOk("Well, then you won't need the seed. Good bye."); + return; + } + + sm.sayOk("I need to look for it, so please talk to me in a little bit."); + sm.forceStartQuest(3609); + } + + @Script("q3609e") + public static void q3609e(ScriptManager sm) { + // The Seed That Swallow Lost (3609 - end) + if (!sm.canAddItem(SWALLOW_SEED, 1)) { + sm.sayOk("Please make sure you have enough inventory space."); + return; + } + + sm.sayOk("Please get this to the rightful owner, although I still have no idea who should receive this."); + + sm.addItem(SWALLOW_SEED, 1); + sm.forceCompleteQuest(3609); + } + + // ADDITIONAL LUDIBRIUM QUESTS (3100-3800) ------------------------------------------------------------------------------------------------------------ + + @Script("q3108s") + public static void q3108s(ScriptManager sm) { + // Quest 3108 - Snowman's Rage-Found a clue (START) + // NPC: 2020012 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(3108); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q3116s") + public static void q3116s(ScriptManager sm) { + // Quest 3116 - Ludibrium Quest (START) + sm.forceStartQuest(3116); + } + + + @Script("q3118s") + public static void q3118s(ScriptManager sm) { + // Quest 3118 - Ludibrium Quest (START) + sm.forceStartQuest(3118); + } + + + @Script("q3122e") + public static void q3122e(ScriptManager sm) { + // Quest 3122 - Ludibrium Quest (END) + sm.forceCompleteQuest(3122); + } + + + @Script("q3122s") + public static void q3122s(ScriptManager sm) { + // Quest 3122 - Ludibrium Quest (START) + sm.forceStartQuest(3122); + } + + + @Script("q3125s") + public static void q3125s(ScriptManager sm) { + // Quest 3125 - Ludibrium Quest (START) + sm.forceStartQuest(3125); + } + + + @Script("q3452e") + public static void q3452e(ScriptManager sm) { + // Quest 3452 - Blocktopus is an Alien? (END) + // NPC: 2050001 + + final int QUEST_ITEM_4000099 = 4000099; + + if (!sm.hasItem(QUEST_ITEM_4000099, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4000099, 1); + sm.forceCompleteQuest(3452); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q3514e") + public static void q3514e(ScriptManager sm) { + // Quest 3514 - The Sorcerer Who Sells Emotions (END) + // NPC: 2140002 + + final int QUEST_ITEM_2022337 = 2022337; + + if (!sm.hasItem(QUEST_ITEM_2022337, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022337, 1); + sm.forceCompleteQuest(3514); + sm.addExp(891500); // EXP reward + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q3523s") + public static void q3523s(ScriptManager sm) { + // Quest 3523 - In Search for the Lost Memory (START) + // NPC: 1022000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(3523); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q3524s") + public static void q3524s(ScriptManager sm) { + // Quest 3524 - In Search for the Lost Memory (START) + // NPC: 1032001 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(3524); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q3525s") + public static void q3525s(ScriptManager sm) { + // Quest 3525 - In Search for the Lost Memory (START) + // NPC: 1012100 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(3525); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q3526s") + public static void q3526s(ScriptManager sm) { + // Quest 3526 - In Search for the Lost Memory (START) + // NPC: 1052001 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(3526); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q3527s") + public static void q3527s(ScriptManager sm) { + // Quest 3527 - In Search for the Lost Memory (START) + // NPC: 1090000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(3527); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q3529s") + public static void q3529s(ScriptManager sm) { + // Quest 3529 - In Search for the Lost Memory (START) + // NPC: 1101002 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(3529); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q3539s") + public static void q3539s(ScriptManager sm) { + // Quest 3539 - Searching for Lost Memories (START) + // NPC: 1201000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(3539); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q3540s") + public static void q3540s(ScriptManager sm) { + // Quest 3540 - In Search of Lost Memories (START) + // NPC: 1012003 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(3540); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q3541s") + public static void q3541s(ScriptManager sm) { + // Quest 3541 - Ludibrium Quest (START) + sm.forceStartQuest(3541); + } + + + @Script("q3714s") + public static void q3714s(ScriptManager sm) { + // Quest 3714 - The Remnants of Horned Tail... (START) + // NPC: 2081011 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(3714); + sm.addItem(4001094, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q3759e") + public static void q3759e(ScriptManager sm) { + // Quest 3759 - Towards the Sky 2 (END) + // NPC: 2085000 + + final int QUEST_ITEM_4032531 = 4032531; + + if (!sm.hasItem(QUEST_ITEM_4032531, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032531, 1); + sm.forceCompleteQuest(3759); + sm.addExp(11000); // EXP reward + sm.addItem(4032531, -1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q3833e") + public static void q3833e(ScriptManager sm) { + // Quest 3833 - Gathering Up the Lacking Ingredients (END) + // NPC: 2092000 + + final int QUEST_ITEM_4000294 = 4000294; + + if (!sm.hasItem(QUEST_ITEM_4000294, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4000294, 1); + sm.forceCompleteQuest(3833); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } +} diff --git a/src/main/java/kinoko/script/quest/MiscQuest.java b/src/main/java/kinoko/script/quest/MiscQuest.java new file mode 100644 index 00000000..db9ee618 --- /dev/null +++ b/src/main/java/kinoko/script/quest/MiscQuest.java @@ -0,0 +1,1240 @@ +package kinoko.script.quest; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +/** + * Miscellaneous Quest System + * Covers various quest ranges (4xxx, 6xxx, 8xxx, 40xxx, 51xxx) + */ +public final class MiscQuest extends ScriptHandler { + + + @Script("q40001s") + public static void q40001s(ScriptManager sm) { + // Quest 40001 - hohoho (START) + // NPC: Unknown + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(40001); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q40002s") + public static void q40002s(ScriptManager sm) { + // Quest 40002 - hohoho (START) + // NPC: 11000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(40002); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q40003s") + public static void q40003s(ScriptManager sm) { + // Quest 40003 - victor (START) + // NPC: Unknown + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(40003); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q40005s") + public static void q40005s(ScriptManager sm) { + // Quest 40005 - victor5 (START) + // NPC: Unknown + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(40005); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q40006s") + public static void q40006s(ScriptManager sm) { + // Quest 40006 - victor6 (START) + // NPC: Unknown + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(40006); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q40007e") + public static void q40007e(ScriptManager sm) { + // Quest 40007 - victor7 (END) + // NPC: Unknown + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(40007); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q40007s") + public static void q40007s(ScriptManager sm) { + // Quest 40007 - victor7 (START) + // NPC: Unknown + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(40007); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q4482e") + public static void q4482e(ScriptManager sm) { + // Quest 4482 - Socks Actual Creation Action (END) + // NPC: 9250051 + + final int QUEST_ITEM_4220022 = 4220022; + + if (!sm.hasItem(QUEST_ITEM_4220022, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4220022, 1); + sm.forceCompleteQuest(4482); + sm.addExp(5000); // EXP reward + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q4482s") + public static void q4482s(ScriptManager sm) { + // Quest 4482 - Socks Actual Creation Action (START) + // NPC: 9250051 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(4482); + sm.addItem(4161039, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q4483e") + public static void q4483e(ScriptManager sm) { + // Quest 4483 - Socks Hanging (END) + // NPC: 9250053 + + final int QUEST_ITEM_4031885 = 4031885; + + if (!sm.hasItem(QUEST_ITEM_4031885, 4)) { + sm.sayOk("You need 4 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031885, 4); + sm.forceCompleteQuest(4483); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q4490e") + public static void q4490e(ScriptManager sm) { + // Quest 4490 - Bicho's snowman (END) + // NPC: 9250054 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(4490); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q4490s") + public static void q4490s(ScriptManager sm) { + // Quest 4490 - Bicho's snowman (START) + // NPC: 9250054 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(4490); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q4647e") + public static void q4647e(ScriptManager sm) { + // Quest 4647 - The Secret Method (END) + // NPC: 1012006 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(4647); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q4659e") + public static void q4659e(ScriptManager sm) { + // Quest 4659 - Robo Upgrade! (END) + // NPC: Unknown + + final int QUEST_ITEM_5380000 = 5380000; + final int QUEST_ITEM_4000111 = 4000111; + + if (!sm.hasItem(QUEST_ITEM_5380000, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4000111, 50)) { + sm.sayOk("You need 50 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_5380000, 1); + sm.removeItem(QUEST_ITEM_4000111, 50); + sm.forceCompleteQuest(4659); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q51180e") + public static void q51180e(ScriptManager sm) { + // Quest 51180 - Building a Time Machine (END) + // NPC: 9250125 + + final int QUEST_ITEM_4032791 = 4032791; + final int QUEST_ITEM_4032792 = 4032792; + final int QUEST_ITEM_4032793 = 4032793; + final int QUEST_ITEM_4032794 = 4032794; + final int QUEST_ITEM_4032795 = 4032795; + final int QUEST_ITEM_4032796 = 4032796; + + if (!sm.hasItem(QUEST_ITEM_4032791, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4032792, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4032793, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4032794, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4032795, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4032796, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032791, 1); + sm.removeItem(QUEST_ITEM_4032792, 1); + sm.removeItem(QUEST_ITEM_4032793, 1); + sm.removeItem(QUEST_ITEM_4032794, 1); + sm.removeItem(QUEST_ITEM_4032795, 1); + sm.removeItem(QUEST_ITEM_4032796, 1); + sm.forceCompleteQuest(51180); + sm.addExp(1); // EXP reward + sm.addItem(4032707, 1); // Reward item + sm.addItem(4032791, 1); // Reward item + sm.addItem(4032792, 1); // Reward item + sm.addItem(4032793, 1); // Reward item + sm.addItem(4032794, 1); // Reward item + sm.addItem(4032795, 1); // Reward item + sm.addItem(4032796, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q51199e") + public static void q51199e(ScriptManager sm) { + // Quest 51199 - Many a Mickle Makes a Muckle (END) + // NPC: 9010000 + + final int QUEST_ITEM_3994199 = 3994199; + + if (!sm.hasItem(QUEST_ITEM_3994199, 9)) { + sm.sayOk("You need 9 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_3994199, 9); + sm.forceCompleteQuest(51199); + sm.addItem(3994200, 1); // Reward item + sm.addItem(3994201, 1); // Reward item + sm.addItem(3994202, 1); // Reward item + sm.addItem(3994203, 1); // Reward item + sm.addItem(3994204, 1); // Reward item + sm.addItem(3994205, 1); // Reward item + sm.addItem(3994206, 1); // Reward item + sm.addItem(3994207, 1); // Reward item + sm.addItem(3994208, 1); // Reward item + sm.addItem(3994199, -20); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q51207e") + public static void q51207e(ScriptManager sm) { + // Quest 51207 - OSSS Mission 1: Alien Elimination (END) + // NPC: 9250133 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(51207); + sm.addItem(4032714, 1); // Reward item + sm.addItem(4032718, 1); // Reward item + sm.addItem(4310004, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q51208e") + public static void q51208e(ScriptManager sm) { + // Quest 51208 - OSSS Mission 2: Protecting the Scientists (END) + // NPC: 9250133 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(51208); + sm.addItem(4032715, 1); // Reward item + sm.addItem(4032719, 1); // Reward item + sm.addItem(4310004, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q51209e") + public static void q51209e(ScriptManager sm) { + // Quest 51209 - OSSS Mission 3: Alien Photo (END) + // NPC: 9250133 + + final int QUEST_ITEM_4032713 = 4032713; + + if (!sm.hasItem(QUEST_ITEM_4032713, 3)) { + sm.sayOk("You need 3 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032713, 3); + sm.forceCompleteQuest(51209); + sm.addItem(4032716, 1); // Reward item + sm.addItem(4032720, 1); // Reward item + sm.addItem(4032713, 1); // Reward item + sm.addItem(4310004, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q51210e") + public static void q51210e(ScriptManager sm) { + // Quest 51210 - OSSS Mission 4: Retrieving Alien Chips and Spy Cameras (END) + // NPC: 9250133 + + final int QUEST_ITEM_4032711 = 4032711; + final int QUEST_ITEM_4032712 = 4032712; + + if (!sm.hasItem(QUEST_ITEM_4032711, 30)) { + sm.sayOk("You need 30 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4032712, 30)) { + sm.sayOk("You need 30 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032711, 30); + sm.removeItem(QUEST_ITEM_4032712, 30); + sm.forceCompleteQuest(51210); + sm.addItem(4032717, 1); // Reward item + sm.addItem(4032721, 1); // Reward item + sm.addItem(4032711, 1); // Reward item + sm.addItem(4032712, 1); // Reward item + sm.addItem(4310004, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q51211e") + public static void q51211e(ScriptManager sm) { + // Quest 51211 - Talk to Principal Researcher Bass (END) + // NPC: Unknown + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(51211); + sm.addExp(1); // EXP reward + sm.addItem(4310004, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q51212e") + public static void q51212e(ScriptManager sm) { + // Quest 51212 - United Against Alien Scum! (END) + // NPC: Unknown + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(51212); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q51212s") + public static void q51212s(ScriptManager sm) { + // Quest 51212 - United Against Alien Scum! (START) + // NPC: 9250134 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(51212); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q51214e") + public static void q51214e(ScriptManager sm) { + // Quest 51214 - OSSS Mission Issued (END) + // NPC: 9250133 + + final int QUEST_ITEM_4032718 = 4032718; + final int QUEST_ITEM_4032719 = 4032719; + final int QUEST_ITEM_4032720 = 4032720; + final int QUEST_ITEM_4032721 = 4032721; + + if (!sm.hasItem(QUEST_ITEM_4032718, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4032719, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4032720, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4032721, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032718, 1); + sm.removeItem(QUEST_ITEM_4032719, 1); + sm.removeItem(QUEST_ITEM_4032720, 1); + sm.removeItem(QUEST_ITEM_4032721, 1); + sm.forceCompleteQuest(51214); + sm.addItem(4032718, 1); // Reward item + sm.addItem(4032719, 1); // Reward item + sm.addItem(4032720, 1); // Reward item + sm.addItem(4032721, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q51216e") + public static void q51216e(ScriptManager sm) { + // Quest 51216 - Clearing Party Quests (END) + // NPC: 9250134 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(51216); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q51216s") + public static void q51216s(ScriptManager sm) { + // Quest 51216 - Clearing Party Quests (START) + // NPC: 9250134 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(51216); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q51239e") + public static void q51239e(ScriptManager sm) { + // Quest 51239 - OSSS Mission 5: Infiltrating the Mothership (END) + // NPC: 9250146 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(51239); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q51240e") + public static void q51240e(ScriptManager sm) { + // Quest 51240 - OSSS Mission 5: Infiltrating the Mothership (END) + // NPC: 9250146 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(51240); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q51241e") + public static void q51241e(ScriptManager sm) { + // Quest 51241 - OSSS Mission 5: Infiltrating the Mothership (END) + // NPC: 9250146 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(51241); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q6030e") + public static void q6030e(ScriptManager sm) { + // Quest 6030 - Carson's Fundamentals of Alchemy (END) + // NPC: 2111000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(6030); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q6031e") + public static void q6031e(ScriptManager sm) { + // Quest 6031 - Hughes the Fuse's Basic Theory of Science (END) + // NPC: 2012017 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(6031); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q6032e") + public static void q6032e(ScriptManager sm) { + // Quest 6032 - Moren's Class on the Actual Practice (END) + // NPC: 2110004 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(6032); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q6033e") + public static void q6033e(ScriptManager sm) { + // Quest 6033 - Moren's Second Round of Teaching (END) + // NPC: 2110004 + + final int QUEST_ITEM_4260003 = 4260003; + + if (!sm.hasItem(QUEST_ITEM_4260003, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4260003, 1); + sm.forceCompleteQuest(6033); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q6036e") + public static void q6036e(ScriptManager sm) { + // Quest 6036 - A Surprise Outcome (END) + // NPC: 2110004 + + final int QUEST_ITEM_4031980 = 4031980; + + if (!sm.hasItem(QUEST_ITEM_4031980, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031980, 1); + sm.forceCompleteQuest(6036); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q6700e") + public static void q6700e(ScriptManager sm) { + // Quest 6700 - The Bowman's Road (END) + // NPC: 1012100 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(6700); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q8185e") + public static void q8185e(ScriptManager sm) { + // Quest 8185 - Pet's Evolution2 (END) + // NPC: Unknown + + final int QUEST_ITEM_5380000 = 5380000; + + if (!sm.hasItem(QUEST_ITEM_5380000, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_5380000, 1); + sm.forceCompleteQuest(8185); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q8189e") + public static void q8189e(ScriptManager sm) { + // Quest 8189 - Pet's Re-Evolution (END) + // NPC: Unknown + + final int QUEST_ITEM_5380000 = 5380000; + + if (!sm.hasItem(QUEST_ITEM_5380000, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_5380000, 1); + sm.forceCompleteQuest(8189); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q8219e") + public static void q8219e(ScriptManager sm) { + // Quest 8219 - Finding Jack (END) + // NPC: 9201096 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(8219); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q8219s") + public static void q8219s(ScriptManager sm) { + // Quest 8219 - Finding Jack (START) + // NPC: 9201051 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8219); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8221s") + public static void q8221s(ScriptManager sm) { + // Quest 8221 - The Mark of Heroism (START) + // NPC: 9201051 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8221); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8222e") + public static void q8222e(ScriptManager sm) { + // Quest 8222 - The Brewing Storm (END) + // NPC: 9201098 + + final int QUEST_ITEM_4032006 = 4032006; + + if (!sm.hasItem(QUEST_ITEM_4032006, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032006, 10); + sm.forceCompleteQuest(8222); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q8222s") + public static void q8222s(ScriptManager sm) { + // Quest 8222 - The Brewing Storm (START) + // NPC: 9201098 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8222); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8223s") + public static void q8223s(ScriptManager sm) { + // Quest 8223 - Storming the Castle (START) + // NPC: 9201098 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8223); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8224s") + public static void q8224s(ScriptManager sm) { + // Quest 8224 - The Fallen Woods (START) + // NPC: 9201100 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8224); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8225s") + public static void q8225s(ScriptManager sm) { + // Quest 8225 - The Right Path (START) + // NPC: 9201100 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8225); + sm.addItem(3992040, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8226s") + public static void q8226s(ScriptManager sm) { + // Quest 8226 - The Fallen Warriors (START) + // NPC: 9201100 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8226); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8227s") + public static void q8227s(ScriptManager sm) { + // Quest 8227 - Lost in Translation 1 (START) + // NPC: 9201096 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8227); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8228e") + public static void q8228e(ScriptManager sm) { + // Quest 8228 - Lost in Translation 2 (END) + // NPC: 9201055 + + final int QUEST_ITEM_4032018 = 4032018; + + if (!sm.hasItem(QUEST_ITEM_4032018, 0)) { + sm.sayOk("You need 0 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4032018, 0); + sm.forceCompleteQuest(8228); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q8228s") + public static void q8228s(ScriptManager sm) { + // Quest 8228 - Lost in Translation 2 (START) + // NPC: 9201051 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8228); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8229e") + public static void q8229e(ScriptManager sm) { + // Quest 8229 - Lost in Translation 3 (END) + // NPC: 9201096 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(8229); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q8229s") + public static void q8229s(ScriptManager sm) { + // Quest 8229 - Lost in Translation 3 (START) + // NPC: 9201051 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8229); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8230e") + public static void q8230e(ScriptManager sm) { + // Quest 8230 - Stemming the Tide (END) + // NPC: 9201096 + + final int QUEST_ITEM_3992041 = 3992041; + + if (!sm.hasItem(QUEST_ITEM_3992041, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_3992041, 1); + sm.forceCompleteQuest(8230); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q8230s") + public static void q8230s(ScriptManager sm) { + // Quest 8230 - Stemming the Tide (START) + // NPC: 9201096 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8230); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8231s") + public static void q8231s(ScriptManager sm) { + // Quest 8231 - Fool's Gold (START) + // NPC: 9201054 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8231); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8232s") + public static void q8232s(ScriptManager sm) { + // Quest 8232 - Fool's Gold. (START) + // NPC: 9201054 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8232); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8233s") + public static void q8233s(ScriptManager sm) { + // Quest 8233 - Rags to Riches (START) + // NPC: 9201054 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8233); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8234s") + public static void q8234s(ScriptManager sm) { + // Quest 8234 - Rags to Riches. (START) + // NPC: 9201054 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8234); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8235s") + public static void q8235s(ScriptManager sm) { + // Quest 8235 - One Step A-Head (START) + // NPC: 9201054 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8235); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8236s") + public static void q8236s(ScriptManager sm) { + // Quest 8236 - One Step A-Head. (START) + // NPC: 9201054 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8236); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8237s") + public static void q8237s(ScriptManager sm) { + // Quest 8237 - Catch a Bigfoot by the Toe (START) + // NPC: 9201054 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8237); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8238s") + public static void q8238s(ScriptManager sm) { + // Quest 8238 - Catch a Bigfoot by the Toe. (START) + // NPC: 9201054 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8238); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8247e") + public static void q8247e(ScriptManager sm) { + // Quest 8247 - Donate Your Notebook (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(8247); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q8251e") + public static void q8251e(ScriptManager sm) { + // Quest 8251 - Helping the daughter of the poultry farm owner, Lazy Daisy (END) + // NPC: 9209008 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(8251); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q8251s") + public static void q8251s(ScriptManager sm) { + // Quest 8251 - Helping the daughter of the poultry farm owner, Lazy Daisy (START) + // NPC: 9209007 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8251); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8255s") + public static void q8255s(ScriptManager sm) { + // Quest 8255 - Lost Spirits (START) + // NPC: 9201106 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8255); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q8256s") + public static void q8256s(ScriptManager sm) { + // Quest 8256 - Lost Spirits. (START) + // NPC: 9201106 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(8256); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + +} diff --git a/src/main/java/kinoko/script/quest/NeoCity.java b/src/main/java/kinoko/script/quest/NeoCity.java index e17f7031..3aa5eaf7 100644 --- a/src/main/java/kinoko/script/quest/NeoCity.java +++ b/src/main/java/kinoko/script/quest/NeoCity.java @@ -66,4 +66,1182 @@ public static void TD_chat_enter(ScriptManager sm) { // TD_neo (491, 151) TD_neoCity_enter(sm); } + + @Script("TD_neo_inTree") + public static void TD_neo_inTree(ScriptManager sm) { + // Neo City : Tera Forest Time Gate (240070000) + // Portal to enter Tera Forest time dungeons based on active quest + + // Map ID array for different Tera Forest areas + final int[] MAP_IDS = {240070010, 240070020, 240070030, 240070040, 240070050, 240070060}; + final int[] QUEST_IDS = {3719, 3724, 3730, 3736, 3742, 3748}; + + // Check which quest the player has active and warp to corresponding map + for (int i = 0; i < QUEST_IDS.length; i++) { + if (sm.hasQuestStarted(QUEST_IDS[i])) { + sm.playPortalSE(); + sm.warp(MAP_IDS[i], "sp"); + return; + } + } + + sm.message("You need an active Tera Forest quest to enter this area."); + } + + @Script("TD_Boss_enter") + public static void TD_Boss_enter(ScriptManager sm) { + // Neo City : Boss Room Entry + // Portals from warehouse areas to boss rooms (240070X02 → 240070X03) + + final int[] WAREHOUSE_MAPS = {240070202, 240070302, 240070402, 240070502, 240070602}; + final int currentMapId = sm.getFieldId(); + + // Check if player is in one of the warehouse maps + for (int warehouseMap : WAREHOUSE_MAPS) { + if (currentMapId == warehouseMap) { + final int bossMap = warehouseMap + 1; // Boss room is +1 from warehouse + sm.playPortalSE(); + sm.warp(bossMap, "sp"); + return; + } + } + + sm.message("This portal can only be used from Neo City warehouse areas."); + } + + @Script("TD_neo_Andy") + public static void TD_neo_Andy(ScriptManager sm) { + // Andy (NPC 2082004) - Time Traveler at Tera Forest Time Gate (240070000) + // Main NPC for Neo City time travel quests + + // Simple dialog for players without quests + sm.sayOk("The answer lies within the passage of time..."); + } + + @Script("npc2082014") + public static void npc2082014(ScriptManager sm) { + // Ayasia (NPC 2082014) - Sky Battle Ship Bow (Year 2503 - 240070600) + // NPC in the final time period + sm.sayOk("Nothing can be foreseen. This is fate."); + } + + // ======================================== + // NEO CITY PREREQUISITE QUESTS (3715-3718) + // ======================================== + + @Script("q3715s") + public static void q3715s(ScriptManager sm) { + // Quest 3715 - The Suspicious Wanderer START + // Han the Broker (NPC 2111007) - Magatia + sm.sayNext("Long time no see. I know what brings you here. People come to me for one thing: #einformation#n. Unfortunately, my memory's gotten a bit fuzzy, so I got nothing. Though #b5000 mesos#k might clear it up."); + + if (!sm.askYesNo("Will you pay 5000 mesos for the information?")) { + sm.sayNext("Hey, business is business. Scram if you aren't interested. I'm a busy guy, you know."); + return; + } + + if (!sm.canAddMoney(-5000)) { + sm.sayOk("You don't have enough mesos!"); + return; + } + + sm.addMoney(-5000); + sm.forceStartQuest(3715); + + sm.sayNext("I knew you'd understand! Now then, let me tell you about this wanderer I met a little while ago."); + sm.sayBoth("One night, not too long ago, the stars stopped twinkling. The next day, a suspicious visitor arrived in Magatia. He wore a cape that covered his features, and I could tell he was from out of town. I started a conversation with him, hoping to get information."); + sm.sayBoth("He was just plain strange. He kept mumbling to himself, some weird phrase over and over. \"#bI've come too far. It's too soon...\"#k\nBefore I could question him, he gave Humanoid A the stink eye and disappeared in the alley near the Alcadno Society."); + sm.sayBoth("Late that night, there was a explosion near the Alcadno Society. There were no witnesses, but after that, no one saw the strange man again. The Alcadno Society blames the Zenumists, of course, but they're wrong. I'm pretty sure that man was responsible. He's got a huge secret. I can just feel it."); + sm.sayOk("I can see in your eyes that you want to pursue that man. Let me offer you some advice. I think he's already left Nihal Desert, so he must have passed through Ariant Station. Go ask #bSyras#k the ticket agent at Ariant Station if he saw anything."); + } + + @Script("q3715e") + public static void q3715e(ScriptManager sm) { + // Quest 3715 - The Suspicious Wanderer END + // Syras (NPC 2102002) - Ariant Station + final int answer1 = sm.askMenu("Here to buy a ticket? I only sell tickets to Orbis. How many would you like? You only need one to board the ship, but you never know...", + Map.of(0, "I came here to ask you something. Did you see an out-of-towner not too long ago?")); + if (answer1 != 0) { + sm.sayOk("Come back if you need a ticket to Orbis!"); + return; + } + + final int answer2 = sm.askMenu("Lookie here, mister. I meet hundreds of out-of-towners every day!", + Map.of(0, "I'm looking for someone who's quiet. He was wearing a cape.")); + if (answer2 != 0) { + sm.sayOk("Talk to me if you remember more details!"); + return; + } + + sm.forceCompleteQuest(3715); + sm.addExp(30000); + sm.sayOk("Hm, I recall two people who fit that description. One passed by around 10AM, the other around 5PM. The first one was a rather extravagant magician, the second was a young warrior in a raggedy cap. The young warrior asked me how to get to Victoria Island. That's about it... Oh, wait! I remember one other person. I saw him at sunrise a few days ago."); + } + + @Script("q3716s") + public static void q3716s(ScriptManager sm) { + // Quest 3716 - The Wanderer's Whereabouts 1 START + // Syras (NPC 2102002) - Ariant Station + sm.sayNext("He made a scene because he wanted to get going before schedule. I didn't know what to do. I tried to calm him down, but I couldn't understand his gibberish. Anyway, he got on the earliest airship and left for Orbis."); + + if (!sm.askYesNo("Now that I think about it, he was rather strange and awkward. If you're looking for that man, I recommend heading over to Orbis. Oh! Talk to #bIsa#k the station guide at Orbis Station. She'd remember the man if she saw him.")) { + sm.sayNext("What? That's not the right guy? Then ask someone else, huh? I'm a busy guy."); + return; + } + + sm.forceStartQuest(3716); + sm.sayOk("The ship is about to take off. If you want to go to Orbis, I suggest you hurry."); + } + + @Script("q3716e") + public static void q3716e(ScriptManager sm) { + // Quest 3716 - The Wanderer's Whereabouts 1 END + // Isa (NPC 2012006) - Orbis Station + final int answer1 = sm.askMenu("Hello, welcome to Orbis Station. My name is Isa. How can I help you?", + Map.of(0, "I'm looking for someone. Have you seen a suspicious fellow wearing a cape wrapped around his entire body?")); + if (answer1 != 0) { + sm.sayOk("Let me know if you need any help!"); + return; + } + + final int answer2 = sm.askMenu("A suspicious fellow? Hmm, I do remember a couple of people wearing big capes, but they weren't exactly suspicious. There was a magician who seemed to be into flashy capes and a warrior who was headed to Victoria Island. And...", + Map.of(0, "Was there someone who seemed a bit absent-minded? Who arrived from Ariant early in the morning.")); + if (answer2 != 0) { + sm.sayOk("Come back if you remember more details!"); + return; + } + + sm.forceCompleteQuest(3716); + sm.addExp(30000); + sm.sayOk("Oh, yes! That guy? He left for Leafre as soon as he got here. As you know, Orbis Station is always so hectic. He didn't know where to go, so I helped him find his way, although he seemed a bit uncomfortable around me for some reason."); + } + + @Script("q3717s") + public static void q3717s(ScriptManager sm) { + // Quest 3717 - The Wanderer's Whereabouts 2 START + // Isa (NPC 2012006) - Orbis Station + final int answer = sm.askMenu("Come to think of it, he did mumble something under his breath... I think he said something about having to go to Tera Forest.", + Map.of(0, "Tera Forest? Are you sure he didn't say Minar Forest?")); + if (answer != 0) { + sm.sayNext("Since you're here in Orbis, why don't you take a tour before you leave? You can catch an airship every hour to any destination."); + return; + } + + sm.forceStartQuest(3717); + sm.sayOk("Corba in Leafre once told me about Tera Forest. Apparently, it's a small forest located near the eastern border of Minar Forest. He said not many people know about it because it's secluded. If you want to go to Leafre, you should leave now. The airship to Leafre will take off soon."); + } + + @Script("q3717e") + public static void q3717e(ScriptManager sm) { + // Quest 3717 - The Wanderer's Whereabouts 2 END + // Andy (NPC 2082004) - Tera Forest + final int answer = sm.askMenu("You found me! But that means nothing! You'll never be able to drag me out of here!!", + Map.of(0, "I only wanted to meet you. I'm not here to drag you anywhere...")); + if (answer != 0) { + sm.sayOk("Leave me alone!"); + return; + } + + sm.forceCompleteQuest(3717); + sm.addExp(30000); + sm.sayOk("I don't know who you are. I don't trust you...or anyone! I won't tell you a thing. It could create time distortion. I don't know! I can't figure it out. I don't know what went wrong where."); + } + + @Script("q3718s") + public static void q3718s(ScriptManager sm) { + // Quest 3718 - Andy the Time Traveler START + // Andy (NPC 2082004) - Tera Forest + int answer = sm.askMenu("Why have you followed me here? I don't know who you are.", + Map.of(0, "Why did you attack the Alcadno Society?")); + if (answer != 0) { + sm.sayOk("Leave me be!"); + return; + } + + answer = sm.askMenu("Alcadno? Oh, you're talking about those fools that rely on machines. They don't think about how their actions will affect the future. Those fools will spend a lifetime regretting what they've done. But I've come too far. It's too early. This isn't where I was trying to visit.", + Map.of(0, "What in the world are you talking about? Where did you come from? Tell me what's going on!")); + if (answer != 0) { + sm.sayOk("I can't tell you anything!"); + return; + } + + answer = sm.askMenu("Can you handle it? You could get lost in time like me. It's a dangerous risk. And I may not be able to complete my mission.", + Map.of(0, "I'll help you. What is this mission you're talking about?")); + if (answer != 0) { + sm.sayOk("It's too dangerous for you!"); + return; + } + + if (!sm.askAccept("You... You seem trustworthy. I can feel it. You can't tell a soul what I'm about to tell you. Can you promise to keep this a secret? Can you handle that?")) { + sm.sayNext("Are you telling me not to trust you?"); + return; + } + + sm.forceStartQuest(3718); + + sm.sayNext("I'm from the year 2503. I came from Neo City to change the errors of the past. But this isn't Neo City. I think I may have transcended time and space. Everything is a mess. And I seem to be lost."); + sm.sayBoth("That door you see over there is called the #bTime Gate#k. It's a the key to traveling through time. I'm from Neo City in the year 2503. Machines have taken over our city and are attacking humans. We could not find a solution to the chaos, so I came to the past to set things right."); + sm.sayBoth("But I've arrived somewhere, or somewhen, totally unexpected. I was running around like a headless chicken, thinking this was the Neo City of the past, but I realized I was in the wrong place and time. I must go to the right time! But I lost the Pocket Watch that acts as the key to the Time Gate."); + sm.sayOk("Help me find the key to the Time Gate! I will compensate you. I must undo the mistakes in the past, but I can't find the Pocket Watch anywhere. It must be somewhere in the forest, though. Right?"); + } + + @Script("q3718e") + public static void q3718e(ScriptManager sm) { + // Quest 3718 - Andy the Time Traveler END + // Andy (NPC 2082004) - Requires Time Traveler's Pocket Watch (4032511) + final int POCKET_WATCH = 4032511; + final int TIME_TRAVELERS_POCKET_WATCH = 4001393; + + if (!sm.hasItem(POCKET_WATCH, 1)) { + sm.sayOk("The Pocket Watch has my name engraved on the back. I'm pretty sure I lost it somewhere in Minar Forest."); + return; + } + + sm.sayNext("Yes, this is the Pocket Watch! Let me see it!"); + sm.sayBoth("Oh no... It's not working. I can no longer carry out my mission. The Pocket Watch only works when its possessor is in an appropriate time. It also checks its possessor's mental and physical state. It probably detects my exhaustion."); + + if (!sm.askYesNo("Wait a minute. I have an idea. Put the Pocket Watch on your palm!\r\n#b(You place the Pocket Watch on one hand and cover it with the other. The Pocket Watch begins to glow, and then the glow disappears.)#k")) { + return; + } + + sm.removeItem(POCKET_WATCH, 1); + sm.addItem(TIME_TRAVELERS_POCKET_WATCH, 1); + sm.forceCompleteQuest(3718); + sm.addExp(40000); + sm.sayOk("You! You might be the one! Please, take over my mission and change the past! I hand over the Pocket Watch to you. Please, you must do this!"); + } + + // ======================================== + // NEO CITY TIME TRAVEL QUESTS (3719-3748) + // ======================================== + + @Script("q3719s") + public static void q3719s(ScriptManager sm) { + // Quest 3719 - Nex the Time Guard (Year 2021) START + // Andy (NPC 2082004) - Requires quests 3715-3718 completed + sm.sayNext("Before you can travel though time, you must first pass Nex's test. Nex is the Gatekeeper of Time, and he lives inside the Old Tree of Tera in Tera Forest. You must defeat him before you can time travel."); + + if (!sm.askAccept("Will you challenge Nex the Gatekeeper of Time?")) { + sm.sayNext("You will not be granted authorization to travel through time unless you defeat Nex the Gatekeeper of Time."); + return; + } + + sm.forceStartQuest(3719); + sm.sayOk("When you defeat Nex, the Time Gate that lets you teleport to year 2021 will open."); + } + + @Script("q3719e") + public static void q3719e(ScriptManager sm) { + // Quest 3719 - Nex the Time Guard (Year 2021) END + // Requires defeating Nex (7120100) + sm.sayNext("Ah, you've passed Nex's first test. This means you have earned the right to travel to year 2021 using the Time Gate. You're doing great! What a relief!"); + + if (!sm.askYesNo("Do you want to complete the quest?")) { + return; + } + + sm.forceCompleteQuest(3719); + sm.addExp(80000); + sm.sayOk("Take the Time Traveler's Pocket Watch and go through the Time Gate."); + } + + // ======================================== + // YEAR 2021 - JUST ANOTHER DAY (3720-3723) + // ======================================== + + @Script("q3720s") + public static void q3720s(ScriptManager sm) { + // Quest 3720 - The First Clue START + // Andy (NPC 2082004) - Year 2021 + sm.sayNext("When you arrive in the year 2021, you need to retrieve a certain item. You see, the cause of all the problems in my time must've started in 2021, when the technology for A.I. machinery was invented."); + sm.sayBoth("From what I've heard, it all started with some scribbles. They were just meaningless doodles, at first, but A.I. robots were built based on that drawing. The scribbled note should be in a trash can somewhere. You must find it! Please, rifle through trash cans and bring me back that piece of paper!"); + + if (!sm.askAccept("Will you help me find this clue?")) { + sm.sayNext("This is important. Before things get worse, we must destroy that paper."); + return; + } + + sm.forceStartQuest(3720); + sm.sayOk("I don't exactly where the paper is... Search in every trash can you come across!"); + } + + @Script("q3720e") + public static void q3720e(ScriptManager sm) { + // Quest 3720 - The First Clue END + // Andy (NPC 2082004) - Requires item 4032512 + final int CLUE_PAPER = 4032512; + + if (!sm.hasItem(CLUE_PAPER, 1)) { + sm.sayOk("Keep searching in trash cans!"); + return; + } + + sm.sayNext("You found it! Let me see!"); + sm.sayBoth("No, no, no. This isn't it. This isn't what I'm looking for. I could've sworn... I mean, I'm sure it was 2021 that A.I.'s were first designed and invented. Could it be that I'm mistaken?"); + + sm.removeItem(CLUE_PAPER, 1); + sm.forceCompleteQuest(3720); + sm.addExp(61000); + } + + @Script("q3721s") + public static void q3721s(ScriptManager sm) { + // Quest 3721 - Dangerous Slimes START + // Brainy Boy (NPC 2082005) - Year 2021 + final int answer = sm.askMenu("Don't come any closer! Who are you? Where is everyone? And where did all these monsters come from?", + Map.of(0, "Don't worry. I'll help you.")); + if (answer != 0) { + sm.sayOk("Please be careful!"); + return; + } + + sm.sayNext("Then defeat all of these monsters. I think they're exponentially increasing."); + + if (!sm.askAccept("Will you eliminate the monsters?")) { + sm.sayNext("You said you were going to help me!! You lied!"); + return; + } + + sm.forceStartQuest(3721); + sm.sayOk("It's not just here. The entire town is crowded with monsters. Please eliminate 50 of every type of monster and collect 30 of each of those monsters' dropped items to stop them from respawning."); + } + + @Script("q3721e") + public static void q3721e(ScriptManager sm) { + // Quest 3721 - Dangerous Slimes END + // Brainy Boy (NPC 2082005) - Requires mob kills and items + final int[] SLIME_ITEMS = {4000545, 4000546, 4000547}; + + for (int item : SLIME_ITEMS) { + if (!sm.hasItem(item, 30)) { + sm.sayOk("Please collect 30 dropped items of each monster."); + return; + } + } + + sm.sayNext("Have you eliminated all the monsters? Did you collect their dropped items too?"); + sm.sayBoth("Even now, these monsters are not going away. What's happening? Where are my mom and dad?"); + + for (int item : SLIME_ITEMS) { + sm.removeItem(item, 30); + } + sm.forceCompleteQuest(3721); + sm.addExp(100000); + } + + @Script("q3722s") + public static void q3722s(ScriptManager sm) { + // Quest 3722 - The Crying Girl's Sketchbook START + // Crying Girl (NPC 2082006) - Year 2021 + sm.sayNext("*sniff sniff* These monsters ate my Sketchbook. Could you find my Sketchbook for me? *sniff sniff*"); + + if (!sm.askAccept("Will you help find the sketchbook pages?")) { + sm.sayNext("*sob sob* My sketchbook!!! *sob sob*"); + return; + } + + sm.forceStartQuest(3722); + sm.sayOk("Please help me find 20 Loose-Leaf Pages of my Sketchbook. *sniff sniff*"); + } + + @Script("q3722e") + public static void q3722e(ScriptManager sm) { + // Quest 3722 - The Crying Girl's Sketchbook END + // Crying Girl (NPC 2082006) - Requires 20x 4032513 + final int SKETCHBOOK_PAGE = 4032513; + + if (!sm.hasItem(SKETCHBOOK_PAGE, 20)) { + sm.sayOk("Please, please help me find 20 Loose-Leaf Pages of my Sketchbook! Those wicked Slimes ate them!"); + return; + } + + sm.sayNext("Oh, my drawings... Did you bring them?"); + sm.sayBoth("Hmph, it's all wrinkled. My drawings... *sniff sniff*"); + + sm.removeItem(SKETCHBOOK_PAGE, 20); + sm.forceCompleteQuest(3722); + sm.addExp(65000); + } + + @Script("q3723s") + public static void q3723s(ScriptManager sm) { + // Quest 3723 - The Boy and the Girl START + // Brainy Boy (NPC 2082005) - Year 2021 + sm.sayNext("I think she's disappointed because she didn't find all of the pages from her Sketchbook. She was so proud of her new robot drawing, too. Could you just keep an eye out for pieces of paper you might see lying around?"); + + if (!sm.askAccept("Will you keep an eye out for the drawings?")) { + sm.sayNext("I wasn't asking you to go out of your way to find them. I just thought you could keep an eye out for them in case you saw any. If you're too lazy to do that... Then...fine."); + return; + } + + sm.forceStartQuest(3723); + sm.sayOk("It really was an incredible drawing. I hope she isn't too bummed out about it, though I know she is."); + } + + @Script("q3723e") + public static void q3723e(ScriptManager sm) { + // Quest 3723 - The Boy and the Girl END + // Andy (NPC 2082004) - Report back + final int answer1 = sm.askMenu("What is it?", + Map.of(0, "I think you should know something.")); + if (answer1 != 0) { + sm.sayOk("Come back if you have information for me."); + return; + } + + final int answer2 = sm.askMenu("What? Did you learn something?", + Map.of(0, "I'm not sure, but I met a boy while I was investigating a little town during year 2016, and he mentioned something about a robot.")); + if (answer2 != 0) { + sm.sayOk("Let me know if you find out more."); + return; + } + + final int answer3 = sm.askMenu("Is that so? Hmm.", + Map.of(0, "Yes, but the kids lost the drawing. I know it's just a child's scribbles, but I couldn't just ignore it.")); + if (answer3 != 0) { + sm.sayOk("Keep investigating."); + return; + } + + sm.sayNext("I see. It really isn't anything definite. But I can't simply ignore it. either. Thank you. Nothing is for sure, but we may have found a clue."); + + sm.forceCompleteQuest(3723); + sm.addExp(30000); + sm.sayOk("Thanks. I think it's time you got ready for your next time travel. Let me know when you're prepared."); + } + + @Script("q3724s") + public static void q3724s(ScriptManager sm) { + // Quest 3724 - Nex the Time Guard (Year 2099) START + // Requires quests 3719-3723 completed + sm.sayNext("You must pass Nex's test but you can travel to a different time. Don't get too comfortable. Nex will be much stronger this time around."); + + if (!sm.askAccept("Will you challenge Nex again?")) { + sm.sayNext("You won't be authorized to travel time unless you defeat Nex the Gatekeeper of Time."); + return; + } + + sm.forceStartQuest(3724); + sm.sayOk("When you defeat Nex, the Time Gate to the year 2099 will open."); + } + + @Script("q3724e") + public static void q3724e(ScriptManager sm) { + // Quest 3724 - Nex the Time Guard (Year 2099) END + // Requires defeating Nex (7120101) + sm.sayNext("Ah, you've passed Nex's second test. Now then, you should be able to access the Time Gate to the year 2099."); + + if (!sm.askYesNo("Do you want to complete the quest?")) { + return; + } + + sm.forceCompleteQuest(3724); + sm.addExp(90000); + sm.sayOk("Take the Time Traveler's Pocket Watch and go through the Time Gate."); + } + + // ======================================== + // YEAR 2099 - THE HARBOR (3725-3729) + // ======================================== + + @Script("q3725s") + public static void q3725s(ScriptManager sm) { + // Quest 3725 - The Hidden Truth about the Past START + // Andy (NPC 2082004) - Year 2099 + sm.sayNext("According to the records I found, a giant robot appeared in the year 2099. At the time, this was regarded as a silly rumor, but I have a hunch it's not something we should overlook. Please travel to year 2099 and investigate."); + + if (!sm.askAccept("Will you investigate the giant robot?")) { + sm.sayNext("This is serious business. If you've lost interest, now would be a good time for you to quit and go about your business."); + return; + } + + sm.forceStartQuest(3725); + sm.sayOk("If you get lucky and find the robot, destroy it. Please. For the sake of the future."); + } + + @Script("q3725e") + public static void q3725e(ScriptManager sm) { + // Quest 3725 - The Hidden Truth about the Past END + // Andy (NPC 2082004) - Kill giant robot + final int answer = sm.askMenu("So? Was there really a giant robot?", + Map.of(0, "Yes, there really was. As you requested, I destroyed it.")); + if (answer != 0) { + sm.sayOk("Please investigate the robot."); + return; + } + + sm.sayNext("You did? Thank you. I wish this were the end to our problem."); + + sm.forceCompleteQuest(3725); + sm.addExp(120000); + } + + @Script("q3726s") + public static void q3726s(ScriptManager sm) { + // Quest 3726 - Policeman in Danger START + // Policeman (NPC 2082007) - Year 2099 + final int answer = sm.askMenu("It's dangerous here. Please go somewhere safer. A bunch of unidentifiable monsters appeared and took over the harbor.", + Map.of(0, "Would you like me to help you?")); + if (answer != 0) { + sm.sayOk("Please be careful!"); + return; + } + + sm.sayNext("Would you? I'm trying to eliminate the monsters that have taken over the harbor, but they outnumber me and I don't know if I can handle all of them on my own. Would you help me eliminate these monsters?"); + + if (!sm.askAccept("Will you help eliminate the monsters?")) { + sm.sayNext("It's dangerous here. Please get yourself to the nearest shelter where it's safe. Hurry!"); + return; + } + + sm.forceStartQuest(3726); + sm.sayOk("If you can, please eliminate 50 Overlord A's and 40 Overlord B's while I evacuate the people."); + } + + @Script("q3726e") + public static void q3726e(ScriptManager sm) { + // Quest 3726 - Policeman in Danger END + // Policeman (NPC 2082007) - Mob kills + final int answer = sm.askMenu("How did it go?", + Map.of(0, "I took care of the monsters as you requested.")); + if (answer != 0) { + sm.sayOk("Please eliminate the monsters."); + return; + } + + sm.sayNext("Thank you. Thank you so much for your hard work. Thanks to you, the people at the harbor were able to evacuate safely."); + + sm.forceCompleteQuest(3726); + sm.addExp(72000); + } + + @Script("q3727s") + public static void q3727s(ScriptManager sm) { + // Quest 3727 - The Shelter Key START + // Policeman (NPC 2082007) - Year 2099 + sm.sayNext("Could I ask you for one last favor? Everyone at the harbor was evacuated except one person. His name is Captain Edmond. He's stubborn and wouldn't cooperate. I think he remained behind, somewhere at the harbor. I must deliver the Shelter Key to him. Will you help me?"); + + if (!sm.askAccept("Will you help find Captain Edmond?")) { + sm.sayNext("I'm so worried about the captain. He's all alone right now."); + return; + } + + sm.forceStartQuest(3727); + sm.sayOk("But there is one problem. I dropped the Shelter Key. I think one of the monsters might have picked it up. Could you find the Shelter Key and take it over to Captain Edmond?"); + } + + @Script("q3727e") + public static void q3727e(ScriptManager sm) { + // Quest 3727 - The Shelter Key END + // Captain Edmond (NPC 2082008) - Deliver item 4032514 + final int SHELTER_KEY = 4032514; + + if (!sm.hasItem(SHELTER_KEY, 1)) { + sm.sayOk("Please retrieve the Shelter Key from the monsters and deliver it to Captain Edmond. He's somewhere at the harbor."); + return; + } + + final int answer = sm.askMenu("What is it? What brings you here?", + Map.of(0, "I've come to give you the Shelter Key. It's dangerous here.")); + if (answer != 0) { + sm.sayOk("Be careful out there."); + return; + } + + sm.sayNext("Nonsense! I'm fine. But I'll take the key."); + + sm.removeItem(SHELTER_KEY, 1); + sm.forceCompleteQuest(3727); + sm.addExp(60000); + } + + @Script("q3728s") + public static void q3728s(ScriptManager sm) { + // Quest 3728 - Temporary Relief START + // Captain Edmond (NPC 2082008) - Year 2099 + sm.sayNext("Look here. The harbor has been entirely overtaken by monsters. The harbor represents the dream and dignity of the men of the sea, and I can't bear to just watch those monsters stomp all over that. I'm planning to drive them out. You seem young and capable. Why don't you help me, huh?"); + + if (!sm.askAccept("Will you help drive out the monsters?")) { + sm.sayNext("You're young, yet you lack determination and courage. Hmph."); + return; + } + + sm.forceStartQuest(3728); + sm.sayOk("Alright! If you have been observing these monsters, you'll know that they have Radar Devices that operate as their eyes. If you eliminate those Radar Devices, they won't be able to do a thing. Bring me 20 Overlord A Radar Devices and 40 Overlord B Radar Devices. This isn't going to put a stop to the problem, but it'll earn us some time."); + } + + @Script("q3728e") + public static void q3728e(ScriptManager sm) { + // Quest 3728 - Temporary Relief END + // Captain Edmond (NPC 2082008) - Collect radar devices + final int OVERLORD_A_RADAR = 4000548; + final int OVERLORD_B_RADAR = 4000549; + + if (!sm.hasItem(OVERLORD_A_RADAR, 20) || !sm.hasItem(OVERLORD_B_RADAR, 40)) { + sm.sayOk("Did you bring me 20 Overlord A Radar Devices and 40 Overlord B Radar Devices?"); + return; + } + + sm.sayNext("Ha, sensational! Thanks to your hard work, I can sit back and watch these monsters completely lose their sense of direction. Even the thought of it makes me laugh."); + + sm.removeItem(OVERLORD_A_RADAR, 20); + sm.removeItem(OVERLORD_B_RADAR, 40); + sm.forceCompleteQuest(3728); + sm.addExp(75000); + } + + @Script("q3729s") + public static void q3729s(ScriptManager sm) { + // Quest 3729 - Time Traveler's Pocket Watch START (Daily Quest) + // Andy (NPC 2082004) - Tera Forest + final int answer1 = sm.askMenu("Time is like a continuum. It can either sweep you down like a raging river or jump you up like a relentless fish. My current location is... (mumble, mumble)", + Map.of(0, "Um... Mr. Andy? What are you talking?")); + if (answer1 != 0) { + sm.sayOk("I'm busy thinking."); + return; + } + + final int answer2 = sm.askMenu("What...? Nevermind. What do you want? I'm very busy. Many things to think about.", + Map.of(0, "I want to obtain the Time Traveler's Pocket Watch.")); + if (answer2 != 0) { + sm.sayOk("Come back when you're ready."); + return; + } + + sm.sayNext("Aah, so you want to time travel. Well, then help me with something first."); + + if (!sm.askAccept("Will you help Andy?")) { + sm.sayNext("Nothing given, then nothing gained. There's another law for ya. If you have no further business, please go away."); + return; + } + + sm.forceStartQuest(3729); + sm.sayOk("This forest is just too noisy. Especially those bugs... what were they called... oh yes, #b'Beetles'#k. Those bugs make this annoying chewing sound on wood. And those #b'Dual Beetles'#k, they're even worse! Can you help decrease their number? Please eliminate 10 of each bug. If you indulge my request, I'll give you the #bTime Traveler's Pocket Watch#k."); + } + + @Script("q3729e") + public static void q3729e(ScriptManager sm) { + // Quest 3729 - Time Traveler's Pocket Watch END (Daily Quest) + // Andy (NPC 2082004) - Kill beetles, get pocket watch + final int TIME_TRAVELERS_POCKET_WATCH = 4001393; + + sm.sayNext("Aah, it's finally gotten a bit more quiet. Though it'll probably get louder again by tomorrow. Still, I can at least enjoy some quiet for today. Here, this is the Time Traveler's Pocket Watch... As I say time and time again, when you're time-traveling you must be very careful, lest you get caught in the vacuum of time."); + + sm.addItem(TIME_TRAVELERS_POCKET_WATCH, 1); + sm.forceCompleteQuest(3729); + sm.addExp(1000); + } + + @Script("q3730s") + public static void q3730s(ScriptManager sm) { + // Quest 3730 - Nex the Time Guard (Year 2215) START + // Requires quests 3725-3729 completed + sm.sayNext("Before your third time travel, you must pass Nex's test again. Don't get too comfortable, though. Nex will be even stronger this time around."); + + if (!sm.askAccept("Will you challenge Nex for the third time?")) { + sm.sayNext("You won't be authorized to travel through time unless you defeat Nex the Gatekeeper of Time."); + return; + } + + sm.forceStartQuest(3730); + sm.sayOk("When you defeat Nex, the Time Gate to the year 2215 will open."); + } + + @Script("q3730e") + public static void q3730e(ScriptManager sm) { + // Quest 3730 - Nex the Time Guard (Year 2215) END + // Requires defeating Nex (7120102) + sm.sayNext("Ah, you've passed Nex's third test. Now then, you should be able to access the Time Gate to the year 2215."); + + if (!sm.askYesNo("Do you want to complete the quest?")) { + return; + } + + sm.forceCompleteQuest(3730); + sm.addExp(110000); + sm.sayOk("Take the Time Traveler's Pocket Watch and go through the Time Gate."); + } + + // ======================================== + // YEAR 2215 - THE BOMBING (3731-3735) + // ======================================== + + @Script("q3731s") + public static void q3731s(ScriptManager sm) { + // Quest 3731 - Identity of the Missile START + // Andy (NPC 2082004) - Year 2215 + sm.sayNext("I have an assignment for you in the year 2215. That year was the worst year in history, when the evolved robots bombed the City Center. The A.I. missile that bombed the City Center was called Dunas. Please travel to year 2215 and destroy the missile."); + + if (!sm.askAccept("Will you destroy Dunas?")) { + sm.sayNext("What, are you getting scared? It's too late. Think about it. You've seen too much. You know too much about the future at this point."); + return; + } + + sm.forceStartQuest(3731); + sm.sayOk("There isn't much recorded about Dunas. I don't even know what it looks like. All I can tell you is to be extra careful out there."); + } + + @Script("q3731e") + public static void q3731e(ScriptManager sm) { + // Quest 3731 - Identity of the Missile END + // Andy (NPC 2082004) - Kill Dunas + sm.sayNext("Dunas is an android? Was that possible in 2215? Something doesn't add up."); + + if (!sm.askYesNo("Do you want to complete the quest?")) { + return; + } + + sm.forceCompleteQuest(3731); + sm.addExp(140000); + sm.sayOk("I have a bad feeling about this. I feel like we're missing a huge piece of the puzzle."); + } + + @Script("q3732s") + public static void q3732s(ScriptManager sm) { + // Quest 3732 - Rambunctious Robots START + // May (NPC 2082009) - Year 2215 + final int answer = sm.askMenu("Hello there, #b#h0##k. This is a very dangerous place. It was bombed recently, and I must warn you that walking around like you are doing right now poses a threat to your safety.", + Map.of(0, "How did you know my name? And how did this happen?")); + if (answer != 0) { + sm.sayOk("Be careful!"); + return; + } + + sm.sayNext("How do I know your name? That's a secret. I have to wipe out these noisy robots. They keep beeping rambunctiously, and it's driving me crazy. Won't you help me?"); + + if (!sm.askAccept("Will you help eliminate the robots?")) { + sm.sayNext("That's alright. I'll manage. I am a bit weak... And a little too pretty to be doing this... But I'll be alright."); + return; + } + + sm.forceStartQuest(3732); + sm.sayOk("On the day of the bombing, I woke up and the City Center was already destroyed. It's been overtaken by those noisy robots. I'm THIS close to having a nervous breakdown. Please #b#h0##k, all you have to do is eliminate 50 of those noisy Robbies."); + } + + @Script("q3732e") + public static void q3732e(ScriptManager sm) { + // Quest 3732 - Rambunctious Robots END + // May (NPC 2082009) - Kill 50 Robbies + sm.sayNext("Thank you. Now I can breathe again. Where could have those robots have come from?"); + + sm.forceCompleteQuest(3732); + sm.addExp(90000); + } + + @Script("q3733s") + public static void q3733s(ScriptManager sm) { + // Quest 3733 - Survivor Search START + // May (NPC 2082009) - Year 2215 + sm.sayNext("I was so happy that those Robbies were silenced, but then I began to think that I may be the only survivor left. That's so frightening. I mean, there must be other survivors, right? #b#h0##k, could you investigate the area and see if you can find any?"); + + if (!sm.askAccept("Will you search for survivors?")) { + sm.sayNext("#b#h0##k, do you think there are any survivors besides me?"); + return; + } + + sm.forceStartQuest(3733); + sm.sayOk("Could you search little deeper into the area, near where the missile landed? I haven't been able to search that far."); + } + + @Script("q3733e") + public static void q3733e(ScriptManager sm) { + // Quest 3733 - Survivor Search END + // Bao (NPC 2082010) - Find the survivor + sm.sayNext("Who is that? There is a survivor! Here, right here!"); + + sm.forceCompleteQuest(3733); + sm.addExp(30000); + sm.sayOk("I'm so relieved that there is another survivor."); + } + + @Script("q3734s") + public static void q3734s(ScriptManager sm) { + // Quest 3734 - The Dangerous Android START + // Bao (NPC 2082010) - Year 2215 + sm.sayNext("Quiet! Keep it down. This place is filled with Iruvatas. Don't you see them? Iruvatas are androids that resemble female warriors. The are made to attack, so they're extremely dangerous. I don't know who you are, but could you please eliminate the Iruvatas?"); + + if (!sm.askAccept("Will you eliminate the Iruvatas?")) { + sm.sayNext("I know. Anyone would be scared. They are, like I said, extremely dangerous."); + return; + } + + sm.forceStartQuest(3734); + sm.sayOk("Wow, you're a brave one, aren't you? Then please eliminate 50 Iruvatas. Woohoo! I'll be rooting for you."); + } + + @Script("q3734e") + public static void q3734e(ScriptManager sm) { + // Quest 3734 - The Dangerous Android END + // Bao (NPC 2082010) - Kill 50 Iruvatas + final int answer = sm.askMenu("I was cheering for you from here. That was incredible! But first things first. Am I the only survivor?", + Map.of(0, "There is a lady named May not too far from here.")); + if (answer != 0) { + sm.sayOk("Thank you for your help!"); + return; + } + + sm.sayNext("Oh... Really? Then I should suck it up and go over there. Thank you so much for your help."); + + sm.forceCompleteQuest(3734); + sm.addExp(90000); + } + + @Script("q3735s") + public static void q3735s(ScriptManager sm) { + // Quest 3735 - The Wreckage of the Missile START + // Bao (NPC 2082010) - Year 2215 + sm.sayNext("Right over there is where the missile landed. I have a feeling the Iruvatas and Robbies are being built there. There must be something going on. If you want to investigate, head right."); + + if (!sm.askAccept("Will you investigate?")) { + sm.sayNext("You seem reluctant. Of course, I understand. After all, that's where the missile landed. It couldn't be safe."); + return; + } + + sm.forceStartQuest(3735); + sm.sayOk("You seem like you're searching for something. I'd go right. It seems suspicious. Alright, I'll see you around then."); + } + + @Script("q3735e") + public static void q3735e(ScriptManager sm) { + // Quest 3735 - The Wreckage of the Missile END + // Andy (NPC 2082004) - Deliver Time Sand 4032516 + final int TIME_SAND = 4032516; + + if (!sm.hasItem(TIME_SAND, 1)) { + sm.sayOk("You haven't found any clues?"); + return; + } + + sm.sayNext("This... This is Time Sand. Time Sand stores the memories of time. This much Time Sand probably has a decent amount of memories. Good. It's definitely worth looking into. Don't you feel like we're getting somewhere?"); + + sm.removeItem(TIME_SAND, 1); + sm.forceCompleteQuest(3735); + sm.addExp(60000); + sm.sayOk("Keep up the good work."); + } + + @Script("q3736s") + public static void q3736s(ScriptManager sm) { + // Quest 3736 - Nex the Time Guard (Year 2216) START + // Requires quests 3731-3735 completed + sm.sayNext("Before your fourth time travel, you must pass Nex's test again. Don't get too comfortable, though. Nex will be even stronger this time around!"); + + if (!sm.askAccept("Will you challenge Nex for the fourth time?")) { + sm.sayNext("You won't be authorized to travel through time unless you defeat Nex the Gatekeeper of Time."); + return; + } + + sm.forceStartQuest(3736); + sm.sayOk("When you defeat Nex, the Time Gate to the year 2216 will open."); + } + + @Script("q3736e") + public static void q3736e(ScriptManager sm) { + // Quest 3736 - Nex the Time Guard (Year 2216) END + // Requires defeating Aufheben (8120100) + sm.sayNext("Ah, you've passed Nex's fourth test. Now then, you should be able to access the Time Gate to year 2216."); + + if (!sm.askYesNo("Do you want to complete the quest?")) { + return; + } + + sm.forceCompleteQuest(3736); + sm.addExp(130000); + sm.sayOk("Take the Time Traveler's Pocket Watch and go through the Time Gate."); + } + + // ======================================== + // YEAR 2216 - THE RUINS (3737-3740) + // ======================================== + + @Script("q3737s") + public static void q3737s(ScriptManager sm) { + // Quest 3737 - Central Robot Aufheben START + // Andy (NPC 2082004) - Year 2216 + sm.sayNext("The purpose of this next time travel is to defeat Aufheben. Aufheben is the name of the central robot that began controlling all electronic devices after the City Center was bombed. Since Dunas was an android, it is quite likely that Aufheben is also an android. Do you think you can defeat Aufheben?"); + + if (!sm.askAccept("Will you defeat Aufheben?")) { + sm.sayNext("Are you not ready? Now, that's a problem. We don't have much time left."); + return; + } + + sm.forceStartQuest(3737); + sm.sayOk("Aufheben is strong beyond your imagination. It won't be easy destroying Aufheben. Best wishes."); + } + + @Script("q3737e") + public static void q3737e(ScriptManager sm) { + // Quest 3737 - Central Robot Aufheben END + // Andy (NPC 2082004) - Kill Aufheben + sm.sayNext("You're getting stronger and stronger. I can't believe you've defeated Aufheben. You are a true hero and an incredible time traveler."); + + sm.forceCompleteQuest(3737); + sm.addExp(170000); + } + + @Script("q3738s") + public static void q3738s(ScriptManager sm) { + // Quest 3738 - Disturbing the Army of Robots START + // Ken (NPC 2082017) - Year 2216 + sm.sayNext("Are you from the support unit? I'm so glad you're here. Completing the mission here seems nearly impossible with the frequent attacks from the Afterlords and Prototype Lords. I'll give you the details a little later. Please just eliminate these monsters first."); + + if (!sm.askAccept("Will you eliminate the monsters?")) { + sm.sayNext("Isn't the support unit supposed to provide support?"); + return; + } + + sm.forceStartQuest(3738); + sm.sayOk("Please eliminate 50 Afterlords and 40 Prototype Lords."); + } + + @Script("q3738e") + public static void q3738e(ScriptManager sm) { + // Quest 3738 - Disturbing the Army of Robots END + // Ken (NPC 2082017) - Mob kills + sm.sayNext("Thank you. You've been extremely helpful."); + + sm.forceCompleteQuest(3738); + sm.addExp(105000); + } + + @Script("q3739s") + public static void q3739s(ScriptManager sm) { + // Quest 3739 - Isabella's Search START + // Ken (NPC 2082017) - Year 2216 + sm.sayNext("I'm glad you came. I received a request for help, but I can't detect the exact location. We have to dispatch a search party, and I need you to lead it."); + + if (!sm.askAccept("Will you lead the search party?")) { + sm.sayNext("Somebody's life depends on this. We don't have time to sit and wait around!"); + return; + } + + sm.forceStartQuest(3739); + sm.sayOk("Okay. The signal is coming from east, and we suspect the victim is a teenage female. Please investigate the eastern regions and let me know as soon as you find someone."); + } + + @Script("q3739e") + public static void q3739e(ScriptManager sm) { + // Quest 3739 - Isabella's Search END + // Isabella (NPC 2082016) - Find survivor + sm.sayNext("Help! Help me! Are you here to rescue me?"); + + sm.forceCompleteQuest(3739); + sm.addExp(50000); + sm.sayOk("Thank you. I've drained all my energy, but I'm hanging in there. I didn't think anyone would ever get here. I was so scared."); + } + + @Script("q3740s") + public static void q3740s(ScriptManager sm) { + // Quest 3740 - What Was That I Saw? START + // Isabella (NPC 2082016) - Year 2216 + final int answer1 = sm.askMenu("By the way, did you see it? I saw an angel.", + Map.of(0, "An angel?")); + if (answer1 != 0) { + sm.sayOk("You have to believe me."); + return; + } + + final int answer2 = sm.askMenu("I saw an angel in the location where the high rise collapsed.", + Map.of(0, "That can't be. Angels don't exist. Are you feeling alright?")); + if (answer2 != 0) { + sm.sayOk("I'm telling you. You should see for yourself."); + return; + } + + sm.sayNext("I'm serious. It's true. The angel was beaming radiantly. If you don't believe me, head east to where the high rise collapsed. You'll see."); + + if (!sm.askAccept("Do you want to investigate?")) { + sm.sayNext("I'm telling you. You should see for yourself."); + return; + } + + sm.forceStartQuest(3740); + sm.sayOk("You have to believe me."); + } + + @Script("q3740e") + public static void q3740e(ScriptManager sm) { + // Quest 3740 - What Was That I Saw? END + // Andy (NPC 2082004) - Deliver Time Sand 4032517 + final int TIME_SAND = 4032517; + + if (!sm.hasItem(TIME_SAND, 1)) { + sm.sayOk("If you happen to find Time Sand, bring it over to me."); + return; + } + + sm.sayNext("Time Sand again. We've got a good amount of Time Sand now, but we still haven't found the pivotal clue. Ugh, this is so frustrating. What are we missing? I'll hang on to this for now."); + + sm.removeItem(TIME_SAND, 1); + sm.forceCompleteQuest(3740); + sm.addExp(70000); + } + + @Script("q3742s") + public static void q3742s(ScriptManager sm) { + // Quest 3742 - Nex the Time Guard (Year 2230) START + // Requires quests 3737-3740 completed + sm.sayNext("Before your fifth time travel, you must pass Nex's test again. I know I keep saying this, but trust me, Nex will be even stronger this time around!"); + + if (!sm.askAccept("Will you challenge Nex for the fifth time?")) { + sm.sayNext("You won't be authorized to travel time until you defeat Nex the Gatekeeper of Time."); + return; + } + + sm.forceStartQuest(3742); + sm.sayOk("When you defeat Nex, the Time Gate to the year 2230 will open."); + } + + @Script("q3742e") + public static void q3742e(ScriptManager sm) { + // Quest 3742 - Nex the Time Guard (Year 2230) END + // Requires defeating Oberon (8120101) + sm.sayNext("Ah, you've passed Nex's fifth test. Now then, you should be able to access the Time Gate to the year 2230."); + + if (!sm.askYesNo("Do you want to complete the quest?")) { + return; + } + + sm.forceCompleteQuest(3742); + sm.addExp(140000); + sm.sayOk("Take the Time Traveler's Pocket Watch and go through the Time Gate."); + } + + // ======================================== + // YEAR 2230 - IMMINENT COLLAPSE (3743-3744) + // ======================================== + + @Script("q3743s") + public static void q3743s(ScriptManager sm) { + // Quest 3743 - The New and Improved, Oberon START + // Andy (NPC 2082004) - Year 2230 + sm.sayNext("You'll now travel through time a fifth time.. An android known as Oberon appeared in the year 2230. He is even more advanced than Aufheben. From what we've studied, there's a good chance that Oberon has the fifth Time Sand. Defeat Oberon and get me that Time Sand."); + + if (!sm.askAccept("Will you defeat Oberon and retrieve the Time Sand?")) { + sm.sayNext("Are you saying you want to give up? Now?! Geez..."); + return; + } + + sm.forceStartQuest(3743); + sm.sayOk("We don't have much time!"); + } + + @Script("q3743e") + public static void q3743e(ScriptManager sm) { + // Quest 3743 - The New and Improved, Oberon END + // Andy (NPC 2082004) - Kill Oberon, deliver Time Sand 4032518 + final int TIME_SAND = 4032518; + + if (!sm.hasItem(TIME_SAND, 1)) { + sm.sayOk("Travel to the year 2230, defeat Oberon, and bring back the Time Sand."); + return; + } + + sm.sayNext("I was right. Oberon had the Time Sand. We're finally getting somewhere. Thank you again."); + + if (!sm.askYesNo("Do you want to complete the quest?")) { + return; + } + + sm.removeItem(TIME_SAND, 1); + sm.forceCompleteQuest(3743); + sm.addExp(200000); + sm.sayOk("Now, I suppose we one last time to travel to..."); + } + + @Script("q3744s") + public static void q3744s(ScriptManager sm) { + // Quest 3744 - Defeat the Mavericks START + // Hoya (NPC 2082011) - Year 2230 + sm.sayNext("Yoohoo! Here! Over here! How did you get in here? This tower could collapse any minute, you know. Since you've come so far, you're probably here to rumble with the Mavericks. Am I right? Then would you do me a favor?"); + + if (!sm.askAccept("Will you help Hoya?")) { + sm.sayNext("Come on, it'll be fun!"); + return; + } + + sm.forceStartQuest(3744); + sm.sayOk("Nalo says I'm too young and can't take on any major tasks. So he asked me to watch the Mavericks' movement patterns. But I know I can handle bigger things. I have something to investigate, so could you eliminate the Mavericks for me and report it to Nalo upstairs? Just 30 Mavericks of each type. Easy, huh? Then I'll see you later!"); + } + + @Script("q3744e") + public static void q3744e(ScriptManager sm) { + // Quest 3744 - Defeat the Mavericks END + // Nalo (NPC 2082012) - Report mob kills + final int answer = sm.askMenu("Who are you? You want something from me?", + Map.of(0, "I did a favor for Hoya, and I'm here to report it.")); + if (answer != 0) { + sm.sayOk("I'm busy right now."); + return; + } + + sm.sayNext("Why, that little rascal. I can't believe he dumped his responsibilities on you! I apologize. He doesn't think things through! How could he entrust a stranger with his duties?"); + + sm.forceCompleteQuest(3744); + sm.addExp(90000); + sm.sayOk("Thank you for your help. Hoya will get an earful from me later."); + } + + @Script("q3748s") + public static void q3748s(ScriptManager sm) { + // Quest 3748 - Nex the Time Guard (Year 2503) START + // Requires quests 3743-3744 completed + sm.sayNext("Before your sixth time travel, you must pass Nex's test again. Don't get too comfortable, though. Nex will be much stronger this time around. Really!"); + + if (!sm.askAccept("Will you challenge Nex for the sixth time?")) { + sm.sayNext("You won't be authorized to travel though time unless you defeat Nex the Gatekeeper of Time."); + return; + } + + sm.forceStartQuest(3748); + sm.sayOk("When you defeat Nex, the Time Gate to the year 2503 will open."); + } + + @Script("q3748e") + public static void q3748e(ScriptManager sm) { + // Quest 3748 - Nex the Time Guard (Year 2503) END + // Requires defeating Nibelung (8140510) + sm.sayNext("Ah, you've passed Nex's sixth test. Now then, you should be able to access the Time Gate to the year 2503."); + + if (!sm.askYesNo("Do you want to complete the quest?")) { + return; + } + + sm.forceCompleteQuest(3748); + sm.addExp(150000); + sm.sayOk("Take the Time Traveler's Pocket Watch and go through the Time Gate."); + } + + // ======================================== + // YEAR 2503 - FROM THE SKY (3749) + // ======================================== + + @Script("q3749s") + public static void q3749s(ScriptManager sm) { + // Quest 3749 - Nibelung's Song START + // Andy (NPC 2082004) - Year 2503 (Final Quest) + sm.sayNext("After all that time traveling, I feel like nothing got resolved. Judging from all the Time Sand we've collected, I think the key clue is in year 2503, where I'm from. Please travel to the year 2503, destroy Nibelung, and report it to Ashura."); + + if (!sm.askAccept("Will you travel to year 2503?")) { + sm.sayNext("Are you going to quit? Give up? Just like that? When we're so close?"); + return; + } + + sm.forceStartQuest(3749); + sm.sayOk("Ashura will be somewhere in Air Battleship Hermes."); + } + + @Script("q3749e") + public static void q3749e(ScriptManager sm) { + // Quest 3749 - Nibelung's Song END (Final Quest) + // Ashura (NPC 2082013) - Year 2503 + final int ALTAIRE_HAT = 1003039; + + sm.sayNext("You've destroyed Nibelung. If you're here to tell me about Andy, I already know. I owe you a debt of gratitude. Of course, this doesn't solve everything. I don't think you fully understand yet, but someday you will. It just isn't time yet."); + + sm.addItem(ALTAIRE_HAT, 1); + sm.forceCompleteQuest(3749); + sm.addExp(220000); + sm.sayOk("I will not forget you, time traveler..."); + } } diff --git a/src/main/java/kinoko/script/quest/NihalQuest.java b/src/main/java/kinoko/script/quest/NihalQuest.java new file mode 100644 index 00000000..38f9c4aa --- /dev/null +++ b/src/main/java/kinoko/script/quest/NihalQuest.java @@ -0,0 +1,1912 @@ +package kinoko.script.quest; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +/** + * Nihal Desert Region Quests + * Area 44 - Ariant, Magatia, Sand Bandits, Alcadno/Zenumist Alchemist storylines + */ +public final class NihalQuest extends ScriptHandler { + + + // QUEST 3900: Learning the Culture of Ariant ======================================== + @Script("q3900s") + public static void q3900s(ScriptManager sm) { + // NPC 2101005 - Byron (Ariant) + sm.sayNext("Hey, you are not from #m260000000#. Huh? How did I know? Come on, it's obvious! You don't look like someone from the desert, no offense. I'm sure you've noticed by now that the people of #m260000000# don't say a word to you..."); + + sm.sayBoth("Nah, they are just extra careful around outsiders. These residents, having spent all their lives in the desert, don't really open up to strangers. It'll take some time for you to get adjusted to this place."); + + if (!sm.askYesNo("All you have to do is to become a citizen of #m260000000#. I'll tell you how. Are you sure you want to become a citizen?")) { + sm.sayOk("Hmmm. Afraid to embrace a new culture? I understand it's hard to adapt, but just try it!"); + return; + } + + sm.sayOk("It's easy! In the center of town, near #m260000300#, you'll find a huge oasis. Drink water from there, and the residents'll accept you."); + sm.forceStartQuest(3900); + } + + @Script("q3900e") + public static void q3900e(ScriptManager sm) { + // NPC 2101005 - Byron + sm.sayNext("Hey, you drank the water! It's very important for the people of #m260000000# that you drink the water from the oasis. It means you're willing to be one of them, a rite of passage, so to speak."); + + sm.sayOk("Now you can start conversations with the people here. Be careful, though. The general mood around #m260000000# isn't very good these days. People are angered over the #bpowerless Sultan and the ever-greedy Queen#k."); + + sm.addExp(300); + sm.forceCompleteQuest(3900); + } + + // QUEST 3901: Tigun the Guard at the Palace ======================================== + @Script("q3901s") + public static void q3901s(ScriptManager sm) { + // NPC 2101004 - Tigun (Ariant Palace entrance) + sm.sayNext("Hey, what are you doing snooping around the palace? This is where the Sultan of #m260000000# resides! What? You want to enter? Why would I let a traveler like you do that? No way will I let you in, unless…"); + + if (!sm.askYesNo("If you really, really want to enter the palace, then there might be a way. You need to show your allegiance to the Sultan. How, you ask? That's easy. Just hand me #b2,000 mesos#k. As your way of paying respect to Sultan and #m260000000#, of course.")) { + sm.sayOk("What? You don't want to pay 2,000 mesos? Then scram before I arrest you!"); + return; + } + + if (!sm.canAddMoney(-2000)) { + sm.sayOk("Hey, you're short on mesos! If you want to enter the palace, the least you can do is give a good faith offering of #b2,000 mesos#k."); + return; + } + + sm.addMoney(-2000); + sm.addItem(4031582, 1); // Palace Entry Pass + sm.sayOk("Alright, alright. I can see now that you're someone who pays allegiance to the Sultan, so I'll give you this #t4031582#. Be thankful I'm feeling generous today."); + sm.forceCompleteQuest(3901); + } + + // QUEST 3902: Queen's Make-up Kit ======================================== + @Script("q3902s") + public static void q3902s(ScriptManager sm) { + // NPC 2101007 - Queen (Ariant Palace) + sm.sayNext("I can't stand the desert! This is not the kind of place for a fairy like me! How did I, #p2101007#, end up here... Dang it, my face's breaking out again! I need a more expensive make-up kit. You! Ready to receive an order from your queen?"); + + if (!sm.askYesNo("Haha, of course, of course you are. This desert is full of useless sand, but there's fine, smooth sand here and there that can be used as exquisite make-up. It's called #b#t4000332##k! I hear it's perfect for skin treatment! Get #b20#k for me.")) { + sm.sayOk("You won't do it? How rude. Guard! Take this vagabond straight to jail!"); + return; + } + + sm.sayOk("You can get #b#t4000332#s#k from #r#o4230600#s#k. They're quite strong, but you'll defeat them for your glorious queen #p2101007#."); + sm.forceStartQuest(3902); + } + + @Script("q3902e") + public static void q3902e(ScriptManager sm) { + // NPC 2101007 - Queen + if (!sm.hasItem(4000332, 20)) { + sm.sayOk("Where's my #b20 #t4000332s##k? My skin needs it. Speed it up! Seriously, outsiders…"); + return; + } + + sm.sayNext("Oh, ho, ho! So this is #t4000332#. It's shiny like gold, perfect for make-up. And it's so soft... Wow, this should be great for my skin."); + sm.sayOk("What are you doing standing there? If you've done your task, then leave the palace immediately! You should be thankful that an outsider like you was even allowed in. You don't expect a reward for your work, do you?"); + + sm.removeItem(4000332, 20); + sm.addExp(15000); + sm.forceCompleteQuest(3902); + } + + // QUEST 3903: Queen's Tea 1 ======================================== + @Script("q3903s") + public static void q3903s(ScriptManager sm) { + // NPC 2101007 - Queen + sm.sayNext("Ick, this is gross! Tea from barbarians. They may consider this drivel the finest around, but it does me no good. It's like drinking the very finest vinegar. A fairy with sensitive taste buds like me can't possibly drink tea made out of #t4000331#. You! Get #b#t4031577##k for me. NOW!"); + + if (!sm.askYesNo("You can't say no to a queen. Hahaha! #b#t4031577##k can be purchased from #b#p2012012##k of #b#m200000000##k.They might not want to sell it to you, but...that's your problem. I want to drink tea right this instant, so go to #m200000000# immediately.")) { + sm.sayOk("What? saying no to the queen? How idiotic... Don't think you'll last long in #m260000000# with that kind of attitude!"); + return; + } + + sm.addExp(1000); + sm.forceStartQuest(3903); + } + + @Script("q3903e") + public static void q3903e(ScriptManager sm) { + // NPC 2012012 - Nella (Orbis - Helios Tower) + sm.sayOk("What is it? Huh? Do I know a fairy named #p2101007#?"); + } + + // QUEST 3904: Queen's Tea 2 ======================================== + @Script("q3904s") + public static void q3904s(ScriptManager sm) { + // NPC 2012012 - Nella + sm.sayNext("What can I do for you? What? You want a #t4031577#? I don't sell that to outsiders... What? A fairy named #p2101007# from #m260000000# wants it? #p2101007#...? But I've never heard that name before…"); + + if (!sm.askYesNo("You look like you really need it. But Fairy Tea Leaves are hard to get, even for fairies. Ah, I know. I'll give you a Fairy Tea Leaf if you do me a favor! Just slay #r30#k #r#o5200000#s#k around #m200000000#...")) { + sm.sayOk("I can't give you a #t4031577# unless you really, really need it."); + return; + } + + sm.sayOk("Actually, the reason I'm not selling Fairy Tea Leaves to outsiders is that it's really hard to get these days. The Fairy Tea plant is very sensitive and needs to be grown in a quiet place, but that's hard to do because of the #o5200000#s."); + sm.forceStartQuest(3904); + } + + @Script("q3904e") + public static void q3904e(ScriptManager sm) { + // NPC 2012012 - Nella + sm.sayNext("Wow, you eliminated 30 #o5200000#s! Thank you so much! Now hold on one second while I bring the #t4031577# to you."); + sm.addExp(1000); + sm.forceCompleteQuest(3904); + } + + // QUEST 3905: Queen's Tea 3 ======================================== + @Script("q3905s") + public static void q3905s(ScriptManager sm) { + // NPC 2012012 - Nella + if (!sm.askYesNo("Now that you've taken care of the #o5200000#s, I'll give you a #t4031577#. It's a precious, rare item, so handle it with care.")) { + sm.sayOk("You need some time before returning to #m260000000#. I'll hold on to the #t4031577# for now, then..."); + return; + } + + sm.sayOk("A fairy called #p2101007# living in #m200000000# is the one who requested this? Odd. I know almost every fairy around, but I've never heard of her before."); + sm.addItem(4031577, 1); // Fairy Tea Leaf + sm.forceStartQuest(3905); + } + + @Script("q3905e") + public static void q3905e(ScriptManager sm) { + // NPC 2101007 - Queen + if (!sm.hasItem(4031577)) { + sm.sayOk("What? You still haven't gotten me a #b#t4031577##k yet? I knew it... Commoners are incompetent."); + return; + } + + sm.sayNext("Did you bring the #t4031577#? Ho, this is a #t4031577#, indeed. The scent of this leaf is amazing compared to #t4000331#. This should be sufficient."); + sm.sayOk("Not bad for a commoner. That's not saying much, since you're not a fairy, after all..."); + + sm.removeItem(4031577); + sm.addExp(2600); + sm.forceCompleteQuest(3905); + } + + // QUEST 3906: Jiyur's Sister 1 ======================================== + @Script("q3906s") + public static void q3906s(ScriptManager sm) { + // NPC 2101001 - Jiyur (Ariant) + sm.sayNext("Excuse me. Um, do you have access to the palace? See, the thing is, my sister's in there, and I was wondering if you could look for her for me."); + + sm.sayBoth("No way! My sister didn't want to go! The guards came and took her away...even though she said she didn't want to go... I think the queen took her after hearing that my sister's a good storyteller. If you...go into the palace, please just tell her I miss her."); + + if (!sm.askYesNo("Thank you so much. My sister knows a lot of stories. She also has a beautiful voice. The stories she told were so much fun to listen to... Please find her and tell her that #p2101001# misses her.")) { + sm.sayOk("You…you don't want to? I see... I'm sorry I asked for something that's so difficult to do."); + return; + } + + sm.addExp(450); + sm.forceStartQuest(3906); + } + + @Script("q3906e") + public static void q3906e(ScriptManager sm) { + // NPC 2101008 - Schegerazade (Ariant Palace) + sm.sayOk("Hello, traveler. Did you see my sister? What? This is a letter from her?! I was so worried, because I heard that the queen is quite scary... I am so glad she's doing all right!"); + } + + // QUEST 3907: A Letter From the Sister ======================================== + @Script("q3907s") + public static void q3907s(ScriptManager sm) { + // NPC 2101008 - Schegerazade + sm.sayNext("Hello, traveler. What kind of a story would you like to hear from me, #p2101008#?"); + sm.sayBoth("#p2101001#!? You know #p2101001#?! How is he?! I hope he's doing all right without me... So, #p2101001# sent you. Thank you. There's no way for me to check up on #p2101001# from the palace. I was so worried."); + + if (!sm.askYesNo("I see... It's only natural, since he can't contact his only sister. Could you tell #p2101001# that I'm doing all right in here?")) { + sm.sayOk("You must be really busy... But there's nothing I can do from here. At least I know how #p2101001#'s doing now, but..."); + return; + } + + sm.sayOk("Thank you so much. Here's a letter I wrote. Please give it to #b#p2101001. I can't see him face to face, but I want to let him know that I am okay and that I miss him terribly."); + sm.addItem(4031589, 1); // Letter from Schegerazade + sm.forceStartQuest(3907); + } + + @Script("q3907e") + public static void q3907e(ScriptManager sm) { + // NPC 2101001 - Jiyur + if (!sm.hasItem(4031589)) { + sm.sayOk("You didn't meet with my sister yet? She's in the palace."); + return; + } + + sm.sayNext("Hello, traveler. Did you see my sister? What? This is a letter from her?! I was so worried, because I heard that the queen is quite scary... I am so glad she's doing all right!"); + sm.sayOk("Can you please wait while I read her letter? Thanks."); + + sm.removeItem(4031589); + sm.addExp(450); + sm.forceCompleteQuest(3907); + } + + // QUEST 3908: A Present for His Sister ======================================== + @Script("q3908s") + public static void q3908s(ScriptManager sm) { + // NPC 2101001 - Jiyur + sm.sayNext("I am so happy that you found my sister and told her how much I miss her. You even brought a letter from her! Can you help me with one more thing?"); + + if (!sm.askYesNo("Reading this letter, I realized my sister must be constantly telling stories, so her throat must ache! I want to make her some #t4000331# tea, but #o2100104# s are too strong for me to fight…")) { + sm.sayOk("Ahhh... you must be tired from all that traveling. Sorry for making these demands."); + return; + } + + sm.sayOk("But you can handle #r#o2100104#s#k easily, right? Please get #b20 #t4000331#s#k for my sister. Please..."); + sm.forceStartQuest(3908); + } + + @Script("q3908e") + public static void q3908e(ScriptManager sm) { + // NPC 2101008 - Schegerazade + if (!sm.hasItem(4000331, 20)) { + sm.sayOk("You're the one came on #p2101001#'s behalf last time. What brings you back here? If you're here to meet the queen, please be careful."); + return; + } + + sm.sayNext("You're the one came on #p2101001#'s behalf last time. What brings you back here? Hmm? #t4000331#s, for me to make tea? You're here on #p2101001#'s behalf again, aren't you?"); + sm.sayOk("I was already so grateful to you last time, but wow... thank you so much. This will really soothe my throat."); + + sm.removeItem(4000331, 20); + sm.addItem(4010007, 5); // Lidium Ore + sm.addExp(4900); + sm.forceCompleteQuest(3908); + } + + // QUEST 3909: Dancer's Ringing ======================================== + @Script("q3909s") + public static void q3909s(ScriptManager sm) { + // NPC 2101000 - Sirin (Ariant) + sm.sayNext("Ahh! Something's missing. Even with all these moves, something's not right. Hey, since you aren't from around here, you may have a different point of view. What's missing from my dance?"); + + sm.sayBoth("Uh, can't you tell just by looking? I, #p2101000#, am the best dancer in #m260000000#. Unfortunately, I still have yet to dance inside the palace. Unless my moves are more radical, the queen won't be interested. Want to help me?"); + + if (!sm.askYesNo("Hah, I knew you'd fall in love with my moves. Come to think of it, if I put some bells on my dress while I dance, that'll really look special. If you can get me #b20 #t4000328#s#k from #r#o2100105#s#k, then I promise I'll perform some better moves. You can count on it.")) { + sm.sayOk("Pssssh, whatever. Are you really going to throw away a golden opportunity to see an amazing new dance?"); + return; + } + + sm.forceStartQuest(3909); + } + + @Script("q3909e") + public static void q3909e(ScriptManager sm) { + // NPC 2101000 - Sirin + if (!sm.hasItem(4000328, 20)) { + sm.sayOk("Hmmm. You didn't get #b20 #t4000328#s#k yet! You can find #t4000328#s off of #r#o2100105#s#k, so all you need to do is defeat them."); + return; + } + + sm.sayNext("Hey! You brought the #t4000328#s! Alright, I'll put these on the dress and once I dance around with all this festive clanging, the dance will seem that much more dynamic. Thanks!"); + sm.sayOk("Hmm? If I ring these bells, #o2100105#s will pop out? Uh… who told you that? They live in the desert. Why would they slide all the way here?"); + + sm.removeItem(4000328, 20); + sm.addExp(6000); + sm.forceCompleteQuest(3909); + } + + // QUEST 3910: Sword Dance 1 ======================================== + @Script("q3910s") + public static void q3910s(ScriptManager sm) { + // NPC 2101000 - Sirin + sm.sayNext("Hey, you're the traveler that got me #t4000328#s from the #o2100105#s, right? Thanks to you, my dance practices have gotten much better lately. Once people see my new dance with the bells ringing, even the queen will have to love it!"); + + sm.sayBoth("But I feel like this dance needs something more. Even with the perfect moves and the perfect dress... I really think it's the props, or lack thereof. The sword I use as a prop is so dull that when I do my patented Sword Dance, it doesn't look very dynamic. Will you help me out?"); + + if (!sm.askYesNo("Haha, I knew you'd help. You are so in love with my moves! The only person in #m260000000# that makes swords is #b#p2100001##k, so ask him if he can make a dancer sword for me.")) { + sm.sayOk("What? You aren't going to help? Come on! All I need is a nice sword, and my dance will be that much better. You really don't want to see #p2101000#'s spectacular Sword Dance?"); + return; + } + + sm.sayOk("I've been asking for a while, but he always says no... But if a traveler like you asks for the dancer sword, maybe he'll relent...right? Right?"); + sm.addExp(100); + sm.forceStartQuest(3910); + } + + // QUEST 3911: Making the Fancy Sword ======================================== + @Script("q3911s") + public static void q3911s(ScriptManager sm) { + // NPC 2100001 - Muhamad (Ariant) + sm.sayNext("What do you want me to make? A sword for dancers? I bet #p2101000# asked you, right? I am sorry, but I can't help. The taxes on dancer's swords have gone up, so I won't make any. But..."); + + if (!sm.askYesNo("Making the sword itself wouldn't be a tough task... All right, if you can get all the materials used to ornament the sword, I'll make it for you. I've refused #p2101000# all this time, but I'll make one if you bring the materials, okay?")) { + sm.sayOk("Hmm, #p2101000# would be disappointed..."); + return; + } + + sm.sayNext("Woah, you must be confident. Then get me #b4 #t4031568#s#k and #b30 #t4000335#s#k to decorate the handle of the sword. Of course, you'll also have to pay #b5,000 mesos#k for the amount of iron that's going towards making the blade. Are you sure you want to do this?"); + sm.sayOk("You can get #b#t4031568#s#k from #r#o2100108##k and #b#t4000335#s#k from #r#o3100102#s#k. Once you bring them all, I'll make a fancy dancer sword worthy of #p2101000#."); + sm.forceStartQuest(3911); + } + + @Script("q3911e") + public static void q3911e(ScriptManager sm) { + // NPC 2100001 - Muhamad + if (!sm.hasItem(4031568, 4) || !sm.hasItem(4000335, 30) || !sm.canAddMoney(-5000)) { + sm.sayOk("You don't have all the materials yet. If you're lacking anything, the sword will not turn out perfectly. Bring #b4 #t4031568#s#k and #b30 #t4000335#s#k, along with #b5,000 mesos#k."); + return; + } + + sm.sayNext("So you brought all the materials needed to decorate the handle? Then show me. Woah, you did bring them all. This should be enough to make the fanciest sword in #m260000000#."); + sm.sayOk("You don't think the sword will be made instantly, do you? It may not be used for combat, but it's not easy to make a sword, period. So please wait a bit."); + + sm.addMoney(-5000); + sm.removeItem(4031568, 4); + sm.removeItem(4000335, 30); + sm.addExp(300); + sm.forceCompleteQuest(3911); + } + + // QUEST 3912: Deliver the Fancy Sword ======================================== + @Script("q3912s") + public static void q3912s(ScriptManager sm) { + // NPC 2100001 - Muhamad + if (!sm.askYesNo("You're back. I'm just about done with the sword for #p2101000#. Take a look. It's more of an accessory than a sword, but for a dancer, this is the ultimate prop. Will you accept it?")) { + sm.sayOk("Are you busy with something? Sirin is waiting for her sword…"); + return; + } + + sm.sayOk("#p2101000# is a smart young woman. She used the very first sword I made her for her very first dance. If not for the Queen's tyranny, I would've given her a sword as a gift. Please take this sword to #b#p2101000##k right now. Your kindess shall not go un-rewarded."); + sm.addItem(4031569, 1); // Fancy Dancer Sword + sm.forceStartQuest(3912); + } + + @Script("q3912e") + public static void q3912e(ScriptManager sm) { + // NPC 2101000 - Sirin + if (!sm.hasItem(4031569)) { + sm.sayOk("You still haven't gone to #b#p2100001##k, yet? Find an old man who looks stubborn. His sword-making is the best in town, so ask him to make one for me."); + return; + } + + sm.sayNext("Did you go see #p2100001#? Did you get the fancy dancer sword for me? Wow! So these are the #t4031569#! Amazing!"); + sm.sayOk("Thank you! This will let me enter the castle with more pizzazz than anyone. I WILL enter the castle. I will be #p2101000#, the greatest dancer of #m260000000#, performing in front of the Sultan. You just wait and see!"); + + sm.removeItem(4031569); + sm.addExp(8000); + sm.forceCompleteQuest(3912); + } + + // QUEST 3913: Schegerazade's Fear ======================================== + @Script("q3913s") + public static void q3913s(ScriptManager sm) { + // NPC 2101008 - Schegerazade + sm.sayNext("Hello, traveler... What kind of a story would you like to hear? A story of a monster trapped in the mountain of snow? A story of a beautiful town inside the ocean? If not, then a story of a race, unlike any other, deep inside the forest?"); + + sm.sayBoth("My story? It's not a fun story to listen to. It's a story of a regular girl with a reputation for great storytelling, living in a regular town, who was dragged into the palace to... weave new stories while constantly trembling with fear."); + + if (!sm.askYesNo("I am afraid of the day when I've told every single story I know. It'd be nice if I were sent back home then...but would the queen really do that? I doubt it. I'm scared of what she WOULD do. So I want to get new books, so I never run out of stories. Could you help me get some?")) { + sm.sayOk("Oh, I see. I guess there's no such thing as a courageous adventurer like in the stories…"); + return; + } + + sm.sayNext("I am sure you've heard of the #m222020000#, considered the biggest and best library in the world. Don't you think a library like that must contain a story or two that's never been heard before?"); + sm.sayOk("This might not work, but... #b#m222020000##k is the only place I can think of. If I can find a way to get my hands on new storybooks, maybe I can buy more time. Please help me, traveler. Please borrow a book from #b#p2040052##k for me."); + + sm.addExp(500); + sm.forceStartQuest(3913); + } + + @Script("q3913e") + public static void q3913e(ScriptManager sm) { + // NPC 2040052 - Wiz the Librarian (Helios Tower Library) + sm.sayOk("Welcome to #m222020000#. All the knowledge and records of Maple World is stored in here. Which book would you like to read...? What? You want me to help #p2101008# of #m260000000#?"); + } + + // QUEST 3914: Borrowing the Book from Wiz ======================================== + @Script("q3914s") + public static void q3914s(ScriptManager sm) { + // NPC 2040052 - Wiz + sm.sayNext("#p2101008# of #m260000000#... I am amazed to find out someone knows that many stories! Even so, there must be stories out there that even #m222020000# doesn't know. Storybooks? I'll definitely lend you some."); + + if (!sm.askYesNo("The thing is, here at the Helios Library, there's one rule that must be obeyed. When you borrow a book, you must also offer a book. So, are you willing to offer a new storybook?")) { + sm.sayOk("It's almost impossible to find a book that's not featured in #m222020000#... I understand that you're overwhelemed."); + return; + } + + sm.sayNext("It'll be hard to find a book that's not featured in this library... Oh, there's a better method, actually."); + sm.sayOk("Wouldn't it be better if you write down the stories that #b#p2101008##k knows and make them into their own storybook? That would be much easier and simpler to do than finding a brand new book."); + + sm.addExp(500); + sm.forceStartQuest(3914); + } + + @Script("q3914e") + public static void q3914e(ScriptManager sm) { + // NPC 2101008 - Schegerazade + sm.sayNext("You must have gone to #m222020000#. Did the librarian lend you a book?"); + sm.sayOk("So if one book is borrowed, it must be replaced by another... I didn't know there was such a rule in #m222020000#. Well, I've actually written down most of the stories I know. There's not much else to do when the queen doesn't need me... I just have to combine all the pages. Please come back in a bit."); + sm.forceCompleteQuest(3914); + } + + // QUEST 3915: Schegerazade's Storybook ======================================== + @Script("q3915s") + public static void q3915s(ScriptManager sm) { + // NPC 2101008 - Schegerazade + if (!sm.askYesNo("As you requested, I've combined all the pages of the stories I know into a book. Can you please give this to #b#p2040052##k of #b#m222020000##k?")) { + sm.sayOk("It's a long, long way from here to Helios Tower. I understand if you don't feel the urge to return there."); + return; + } + + sm.sayOk("Thank you so much. Without your help, I would have been trapped in here, worried sick..."); + sm.addItem(4031572, 1); // Schegerazade's Storybook + sm.forceStartQuest(3915); + } + + @Script("q3915e") + public static void q3915e(ScriptManager sm) { + // NPC 2040052 - Wiz + if (!sm.hasItem(4031572)) { + sm.sayOk("When will I be able to receive #b#p2101008##k's storybook? I am anxious to see what's inside...."); + return; + } + + sm.sayNext("Wow, so you went all the way back to #m260000000# where #p2101008# is! I can tell because you're covered in sand. So. That's the storybook compiled by #p2101008#? Ohh... Ahh… Hmm... Amazing! There are so many good stories in here!"); + sm.sayOk("Now, I'll give you the book for #p2101008#. Where did I put it... Can you hold on one second?"); + + sm.removeItem(4031572); + sm.addExp(500); + sm.forceCompleteQuest(3915); + } + + // QUEST 3916: Schegerazade the Storyteller ======================================== + @Script("q3916s") + public static void q3916s(ScriptManager sm) { + // NPC 2040052 - Wiz + if (!sm.askYesNo("Okay, this is a book that contains desert stories and other stories from afar that #p2101008# may not be aware of. It has a lot of stories in it, so she should be fine for a while. Please take it.")) { + sm.sayOk("You don't plan on returning to #m260000000#? #p2101008# seemed pretty desperate..."); + return; + } + + sm.sayOk("It's amazing to see a woman in such a stressful environment who nevertheless constantly searches for great stories... It really is. Once you go back to #b#m260000000##k, tell #b#p2101008##k I'd most like to meet her here at #m222020000# when she gets free of the queen."); + sm.addItem(4031573, 1); // Storybook from Wiz + sm.forceStartQuest(3916); + } + + @Script("q3916e") + public static void q3916e(ScriptManager sm) { + // NPC 2101008 - Schegerazade + if (!sm.hasItem(4031573)) { + sm.sayOk("Have you not gone to #m222020000# yet? It's not close to here, so you should get moving."); + return; + } + + sm.sayNext("Welcome, traveler... Did you really go to #m222020000# and back? Ah, so this is the book from #p2040052#... Thank you very much. With this, I'll have enough stories to last a while."); + sm.sayOk("I can't help but envy you for being able to travel to far places like #m220000000# and even #m222020000#... I wonder when I'll be free of the queen's grasp... I dream of the day I can travel the world with my brother."); + + sm.removeItem(4031573); + sm.addExp(9000); + sm.forceCompleteQuest(3916); + } + + // QUEST 3917: The Little Prince that Loves Roses ======================================== + @Script("q3917s") + public static void q3917s(ScriptManager sm) { + // NPC 2101006 - Little Prince (Nihal Desert) + sm.sayNext("Why is the desert so big. It's so big that I can't see a thing..."); + + if (!sm.askYesNo("Come to think of it, this rose has been dying for a while. The desert heat must be too much for her. Can you get some #t2022155# for this little rose?")) { + sm.sayOk("Why is there no pond in the desert?"); + return; + } + + sm.sayOk("Wow... Thank you. I think #b5 #t2022155#s#k should be enough for the rose. It's such a small little thing."); + sm.forceStartQuest(3917); + } + + @Script("q3917e") + public static void q3917e(ScriptManager sm) { + // NPC 2101006 - Little Prince + if (!sm.hasItem(2022155, 5)) { + sm.sayOk("I don't think you've gotten #b5 #t2022155#s#k yet. I'm sorry, but she's quite selfish, and she demands more and more."); + return; + } + + sm.sayNext("Wow… You brought some #t2022155#... You must like flowers, too."); + sm.sayOk("With this, I am sure the rose will not be mad anymore. It's been cranky at me for a while since it's been so thirsty. Thank you so much for your help... She's the prettiest, nicest rose you'll find in this world."); + + sm.removeItem(2022155, 5); + sm.addExp(4200); + sm.forceCompleteQuest(3917); + } + + // QUEST 3918: The Little Prince that Loves the Stars ======================================== + @Script("q3918s") + public static void q3918s(ScriptManager sm) { + // NPC 2101006 - Little Prince + sm.sayNext("..."); + + sm.sayBoth("Ah... You're the one that got me the #t2022155# for the rose the other day... I can't thank you enough. Why am I gazing up at the sky? Because... that's where the stars are."); + + sm.sayBoth("Yes, that star you see over therrrrrrrre... Yes, I'm looking at that one. Isn't it pretty?"); + + if (!sm.askYesNo("Yes, I think stars are hard to see because of the sand dust. I wish I could see the stars come clearly... Hey, if it isn't too much to ask...")) { + sm.sayOk("If I could hold that star... That'd be so wonderful..."); + return; + } + + sm.sayOk("Ah… Can you get me #b20 #t4000333#s#k? I've seen #r#o2100108#s#k carry them around... I think I'll really be able to see the stars better with them. Thanks…"); + sm.forceStartQuest(3918); + } + + @Script("q3918e") + public static void q3918e(ScriptManager sm) { + // NPC 2101006 - Little Prince + if (!sm.hasItem(4000333, 20)) { + sm.sayOk("I don't think you've gotten #b20 #t4000333#s#k yet... Was it too much to ask?"); + return; + } + + sm.sayNext("Ah. The #t4000333#s. With this, I'll be able to see the stars... I'm so happy now."); + sm.sayOk("Look at that star! That small yet beautiful star...is actually my home. Hopefully I'll be able to go home someday."); + + sm.removeItem(4000333, 20); + sm.addItem(1032010, 1); // Star Earring + sm.addExp(4650); + sm.forceCompleteQuest(3918); + } + + // QUEST 3919: Recovering the Book The Little Prince 1 ======================================== + @Script("q3919s") + public static void q3919s(ScriptManager sm) { + // NPC 2040052 - Wiz + sm.sayNext("Oh no... This is not good. The story of #p2101006# has been mixed to unrecognizable bits because of the dimensional rift in Ludibrium. I wonder where #p2101006# is... Have you seen #p2101006# by any chance? It's a kid wearing a scarf. He really likes roses..."); + + sm.sayBoth("What? Nihal Desert? You saw a blonde kid who likes roses and just gazes around absently? Ohhh...#p2101006#'s there. But...was he with anyone? Besides the rose, I mean?"); + + if (!sm.askYesNo("Oh no... #p2101006# is supposed to meet a fox in the desert... The story's all jumbled up. Can you go to #p2101006# and get the #p2101006# storybook from him for me?")) { + sm.sayOk("You seem busy. Then who should I ask for the storybook of #p2101006#?"); + return; + } + + sm.sayOk("I can't even see how #p2101006# can be straightened out... How will I find the fox for him... Please help #b#p2101006##k make a friend of any kind. Then he should give you the book..."); + sm.addExp(2000); + sm.forceStartQuest(3919); + } + + @Script("q3919e") + public static void q3919e(ScriptManager sm) { + // NPC 2101006 - Little Prince + sm.sayOk("Ahh... You're the kind person that got me the #t2022155#s for the rose and the #t4000333#s to help me see the stars... I'm glad to see you again..."); + } + + // QUEST 3920: Recovering the Book The Little Prince 2 ======================================== + @Script("q3920s") + public static void q3920s(ScriptManager sm) { + // NPC 2101006 - Little Prince + sm.sayNext("I've sat in this desert for a long time, and no one else has talked to me except you... And no one has offered to help me as much as you..."); + + sm.sayBoth("A fox? I've seen some rabbits and #o2100108#s, but not a single fox... Why do you need a fox? For you, I'll do anything to help."); + + sm.sayBoth("I am lo-- actually, no, I am not. I'm not, since you're here with me. Maybe I arrived in this desert just to meet someone like you... I do feel like that sometimes."); + + if (!sm.askYesNo("I don't know... I do want to return home...but...that means I won't be able to see you anymore. The only reason this desert is even remotely seem beautiful to me is because you're here.")) { + sm.sayOk("A friend... Can a rose be a friend?"); + return; + } + + sm.sayNext("Honestly, this is the first time I've ever made a true friend. A friend... I like...being friends with someone. I'm so happy. You ARE a friend."); + sm.sayOk("As a way to celebrate our friendship, I'll give you this book. I feel like you need it for some reason. Hopefully this will help."); + + sm.addItem(4031591, 1); // The Little Prince Book + sm.forceStartQuest(3920); + } + + @Script("q3920e") + public static void q3920e(ScriptManager sm) { + // NPC 2040052 - Wiz + if (!sm.hasItem(4031591)) { + sm.sayOk("I don't think you've gotten the #bstorybook of #p2101006##k yet. Please observe #b#p2101006##k and discover what he's missing... If you can fill the void, he'll give you the storybook of #p2101006#."); + return; + } + + sm.sayNext("Wow, you got the storybook of #p2101006#! How is #p2101006#? Was he with a fox? What? He gave you the book after you told him you want to be his friend? Ah, so that's how the story found its equilibrium. I'm glad that happened."); + sm.sayOk("You acted as the fox to #p2101006#, so the story of #p2101006# has straightened its course. Thank you so much. This will also help put #m222020000# back on course."); + + sm.removeItem(4031591); + sm.addItem(2020012, 30); // Cider + sm.addExp(6800); + sm.forceCompleteQuest(3920); + } + + // QUEST 3921: A Request from a Member of the Sand Bandits?! 1 ======================================== + @Script("q3921s") + public static void q3921s(ScriptManager sm) { + // NPC 2101012 - Red Scorpion Member (outside Ariant) + sm.sayNext("Shush! Lower your voice. I can't be seen... What do you want?"); + + sm.sayBoth("Sand Bandits...? Hm, Sand Bandits... Why are you asking? You don't look one of the queen's guards..."); + + if (!sm.askYesNo("Ooh. In that case. I mean, yes. Why, yes, I'm a member of the Sand Bandits. You caught me. So, knowing that, will you help me?")) { + sm.sayOk("Hey, if you aren't going to help, why'd you bother me in the first place?"); + return; + } + + sm.sayNext("You know what we do, right? We steal the queen's treasures to, ah, help the poor. That's right. Help the poor. Now, to attack the transport group carrying her treasures, we need a rope. But first, I need material to craft the rope. You with me so far?"); + sm.sayOk("In the desert, #b#t4000329#s#k are used to create rope. Dry #t4000329#s can be pulled and stretch to make very fine rope indeed. Slay some #r#o2100102#s#k to get me #t4000329#s. #b30#k should do. I'll be waiting."); + + sm.forceStartQuest(3921); + } + + @Script("q3921e") + public static void q3921e(ScriptManager sm) { + // NPC 2101012 - Red Scorpion Member + if (!sm.hasItem(4000329, 30)) { + sm.sayOk("You're supposed to get me #b30 #t4000329#s#k to help the Sand Bandits. They drop off of #r#o2100102#s#k."); + return; + } + + sm.sayNext("Who-who is it?! Ohhh, you're the one that offered to help the Sand Bandits! Ah, hahaha, yes, yes. So did you get the #t4000329#s?"); + sm.sayOk("Ah, yes, this will definitely help us Sand Bandits do good in the world... It's people like you that makes my job so much easier..."); + + sm.removeItem(4000329, 30); + sm.addExp(3750); + sm.forceCompleteQuest(3921); + } + + // QUEST 3922: A Request from a Member of the Sand Bandits?! 2 ======================================== + @Script("q3922s") + public static void q3922s(ScriptManager sm) { + // NPC 2101012 - Red Scorpion Member + sm.sayNext("Shush! Lower your voice. You're the one that got me the #t4000329#s the other day... Thanks to you, I was able to make some rope. It should prove very profitable…for the people of Ariant, of course."); + + if (!sm.askYesNo("Honestly, though, our work isn't done. The transport group will be guarded, you see, and to defeat the guards, we'll need Poison Needles. Can you help? It's for the Sand Bandits, remember.")) { + sm.sayOk("Really? You're not willing to help the Sand Bandits? I'm shocked! You are about that?"); + return; + } + + sm.sayOk("Haha, I knew you'd agree. To make Poisoned Needles, we just need #b#t4000330#s#k since I already have the poison. You can get them from #r#o2100103#s#k, so get me around #b50#k."); + sm.forceStartQuest(3922); + } + + @Script("q3922e") + public static void q3922e(ScriptManager sm) { + // NPC 2101012 - Red Scorpion Member + if (!sm.hasItem(4000330, 50)) { + sm.sayOk("You don't have #b50 #t4000330#s#k yet. All you need to do to get it is defeat #r#o2100103#s#k. This is for Sand Bandits. Please work harder."); + return; + } + + sm.sayNext("You brought the #t4000330#s. This should be more than enough for the Poisoned Needles. As a member of the Sand Bandits, I thank you deeply."); + sm.sayOk("We Sand Bandits have a lot on our plates. If people like you didn't help us out, then we'd definitely be struggling."); + + sm.removeItem(4000330, 50); + sm.addExp(5250); + sm.forceCompleteQuest(3922); + } + + // QUEST 3923: A Request from a Member of the Sand Bandits?! 3 ======================================== + @Script("q3923s") + public static void q3923s(ScriptManager sm) { + // NPC 2101012 - Red Scorpion Member + sm.sayNext("Hello there, loyal, faithful, unyielding supporter of the Sand Bandits. I've been waiting for you. This time, we're going to steal the queen's treasure, and you'll help of course, right?"); + + if (!sm.askYesNo("I knew it. Your task is to steal the #bqueen's treasure#k from the #bqueen's accessory chest#k. It's placed in the deepest part of the palace, near the king. It'll be hard for you to get close to it, but this is for Sand Bandits. I am sure you're up to the task.")) { + sm.sayOk("Hmmmph! You're backing out now?"); + return; + } + + sm.sayOk("Security inside the castle is very tight these days. There will guards everywhere, ready to pounce. So once you enter, be careful where you walk. Use hidden portals to discreetly approach the chest and retrieve the jewel. I'll wait here."); + sm.forceStartQuest(3923); + } + + @Script("q3923e") + public static void q3923e(ScriptManager sm) { + // NPC 2101012 - Red Scorpion Member + if (!sm.hasItem(4031578)) { + sm.sayOk("Hmmm... I don't think you've obtained the #b#t4031578##k yet. It should be inside the #bqueen's accessory chest#k inside the #bpalace#k. Of course, you must be discreet and use hidden portals. Good luck."); + return; + } + + sm.sayNext("Welcome back! Did you get the #t4031578#? Ohhhh, just as I expected! Nice, this should fetch some money…"); + sm.sayOk("Thanks to you, the Red Scor...er, I mean, Sand Bandits have been doing very, very well lately. Thank you. Hahaha... I hope you keep helping us down the road."); + + sm.removeItem(4031578); + sm.addExp(6300); + sm.forceCompleteQuest(3923); + } + + // QUEST 3924: Sirin Speaks ======================================== + @Script("q3924s") + public static void q3924s(ScriptManager sm) { + // NPC 2101012 - Red Scorpion Member + sm.sayNext("Argh!! I can't believe it! The jewel I stole just got stolen. I won't let whoever stole it get away with this! Oh! You're here. Thanks for coming. Someone stole the jewel you worked so hard to get us. I didn't even have time to store it safely in the cave. I just stashed it behind that rock over there. I think I know who took it, too. Can you help?"); + + if (!sm.askYesNo("It was #b#p2101000##k, the dancer. How do I know this? Because besides #p2101000#, no one else ever come here! I should've been more suspicious when she started practicing her dance moves out here. It had to be #p2101000#! Please, get the jewel back from her.")) { + sm.sayOk("What?! The Sand Bandits' precious stolen jewel just got stolen, and you aren't curious who did it?"); + return; + } + + sm.addExp(1200); + sm.forceStartQuest(3924); + } + + @Script("q3924e") + public static void q3924e(ScriptManager sm) { + // NPC 2101000 - Sirin + sm.sayNext("What's going on? It's almost showtime, and I'm practicing my new moves. I'll show them to you later... What? Did I steal a piece of jewelry that was hidden behind a rock?"); + sm.sayOk("Hmm, a jewel hidden behind a rock... Ohh, that! Yes, I took it and gave it away to feed the hungry. Why do you ask? What? I'm interfering in the work of the Sand Bandits? No way! What are you saying? I, Sirin, know more than anyone how much the Sand Bandits help the people of #m260000000#, and I would never disturb their work. The jewel hidden behind the rock was stolen by real bandits. That's why I took it."); + sm.forceCompleteQuest(3924); + } + + // QUEST 3925: True or False ======================================== + @Script("q3925s") + public static void q3925s(ScriptManager sm) { + // NPC 2101000 - Sirin + sm.sayNext("Sigh... you look so clueless. Think! The Sand Bandits are based in #m260000000#. They hide their identities so no one suspects them of going against the queen. You really think they'd hide their treasures out in the open outside town?"); + + sm.sayBoth("And the guy you said you helped... It sounds fishy to me. Why would he stand in the middle of the desert when everyone else is here in Ariant, doing their best to defy the queen?"); + + sm.sayBoth("And the guy ordered you to steal the queen's ring, right out from under her nose? No, no, that's too risky. Imagine if you were caught! The Sand Bandits would be totally compromised. No, they'd never ask you to do that. You definitely weren't working with a member of the Sand Bandits..."); + + if (!sm.askYesNo("Seriously, who fed you that junk? It's a mound of dung, my friend, one so large that a Mushmom could sprout out of it. Ah ha! I'll bet it was #p2101012#? I'm right, aren't I? He sounds like a member of the #rRed Scorpions#k. What? You don't even know about the Red Scorpions? Jeez... I'd better explain, lest you find yourself on the wrong end of a pointed sword. Will you listen?")) { + sm.sayOk("Hmmm... I've been telling you the truth, and you don't trust me? Quite a complex you have there, my friend..."); + return; + } + + sm.sayNext("The Red Scorpions are a notorious band of thieves based right outside #m260000000#. Unlike the Sand Bandits, they are in it for their own good, and they disturb the peace and take things from the innocent. I think you got duped by the Red Scorpions."); + sm.sayOk("You've been tricked by the Red Scorpions all this time! You are quite gullible. Perhaps I should offer to sell you a rare and magical Flaming Sword for the low, low price of 550 million mesos... HA! I mock you, my friend, but I don't think you're a bad person. Hmm, are you interested in getting revenge on the Red Scorpions? They did play you like a trumpet, after all... If you want some payback on the Red Scorpions, let me know. I'll tell you exactly what to do."); + + sm.addExp(100); + sm.forceStartQuest(3925); + } + + @Script("q3925e") + public static void q3925e(ScriptManager sm) { + // NPC 2101000 - Sirin + sm.sayNext("You're back, so I suppose you really want to turn the tables on the Red Scorpions. The way to do that is quite simple. Take their stolen treasures and give them out to the residents here. You're not only hurting the Red Scorpions, but you're also helping out the good people of Ariant. Poetic justice, don't you think?"); + + sm.sayNext("The Red Scorpion's hideout is in The Scorching Desert, so the treasures they've stolen should be there as well. A suspicious man stands in front of the hideout like a guard. The thing is, even if you find the place, you won't be able to go in. The door can only be open by yelling out the magic words."); + sm.sayOk("So you need to discover the password, then go to their hideout and steal their stolen treasures. What's the password? Hah...#byou probably already know it#k. It is a bandit hideout in the middle of a desert, after all. Afterwards, drop those treasures at secret stash spots in various homes in town. That's what I'd call revenge, served piping hot in this case! Oh, and if you see an X on any of the houses, then that means it belongs to a known thief, so stay away. Once you've robbed the robbers, let me know, alright? I'd love to hear details on how the Red Scorpions got a taste of their own medicine."); + + sm.forceCompleteQuest(3925); + } + + // QUEST 3926: Screwing the Red Scorpions ======================================== + @Script("q3926s") + public static void q3926s(ScriptManager sm) { + // NPC 2103007 - Red Scorpion Treasure Chest + sm.sayNext("This giant treasure chest is full of expensive treasures. There's plenty of stuff inside."); + + if (!sm.askYesNo("Remove the treasure from the chest and place it in a pocket.")) { + return; + } + + sm.sayOk("#b(Now, to give these to the people in town who need it.)#k"); + sm.addItem(4031579, 4); // Stolen Treasure + sm.forceStartQuest(3926); + } + + @Script("q3926e") + public static void q3926e(ScriptManager sm) { + // NPC 2101000 - Sirin + sm.sayNext("Hahaha, looking at you, I can tell you took the stolen treasures from the Red Scorpions and gave them to the people of #m260000000#. Feel better now?"); + sm.sayOk("The silly queen was already a big enough headache for everyone in #m260000000#, and now the Red Scorpions... Seriously, why are things in #m260000000# are becoming such a mess?"); + + sm.addExp(6500); + sm.forceCompleteQuest(3926); + } + + // QUEST 3927: The Existence of the Secret Organization ======================================== + @Script("q3927s") + public static void q3927s(ScriptManager sm) { + // NPC 2101000 - Sirin + sm.sayNext("#m260000000# is a mineral-rich area with a lot of Lidium, an ore that can only be found at the Oasis and the Nihal Desert. But no thanks to the greedy queen, who charges ridiculous amount of taxes, everyone's struggling these days. Apparently the queen is a fairy from Orbis... She spends all the tax money to bring in unfathomable amounts of treasures."); + + sm.sayBoth("The Sultan? #p2101009# is supposedly brilliant but I've never seen him act that way. He certainly sleeps brilliantly... He has virtually no power. The word is, the queen put a spell on him..."); + + if (!sm.askYesNo("Fortunately there are brave people who do. They serve as inspiration to the residents of #m260000000#. If this heroic group of bandits, called the Sand Bandits, didn't steal the queen's treasures and hand them to the people here, then the city would've been long dead by now. You want to join the Sand Bandits? But you're an outsider! Hm, but you give off a good vibe. Want me to tell you more about them?")) { + sm.sayOk("A giant Pyramid has revealed itself out of the blue! Aren't you excited to find out what's inside?"); + return; + } + + sm.sayNext("The Sand Bandits are despised by the queen, so they disguise themselves as common folk. Contacting them is not impossible, however…"); + sm.sayBoth("I heard a story, just a story, you hear, that the Sand Bandits write hints #bon the walls of a house somewhere in town#k. It's the key to contacting them. Find the wall, meet some members, and maybe, just maybe, you'll become one of them…"); + sm.sayOk("Of course, the wall looks just like any other wall, from a distance… So you need to check every wall. Here's a clue. It could be closer than you'd think. But even if you do everything right, it still might not work out. Don't get your hopes up. It's not an easy organization to join."); + + sm.addExp(1000); + sm.forceStartQuest(3927); + } + + @Script("q3927e") + public static void q3927e(ScriptManager sm) { + // NPC 2101000 - Sirin + sm.sayNext("Did you find the wall?"); + + final int answer = sm.askMenu("What did it say?", java.util.Map.of( + 0, "'If I had an iron hammer and a dagger, a bow and an arrow...'", + 1, "'Byron♡ Sirin'", + 2, "'Ahhh I forgot.'" + )); + + if (answer == 1) { + sm.sayOk("Man, Jiyur wrote on the wall again? Arrgh! I'll get that kid…"); + return; + } else if (answer == 2) { + sm.sayOk("Really? You forgot? Well, do you remember where it was written?"); + return; + } + + sm.sayOk("If I had an iron hammer and a dagger, a bow and an arrow, eh? Think about this! A weapon is just an item...until someone uses it, right? That's the only clue I can think of…"); + sm.forceCompleteQuest(3927); + } + + // QUEST 3928: The Man with a Bow ======================================== + @Script("q3928s") + public static void q3928s(ScriptManager sm) { + // NPC 2101011 - Sejan (Ariant) + sm.sayNext("What?! If you just want to chat, buzz off. What? You want to join the Sand Bandits? Hmmm, you must have seen the writing on the wall. Haha! That means #p2101000# sent you…"); + + if (!sm.askYesNo("You are not from #m260000000#. You can't even begin to understand the pain the people in #m260000000# are suffering, so what qualifies you to become a member of our group? How do we know you're not a spy from the queen? You willing to do some work for us?")) { + sm.sayOk("Look at that, you refuse! You must be a spy sent by the queen!"); + return; + } + + sm.sayNext("Alright, then I'll test you. You know the Sand Bandits collect goods for the residents of #m260000000#, right? I want you to get some food for the people here. Get #b50 #t4000325#s#k, #b10 #t2010002#s#k, and #b10 #t2010000#s#k."); + sm.sayOk("Don't tell me this is hard, okay? Our real missions are much, much tougher than this. Gathering #b#t4000325#s#k by killing #r#o2100101#s#k is nothing."); + + sm.forceStartQuest(3928); + } + + @Script("q3928e") + public static void q3928e(ScriptManager sm) { + // NPC 2101011 - Sejan + if (!sm.hasItem(4000325, 50) || !sm.hasItem(2010000, 10) || !sm.hasItem(2010002, 10)) { + sm.sayOk("Hey, you didn't get it all! I told you, I need #b50 #t4000325#s#k, #b10 #t2010002#s#k, and #b10 #t2010000#s#k."); + return; + } + + sm.sayNext("Hmmm, did you bring all the items I requested? Let's see. One, two, three... Not bad. You got it all."); + sm.sayOk("But this is nothing. Even spies from the queen can do this. If you really want to be accepted as a member of the Sand Bandits, you'll need to do more."); + + sm.removeItem(4000325, 50); + sm.removeItem(2010000, 10); + sm.removeItem(2010002, 10); + sm.addExp(1000); + sm.forceCompleteQuest(3928); + } + + // QUEST 3929: Sejan's Test ======================================== + @Script("q3929s") + public static void q3929s(ScriptManager sm) { + // NPC 2101011 - Sejan + if (!sm.askYesNo("It's good that you brought the food, but you need to help distribute it to the people of Ariant, too. If you are not a spy, then you have no problem with that, right?")) { + sm.sayOk("You must be a spy from the queen. It was a mistake to trust you..."); + return; + } + + sm.sayNext("Here, take these food packages. Your task is to drop them off at certain houses. Do not, I repeat, do not just place them anywhere. You have no idea how many people do that... Find a safe, secure hiding place to store the food in each house, understand?"); + sm.sayOk("Oh, and one more thing. Ariant has more than its share of thieves and crooks. We don't need to give them any food, obviously. You only need to drop the food packages at the homes of innocent residents, marked by a #bsign on the door#k. So check doors before entering."); + + sm.addItem(4031580, 4); // Food Package + sm.forceStartQuest(3929); + } + + @Script("q3929e") + public static void q3929e(ScriptManager sm) { + // NPC 2101011 - Sejan + sm.sayNext("That was great. Now I can really tell you're not a spy from the queen."); + sm.sayOk("You are definitely a good candidate to become a member of the Sand Bandits."); + + sm.addExp(2000); + sm.forceCompleteQuest(3929); + } + + // QUEST 3930: Sejan's Sand Bandits ======================================== + @Script("q3930s") + public static void q3930s(ScriptManager sm) { + // NPC 2101011 - Sejan + sm.sayNext("One of the most important qualities of a Sand Bandit is selflessness. True, Sand Bandits steal riches from the queen, but it's not for us. It's for the people of #m260000000#."); + + if (!sm.askYesNo("A selfish individual cannot contain his greed. He will inevitably want to keep the expensive items for himself. This is taboo for a Sand Bandit. Thus, I must test how selfless you really are. It's simple. I need you to donate #b10,000 mesos#k. Too much? Think about it, we'll need much more than that to save #m260000000# from the queen. Selflessly part with 10,000 mesos, and I will believe in you.")) { + sm.sayOk("You've done well so far. Don't falter now... Then again, it is hard to let go of mesos…"); + return; + } + + if (!sm.canAddMoney(-10000)) { + sm.sayOk("That's not enough. #b10,000 mesos#k shouldn't be hard to obtain."); + return; + } + + sm.sayNext("You've decided to donate 10,000 mesos? I am sure there was much you could have bought with that money, yet you selflessly sacrificed it for the people of #m260000000#. That's commendable."); + sm.sayOk("Then you have passed the tests that I, #p2101011#, have for you. Now you just have to earn the trust of the other Sand Bandits hidden around town. Keep at it."); + + sm.addMoney(-10000); + sm.addItem(2000002, 100); // White Potion + sm.addItem(2000006, 100); // Mana Elixir + sm.addExp(5000); + sm.forceCompleteQuest(3930); + } + + // QUEST 3931: The Man with the Iron Hammer ======================================== + @Script("q3931s") + public static void q3931s(ScriptManager sm) { + // NPC 2101003 - Ardin (Ariant) + if (!sm.askYesNo("Who are you? What do you want? What? You want to join the Sand Bandits? How did you…nevermind. That's not important. Do you really want to be a member?")) { + sm.sayOk("If you don't want to do it, then get out of my face."); + return; + } + + sm.sayOk("Hmmm, Sand Bandits are asked to perform difficult missions. You look like a skilled traveler, but I can't trust that you have the strength to be a Sand Bandit. Show me you're worth. Slay #r100 #o2100105#s#k."); + sm.forceStartQuest(3931); + } + + @Script("q3931e") + public static void q3931e(ScriptManager sm) { + // NPC 2101003 - Ardin + sm.sayNext("You slew 100 #o2100105#s? Really? I find that hard to believe. Wow, you really did!"); + sm.sayOk("But #o2100105#s aren't really anything. Sand Bandits constantly face bigger and stronger monsters than that. You can't be a member just by destroying #o2100105#s."); + + sm.addExp(3000); + sm.forceCompleteQuest(3931); + } + + // QUEST 3932: Ardin's Test ======================================== + @Script("q3932s") + public static void q3932s(ScriptManager sm) { + // NPC 2101003 - Ardin + sm.sayNext("Ahh, what a headache. What is it? Well, #o2100106# and #o2100107# are such a hassle to everyone in town, but the queen doesn't care a whit! She's only interested in collecting weird treasures. That's why everyone's struggling."); + + if (!sm.askYesNo("So it's up to the Sand Bandits to deal with those pesky monsters. That's why we can't accept weaklings. If you want to be a member, can you take care of this task for us?")) { + sm.sayOk("Too hard, eh? That's why we don't let people into our group that easily."); + return; + } + + sm.sayOk("Fine. I want you to take care of #r30 #o2100106#s#k and #r30 #o2100107#s#k, and bring back #b20 #t4000326#s#k and #b20 #t4000327#s#k as proof. Experience the true hardship of being a member before you commit yourself to the group."); + sm.forceStartQuest(3932); + } + + @Script("q3932e") + public static void q3932e(ScriptManager sm) { + // NPC 2101003 - Ardin + if (!sm.hasItem(4000326, 20) || !sm.hasItem(4000327, 20)) { + sm.sayOk("I guess you haven't slain #r30 #o2100106#s#k and #r30 #o2100107#s#k and brought back #b20 #t4000326#s#k and #b20 #t4000327#s#k yet. I told you it's not easy."); + return; + } + + sm.sayNext("Whoa, did you already take care of all those #o2100106#s and #o2100107#s? Whoa, you also brought the #t4000326#s and #t4000327#s? You really did that? No way!"); + sm.sayOk("You're much stronger than I thought. I didn't think you'd handle #o2100106#s and #o2100107#s that easily. I think this...should be enough for you to join the Sand Bandits..."); + + sm.removeItem(4000326, 20); + sm.removeItem(4000327, 20); + sm.addExp(4000); + sm.forceCompleteQuest(3932); + } + + // QUEST 3933: Ardin's Sand Bandits ======================================== + @Script("q3933s") + public static void q3933s(ScriptManager sm) { + // NPC 2101003 - Ardin - Summon Battle + sm.sayNext("You've done well so far. I'm impressed. Here's the final test. I will test your strength directly. Are you ready?"); + + if (!sm.askYesNo("If you can defeat me in battle, you will have proven your worth!")) { + sm.sayOk("Not ready yet? Come back when you are."); + return; + } + + sm.sayOk("Let's begin! Show me what you've got!"); + sm.forceStartQuest(3933); + } + + @Script("q3933e") + public static void q3933e(ScriptManager sm) { + // NPC 2101003 - Ardin + sm.sayNext("Wow! I don't think I've had that much fun battling in a long time. To battle the queen, who's overly zealous and uses weird spells, we need to place her against a very formidable foe. Someone as powerful as you is definitely useful for the Sand Bandits."); + sm.sayOk("That doesn't mean you're a member just yet. You may have passed my test, but you still haven't taken all the tests required to join the Sand Bandits. You'll need to find other members and pass their tests, too. Personally, I hope you become one of us."); + + sm.addItem(2012000, 10); // All Cure Potion + sm.addItem(2012002, 10); // All Cure Potion + sm.addExp(7250); + sm.forceCompleteQuest(3933); + } + + // QUEST 3934: The Lady with the Dagger ======================================== + @Script("q3934s") + public static void q3934s(ScriptManager sm) { + // NPC 2101002 - Eleska (Ariant) + sm.sayNext("Why are you looking at me like that? If you don't turn around right now, I'll take it as an offense! What? Sand Bandits? How do you know that name?"); + + sm.sayBoth("#p2101000#?! She's still doing stupid things. I appreciate that she's trying to help the Sand Bandits, but... Argh. Anyway, what do you want from me?"); + + if (!sm.askYesNo("No way. An outsider like wouldn't understand the suffering of #m260000000# enough to join Sand Bandits? Do you even know anything about the queen? Tell you what. If you have the guts to break into the queen's storage room and steal some treasure, I'll accept you as one of us.")) { + sm.sayOk("Of course you were all talk! I'm not putting up with this! Get out of here."); + return; + } + + sm.sayNext("Hah! At least you talk the talk. If you're so confident, then go into the queen's treasure storage room and steal the most expensive treasure there, #b#t4031574##k. It's at the very top of the treasure storage room, so it won't be easy."); + sm.sayOk("How do you get in the treasure storage room? Someone in the palace should know..."); + + sm.addExp(500); + sm.forceStartQuest(3934); + } + + @Script("q3934e") + public static void q3934e(ScriptManager sm) { + // NPC 2101008 - Schegerazade + sm.sayOk("Hello, traveler... I don't know how you made your way into the palace, but please be careful inside."); + } + + // QUEST 3935: Eleska's Test ======================================== + @Script("q3935s") + public static void q3935s(ScriptManager sm) { + // NPC 2101008 - Schegerazade + sm.sayNext("Hello, traveler... What kind of story would you like to listen to?"); + + final int answer = sm.askMenu("", java.util.Map.of( + 0, "A Story about the Cat's Eye Jewel", + 1, "A Story about a Jewel forged from the Desert Heat", + 2, "A Story about a Jewel with Heavenly Power" + )); + + if (answer != 2) { + sm.sayOk("That's a wonderful story..."); + return; + } + + sm.sayNext("#t4031574#... She's a jewel with Heavenly Power. A jewel that is most beautiful when shined upon by the sun... But she now sleeps somewhere in the treasure storage room, deep in the palace where there is no light. She can only be reached by a secret path... #e#basleep under the lamp that's never lit.#k#n"); + sm.sayOk("For #t4031574#, the palace is the worst kind of fit for her... I am glad to know that someone from a special group is trying to rescue her..."); + + sm.forceStartQuest(3935); + } + + @Script("q3935e") + public static void q3935e(ScriptManager sm) { + // NPC 2101002 - Eleska + if (!sm.hasItem(4031574)) { + sm.sayOk("Haven't you gotten the #b#t4031574##k yet? I warned you it wasn't an easy task..."); + return; + } + + sm.sayNext("You... You brought the #t4031574#! I honestly wasn't expecting it. That's incredible! You just might be the kind of person that the Sand Bandits have been looking for..."); + + sm.removeItem(4031574); + sm.addExp(3750); + sm.forceCompleteQuest(3935); + } + + // QUEST 3936: Eleska's Sand Bandits ======================================== + @Script("q3936s") + public static void q3936s(ScriptManager sm) { + // NPC 2101002 - Eleska + sm.sayNext("I thought it was a case of an outsider mindlessly wanting to join the hype. I apologize for underestimating you. So you really want to join the Sand Bandits? Then I'll have to test you once more."); + + if (!sm.askYesNo("Every member of the Sand Bandits has a deep hatred towards the queen. We just don't show it publicly. You can't achieve anything if you don't even have self control. That is why the most important trait of a Sand Bandits is patience. Being patient enough not to be swayed by what's unfolding right in front of your eyes, but patiently waiting for the right time. You know of Lidium. It's a jewel you can find by slaying monsters in the desert. If you bring me #b2 #t4011008#s#k, then I'll personally accept you as a member of the Sand Bandits. It's not easy refining small ores into jewels, so... I think this will be a good test to gauge your patience.")) { + sm.sayOk("This must be too tough for you to handle. If you can't do it, then just forget about it."); + return; + } + + sm.forceStartQuest(3936); + } + + @Script("q3936e") + public static void q3936e(ScriptManager sm) { + // NPC 2101002 - Eleska + if (!sm.hasItem(4011008, 2)) { + sm.sayOk("I don't think you have gotten #b2 #t4011008#s#k yet. Lidium Ore can be refined by Muhammad, so once you get the ores, take them to him, okay?"); + return; + } + + sm.sayNext("Ho! You brought 2 #t4011008#s. This can only be found in the area around Ariant, and it's hard to make. But once it's made, it lasts for a long time. It may look brittle, but it's actually much harder than a diamond."); + + sm.sayBoth("#t4011008# has characteristics of the people in the desert. People here may not trust you at first. You have to earn it. But once you earn it, they never turn their back on you. You have definitely shown me your true character. I, Eleska, the desert warrior, now anoint you as a new member of the Sand Bandits!"); + + sm.sayOk("But don't be satisfied with just this. You have earned one person's trust, that's all. You are not yet officially accepted as a true member of the Sand Bandits. If you really wish to become a part of our group, you have to find the other members and prove your worth to each and every one of them."); + + sm.removeItem(4011008, 2); + sm.addItem(4020007, 3); // Diamond Ore + sm.addItem(4010003, 3); // Silver Ore + sm.addExp(4200); + sm.forceCompleteQuest(3936); + } + + // QUEST 3937: The True Identity of Sand Bandits ======================================== + @Script("q3937s") + public static void q3937s(ScriptManager sm) { + // NPC 2101010 - Ardin (Sand Bandits leader) + sm.sayNext("You must be the one that #p2101011#, #p2101003#, and #p2101002# have been talking about. You've done well passing their tests. Now, let me tell you a story about #m260000000#."); + + sm.sayBoth("#m260000000# was founded by wanderers in Nihal Desert. Now, the place may not look like much, but thanks to #t4011008# and the oasis, Ariant has become a center of commerce. #t4011008# is an ore found only in the desert. Since it's very valuable, it's the main source of wealth for #m260000000#."); + + if (!sm.askYesNo("But look around. Where has that wealth gone? #m260000000# is falling apart around us, and there's only one reason: the queen. This so-called fairy queen is frivolous and heartless. Our taxes go to pay for her jewels while #m260000000# suffers! Do you understand now the pain of #m260000000#? Then wander through #m260000000# some more and open your eyes. See what this place is really like! If you are sure you want to be a part of the Sand Bandits, let me know.")) { + sm.sayOk("You still don't get it? Then I'll tell you the story again."); + return; + } + + sm.forceStartQuest(3937); + } + + @Script("q3937e") + public static void q3937e(ScriptManager sm) { + // NPC 2101010 - Ardin + if (!sm.askYesNo("Are you ready to fight the queen as a member of the Sand Bandits, for the sake of #m260000000#?")) { + return; + } + + sm.sayOk("Then from here on out, I proudly accept you as an official member of the Sand Bandits. Go now to meet the brothers and sister who stand by your side."); + + sm.addItem(4031581, 1); // Sand Bandits Emblem + sm.addExp(10000); + sm.forceCompleteQuest(3937); + } + + // QUEST 3938: First Mission ======================================== + @Script("q3938s") + public static void q3938s(ScriptManager sm) { + // NPC 2101010 - Ardin (Sand Bandits leader) + sm.sayNext("The Sand Bandits want to steal an batch of silk that the queen ordered, and I want you to do the job. Are you up for it?"); + + if (!sm.askYesNo("It's much too dangerous to just carry around an item stolen from the queen, so we have a different plan. Since the merchant is supposed to give the item to the guard #p2101004#, you should dress up as #p2101004# and pick up the item in his place. Brilliant, right?")) { + sm.sayOk("I guess you're not ready to take on a mission yet."); + return; + } + + sm.sayOk("I already have a Transforming Potion from the magicians of #m101000000#. To transform into a specific person, however, you also need a piece of their hair. So grab a #bpiece of hair from #p2101004##k. Once you do that, I'll make the transforming potion for #p2101004#."); + sm.forceStartQuest(3938); + } + + @Script("q3938e") + public static void q3938e(ScriptManager sm) { + // NPC 2101010 - Ardin + if (!sm.hasItem(4031570)) { + sm.sayOk("Hmmm... I need some of #p2101004#'s hair. One piece is all I need."); + return; + } + + sm.sayOk("This is #p2101004#'s hair? Er, it doesn't look like head hair... What? Ohhh, from his beard? Well, hair's hair, so it shouldn't be a problem. Please wait as I get the Transforming Potion ready."); + + sm.removeItem(4031570); + sm.addExp(1000); + sm.forceCompleteQuest(3938); + } + + // QUEST 3939: Tigun's Hair ======================================== + @Script("q3939s") + public static void q3939s(ScriptManager sm) { + // NPC 2101004 - Tigun + sm.sayNext("What is it? We don't let any old stranger in the palace. What? You want a piece of my hair? Why? What do you plan on doing with it?"); + + if (!sm.askYesNo("It doesn't matter. I can't give you the hair on my head. I need to save each and every last strand. But I CAN give you a hair from my beard...if you can do me a favor.")) { + sm.sayOk("Then my hair is off limits. Scram."); + return; + } + + sm.sayOk("I'll tell you what I want. Have you heard of #t4011008#? It's an ore that's only produced in #m260000000#. If you can get me #b1 #t4010007##k, then I'll give a hair from my beard."); + sm.forceStartQuest(3939); + } + + @Script("q3939e") + public static void q3939e(ScriptManager sm) { + // NPC 2101004 - Tigun + if (!sm.hasItem(4010007)) { + sm.sayOk("What? You still haven't gotten #b1 #t4010007##k? I know it's expensive, but it's not as valuable as a hair from my beard!"); + return; + } + + sm.sayNext("I don't know what you want with this, but a deal is a deal. I'm not giving back the Lidium Ore, you got me?"); + sm.sayOk("Now that you have a hair from my beard, get out of here."); + + sm.removeItem(4010007); + sm.addItem(4031570, 1); // Ahmed's Hair + sm.forceCompleteQuest(3939); + } + + // QUEST 3940: Mission Complete! ======================================== + @Script("q3940s") + public static void q3940s(ScriptManager sm) { + // NPC 2101010 - Ardin + if (!sm.askYesNo("Ah, you're back. The Tigun Transformation Potion is now complete. Drink the potion and go to #b#p2101013##k to receive an order of silk. The merchant that deals with the queen should be around #b#m260010600##k.")) { + sm.sayOk("Did the potion scare you off? Hmmm, then who should I ask to do this? #p2101003#, since he looks a lot like Tigun?"); + return; + } + + sm.sayNext("Once you have transformed into #p2101004#, you can't attack anyone. If you accidentally use a skill when using the disguise, everything will be ruined. Also, if you're attacked, the transformation will end. To summarize, do not engage in a fight on your way to #p2101013#."); + sm.sayOk("You will be transformed for #b1 hour#k. During that time, you should #bnever attack or be attacked#k. In that time, you must also get an order of silk from #p2101013# and safely come back. Good luck."); + + sm.addItem(2210005, 1); // Tigun Transformation Potion + sm.forceStartQuest(3940); + } + + @Script("q3940e") + public static void q3940e(ScriptManager sm) { + // NPC 2101010 - Ardin + if (!sm.hasItem(4031571)) { + sm.sayOk("Hmmm... Haven't obtained the #b#t4031571##k yet? With the money we'll earn from that, the residents of #m260000000# won't go hungry for a while."); + return; + } + + sm.sayNext("Ohhhh, you're back. Did you get the order of silk from #p2101013#?"); + sm.sayOk("Brilliant. Instead of fighting our fellow residents to steal the queen's treasures, it's much better to steal like this, nice and quiet, with no one realizing exactly what happened."); + + sm.removeItem(4031571); + sm.addItem(2040701, 1); // Scroll for Gloves for ATT + sm.addExp(4000); + sm.forceCompleteQuest(3940); + } + + // QUEST 3941: Stealing Queen's Order of Silk ======================================== + @Script("q3941s") + public static void q3941s(ScriptManager sm) { + // NPC 2101013 - Silk Merchant (Nihal Desert) + // Transformation script + sm.sayOk("I'll check if you're transformed as Tigun..."); + } + + @Script("q3941e") + public static void q3941e(ScriptManager sm) { + // NPC 2101013 - Silk Merchant + sm.sayNext("Okay, here it is. Please handle it with care. This silk is very hard to get, and if any part of this fabric is ripped, the queen will put you in jail, #p2101004#."); + + sm.addItem(4031571, 1); // Queen's Silk Order + sm.forceCompleteQuest(3941); + } + + // QUEST 3942-3947: Byron's Recommendation Letters (Job Advancement) + @Script("q3942s") + public static void q3942s(ScriptManager sm) { + // Warrior Recommendation + sm.sayNext("Hey, how's it going? I've been watching your every move, and... you look like you're ready for an advancement... and I want to recommend you to #p1022000#. What do you think?"); + + if (!sm.askYesNo("I've known #p1022000# for a long time. Please see #b#p1022000##k at #m102000000#. You may be able to make the advancement through him, too.")) { + sm.sayOk("Do you feel like you're not ready to make the leap? You're humble, which is great, but keep in mind that this opportunity to make a job advancement disappears after a while..."); + return; + } + + sm.addItem(4031620, 1); // Byron's Recommendation Letter + sm.forceStartQuest(3942); + } + + @Script("q3942e") + public static void q3942e(ScriptManager sm) { + // NPC 1022000 - Dances with Balrog + if (!sm.hasItem(4031620)) { + sm.sayOk("This is where you can make the advancement as a true warrior."); + return; + } + + sm.sayNext("Who are you? You have something for me? Let me see..."); + sm.sayOk("Oh, it's a recommendation letter from #p2101005#. If it's from #p2101005#, then it's 100% legit. You do look like someone who's primed for an advancement. If you feel like you're ready, then let me know. I'll put you to the test."); + + sm.removeItem(4031620); + sm.addExp(3500); + sm.forceCompleteQuest(3942); + } + + @Script("q3943s") + public static void q3943s(ScriptManager sm) { + // Magician Recommendation + sm.sayNext("Hey, how's it going? I've been watching your every move, and... you look like you're ready for an advancement... and I want to recommend you to #p1032001#. What do you think?"); + + if (!sm.askYesNo("I've known #p1032001# for a long time. Please see #b#p1032001##k at #m102000000#. You may be able to make the advancement through him, too.")) { + sm.sayOk("Do you feel like you're not ready to make the leap? You're humble, which is great, but keep in mind that this opportunity to make a job advancement disappears after a while..."); + return; + } + + sm.addItem(4031621, 1); + sm.forceStartQuest(3943); + } + + @Script("q3943e") + public static void q3943e(ScriptManager sm) { + if (!sm.hasItem(4031621)) { + sm.sayOk("This is where you can make the advancement as an intelligent magician."); + return; + } + + sm.sayNext("Who are you? You have something for me? Let me see..."); + sm.sayOk("Oh, it's a recommendation letter from #p2101005#. If it's from #p2101005#, then it's 100% legit. You do look like someone who's primed for an advancement. If you feel like you're ready, then let me know. I'll put you to the test."); + + sm.removeItem(4031621); + sm.addExp(3500); + sm.forceCompleteQuest(3943); + } + + @Script("q3944s") + public static void q3944s(ScriptManager sm) { + // Bowman Recommendation + sm.sayNext("Hey, how's it going? I've been watching your every move, and... you look like you're ready for an advancement... and I want to recommend you to #p1012100#. What do you think?"); + + if (!sm.askYesNo("I've known #p1012100# for a long time. Please see #b#p1012100##k at #m100000000#. You may be able to make the advancement through her, too.")) { + sm.sayOk("Do you feel like you're not ready to make the leap? You're humble, which is great, but keep in mind that this opportunity to make a job advancement disappears after a while..."); + return; + } + + sm.addItem(4031622, 1); + sm.forceStartQuest(3944); + } + + @Script("q3944e") + public static void q3944e(ScriptManager sm) { + if (!sm.hasItem(4031622)) { + sm.sayOk("This is where you can make the advancement as a careful bowman."); + return; + } + + sm.sayNext("Who are you? You have something for me? Let me see..."); + sm.sayOk("Oh, it's a recommendation letter from #p2101005#. If it's from #p2101005#, then it's 100% legit. You do look like someone who's primed for an advancement. If you feel like you're ready, then let me know. I'll put you to the test."); + + sm.removeItem(4031622); + sm.addExp(3500); + sm.forceCompleteQuest(3944); + } + + @Script("q3945s") + public static void q3945s(ScriptManager sm) { + // Thief Recommendation + sm.sayNext("Hey, how's it going? I've been watching your every move, and... you look like you're ready for an advancement... and I want to recommend you to #p1052001#. What do you think?"); + + if (!sm.askYesNo("I've known #p1052001# for a long time. Please see #b#p1052001##k at #m103000000#. You may be able to make the advancement through her, too.")) { + sm.sayOk("Do you feel like you're not ready to make the leap? You're humble, which is great, but keep in mind that this opportunity to make a job advancement disappears after a while..."); + return; + } + + sm.addItem(4031623, 1); + sm.forceStartQuest(3945); + } + + @Script("q3945e") + public static void q3945e(ScriptManager sm) { + if (!sm.hasItem(4031623)) { + sm.sayOk("This is where you can make the advancement as a brilliant thief."); + return; + } + + sm.sayNext("Who are you? You have something for me? Let me see..."); + sm.sayOk("Oh, it's a recommendation letter from #p2101005#. If it's from #p2101005#, then it's 100% legit. You do look like someone who's primed for an advancement. If you feel like you're ready, then let me know. I'll put you to the test."); + + sm.removeItem(4031623); + sm.addExp(3500); + sm.forceCompleteQuest(3945); + } + + @Script("q3946s") + public static void q3946s(ScriptManager sm) { + // A Proper Reward for a Good Deed + sm.sayNext("Hey, aren't you being a little too unselfish? What you've done has been quite incredible. Thanks to you, the Red Scorpions are a mess. That won't stop them from stealing stuff in the future, but when that happens, you can steal back the stuff they stole."); + + if (!sm.askYesNo("I know you helped the Red Scorpions, but that's because you didn't know any better. You've done a lot of good, too! It wouldn't be terrible for you to ask for a reward... Why are you so selfless?")) { + sm.sayOk("Huh? You really don't want a reward? You must be an angel or something..."); + return; + } + + sm.sayNext("As you know, the people of Ariant can't afford to reward you, and of course, the queen won't do a thing. And you couldn't possibly expect anything out of a poor dancer like me, right? So...take care of your own reward!"); + sm.sayOk("You really don't understand what I'm hinting at? Seriously, there's a place with a ton of treasure just sitting there... Think about it. I'll look the other way this one time. You know what I'm talking about now, right?"); + + sm.forceStartQuest(3946); + } + + @Script("q3946e") + public static void q3946e(ScriptManager sm) { + // Red Scorpion Treasure Chest - Give random ore + sm.sayNext("This giant treasure chest is full of expensive treasures. There's plenty of stuff inside."); + + if (!sm.askYesNo("Remove the treasure from the chest and place it in a pocket.")) { + return; + } + + sm.sayOk("#b(Well, this is probably enough...)#k #b(Doing a good deed definitely pays off...)#k"); + + // Random ore reward + final int[] ores = {4020007, 4020005, 4020001, 4020002, 4020004, 4020006, 4020000, 4020008, 4020003, 4010007}; + final int randomOre = ores[(int) (Math.random() * ores.length)]; + sm.addItem(randomOre, 5); + sm.forceCompleteQuest(3946); + } + + @Script("q3947s") + public static void q3947s(ScriptManager sm) { + // Pirate Recommendation + sm.sayNext("Hey, how's it going? I've been watching your every move, and... you look like you're ready for an advancement... and I want to recommend you to #p1090000#. What do you think?"); + + if (!sm.askYesNo("I've known #p1090000# for a long time. Please visit #b#p1090000##k on the Nautilus. He'll be help you advance your job.")) { + sm.sayOk("Do you feel like you're not ready to make the leap? You're humble, which is great, but keep in mind that this opportunity to make a job advancement disappears after a while..."); + return; + } + + sm.addItem(4031893, 1); + sm.forceStartQuest(3947); + } + + @Script("q3947e") + public static void q3947e(ScriptManager sm) { + if (!sm.hasItem(4031893)) { + sm.sayOk("Here, you can become a great Pirate."); + return; + } + + sm.sayNext("Do I know you? Are you here to deliver something to me?"); + sm.sayOk("#p2101005#'s recommendation, huh? You must be seeking job advancement. If you really want to change your job, you need to take the test."); + + sm.removeItem(4031893); + sm.addExp(3500); + sm.forceCompleteQuest(3947); + } + + // QUEST 3948: Sejan's Good Habit ======================================== + @Script("q3948s") + public static void q3948s(ScriptManager sm) { + // NPC 2101011 - Sejan + sm.sayNext("Hm? Are you one of us? Wait, it doesn't even matter...just find me my arrows. I usually collect them right after I shoot them, but a few days ago, I wasn't feeling well so I didn't. This morning, I finally got out of bed and looked all over town for my arrows, but couldn't find them all. Now I'm missing some. Can you help me find them?"); + + if (!sm.askYesNo("I only use #b#t02060001##k. A true skillsman only uses the one type of arrow that fit them perfectly. Right now, I am missing #b10000#k arrows. Think that's a lot? In order to rescue Ariant from the queen, that's nothing. I use way more than that in a single day. Bring them to me once you find them all. You're my only hope.")) { + sm.sayOk("People who constantly change their mind can't be trusted. I'll be keeping an eye on you from now on."); + return; + } + + sm.forceStartQuest(3948); + } + + @Script("q3948e") + public static void q3948e(ScriptManager sm) { + // NPC 2101011 - Sejan + if (!sm.hasItem(2060001, 10000)) { + sm.sayOk("Did you find all the arrows? Search carefully near town as well..."); + return; + } + + sm.sayNext("Great. Now I can continue to fight for Ariant. I will never forget what you've done for me. But, you didn't just buy any old arrows at a store, right? I make my own, so I can tell if you're trying to fool me... If you're trying to trick me, you will regret it."); + sm.sayOk("Mmm... These are it. All #b10000#k of them. Thank you."); + + sm.removeItem(2060001, 10000); + sm.addExp(15000); + sm.forceCompleteQuest(3948); + } + + // QUEST 3949: Jiyur's Palace Entry Pass ======================================== + @Script("q3949s") + public static void q3949s(ScriptManager sm) { + // NPC 2101001 - Jiyur + sm.sayNext("I decided go see my sister! I discovered a way to get into the palace."); + + sm.sayBoth("I talked to some grown-ups and they said I can pay a man named #p2101004# to give me a #t04031582#. Only thing is, I don't have any money. Can buy it for me? When I grow up and make lots of money, I'll pay you back. I promise!"); + + sm.sayBoth("Wow! Thanks! With the #t04031582#, I can go see my sister. But, what should I say to #p2101004#? I've seen him from afar and he doesn't look friendly..."); + + if (!sm.askYesNo("You would really do that for me?")) { + sm.sayOk("If you change your mind, come see me. I'll be here."); + return; + } + + sm.sayOk("Sniff. Thank you so much. I will never forget this. After you buy the #b#t04031582##k from #b#p2101004##k, please bring it to me. I'll be waiting here."); + sm.forceStartQuest(3949); + } + + @Script("q3949e") + public static void q3949e(ScriptManager sm) { + // NPC 2101001 - Jiyur + if (!sm.hasItem(4031582)) { + sm.sayOk("Did you go see #b#p2101004##k? #b#p2101004##k is the man who stands guard at the Ariant palace entrance. I heard he will give you a palace entry pass if you bring him 2000 mesos."); + return; + } + + sm.sayNext("Wow!! You really got me the #t04031582#? Thank you so much!"); + sm.sayOk("So this is the #t04031582#? With this, I can get inside the palace, right? Hm...? What's this empty space for? Am I supposed to write my name here? *Scribble, scribble* Hehe, sister, here I come!"); + + sm.removeItem(4031582); + sm.addExp(15000); + sm.forceCompleteQuest(3949); + } + + // QUEST 3950: Dealing with Tigun ======================================== + @Script("q3950s") + public static void q3950s(ScriptManager sm) { + // NPC 2101001 - Jiyur (crying) + sm.sayNext("Sniff, sniff... Wahh!"); + + sm.sayBoth("#h0#! No, I didn't get to see my sister..."); + + sm.sayBoth("Tigun said...sniff...because I wrote my name in that empty space...sniff...that I can't use that entry pass anymore...sniff. Plus...plus, he said I have to pay a fine for scribbling on such an important pass! He said he'll throw me in prison if I don't! Wah!! I want my sister!"); + + if (!sm.askYesNo("Sniff... It's #b50000 mesos#k... I think so...sniff... Will you really help me?")) { + sm.sayOk("Oh... Oh I see... Sniff, sniff... Thanks anyway."); + return; + } + + sm.sayOk("Sniff, thank you so much. You know where to find #b#p2101004##k, right? He's probably still at the palace entrance. Thank you, #h0#."); + sm.forceStartQuest(3950); + } + + @Script("q3950e") + public static void q3950e(ScriptManager sm) { + // NPC 2101004 - Tigun + if (!sm.canAddMoney(-50000)) { + sm.sayOk("What is it? You want to scribble on a #t04031582# too? If you don't want to pay the 50000 mesos, then fine, go to prison."); + return; + } + + sm.sayNext("#p2101001#? That little bratty kid?! Ha! I let people in the palace if they have the #b#t04031582##k, but I'll never accept a pass that's been scribbled on like that! Do you know the work I put into those entry passes!? Ha, if he doesn't pay 50000 mesos, I'll throw him in prison!"); + + sm.sayBoth("Huh? Really? I can't do that! She's very important to the queen. She is the queen's storyteller... How about this? I'll deliver letters to her instead... Sound like a good deal?"); + + sm.sayNext("Very well, hehe. Any time the kid brings me a letter, I promise on my sacred name to deliver it to his sister. If she wrote one for him, I'll give pass it to him, too. I'll collect #b50000 mesos#k as a small delivery fee though."); + sm.sayOk("I'll explain it to #p2101001# when he comes to see me, so you can be on your way. What? Okay ,okay. I won't mention the delivery fee. Hehe."); + + sm.addMoney(-50000); + sm.addExp(35000); + sm.forceCompleteQuest(3950); + } + + // QUEST 3952-3954: The Desert Bounty ======================================== + @Script("q3952s") + public static void q3952s(ScriptManager sm) { + // NPC 2101000 - Sirin + sm.sayNext("If you're here to see the dance, wait until the performance... Oooooh. Are you here about the rumor?"); + + sm.sayBoth("Oy vey! How could the dance of the amazing #p2101000# attract so few people? Ah well, so it goes. I'll tell you about the rumor then. A bounty has put on #r#o3220001##k."); + + sm.sayBoth("What? Who is #o3220001#? Are you serious? He's only the biggest threat in the desert, the ruler of all the Cactus, the most powerful monster in all the Burning Road! You should learn more about #m260000000# from Byron, honestly!"); + + sm.sayOk("Anyway, a bounty has been put on #o3220001#. And the reward is quite generous. You've know about #m260000000#'s underground organization, #bthe Sand Bandits#k, right? That's where the rumor started. You want to hear more? If you want details, find out on your own. Maybe you should look for the #bleader of the Sand Bandits#k or something."); + + sm.forceStartQuest(3952); + } + + @Script("q3952e") + public static void q3952e(ScriptManager sm) { + // NPC 2101010 - Ardin + sm.sayNext("Oh, #h0# is that you? What is it...? Huh? A bounty...? Oh, you're talking about a bounty for #o3220001#. That means you heard the rumor from #p2101000#? It's true. The Sand Bandits have put a bounty on #o3220001#."); + + sm.sayBoth("#o3220001# is a dangerous monster out to destroy the desert. A group of merchants traveling the Burning Road has been attacked a number of times, but the sultan and the queen couldn't care less. Hence, the Sand Bandits took matters into our own hands."); + + sm.sayNext("But this is too much for the Sand Bandits to take care of on our own, which is why we have put a bounty on the monster... Why, you ask? Well..."); + + sm.sayBoth("#o3220001#is an ancient #o2100104# that evolved into a monster. He's very smart. He can even use magic! That explains why some people worship him as the guardian of the desert. It sounds foolish, I know, but a good number of people actually believe that."); + + sm.sayNext("We can't just go out and defeat #o3220001#, as that will turn many people against the Sand Bandits. We must first convince the people... Will you take on this task?"); + + sm.sayOk("The spokesman for the people who believe #o3220001# is the guardian of the desert is #b#p2100001##k. He is as stiff and stubborn as #t4011008#. #bConvincing#k that old man will be our first battle, before we even hunt the monster. Convincing him won't be easy. You might have to give him a #bLidium#k as a gift before you can get him to listen... Often, when you talk to someone that stubborn, it helps to #bbeat around the bush and change the subject to get him where you want him, then put your foot down and convince him when the time is right.#k"); + + sm.forceCompleteQuest(3952); + } + + // QUEST 3953: Convince Muhamad ======================================== + @Script("q3953s") + public static void q3953s(ScriptManager sm) { + // NPC 2100001 - Muhamad + sm.sayNext("Are you here to make an item? Huh? #o3220001#? You're not here to spout that nonsense about #o3220001# being a monster, are you?"); + + if (!sm.askYesNo("#o3220001#? A monster, huh? What an absurd thing to say! How dare you speak of the guardian of the desert that way! Sheesh, no respect. If you're going to believe that nonsense, get out of here! I have nothing to say to you! How dare you come #bempty-handed#k and...")) { + sm.sayOk("What a relief. I've been rather irritable because people keep spewing that ridiculous nonsense about #o3220001# being a monster."); + return; + } + + sm.forceStartQuest(3953); + } + + @Script("q3953e") + public static void q3953e(ScriptManager sm) { + // NPC 2100001 - Muhamad - Convince him with Lidium + if (!sm.hasItem(4011008)) { + return; + } + + sm.sayOk("Well... you brought Lidium... I suppose I can listen to what you have to say..."); + sm.removeItem(4011008); + sm.forceCompleteQuest(3953); + } + + // QUEST 3954: Defeat Deo ======================================== + @Script("q3954s") + public static void q3954s(ScriptManager sm) { + // NPC 2101010 - Ardin + sm.sayNext("You convinced #p2100001#? I'm impressed. No member of the Sand Bandits has been able to win over that stubborn old man... You're incredible. You are the pride and joy of the Sand Bandits."); + + if (!sm.askYesNo("Since you were able to do something no one has been able to do, you'll also defeat #o3220001# for us, won't you?")) { + sm.sayOk("Too much for you? Not something I expected to hear. But, come closer and listen to me... There is a bounty being offered..."); + return; + } + + sm.sayOk("Now, go and defeat #r1 #o3220001##k for us. It shouldn't be too difficult. Not for you. You want to know where #o3220001# is? Well, I'm not sure. Given that he's the leader of Cactus, wouldn't he be in #b#o2100104##k Desert? I don't think he appears that frequently, so be patient."); + + sm.forceStartQuest(3954); + } + + @Script("q3954e") + public static void q3954e(ScriptManager sm) { + // NPC 2101010 - Ardin + sm.sayNext("You've defeated #o3220001#! You're incredible! You have what it takes to protect this Desert! What? You want a reward?"); + sm.sayOk("HAHAHA! The Sand Bandits offered a bounty to the anyone who defeat #o3220001#, but that doesn't include members of the Sand Bandits! It's your duty to protect the desert! It's only right that you helped. HAHAHAHA! Mmm..."); + + sm.addExp(55500); + sm.forceCompleteQuest(3954); + } + + // QUEST 3955: In Search of the Long Lost Pyramid ======================================== + @Script("q3955s") + public static void q3955s(ScriptManager sm) { + // NPC 2101005 - Byron (Auto-start quest) + sm.sayNext("Did you hear the story? Recently, a huge sandstorm swept across Sunset Road in Nihal Desert. The path that connects Ariant to Magatia disappeared completely for a whole week. Even the residents there claim it was the biggest sandstorm they'd seen in a hundred years."); + + sm.sayBoth("After the sandstorm passed, I checked to see how it affected the creatures and discovered something amazing. A Pyramid! A giant Pyramid standing right before my eyes! I think it's a Pyramid that used to be buried underground but resurfaced after the sandstorm drastically changed the landscape. The local residents claim the Pyramid is evil and don't go anywhere near it."); + + sm.sayBoth("But I disagree with them. An evil Pyramid? That doesn't make any sense! So I decided to enter the Pyramid and study the basics of the ancient burial process. But someone stopped me in my tracks, saying that I was not qualified and that I would only enrage some god known as Nett."); + + if (!sm.askYesNo("Apparently, only people over Level 40 are allowed in. Unfortunately, I am only at Level 39. This is why I am suggesting that you venture inside the Pyramid. What do you say?")) { + sm.sayOk("A giant Pyramid has revealed itself out of the blue! Aren't you excited to find out what's inside?"); + return; + } + + sm.sayOk("Awesome! I want you to investigate the inside of the Pyramid thoroughly and tell me what it's like in detail. Head to #bSahel 3#k, and you'll find a new path that leads you to the Pyramid. I'll be here waiting. The man that stopped me at the entrance of the Pyramid is called #b#eDuarte#n#k. Strange name, don't you think?"); + + sm.forceStartQuest(3955); + } + + @Script("q3955e") + public static void q3955e(ScriptManager sm) { + // NPC 2103013 - Duarte (Pyramid entrance) + sm.sayNext("Stop immedietely, foolish one. Are you not afraid of death?"); + + sm.sayBoth("Fools. A sea of fools. Are all humans this foolish? Even one who called himself a scholar dared to venture here. But is not death something to avoid?"); + + sm.sayBoth("I see. That fool. I was merciful enough to save him from the throngs of death, and instead he drags himself to another one of its doorsteps. So be it. Let us see if he can escape the breath of Anubis."); + + sm.sayOk("I will permit your entrance, but it remains to be seen if the Pyramid will accept you as well. If it does, then you will acquire the Pharaoh Yeti's Gem. If you bring that to me, I will lead you directly to a spot where you can acquire many other rare gems."); + + sm.addExp(6000); + sm.forceCompleteQuest(3955); + } + @Script("q3311s") + public static void q3311s(ScriptManager sm) { + // Clue (3311 - start) + // NPC 2111000 - Zenumist (Magatia) + // Part of "Zenumist and the Missing Alchemist" questline + // Level 70+ requirement + sm.sayNext("What I want you to do now is to search the house of the alchemist that has gone missing. It's the very place where the accident occurred. We've already searched the house a number of times, but I'm sure there's still a lot that hasn't been found, especially #bDe Lang's Secret Note#k..."); + sm.sayBoth("That's why I want you to go there and find even the smallest piece of evidence. It'd be fantastic if you can find the Secret Note, but even if you can't, a nondescript sentence will be better than nothing."); + + if (!sm.askYesNo("Will you search De Lang's house for clues?")) { + sm.sayOk("If you are not interested, then that's okay. Just don't ever mention the incident ever again."); + return; + } + + sm.sayOk("I do believe that someone like you, who never knew about the missing alchemist, can provide a fresh eye to this investigation and find something we may have missed..."); + sm.forceStartQuest(3311); + } + + @Script("q3311e") + public static void q3311e(ScriptManager sm) { + // Clue (3311 - end) + // NPC 2111000 - Zenumist + final int answer = sm.askMenu("How was the search? Did you find anything particularly interesting? Any clues?", java.util.Map.of( + 0, "I wasn't able to find anything.", + 1, "(What did it Say again...)", + 2, "I saw a formula to making a mechanical glove.", + 3, "It really wasn't much... just a story about a pendant..." + )); + + if (answer == 0) { + sm.sayOk("Hmm... How was it? I thought a fresh eye like you who doesn't know anything about the missing alchemist would find something new..."); + return; + } else if (answer == 1) { + sm.sayOk("Why aren't you saying something? Did you forget the clue already?"); + return; + } else if (answer == 2) { + sm.sayOk("A formula for the mechanical glove? The missing alchemist was an Alcadno, so of course he should have something like that."); + return; + } else if (answer == 3) { + sm.sayNext("...really? A pendant... a pendant... maybe that's..."); + final int followUp = sm.askMenu("", java.util.Map.of( + 0, "What do you mean by that? Is there a special meaning to the word Pendant that was written on the wall?" + )); + sm.sayOk("No! It's nothing. We better find out more information about the missing alchemist. Thank you for your help. Now, if you'll excuse me..."); + + sm.addExp(60000); + sm.forceCompleteQuest(3311); + } + } + + @Script("q3301s") + public static void q3301s(ScriptManager sm) { + // Test from the Head of Zenumist Society (3301 - start) + // NPC 2111007 - Han the Broker + // Part of "Joining Zenumist" questline + final int answer1 = sm.askMenu("Do you really think Magatia is a town full of scholars with pure intent on research? That's nonsense. There's no other town that bickers over territory for their own good like them. The days of scholars purely searching for the truth is long gone... well, that's perfect for brokers, though.", java.util.Map.of( + 0, "Is there a big conflict?" + )); + + final int answer2 = sm.askMenu("Yes, which makes life harder for travelers like you. People are in so much conflict between one another that it's hard to join either of the two forces. There's so much mistrust in the air that the outsiders will have a hard time entering their tight circle. If you ever went there, you know what I am talking about.", java.util.Map.of( + 0, "A little bit..." + )); + + final int answer3 = sm.askMenu("If you want to know more about the situation in Magatia, you will have to join either Zenumist or Alcadno, and be part of the group. Even that is going to be difficult, but... your humble broker knows a way or two to get in. What do you think? Are you interested in joining #bZenumist#k?", java.util.Map.of( + 0, "I am interested in becoming a part of Zenumist." + )); + + final int answer4 = sm.askMenu("Hahaha... I knew you would Say that. An adventurer like you would never reject an opportunity like this. I have to warn you, though, that joining the Zenumist sect is not easy. They believe that they are the true scholars, and they treat themselves as an exclusive bunch. In order to join Zenumist, you'll need to present to them your work as an alchemist.", java.util.Map.of( + 0, "Does that mean it's impossible?" + )); + + if (!sm.askYesNo("Of course not... haha. I, #p2111007#, find ways to satisfy every client that pays me. If you can bring me 2 ores of a jewel that can be turned into mesos, then I'll help you join them. I am telling you, I am the only person here that can help you here in #m261000000#.")) { + sm.sayOk("You declined? You must be very confident in conducting alchemy in front of those scholars."); + return; + } + + sm.sayOk("That's the right decision. Come back to me when you are ready to make the deal. I don't care which jewel it is, just get me #b2 jewel ores#k that'll make me some money. It's not that hard, is it? That should be enough for me to help you join the Zenumists..."); + sm.forceStartQuest(3301); + } + + @Script("q3301e") + public static void q3301e(ScriptManager sm) { + // Test from the Head of Zenumist Society (3301 - end) + // NPC 2111007 - Han the Broker + // Auto-complete via quest system (no dialogue needed) + } + + @Script("q3303s") + public static void q3303s(ScriptManager sm) { + // Test from the Head of Alcadno Society (3303 - start) + // NPC 2111007 - Han the Broker + // Part of "Joining Alcadno" questline + final int answer1 = sm.askMenu("Do you really think Magatia is a town full of scholars with pure intent on research? That's nonsense. There's no other town that bickers over territory for their own good like them. The days of scholars purely searching for the truth is long gone... well, that's perfect for brokers, though.", java.util.Map.of( + 0, "Is there a big conflict?" + )); + + final int answer2 = sm.askMenu("Yes, which makes life harder for travelers like you. People are in so much conflict between one another that it's hard to join either of the two forces. There's so much mistrust in the air that any outsiders will have a hard time entering their tight circle. If you ever went there, you know what I am talking about.", java.util.Map.of( + 0, "A little bit..." + )); + + final int answer3 = sm.askMenu("If you want to know more about the situation in Magatia, you will have to join either Zenumist or Alcadno, and be part of the group. Even that is going to be difficult, but... your humble broker knows a way or two to get in. What do you think? Are you interested in joining #bAlcadno#k?", java.util.Map.of( + 0, "I am interested in becoming a part of Alcadno." + )); + + if (!sm.askYesNo("I knew it! Hahaha... you know nothing's free in life, right? If you don't mind paying, then I will help you become a member. Just give me a few ores of jewels, and I'll give you a Report that'll let you in Alcadno.")) { + sm.sayOk("Hmm... you must be struggling to come up with the ores. It's not easy, sure, but if you don't turn in that report, then you won't be able to join Alcadno. Think!"); + return; + } + + sm.sayNext("In order to join Alcadno, you'll have to compile a report of all the experiments you've conducted, and hand them to the leader of Alcadno Society, #p2111001#. Of course, the test doesn't end there... but I'll tell you the rest after you give me the ores."); + sm.sayBoth("Let me warn you in advance that Alcadno is full of mechanical engineers, so they are a bit...um... cranky. They are also shunned by other scholars, so you get the idea. Even with all that, if you still want to join Alcadno, then just get me #b2 ores of jewels#k. It doesn't matter what kind, just get me two."); + sm.forceStartQuest(3303); + } + + @Script("q3303e") + public static void q3303e(ScriptManager sm) { + // Test from the Head of Alcadno Society (3303 - end) + // NPC 2111007 - Han the Broker + // Auto-complete via quest system (no dialogue needed) + } + + @Script("q3305s") + public static void q3305s(ScriptManager sm) { + // Re-acquiring Zenumist Cape (3305 - start) + // NPC 2111000 - Zenumist + // Lost cape replacement quest + if (!sm.hasItem(4000021, 10) || !sm.hasItem(4021003, 5) || !sm.canAddMoney(-10000)) { + sm.sayOk("You need #b10 Pig Ribbons#k, #b5 Mithril Ores#k, and #b10,000 mesos#k to get a replacement Zenumist Cape."); + return; + } + + if (!sm.askYesNo("You brought all the items. Do you want me to make you a new Zenumist Cape?")) { + return; + } + + sm.sayNext("Great job. Here, please take the Zenumist Cape."); + sm.sayBoth("This will be the only time I'll make the cape for you this easily. A Zenumist Cape is a form of identification that proves that you're a member of Zenumist. If you are not wearing the cape, the you might risk being recognized as Alcadno. Please don't lose it... and remember, any alchemist that makes this cape will need to rest for the next 3 days to recuperate, so again... please don't lose it."); + + sm.removeItem(4000021, 10); + sm.removeItem(4021003, 5); + sm.addMoney(-10000); + sm.addItem(1102135, 1); + sm.forceStartQuest(3305); + sm.forceCompleteQuest(3305); + } + + @Script("q3306s") + public static void q3306s(ScriptManager sm) { + // Re-acquiring Alcadno Cape (3306 - start) + // NPC 2111001 - Alcadno Society Chief + // Lost cape replacement quest + if (!sm.hasItem(4000021, 10) || !sm.hasItem(4021006, 5) || !sm.canAddMoney(-10000)) { + sm.sayOk("You need #b10 Pig Ribbons#k, #b5 Gold Ores#k, and #b10,000 mesos#k to get a replacement Alcadno Cape."); + return; + } + + if (!sm.askYesNo("You brought all the items. Do you want me to make you a new Alcadno Cape?")) { + return; + } + + sm.sayNext("Since you have brought all the items, I'll make you another Alcadno Cape, but... please remember this. Wearing Alcadno Cape signifies that you are a member of Alcadno. The cape is also laden with a special spell. Please don't lose it. It can only be made once every 3 days."); + sm.sayBoth("As a proud member of Alcadno, you should handle the cape with utmost pride and care."); + + sm.removeItem(4000021, 10); + sm.removeItem(4021006, 5); + sm.addMoney(-10000); + sm.addItem(1102136, 1); + sm.forceStartQuest(3306); + sm.forceCompleteQuest(3306); + } + + @Script("q3314e") + public static void q3314e(ScriptManager sm) { + // Life Alchemy, and the Missing Alchemist (3314 - end) + // NPC 2111009 - Russellon + // Auto-start quest completion (rewards handled by quest system) + sm.addExp(12500); + sm.forceCompleteQuest(3314); + } + + @Script("q3320s") + public static void q3320s(ScriptManager sm) { + // What Parwen Knows (3320 - start) + // NPC 2111006 - Parwen the Ghost + // Part of "Parwen's Memory" questline + sm.sayNext("Hmm? I guess you haven't met him yet... Do you not wish to go to the world of afterimage? Let me know if you are ready to head there."); + sm.forceStartQuest(3320); + } + + @Script("q3321s") + public static void q3321s(ScriptManager sm) { + // Dr. De Lang, the Missing Alchemist (3321 - start) + // NPC 2111006 - Parwen the Ghost + final int answer1 = sm.askMenu("...Hmm... You look quite indifferent. I thought the person you mentioned was that alchemist, so I brought you to him but... was he the right person? I guess not... I mean, unlike you, he has that dark, gloomy shadow that's cast over him. He's someone that's hard to approach.", java.util.Map.of( + 0, "Any idea where he is?" + )); + + final int answer2 = sm.askMenu("Where? Hmm... there's no such place where he resides. It's just a afterimage from the past.", java.util.Map.of( + 0, "Can you tell me where he is right now?" + )); + + sm.sayOk("There's no way you can tell. Like I said, it's just a afterimage. It's nothing important, so just forget about it."); + sm.addExp(5000); + sm.forceStartQuest(3321); + sm.forceCompleteQuest(3321); + } + + @Script("q3353s") + public static void q3353s(ScriptManager sm) { + // What De Lang Wants (3353 - start) + // NPC 2111006 - Parwen the Ghost + // Auto-start when entering map 261020401 + sm.sayNext("Huh? You didn't meet with De Lang yet? What are you doing just standing around here then?"); + sm.forceStartQuest(3353); + } + + @Script("q3354s") + public static void q3354s(ScriptManager sm) { + // De Lang's Potion (3354 - start) + // NPC 2111001 - Alcadno Society Chief + sm.sayNext("Hmm... this is interesting. Why isn't he banned?"); + sm.addExp(100); + sm.forceStartQuest(3354); + sm.forceCompleteQuest(3354); + } + + @Script("q3360s") + public static void q3360s(ScriptManager sm) { + // Verifying the Password (3360 - start) + // NPC 2111006 - Parwen the Ghost + // Part of "The Secret, Quiet Passage" questline + if (!sm.askYesNo("How was it? Were you able to gain access to the Secret Passage? I knew it. My brain's still working well... even though I don't have one now.")) { + return; + } + + sm.sayOk("The existence of the Secret Passage should not be made public to others. Why? Because inside the Secret Passage, you'll be led to... uhm, never mind. You didn't hear that from me. Anyway, keep this a secret, okay?"); + sm.setQRValue(3360, "1"); + sm.forceStartQuest(3360); + sm.forceCompleteQuest(3360); + } + + @Script("q3382s") + public static void q3382s(ScriptManager sm) { + // Yulete's Reward (3382 - start) + // NPC 2112014 - Yulete + sm.sayNext("It's been a while. How are things with #m261000000#? I have been spending most of my time reading at home, and have been detached from the rest of the world... Hmm? Why are you asking me questions on my experiment? ...! Is that what I think it is that you're holding? #t4001159# and #t4001160#...?"); + + if (!sm.askYesNo("Do you have the Zenumist and Alcadno marbles?")) { + sm.sayOk("Hmm... are they really #t4001159# and #t4001160#? My eyes have gone bad..."); + return; + } + + sm.sayNext("Ah... someone like you may hold this marble without any problems, since you experienced firsthand the conflict within #m261000000#, and yet you've managed to keep your integrity in tact, not taking any sides on issues... Thanks to you I am here safely conducting experiments, right? Again, thank you."); + sm.sayBoth("As my way of saying thanks... I know this may not be much, but I think it'll help you a bit. As someone that studied both Zenumist and Alcadno methods, I feel that I can create a #bnew power by combining the essence of the two studies#k... what do you think? Are you interested?"); + sm.sayBoth("Then I want you to gather up more #b#t4001159#s#k and #b#t4001160#s#k. I need more marbles of the two in order to fuse the two together. Once I get enough marbles, I'll make something you've always wanted."); + sm.forceStartQuest(3382); + } + + @Script("q3382e") + public static void q3382e(ScriptManager sm) { + // Yulete's Reward (3382 - end) + // NPC 2112014 - Yulete + // Auto-complete via quest system (no dialogue needed) + } +} diff --git a/src/main/java/kinoko/script/quest/PirateQuest.java b/src/main/java/kinoko/script/quest/PirateQuest.java new file mode 100644 index 00000000..89e62ad3 --- /dev/null +++ b/src/main/java/kinoko/script/quest/PirateQuest.java @@ -0,0 +1,138 @@ +package kinoko.script.quest; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; +import kinoko.world.job.Job; + +/** + * Pirate Job Advancement Quests + * + * Quest 2191 - How to become a Brawler (2nd job) + * Quest 2192 - How to Become a Gunslinger (2nd job) + * Quest 2304 - Endangered Mushking Empire (3rd job letter of recommendation) + */ +public final class PirateQuest extends ScriptHandler { + + // BRAWLER PATH (Quest 2191) --------------------------------------------------------------------------------- + + @Script("q2191s") + public static void q2191s(ScriptManager sm) { + // Quest 2191 - How to become a Brawler (START) + // Kyrin (1090000) - Nautilus + sm.sayNext("You have reached Level 30 already. Are you here to become the Brawler? Then you've come to the right place."); + + if (sm.askYesNo("But I can't just give you a job advancement that easily. I need you to prove yourself worthy of it. Brawlers use brute strength, steel fists, and their full bodies' power to eliminate all opposition. In order to become a commendable Brawler, you'll need to be good with your fists! Prove to me that you are.")) { + sm.forceStartQuest(2191); + sm.sayNext("It's simple really. You'll just need to head to the test room where I'll send you, and fight #rOctopirates#k. Your task is to bring back #b15 Potent Power Crystals#k."); + sm.sayOk("You think it's easy? Then think again. These Octopirates can only be attacked with #rFlash Fist#k. Other attacks will be fruitless. It may be tough, but any honorable Brawler must go through this. Good luck..."); + // TODO: Warp to test room when implemented + } else { + sm.sayOk("Do you not wish to become a Brawler?"); + } + } + + @Script("q2191e") + public static void q2191e(ScriptManager sm) { + // Quest 2191 - How to become a Brawler (END) + final int POTENT_POWER_CRYSTAL = 4031856; + + if (!sm.hasItem(POTENT_POWER_CRYSTAL, 15)) { + sm.sayOk("Bring 15 Potent Power Crystals."); + return; + } + + sm.sayNext("Good. You got all 15."); + + if (sm.askYesNo("That performance in the testing ground was impressive. You look like you can become a legitimate force as a Brawler. Now tell me when you're ready to make the job advancement, and I'll go ahead and do it for you.")) { + sm.removeItem(POTENT_POWER_CRYSTAL, 15); + sm.forceCompleteQuest(2191); + sm.setJob(Job.BRAWLER); // Brawler + sm.sayOk("Congratulations! You are now a #bBrawler#k. Train hard and become stronger!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + // GUNSLINGER PATH (Quest 2192) --------------------------------------------------------------------------------- + + @Script("q2192s") + public static void q2192s(ScriptManager sm) { + // Quest 2192 - How to Become a Gunslinger (START) + // Kyrin (1090000) - Nautilus + sm.sayNext("You have reached Level 30 already. Are you here to become a Gunslinger? Then you've come to the right place."); + + if (sm.askYesNo("But I can't just give you a job advancement that easily. I need you to prove yourself worthy of it. Gunslingers use Guns to eliminate oppositions. In order to become a commendable Gunslinger, you'll need to be good with your Gun, right? Prove it to me that you are.")) { + sm.forceStartQuest(2192); + sm.sayNext("It's simple really. You'll just need to head to the test room where I'll send you, and fight #rOctopirates#k. Your task is to bring back #b15 Potent Wind Crystals#k."); + sm.sayOk("You think it's easy? Then think again. These Octopirates can only be attacked with #rDouble Shot#k. Other attacks will be fruitless. It may be tough, but any honorable Gunslinger must go through this. Good luck..."); + // TODO: Warp to test room when implemented + } else { + sm.sayOk("Do you not wish to become a Gunslinger?"); + } + } + + @Script("q2192e") + public static void q2192e(ScriptManager sm) { + // Quest 2192 - How to Become a Gunslinger (END) + final int POTENT_WIND_CRYSTAL = 4031857; + + if (!sm.hasItem(POTENT_WIND_CRYSTAL, 15)) { + sm.sayOk("Bring 15 Potent Wind Crystals."); + return; + } + + sm.sayNext("Good. You got all 15."); + + if (sm.askYesNo("That performance in the testing ground was impressive. You look like you can become a legitimate force as a Gunslinger. Now tell me when you're ready to make the job advancement, and I'll go ahead and do it for you.")) { + sm.removeItem(POTENT_WIND_CRYSTAL, 15); + sm.forceCompleteQuest(2192); + sm.setJob(Job.GUNSLINGER); // Gunslinger + sm.sayOk("Congratulations! You are now a #bGunslinger#k. Train hard and become stronger!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + // ENDANGERED MUSHKING EMPIRE (Quest 2304) ---------------------------------------------------------------- + + @Script("q2304s") + public static void q2304s(ScriptManager sm) { + // Quest 2304 - Endangered Mushking Empire (START) + // Kyrin (1090000) - Nautilus + // This is an optional quest for 3rd job Pirates to help the Mushking Empire + + sm.sayNext("The Mushking Empire is in dire straits and in desperate need of help! As a strong Pirate, I believe you can make a difference there."); + + if (sm.askYesNo("I'd like to give you a #bletter of recommendation#k to take to #b#p1300005##k, the Head Security Officer of the Mushking Empire. Will you help them?")) { + sm.forceStartQuest(2304); + sm.addItem(4032375, 1); // Letter of Recommendation + sm.sayNext("Thank you! Please take this letter to #b#p1300005##k in the Mushking Empire. They need all the help they can get!"); + sm.sayOk("You can find the Mushking Empire through the dimensional portal. Good luck!"); + } else { + sm.sayOk("If you change your mind, come back and talk to me."); + } + } + + @Script("q2304e") + public static void q2304e(ScriptManager sm) { + // Quest 2304 - Endangered Mushking Empire (END) + // Head Security Officer (1300005) - Mushking Empire + final int LETTER_OF_RECOMMENDATION = 4032375; + + if (!sm.hasItem(LETTER_OF_RECOMMENDATION, 1)) { + sm.sayOk("You need to bring the letter of recommendation from Kyrin."); + return; + } + + sm.sayNext("Ah, you have a letter of recommendation from Kyrin! Thank you for coming to help the Mushking Empire in our time of need."); + + if (sm.askYesNo("We are grateful for your assistance. Will you help us defend the Empire?")) { + sm.removeItem(LETTER_OF_RECOMMENDATION, 1); + sm.forceCompleteQuest(2304); + sm.sayOk("Thank you! The Mushking Empire is in your debt. There will be more challenges ahead, but with your help, we can overcome them!"); + } else { + sm.sayOk("Please reconsider. We really need your help."); + } + } +} diff --git a/src/main/java/kinoko/script/quest/ResistanceQuest.java b/src/main/java/kinoko/script/quest/ResistanceQuest.java index 8dbc8119..ed82e621 100644 --- a/src/main/java/kinoko/script/quest/ResistanceQuest.java +++ b/src/main/java/kinoko/script/quest/ResistanceQuest.java @@ -1,10 +1,13 @@ package kinoko.script.quest; +import kinoko.packet.field.FieldEffectPacket; +import kinoko.packet.field.FieldPacket; import kinoko.packet.user.QuestPacket; import kinoko.script.common.Script; import kinoko.script.common.ScriptHandler; import kinoko.script.common.ScriptManager; import kinoko.script.common.ScriptMessageParam; +import kinoko.server.node.ServerExecutor; import kinoko.util.Tuple; import kinoko.world.field.mob.MobAppearType; import kinoko.world.job.Job; @@ -119,10 +122,23 @@ public static void q23127s(ScriptManager sm) { if (sm.askAccept("Heh, you think they can frighten this old man. Nah, I trust you. You're a strong member of the Resistance. You'll keep me safe. Now, let's go someplace more secluded, where the Black Wings will feel safe enough to show their faces.")) { sm.forceStartQuest(23127); sm.warpInstance(931000441, "out00", 931000440, 60); - //Should start a 55 second long time limit to keep Surl safe. Referenced current GMS for this. - //TODO: Handle QuestCheck: fieldset, fieldsetkeeptime. - sm.write(QuestPacket.startTimeKeepQuestTimer(23127, 55000)); //startQuestTimer crashes the game, startTimeKeepQuestTimer doesn't end... + // Start 55 second quest timer + sm.write(QuestPacket.startTimeKeepQuestTimer(23127, 55000)); sm.message("Protect Surl from the Black Wings for a set amount of time!"); + + // Schedule timer end after 55 seconds + final User user = sm.getUser(); + ServerExecutor.schedule(user, () -> { + // End the quest timer + user.write(QuestPacket.endTimeKeepQuestTimer(23127, 0)); + // Check if Surl (mob 9300415) is still alive and player is still in map + if (user.getField().getFieldId() == 931000441 && !user.getField().getMobPool().isEmpty()) { + // Success - Surl survived + user.getField().broadcastPacket(FieldEffectPacket.screen("quest/party/clear")); + user.getField().broadcastPacket(FieldEffectPacket.sound("Party1/Clear")); + user.getField().blowWeather(5120024, "You successfully protected Surl!", 5); + } + }, 55, java.util.concurrent.TimeUnit.SECONDS); return; } sm.sayOk("I think the Black Wings are too chicken to come out with you around."); //Not GMS-like, unsure what he'd say @@ -358,6 +374,39 @@ public static void q23025e(ScriptManager sm) { sm.sayPrev("I will see you at the next lesson. Until then, keep up the good fight!"); } + @Script("q23033s") + public static void q23033s(ScriptManager sm) { + // Destroying the Energy Conducting Device (23033 - start) - Battle Mage + if (!sm.askYesNo("The Black Wings are sapping the power from our energy source at the #bPower Plant in Verne Mine#k. They're using it to power their machines. Because of this, the people of Edelstein have to ration their energy usage. It's absurd! Will you go to the Power Plant and destroy their Energy Conducting Device?")) { + sm.sayOk("Come back when you're ready to help Edelstein."); + return; + } + sm.forceStartQuest(23033); + sm.sayNext("You'll need a #bBlack Wings Hat#k to get past the guard at the mine entrance. I've heard there's someone who sells them... look around and see if you can find a way to get one. The Energy Conducting Device is deep inside the Power Plant. Good luck!"); + } + + @Script("q23034s") + public static void q23034s(ScriptManager sm) { + // Destroying the Energy Conducting Device (23034 - start) - Wild Hunter + if (!sm.askYesNo("The Black Wings are stealing power from our energy source at the #bPower Plant in Verne Mine#k. They use it to run their wicked machines while the people of Edelstein suffer from energy shortages. Will you go to the Power Plant and destroy their Energy Conducting Device?")) { + sm.sayOk("Come back when you're ready to help Edelstein."); + return; + } + sm.forceStartQuest(23034); + sm.sayNext("You'll need a #bBlack Wings Hat#k to get past the guard at the mine entrance. I've heard rumors of someone who might sell them... find a way to get one. Head to the Power Plant deep in Verne Mine and destroy that device!"); + } + + @Script("q23035s") + public static void q23035s(ScriptManager sm) { + // Destroying the Energy Conducting Device (23035 - start) - Mechanic + if (!sm.askYesNo("The Black Wings are draining power from our energy source at the #bPower Plant in Verne Mine#k for their own purposes. Meanwhile, the people of Edelstein must ration energy. This is unacceptable! Will you go to the Power Plant and destroy their Energy Conducting Device?")) { + sm.sayOk("Come back when you're ready to help Edelstein."); + return; + } + sm.forceStartQuest(23035); + sm.sayNext("You'll need a #bBlack Wings Hat#k to get past the gatekeeper at the mine entrance. Find someone who can get you one - I've heard there's a Black Wing watchman who might be willing to make a deal. The Energy Conducting Device is deep in the Power Plant. Destroy it and return to me!"); + } + @Script("q23033e") public static void q23033e(ScriptManager sm) { // Destroying the Energy Conducting Device (23033 - end) @@ -537,4 +586,155 @@ public static void q23054s(ScriptManager sm) { sm.sayBoth("With this, the end of my lessons has... neared. Though you are stronger than I am, there are a lot of things you can still learn from me. I will see you at our next lesson... Whenever that may be..."); sm.sayPrev("Until then, I look forward to seeing your accomplishments!"); } + + @Script("q23139e") + public static void q23139e(ScriptManager sm) { + // Obtaining Antidote Herbs (23139 - end) + if (!sm.hasItem(4033093, 1)) { + sm.sayOk("Please bring me the Feather Plants so I can make the antidote."); + return; + } + sm.sayNext("Do you have the Feather Plants? Let me see."); + sm.sayBoth("...This is only enough for half the antidote we need. But if #p1061005# says this is all he has, I don't know what more we can do..."); + sm.sayBoth("#p1061005# says they sell Feather Plants in #m130000000#... I'll try there.", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("#m130000000#? Are you suggesting we beg the #p1101000# Knights for help? No! Absolutely not! Any self-respecting member of the Resistance would rather stay poisoned then ask them for anything!"); + sm.sayBoth("Don't you know? In #m300000000#'s time of need, we asked the #p1101000# Knights for help, and they did nothing! We're suffering under the Black Wings because of them!"); + sm.sayPrev("Maybe I can pad the Feather Plants out with some other herbs... Thanks for your help."); + sm.removeItem(4033093, 1); + sm.addExp(3200); + sm.forceCompleteQuest(23139); + } + + @Script("q23144s") + public static void q23144s(ScriptManager sm) { + // Gelimer's Trap (23144 - start) + sm.sayNext("We can't just stand by and do nothing! #p2159348# is a victim here! #p2151003#, you have to let me go save her! Please!", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("I can't! This is obviously a trap, and I don't have the agents to spare to send with you."); + sm.sayBoth("Then I'll go alone!", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("It's too dangerous!"); + sm.sayBoth("#p2159348# is my friend!", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("......"); + sm.sayBoth("...I understand. But if you're captured, there won't be anyone to rescue you OR #p2159348#! Don't take any risks you can avoid."); + sm.sayBoth("...Should I have accepted the #p1101000# Knight's offer of help?"); + sm.forceStartQuest(23144); + } + + @Script("q23144e") + public static void q23144e(ScriptManager sm) { + // Gelimer's Trap (23144 - end) + sm.sayNext("#p2159348#!", ScriptMessageParam.NOT_CANCELLABLE, ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("#h0#...?"); + sm.sayBoth("You're back to normal? Come on, let's get out of here!", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("Why are you...? You dummy! This is a trap! #p2154009# wanted you to bring a lot of Resistance members here so he could destroy them!"); + sm.sayBoth("We can talk later. Let's get out of here!", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("I can't! I'm...I'm #p2154009#'s test subject! Even if I run away with you, I'll only cause more trouble for the Resistance, whether I want to...or not."); + sm.sayBoth("Who cares about that? You're my friend!", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("......"); + sm.sayBoth("I wasn't sure at first, to be honest. Everyone thought you might be a Black Wing spy... But now we know that's not true! This is all #p2154009#'s fault! You shouldn't blame yourself for any of this!", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("You...you're a good friend."); + sm.sayBoth("#p2159348#?", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("But I really can't run away. If I do, I'll blow up..."); + sm.sayBoth("!!", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("And if you stay, you'll blow up with me! You've got to get out of here!"); + sm.sayBoth("What?! Is there no way to stop it from happening?", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("#p2154009# used me as bait. He planned to wait for you to show up, then blow me up, along with you and everyone you brought with you. But there is one way to escape..."); + sm.sayBoth("How? Tell me!", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("I have a Town Return Scroll. I was saving it so I could go home, but I didn't want to cause any more trouble for you. I thought if I stayed here, I could just quietly...disappear."); + sm.sayBoth("But seeing you now makes me miss town! Clever #p2151003#... Brave Belle... Cool Brighton... Cute Checky and Wendelline... Tough Elex... I miss them all!"); + sm.sayBoth("And they all miss you!", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("I love them so much...and that's why I can't go back. As long as Gellimer has control of me, I'm a danger to everyone around me."); + sm.sayBoth("Then we just have to free you from Gellimer!", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("You're right. They'll try. But it's too dangerous... I can't put them through anything else..."); + sm.sayBoth("If I can just save you...I'll be happy."); + sm.sayBoth("...#p2159348#!", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("I'm counting on you to beat the Black Wings. Don't let #p2154009# do this to anyone else!"); + sm.sayBoth("Stop!", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.forceCompleteQuest(23144); + sm.warp(931000632, "sp"); + } + + @Script("q23145s") + public static void q23145s(ScriptManager sm) { + // Regret (23145 - start) + sm.sayNext("Where's #p2159348#? Weren't you going to get her?"); + sm.sayBoth("(You explain #p2154009#'s trap, and the explosion that swallowed #p2159348# up...)", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("Then #p2159348# is..."); + sm.sayBoth("......", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.sayBoth("What a fool I've been! I should have kept a closer eye on her when she first got here... I should have realized what #p2154009# was up to..."); + sm.sayBoth("I shouldn't have been too prideful to accept the #p1101000# Knights' help when they offered to send us Feather Plant..."); + sm.sayBoth("I thought we could take down the Black Wings on our own, but I guess I was wrong. At the end of the day, #p2154009# still fooled us, and #p2159348#..."); + sm.sayBoth("I'm sorry, but could you give me some time alone? I'm sure you need some time, too..."); + sm.addExp(10000); + sm.forceCompleteQuest(23145); + } + + @Script("q23148s") + public static void q23148s(ScriptManager sm) { + // Time to Conclude (23148 - start) + sm.forceStartQuest(23148); + sm.warp(931000660, "sp"); + } + + @Script("q23949s") + public static void q23949s(ScriptManager sm) { + // Crime Prevention System Inspection (23949 - start) + sm.sayNext("Greetings. I am Android Unit 2154003, assigned to Power Plant Security monitoring. How may I assist you?"); + sm.sayBoth("The Crime Prevention Systems require routine inspection to ensure optimal performance. Would you perform this inspection?"); + + if (!sm.askAccept("Inspection procedure: Navigate to Intruder Search Warrant 3 and physically test each Crime Prevention System unit. Attack each system to verify response protocols are functioning correctly.")) { + sm.sayOk("Understood. Please return when you are prepared to conduct the inspection."); + return; + } + + sm.forceStartQuest(23949); + sm.sayNext("Acknowledged. Proceed to #bIntruder Search Warrant 3#k and test #r10 Crime Prevention System#k units. Use standard attack protocols to verify system responsiveness."); + sm.sayBoth("Return here upon completion of the inspection for analysis."); + } + + @Script("q23949e") + public static void q23949e(ScriptManager sm) { + // Crime Prevention System Inspection (23949 - end) + sm.sayNext("Inspection data received. Analyzing Crime Prevention System performance metrics..."); + sm.sayBoth("Analysis complete. All Crime Prevention Systems are functioning within normal parameters. System response time: Optimal. Threat detection accuracy: 99.7%."); + sm.sayBoth("Your assistance with this inspection is appreciated. The Crime Prevention Systems will continue to protect this facility efficiently."); + sm.addExp(7500); + sm.forceCompleteQuest(23949); + } + + @Script("q23951s") + public static void q23951s(ScriptManager sm) { + // An Attempt to Escape (23951 - start) + sm.sayNext("I made it in! The #t1003134# worked like a charm. Those gatekeepers didn't suspect a thing."); + sm.sayBoth("But there's a problem... I found where they're keeping #p2154004#, but #p2154005# is guarding her. He won't leave his post, and I can't take him on alone."); + sm.sayBoth("I need you to create a distraction. If we can make #p2154005# think there's an intruder somewhere else, he'll have to leave his post to investigate."); + + if (!sm.askAccept("Here's the plan: defeat some #r#o6150000#s#k and collect their #b#t4000608#s#k. Then show the batons to #p2154005# and tell him you found signs of an intruder. While he's distracted, I'll get #p2154004# out of there!")) { + sm.sayOk("What? You're backing out now? Fine, but we're running out of time here!"); + return; + } + + sm.forceStartQuest(23951); + sm.sayNext("Perfect! Get me #b30 #t4000608#s#k from the #r#o6150000#s#k, then show them to #p2154005#. Make it convincing - tell him there's been a security breach!"); + sm.sayBoth("I'll be waiting for my chance. Don't mess this up!"); + } + + @Script("q23951e") + public static void q23951e(ScriptManager sm) { + // An Attempt to Escape (23951 - end) + sm.sayNext("What?! You found #b30 #t4000608#s#k? That many Guard Robots destroyed? There must be an intruder in the facility!"); + sm.sayBoth("Come with me! We need to investigate this immediately! The security of this facility is my responsibility!"); + sm.sayBoth("#b(#p2154005# rushes outside to investigate the supposed security breach. You successfully distracted him!)#k", ScriptMessageParam.PLAYER_AS_SPEAKER); + sm.addExp(9000); + sm.forceCompleteQuest(23951); + } + + @Script("enter931000441") + public static void enter931000441(ScriptManager sm) { + // Resistance Hideout : Park Corner (931000441) + // Spawn Surl (mob 9300415) when player enters for quest 23127 + if (sm.hasQuestStarted(23127) && sm.getField().getMobPool().isEmpty()) { + sm.spawnMob(9300415, MobAppearType.NORMAL, 0, 0, false); + sm.message("Protect Surl from the Black Wings for a set amount of time!"); + } + } } diff --git a/src/main/java/kinoko/script/quest/ResistanceQuest2.java b/src/main/java/kinoko/script/quest/ResistanceQuest2.java new file mode 100644 index 00000000..2ca2209f --- /dev/null +++ b/src/main/java/kinoko/script/quest/ResistanceQuest2.java @@ -0,0 +1,119 @@ +package kinoko.script.quest; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +/** + * Resistance Quest System - Additional Quests + * Covers quests 19000-19999 range for Resistance-related quests + */ +public final class ResistanceQuest2 extends ScriptHandler { + + // RESISTANCE QUEST SCRIPTS + // q19000-q19012 - Honorable Mesoranger and event quests + + @Script("q19000s") + public static void q19000s(ScriptManager sm) { + // Quest 19000 - Honorable Mesoranger (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(19000); + sm.addItem(1142076, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q19001s") + public static void q19001s(ScriptManager sm) { + // Quest 19001 - Maple Nut (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(19001); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q19002s") + public static void q19002s(ScriptManager sm) { + // Quest 19002 - The 2nd Honorable Mesoranger (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(19002); + sm.addItem(1142123, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q19005s") + public static void q19005s(ScriptManager sm) { + // Quest 19005 - Top 10 in Artifact Hunt (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(19005); + sm.addItem(1142124, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q19006s") + public static void q19006s(ScriptManager sm) { + // Quest 19006 - Mystical Artifact Discoverer (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(19006); + sm.addItem(1142125, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q19011s") + public static void q19011s(ScriptManager sm) { + // Quest 19011 - The 3rd Honorable Mesoranger (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(19011); + sm.addItem(1142170, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q19012s") + public static void q19012s(ScriptManager sm) { + // Quest 19012 - Honorable Explorer (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(19012); + sm.addItem(1142184, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } +} diff --git a/src/main/java/kinoko/script/quest/SpecialQuest.java b/src/main/java/kinoko/script/quest/SpecialQuest.java new file mode 100644 index 00000000..726eb048 --- /dev/null +++ b/src/main/java/kinoko/script/quest/SpecialQuest.java @@ -0,0 +1,1346 @@ +package kinoko.script.quest; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +/** + * Special Quest System + * Covers quests 9000-9999 range for special/event quests + */ +public final class SpecialQuest extends ScriptHandler { + + + @Script("q9432e") + public static void q9432e(ScriptManager sm) { + // Quest 9432 - 북극곰 포치의 부탁 (END) + // NPC: 9001002 + + final int QUEST_ITEM_4031322 = 4031322; + final int QUEST_ITEM_4031323 = 4031323; + final int QUEST_ITEM_4000216 = 4000216; + + if (!sm.hasItem(QUEST_ITEM_4031322, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4031323, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + if (!sm.hasItem(QUEST_ITEM_4000216, 50)) { + sm.sayOk("You need 50 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031322, 1); + sm.removeItem(QUEST_ITEM_4031323, 1); + sm.removeItem(QUEST_ITEM_4000216, 50); + sm.forceCompleteQuest(9432); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9633s") + public static void q9633s(ScriptManager sm) { + // Quest 9633 - Teo's Nostalgic Reminiscing II (START) + // NPC: 1002001 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9633); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9682e") + public static void q9682e(ScriptManager sm) { + // Quest 9682 - Spirit Week Sept 6th (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9682); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9683e") + public static void q9683e(ScriptManager sm) { + // Quest 9683 - Spirit Week Sept 13th (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9683); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9730e") + public static void q9730e(ScriptManager sm) { + // Quest 9730 - To Fool a Liar (END) + // NPC: 9010011 + + final int QUEST_ITEM_4031583 = 4031583; + + if (!sm.hasItem(QUEST_ITEM_4031583, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031583, 10); + sm.forceCompleteQuest(9730); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9731e") + public static void q9731e(ScriptManager sm) { + // Quest 9731 - To Fool a Liar (END) + // NPC: 9010012 + + final int QUEST_ITEM_4031584 = 4031584; + + if (!sm.hasItem(QUEST_ITEM_4031584, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031584, 10); + sm.forceCompleteQuest(9731); + sm.addExp(8500); // EXP reward + sm.addItem(4031584, -10); // Reward item + sm.addItem(1012058, 1); // Reward item + sm.addItem(1012059, 1); // Reward item + sm.addItem(1012060, 1); // Reward item + sm.addItem(1012061, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9732e") + public static void q9732e(ScriptManager sm) { + // Quest 9732 - To Fool a Liar (END) + // NPC: 9010013 + + final int QUEST_ITEM_4031585 = 4031585; + + if (!sm.hasItem(QUEST_ITEM_4031585, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031585, 10); + sm.forceCompleteQuest(9732); + sm.addExp(17400); // EXP reward + sm.addItem(4031585, -10); // Reward item + sm.addItem(1012059, 1); // Reward item + sm.addItem(1012060, 1); // Reward item + sm.addItem(1012061, 1); // Reward item + sm.addItem(1012058, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9733e") + public static void q9733e(ScriptManager sm) { + // Quest 9733 - Help out Gordon! (END) + // NPC: 9010010 + + final int QUEST_ITEM_4031933 = 4031933; + + if (!sm.hasItem(QUEST_ITEM_4031933, 30)) { + sm.sayOk("You need 30 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031933, 30); + sm.forceCompleteQuest(9733); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9734e") + public static void q9734e(ScriptManager sm) { + // Quest 9734 - Help out Gordon!! (END) + // NPC: 9010010 + + final int QUEST_ITEM_4031934 = 4031934; + + if (!sm.hasItem(QUEST_ITEM_4031934, 30)) { + sm.sayOk("You need 30 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031934, 30); + sm.forceCompleteQuest(9734); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9735e") + public static void q9735e(ScriptManager sm) { + // Quest 9735 - Help out Gordon!!! (END) + // NPC: 9010010 + + final int QUEST_ITEM_4031935 = 4031935; + + if (!sm.hasItem(QUEST_ITEM_4031935, 30)) { + sm.sayOk("You need 30 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031935, 30); + sm.forceCompleteQuest(9735); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9745e") + public static void q9745e(ScriptManager sm) { + // Quest 9745 - 커플을 위한 다크 초콜릿 (END) + // NPC: 1012108 + + final int QUEST_ITEM_4031938 = 4031938; + + if (!sm.hasItem(QUEST_ITEM_4031938, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031938, 10); + sm.forceCompleteQuest(9745); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9746e") + public static void q9746e(ScriptManager sm) { + // Quest 9746 - 커플을 위한 다크 초콜릿! (END) + // NPC: 1022002 + + final int QUEST_ITEM_4031939 = 4031939; + + if (!sm.hasItem(QUEST_ITEM_4031939, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031939, 10); + sm.forceCompleteQuest(9746); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9747e") + public static void q9747e(ScriptManager sm) { + // Quest 9747 - 커플을 위한 다크 초콜릿!! (END) + // NPC: 2020006 + + final int QUEST_ITEM_4031940 = 4031940; + + if (!sm.hasItem(QUEST_ITEM_4031940, 10)) { + sm.sayOk("You need 10 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031940, 10); + sm.forceCompleteQuest(9747); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9851s") + public static void q9851s(ScriptManager sm) { + // Quest 9851 - Snake Pit in the Swamp (START) + // NPC: 1052000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9851); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9875e") + public static void q9875e(ScriptManager sm) { + // Quest 9875 - Growing a Sprout (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9875); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9875s") + public static void q9875s(ScriptManager sm) { + // Quest 9875 - Growing a Sprout (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9875); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9878e") + public static void q9878e(ScriptManager sm) { + // Quest 9878 - Magatia, the City of Alchemy (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9878); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9880e") + public static void q9880e(ScriptManager sm) { + // Quest 9880 - Wanted: Mano (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9880); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9880s") + public static void q9880s(ScriptManager sm) { + // Quest 9880 - Wanted: Mano (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9880); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9881e") + public static void q9881e(ScriptManager sm) { + // Quest 9881 - Wanted: Stumpy (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9881); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9881s") + public static void q9881s(ScriptManager sm) { + // Quest 9881 - Wanted: Stumpy (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9881); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9882e") + public static void q9882e(ScriptManager sm) { + // Quest 9882 - Wanted: King Clang (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9882); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9882s") + public static void q9882s(ScriptManager sm) { + // Quest 9882 - Wanted: King Clang (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9882); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9883e") + public static void q9883e(ScriptManager sm) { + // Quest 9883 - Wanted: Tae Roon (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9883); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9883s") + public static void q9883s(ScriptManager sm) { + // Quest 9883 - Wanted: Tae Roon (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9883); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9884e") + public static void q9884e(ScriptManager sm) { + // Quest 9884 - Wanted: Eliza (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9884); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9884s") + public static void q9884s(ScriptManager sm) { + // Quest 9884 - Wanted: Eliza (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9884); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9885e") + public static void q9885e(ScriptManager sm) { + // Quest 9885 - Wanted: Snowman (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9885); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9885s") + public static void q9885s(ScriptManager sm) { + // Quest 9885 - Wanted: Snowman (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9885); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9902e") + public static void q9902e(ScriptManager sm) { + // Quest 9902 - 마노 퇴치하기 (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9902); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9903e") + public static void q9903e(ScriptManager sm) { + // Quest 9903 - 스텀피 퇴치하기 (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9903); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9904e") + public static void q9904e(ScriptManager sm) { + // Quest 9904 - 킹크랑 퇴치하기 (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9904); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9905e") + public static void q9905e(ScriptManager sm) { + // Quest 9905 - 구미호 퇴치하기 (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9905); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9906e") + public static void q9906e(ScriptManager sm) { + // Quest 9906 - 태륜 퇴치하기 (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9906); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9907e") + public static void q9907e(ScriptManager sm) { + // Quest 9907 - 요괴선사 퇴치하기 (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9907); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9908e") + public static void q9908e(ScriptManager sm) { + // Quest 9908 - 엘리쟈 퇴치하기 (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9908); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9909e") + public static void q9909e(ScriptManager sm) { + // Quest 9909 - 스노우맨 퇴치하기 (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9909); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9920e") + public static void q9920e(ScriptManager sm) { + // Quest 9920 - 가면신사의 초대 (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9920); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9922e") + public static void q9922e(ScriptManager sm) { + // Quest 9922 - 고양이의 충고 (END) + // NPC: 2121000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9922); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9922s") + public static void q9922s(ScriptManager sm) { + // Quest 9922 - 고양이의 충고 (START) + // NPC: 2121000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9922); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9924s") + public static void q9924s(ScriptManager sm) { + // Quest 9924 - 사탕주지 않으면 장난칠 테야! (START) + // NPC: 2121012 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9924); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9925s") + public static void q9925s(ScriptManager sm) { + // Quest 9925 - 사탕주지 않으면 장난칠 테야!! (START) + // NPC: 2121012 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9925); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9926s") + public static void q9926s(ScriptManager sm) { + // Quest 9926 - 사탕주지 않으면 장난칠 테야!!! (START) + // NPC: 2121012 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9926); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9927e") + public static void q9927e(ScriptManager sm) { + // Quest 9927 - 가면신사와의 대화 (END) + // NPC: 2120000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9927); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9928e") + public static void q9928e(ScriptManager sm) { + // Quest 9928 - 이상한 소녀 (END) + // NPC: 2120005 + + final int QUEST_ITEM_2022256 = 2022256; + + if (!sm.hasItem(QUEST_ITEM_2022256, 5)) { + sm.sayOk("You need 5 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_2022256, 5); + sm.forceCompleteQuest(9928); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9929e") + public static void q9929e(ScriptManager sm) { + // Quest 9929 - 인형장인 조나스 (END) + // NPC: 2120004 + + final int QUEST_ITEM_4220021 = 4220021; + + if (!sm.hasItem(QUEST_ITEM_4220021, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4220021, 1); + sm.forceCompleteQuest(9929); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9929s") + public static void q9929s(ScriptManager sm) { + // Quest 9929 - 인형장인 조나스 (START) + // NPC: 2120004 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9929); + sm.addItem(4220021, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9930e") + public static void q9930e(ScriptManager sm) { + // Quest 9930 - 완벽한 연장 (END) + // NPC: 2120004 + + final int QUEST_ITEM_4031834 = 4031834; + + if (!sm.hasItem(QUEST_ITEM_4031834, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031834, 1); + sm.forceCompleteQuest(9930); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9931e") + public static void q9931e(ScriptManager sm) { + // Quest 9931 - 소필리아의 초상화 (END) + // NPC: 2120004 + + final int QUEST_ITEM_4031832 = 4031832; + + if (!sm.hasItem(QUEST_ITEM_4031832, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031832, 1); + sm.forceCompleteQuest(9931); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9932e") + public static void q9932e(ScriptManager sm) { + // Quest 9932 - 루드밀라의 귀고리 (END) + // NPC: 2120006 + + final int QUEST_ITEM_4031835 = 4031835; + + if (!sm.hasItem(QUEST_ITEM_4031835, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031835, 1); + sm.forceCompleteQuest(9932); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9933e") + public static void q9933e(ScriptManager sm) { + // Quest 9933 - 좋은 음악 (END) + // NPC: 2120006 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9933); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9934e") + public static void q9934e(ScriptManager sm) { + // Quest 9934 - 향기로운 음식 (END) + // NPC: 2120006 + + final int QUEST_ITEM_4031833 = 4031833; + + if (!sm.hasItem(QUEST_ITEM_4031833, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031833, 1); + sm.forceCompleteQuest(9934); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9935e") + public static void q9935e(ScriptManager sm) { + // Quest 9935 - 소필리아의 눈물 (END) + // NPC: 2120005 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9935); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9936e") + public static void q9936e(ScriptManager sm) { + // Quest 9936 - 새로운 기회 (END) + // NPC: 2120000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9936); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9937s") + public static void q9937s(ScriptManager sm) { + // Quest 9937 - 다른 쪽으로 가고 싶어졌어 (START) + // NPC: 2120005 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9937); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9938s") + public static void q9938s(ScriptManager sm) { + // Quest 9938 - 결정을 번복하고 싶다 (START) + // NPC: 2120004 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9938); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9939s") + public static void q9939s(ScriptManager sm) { + // Quest 9939 - 잘못 대답한 것 같다 (START) + // NPC: 2120006 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9939); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9943e") + public static void q9943e(ScriptManager sm) { + // Quest 9943 - 잊혀진 이름 (END) + // NPC: 2120007 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9943); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9947s") + public static void q9947s(ScriptManager sm) { + // Quest 9947 - 인형을 돌려드리겠어요. (START) + // NPC: 2120004 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9947); + sm.addItem(4220021, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9950e") + public static void q9950e(ScriptManager sm) { + // Quest 9950 - 해적 레벨업 이벤트 (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9950); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9951e") + public static void q9951e(ScriptManager sm) { + // Quest 9951 - ...Pirates!? (END) + // NPC: 9010010 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9951); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9961e") + public static void q9961e(ScriptManager sm) { + // Quest 9961 - 새해 운세보기 (END) + // NPC: 9010010 + + final int QUEST_ITEM_4220023 = 4220023; + + if (!sm.hasItem(QUEST_ITEM_4220023, 1)) { + sm.sayOk("You need 1 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4220023, 1); + sm.forceCompleteQuest(9961); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9961s") + public static void q9961s(ScriptManager sm) { + // Quest 9961 - 새해 운세보기 (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9961); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9965s") + public static void q9965s(ScriptManager sm) { + // Quest 9965 - 별도장 받기 (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9965); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9970e") + public static void q9970e(ScriptManager sm) { + // Quest 9970 - 페티트의 수리부품 (END) + // NPC: 9010010 + + final int QUEST_ITEM_4031923 = 4031923; + + if (!sm.hasItem(QUEST_ITEM_4031923, 30)) { + sm.sayOk("You need 30 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4031923, 30); + sm.forceCompleteQuest(9970); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9980s") + public static void q9980s(ScriptManager sm) { + // Quest 9980 - Tienk's Monster Card (START) + // NPC: 2006 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9980); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9981e") + public static void q9981e(ScriptManager sm) { + // Quest 9981 - 4 Year Anniversary Level Up event (END) + // NPC: 9010000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(9981); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9982s") + public static void q9982s(ScriptManager sm) { + // Quest 9982 - 5개의 촛불 (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9982); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9983s") + public static void q9983s(ScriptManager sm) { + // Quest 9983 - 5주년 축하 케이크 (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9983); + sm.addItem(4220045, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9984e") + public static void q9984e(ScriptManager sm) { + // Quest 9984 - Putting the Maple Leaf in my mouth... (END) + // NPC: 9010010 + + final int QUEST_ITEM_4001126 = 4001126; + + if (!sm.hasItem(QUEST_ITEM_4001126, 50)) { + sm.sayOk("You need 50 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4001126, 50); + sm.forceCompleteQuest(9984); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9985e") + public static void q9985e(ScriptManager sm) { + // Quest 9985 - Making Maple Syrup (END) + // NPC: 9010010 + + final int QUEST_ITEM_4001126 = 4001126; + + if (!sm.hasItem(QUEST_ITEM_4001126, 25)) { + sm.sayOk("You need 25 of the required quest items."); + return; + } + + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.removeItem(QUEST_ITEM_4001126, 25); + sm.forceCompleteQuest(9985); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q9990s") + public static void q9990s(ScriptManager sm) { + // Quest 9990 - Gaga's Maple Leaf (START) + // NPC: 9000021 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9990); + sm.addItem(4001168, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9991s") + public static void q9991s(ScriptManager sm) { + // Quest 9991 - Speed Quiz (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9991); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q9997s") + public static void q9997s(ScriptManager sm) { + // Quest 9997 - Operation MS-07 (START) + // NPC: 9000032 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(9997); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q99999s") + public static void q99999s(ScriptManager sm) { + // Quest 99999 - 123 (START) + // NPC: 10000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(99999); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + +} diff --git a/src/main/java/kinoko/script/quest/TitleQuest.java b/src/main/java/kinoko/script/quest/TitleQuest.java index 053697ae..8e4b3724 100644 --- a/src/main/java/kinoko/script/quest/TitleQuest.java +++ b/src/main/java/kinoko/script/quest/TitleQuest.java @@ -252,4 +252,619 @@ public static void q29945s(ScriptManager sm) { // Special Training Master (29945 - start) sm.forceCompleteQuest(29945); } + + // ADDITIONAL TITLE QUESTS (29000-29999) ------------------------------------------------------------------------------------------------------------ + + @Script("q29002e") + public static void q29002e(ScriptManager sm) { + // Quest 29002 - Title Challenge - Celebrity! (END) + // NPC: 9000040 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(29002); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q29002s") + public static void q29002s(ScriptManager sm) { + // Quest 29002 - Title Challenge - Celebrity! (START) + // NPC: 9000040 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29002); + sm.addItem(1142003, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29021e") + public static void q29021e(ScriptManager sm) { + // Quest 29021 - Title - Carnival Winner (END) + // NPC: 2042000 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(29021); + sm.addExp(10000); // EXP reward + sm.addItem(1142187, 1); // Reward item + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q29400e") + public static void q29400e(ScriptManager sm) { + // Quest 29400 - Title Challenge - Veteran Hunter (END) + // NPC: 9000040 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(29400); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q29400s") + public static void q29400s(ScriptManager sm) { + // Quest 29400 - Title Challenge - Veteran Hunter (START) + // NPC: 9000040 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29400); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29500e") + public static void q29500e(ScriptManager sm) { + // Quest 29500 - Title Challenge - Maple Idol Star (END) + // NPC: 9000040 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(29500); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q29500s") + public static void q29500s(ScriptManager sm) { + // Quest 29500 - Title Challenge - Maple Idol Star (START) + // NPC: 9000040 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29500); + sm.addItem(1142003, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29501e") + public static void q29501e(ScriptManager sm) { + // Quest 29501 - Title Challenge - Horned Tail Slayer (END) + // NPC: 9000040 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(29501); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q29501s") + public static void q29501s(ScriptManager sm) { + // Quest 29501 - Title Challenge - Horned Tail Slayer (START) + // NPC: 9000040 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29501); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29502e") + public static void q29502e(ScriptManager sm) { + // Quest 29502 - Title Challenge - Pink Bean Slayer (END) + // NPC: 9000040 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(29502); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q29502s") + public static void q29502s(ScriptManager sm) { + // Quest 29502 - Title Challenge - Pink Bean Slayer (START) + // NPC: 9000040 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29502); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29503e") + public static void q29503e(ScriptManager sm) { + // Quest 29503 - Title Challenge - Donation King (END) + // NPC: 9000040 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(29503); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q29503s") + public static void q29503s(ScriptManager sm) { + // Quest 29503 - Title Challenge - Donation King (START) + // NPC: 9000040 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29503); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29505e") + public static void q29505e(ScriptManager sm) { + // Quest 29505 - The Carnivalian of Absolute Victory (END) + // NPC: 2042005 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(29505); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q29505s") + public static void q29505s(ScriptManager sm) { + // Quest 29505 - The Carnivalian of Absolute Victory (START) + // NPC: 2042005 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29505); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29506e") + public static void q29506e(ScriptManager sm) { + // Quest 29506 - The Gifted Carnivalian (END) + // NPC: 2042005 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(29506); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q29506s") + public static void q29506s(ScriptManager sm) { + // Quest 29506 - The Gifted Carnivalian (START) + // NPC: 2042005 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29506); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29508e") + public static void q29508e(ScriptManager sm) { + // Quest 29508 - Outstanding Citizen (END) + // NPC: 9000040 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(29508); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q29510s") + public static void q29510s(ScriptManager sm) { + // Quest 29510 - Endless Journey Medal (START) + // NPC: 9010010 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29510); + sm.addItem(1142099, 1); // Quest item + sm.addItem(1142100, 1); // Quest item + sm.addItem(1142101, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29900e") + public static void q29900e(ScriptManager sm) { + // Quest 29900 - Beginner Adventurer (END) + // NPC: 9000040 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(29900); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q29901e") + public static void q29901e(ScriptManager sm) { + // Quest 29901 - Junior Adventurer (END) + // NPC: 9000040 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(29901); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q29902e") + public static void q29902e(ScriptManager sm) { + // Quest 29902 - Veteran Adventurer (END) + // NPC: 9000040 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(29902); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q29903e") + public static void q29903e(ScriptManager sm) { + // Quest 29903 - Master Adventurer (END) + // NPC: 9000040 + sm.sayNext("You have completed the quest!"); + + if (sm.askYesNo("Would you like to complete this quest and receive your reward?")) { + sm.forceCompleteQuest(29903); + sm.sayOk("Congratulations on completing the quest!"); + } else { + sm.sayOk("Come back when you're ready to complete the quest."); + } + } + + + @Script("q29904s") + public static void q29904s(ScriptManager sm) { + // Quest 29904 - Noblesse (START) + // NPC: 1101000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29904); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29910s") + public static void q29910s(ScriptManager sm) { + // Quest 29910 - Gallant Warrior (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29910); + sm.addItem(1142009, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29911s") + public static void q29911s(ScriptManager sm) { + // Quest 29911 - Wiseman (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29911); + sm.addItem(1142010, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29912s") + public static void q29912s(ScriptManager sm) { + // Quest 29912 - Lord Sniper (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29912); + sm.addItem(1142011, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29913s") + public static void q29913s(ScriptManager sm) { + // Quest 29913 - Legendary Thief (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29913); + sm.addItem(1142012, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29914s") + public static void q29914s(ScriptManager sm) { + // Quest 29914 - King Pirate (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29914); + sm.addItem(1142013, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29915s") + public static void q29915s(ScriptManager sm) { + // Quest 29915 - Mu Lung Dojo Vanquisher (START) + // NPC: 2091005 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29915); + sm.addItem(1142064, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29916s") + public static void q29916s(ScriptManager sm) { + // Quest 29916 - Henesys Donor Medal (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29916); + sm.addItem(1142014, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29917s") + public static void q29917s(ScriptManager sm) { + // Quest 29917 - Ellinia Donor Medal (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29917); + sm.addItem(1142015, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29918s") + public static void q29918s(ScriptManager sm) { + // Quest 29918 - Perion Donor Medal (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29918); + sm.addItem(1142016, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29919s") + public static void q29919s(ScriptManager sm) { + // Quest 29919 - Kerning City Donor Medal (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29919); + sm.addItem(1142017, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29920s") + public static void q29920s(ScriptManager sm) { + // Quest 29920 - Sleepywood Donor Medal (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29920); + sm.addItem(1142018, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29921s") + public static void q29921s(ScriptManager sm) { + // Quest 29921 - Nautilus Donor Medal (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29921); + sm.addItem(1142019, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29922s") + public static void q29922s(ScriptManager sm) { + // Quest 29922 - Veteran Hunter (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29922); + sm.addItem(1142004, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29923s") + public static void q29923s(ScriptManager sm) { + // Quest 29923 - Legendary Hunter (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29923); + sm.addItem(1142005, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29933s") + public static void q29933s(ScriptManager sm) { + // Quest 29933 - Lith Harbor Donor (START) + // NPC: 9000066 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(29933); + sm.addItem(1142030, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + + @Script("q29946s") + public static void q29946s(ScriptManager sm) { + // Quest 29946 - Title Quest (START) + sm.forceStartQuest(29946); + } } diff --git a/src/main/java/kinoko/script/quest/TrainingQuest.java b/src/main/java/kinoko/script/quest/TrainingQuest.java new file mode 100644 index 00000000..4cc46246 --- /dev/null +++ b/src/main/java/kinoko/script/quest/TrainingQuest.java @@ -0,0 +1,428 @@ +package kinoko.script.quest; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +/** + * Beginner Training Quests (2128-2143) + * Auto-start and auto-complete quests for new players + */ +public final class TrainingQuest extends ScriptHandler { + + // WARRIOR TRAINING QUESTS (2128-2130) ---------------------------------------------------------------- + + @Script("q2128s") + public static void q2128s(ScriptManager sm) { + // Quest 2128 - Beginner Warrior's First Training Session (START) + // Dances with Balrog (1022000) - Perion + sm.sayNext("Little Warrior brother. Just becoming a warrior doesn't mean that you're completely strong. You still need to be trained.. I think this #p1022000# training is useful. What do you think? Do you want to go for a training session?"); + + if (sm.askYesNo("Are you ready to begin your training?")) { + sm.forceStartQuest(2128); + sm.sayNext("You are far too weak to fight against strong monsters. You had better learn how to hunt by first hunting #o0130100#s. #o0130100#s live around #m102000000#. It won't be hard to hunt them. Please let me know when you have defeated twenty #o0130100#s."); + } else { + sm.sayOk("You're not ready yet to be trained."); + } + } + + @Script("q2128e") + public static void q2128e(ScriptManager sm) { + // Quest 2128 - Beginner Warrior's First Training Session (END) + final int STUMP = 130100; +sm.sayNext("You got rid of twenty #o0130100#. Good. You can't be satisfied with this. This is the first step to be a Warrior."); + + if (sm.askYesNo("Are you ready to complete your training?")) { + sm.addExp(590); + sm.addItem(2000000, 30); // Red Potion + sm.addItem(2000003, 30); // Orange Potion + sm.forceCompleteQuest(2128); + sm.sayOk("When I think you need more training, I'll call you. Until we meet again...keep training hard!"); + } + } + + @Script("q2129s") + public static void q2129s(ScriptManager sm) { + // Quest 2129 - Beginner Warrior's Second Training Session (START) + // Dances with Balrog (1022000) - Perion + sm.sayNext("Young Warrior! You've improved. But, you not yet ready for the dangers of the Maple World. You need more basic training. This #p1022000# will help you train. Do you want to have a training session?"); + + if (sm.askYesNo("Are you ready for more training?")) { + sm.forceStartQuest(2129); + sm.sayNext("Then hunt #rfifty #o0130100##k. You've hunted these monsters before, so it won't be that hard. After hunting all them, don't forget to report back to me."); + } else { + sm.sayOk("It's good for you to train yourself if you can."); + } + } + + @Script("q2129e") + public static void q2129e(ScriptManager sm) { + // Quest 2129 - Beginner Warrior's Second Training Session (END) + final int STUMP = 130100; +sm.sayNext("You took out 50 #o0130100#s! Wonderful. I, #p1022000#, am happy to see you improve. But, don't be satisfied with this. You're still fairly weak."); + + if (sm.askYesNo("Are you ready to complete your training?")) { + sm.addExp(610); + sm.addItem(2000001, 30); // Blue Potion + sm.addItem(2000003, 30); // Orange Potion + sm.forceCompleteQuest(2129); + sm.sayOk("When I think you need more training, I'll call you. Until we meet again...keep training hard!"); + } + } + + @Script("q2130s") + public static void q2130s(ScriptManager sm) { + // Quest 2130 - Beginner Warrior's Third Training Session (START) + // Dances with Balrog (1022000) - Perion + sm.sayNext("Young Warrior! I'm happy to see you improve. I can see a strong Warrior's gaze in your eyes. Still... You're short of something. Would you like to go for another training?"); + + if (sm.askYesNo("Are you ready for the final basic training?")) { + sm.forceStartQuest(2130); + sm.sayNext("Good choice. Your mission is to hunt #r80 #o0130100#s#k. It's quite a big number. If you are a Warrior, you need to have patience and endurance. Do not stop until you have achieved victory!"); + } else { + sm.sayOk("If you can train yourself, I won't ask you."); + } + } + + @Script("q2130e") + public static void q2130e(ScriptManager sm) { + // Quest 2130 - Beginner Warrior's Third Training Session (END) + final int STUMP = 130100; +sm.sayNext("You came back after hunting 80 #o0130100#. I pay homage to you for finishing this hard training. Impressive!"); + + if (sm.askYesNo("Are you ready to complete your training?")) { + sm.addExp(645); + sm.addItem(2000002, 30); // White Potion + sm.addItem(2000003, 50); // Orange Potion + sm.forceCompleteQuest(2130); + sm.sayOk("When I think you need more training, I'll call you. Until we meet again...keep training hard!"); + } + } + + // MAGICIAN TRAINING QUESTS (2132-2135) --------------------------------------------------------------- + + @Script("q2132s") + public static void q2132s(ScriptManager sm) { + // Quest 2132 - Beginner Magician's First Training Session (START) + // Grendel the Really Old (1032001) - Ellinia + sm.sayNext("Hey, young Magician. You have many problems as a beginner. You don't know much even though you became a Magician. I guess you find it hard to hunt. Am I right? Do you mind if #p1032001# helps you with training?"); + + if (sm.askYesNo("Will you train?")) { + sm.forceStartQuest(2132); + sm.sayNext("You're too weak to compete with strong monsters. First, you had better learn how to hunt. If you hunt eight #r#o0210100#s#k, you'll master basic skills. #o0210100#s live around #m101000000#."); + } else { + sm.sayOk("You're not ready to be trained."); + } + } + + @Script("q2132e") + public static void q2132e(ScriptManager sm) { + // Quest 2132 - Beginner Magician's First Training Session (END) + final int SNAIL = 210100; +sm.sayNext("Oh~ You killed eight #o0210100#. Much faster than I expected. Wonderful. Fantastic."); + + if (sm.askYesNo("Are you ready to complete your training?")) { + sm.addExp(290); + sm.addItem(2000000, 30); // Red Potion + sm.addItem(2000003, 30); // Orange Potion + sm.forceCompleteQuest(2132); + sm.sayOk("But, you can't be happy with this. Until we meet again...keep training hard!"); + } + } + + @Script("q2133s") + public static void q2133s(ScriptManager sm) { + // Quest 2133 - Beginner Magician's Second Training Session (START) + // Grendel the Really Old (1032001) - Ellinia + sm.sayNext("You have improved a lot. You wouldn't notice it but I, #p1032001#, can see it. Still, you are not good enough. I, #p1032001#, will help you out little bit more. What do you think? Do you want to be trained a bit more?"); + + if (sm.askYesNo("Will you continue your training?")) { + sm.forceStartQuest(2133); + sm.sayNext("Then, eliminate #r20 #o0210100##k. What is important is basic training. You have already eliminated 8 of them before so it shouldn't be hard this time."); + } else { + sm.sayOk("It's good if you can train by yourself."); + } + } + + @Script("q2133e") + public static void q2133e(ScriptManager sm) { + // Quest 2133 - Beginner Magician's Second Training Session (END) + final int SNAIL = 210100; +sm.sayNext("Did you clear 20 #o0210100#? Oh, good. You made it. Wonderful. Keep your pace and you'll get better soon."); + + if (sm.askYesNo("Are you ready to complete your training?")) { + sm.addExp(323); + sm.addItem(2000001, 30); // Blue Potion + sm.addItem(2000003, 30); // Orange Potion + sm.forceCompleteQuest(2133); + sm.sayOk("You can't be a good Magician if you're satisfied with this. Until we meet again...keep training hard!"); + } + } + + @Script("q2134s") + public static void q2134s(ScriptManager sm) { + // Quest 2134 - Beginner Magician's Third Training Session (START) + // Grendel the Really Old (1032001) - Ellinia + sm.sayNext("You finished your second training session! You're improved. If you keep training yourself, you can be the best. Now, you're still a beginner and you'll need #p1032001#'s help."); + + if (sm.askYesNo("Will you continue your training?")) { + sm.forceStartQuest(2134); + sm.sayNext("Your mission is to kill #r35 #o0210100##k. Too many? It's not a small number for sure. But, you can handle it. If you want to be stronger, you must learn to train hard. Go and eliminate #o0210100#. I wish you luck!"); + } else { + sm.sayOk("It's the best if you can train by yourself."); + } + } + + @Script("q2134e") + public static void q2134e(ScriptManager sm) { + // Quest 2134 - Beginner Magician's Third Training Session (END) + final int SNAIL = 210100; +sm.sayNext("You killed 35 #o0210100#s! I'm mildly astonished by your skill thus far. See me if you want another traning session!"); + + if (sm.askYesNo("Are you ready to complete your training?")) { + sm.addExp(345); + sm.addItem(2000002, 30); // White Potion + sm.addItem(2000003, 50); // Orange Potion + sm.forceCompleteQuest(2134); + sm.sayOk("You shouldn't be satisfied with this. Until we meet again...keep training hard!"); + } + } + + @Script("q2135s") + public static void q2135s(ScriptManager sm) { + // Quest 2135 - Beginner Magician's Last Training Session (START) + // Grendel the Really Old (1032001) - Ellinia + sm.sayNext("Was your training helpful? You are quite ready to be a real Magician. Now, let me test your skills. Don't be afraid. It won't be that hard...or will it? Are you ready?"); + + if (sm.askYesNo("Are you ready for the final test?")) { + sm.forceStartQuest(2135); + sm.sayNext("Eliminate #r10 #o1110101##k found in #m101010000# and #m101010100#. They may look cute, but trust me, they're much stronger monsters than #o0210100#. STAY ALERT! Now go and eliminate 10 #o1110101#!"); + } else { + sm.sayOk("If you train by yourself, I won't ask you any more."); + } + } + + @Script("q2135e") + public static void q2135e(ScriptManager sm) { + // Quest 2135 - Beginner Magician's Last Training Session (END) + final int OCTOPUS = 1110101; +sm.sayNext("You succeeded in eliminating 10 #o1110101#. Fabulous! Magicians rarely improve as fast as you do. Now, you learned everything that you need to be a real Magician."); + + if (sm.askYesNo("Are you ready to complete your training?")) { + sm.addExp(470); + sm.addItem(2000002, 50); // White Potion + sm.addItem(2000003, 50); // Orange Potion + sm.addItem(2030000, 10); // Mana Elixir + sm.forceCompleteQuest(2135); + sm.sayOk("You've improved, but you can't be satisfied with this level of power. The road to becoming an Arch Mage is a long one. Although it'll be sometimes hard and tough, you can achieve many things if you're willing to make the effort. That's who Magicians are. Our power comes from within, and can overcome anything, so long as we are willing to push forward! I hope to see you again!"); + } + } + + // BOWMAN TRAINING QUESTS (2136-2138) ----------------------------------------------------------------- + + @Script("q2136s") + public static void q2136s(ScriptManager sm) { + // Quest 2136 - Beginner Bowman's First Training Session (START) + // Athena Pierce (1012100) - Henesys + sm.sayNext("Young Bowman! You are no longer a beginner. I guess you still have many difficulties. It's hard to fight by yourself, isn't it? I can show you some guidelines for your training. What do you say? Would you like to go for it?"); + + if (sm.askYesNo("I want to be trained.")) { + sm.forceStartQuest(2136); + sm.sayNext("You're not familiar with controlling a bow. It's better to hunt #o0210100# and learn how to hunt. Please go and hunt #rsixteen #o0210100##k.#o0210100# live near #m100000000# town. It's easy to find them."); + } else { + sm.sayOk("You're not ready to be trained."); + } + } + + @Script("q2136e") + public static void q2136e(ScriptManager sm) { + // Quest 2136 - Beginner Bowman's First Training Session (END) + final int SNAIL = 210100; +sm.sayNext("You killed 16 #o0210100#. Good. You can't be stronger if you're happy with this. It is just first step as a Bowman."); + + if (sm.askYesNo("Are you ready to complete your training?")) { + sm.addExp(590); + sm.addItem(2000000, 30); // Red Potion + sm.addItem(2000003, 30); // Orange Potion + sm.forceCompleteQuest(2136); + sm.sayOk("I'll call you when you need another training session. Until we meet again...keep training hard!"); + } + } + + @Script("q2137s") + public static void q2137s(ScriptManager sm) { + // Quest 2137 - Beginner Bowman's Second Training Session (START) + // Athena Pierce (1012100) - Henesys + sm.sayNext("You've improved. That's not enough though. I believe you need more training. What do you say? If you want, #p1012100# can help you out with training."); + + if (sm.askYesNo("Will you continue your training?")) { + sm.forceStartQuest(2137); + sm.sayNext("Then hunt #r40 #o0210100##k.You've hunted them before so it won't be hard. After hunting all #o0210100#, come back here to tell me what you did."); + } else { + sm.sayOk("It's good to train by yourself."); + } + } + + @Script("q2137e") + public static void q2137e(ScriptManager sm) { + // Quest 2137 - Beginner Bowman's Second Training Session (END) + final int SNAIL = 210100; +sm.sayNext("You've eliminated forty #o0210100#. Fantastic. I'm happy with your enhancement. Please don't be satisfied with this. You're still weak compared to many monsters in Maple World."); + + if (sm.askYesNo("Are you ready to complete your training?")) { + sm.addExp(610); + sm.addItem(2000001, 30); // Blue Potion + sm.addItem(2000003, 30); // Orange Potion + sm.forceCompleteQuest(2137); + sm.sayOk("I'll call you when you need more training. Until we meet again...keep training hard!"); + } + } + + @Script("q2138s") + public static void q2138s(ScriptManager sm) { + // Quest 2138 - Beginner Bowman's Third Training Session (START) + // Athena Pierce (1012100) - Henesys + sm.sayNext("Bowman with unskilled arms. I'm impressed by your enhancement.Your eyes already belong to another strong Bowman. But, would you like to go for one more training for further improvement?"); + + if (sm.askYesNo("Are you ready for the final basic training?")) { + sm.forceStartQuest(2138); + sm.sayNext("Good choice. Your mission is to hunt #r65 #o0210100##k. 65... It's quite many. You are calm and have a strong will so that I believe you can do it. Now, let me see your strength."); + } else { + sm.sayOk("If you train by yourself, I won't ask you any more."); + } + } + + @Script("q2138e") + public static void q2138e(ScriptManager sm) { + // Quest 2138 - Beginner Bowman's Third Training Session (END) + final int SNAIL = 210100; +sm.sayNext("You killed 65#o0210100#. I pay homage to you for finishing this hard training."); + + if (sm.askYesNo("Are you ready to complete your training?")) { + sm.addExp(645); + sm.addItem(2000002, 30); // White Potion + sm.addItem(2000003, 50); // Orange Potion + sm.forceCompleteQuest(2138); + sm.sayOk("I'll call you when you need another training. Keep training yourself."); + } + } + + // THIEF TRAINING QUESTS (2140-2143) ------------------------------------------------------------------ + + @Script("q2140s") + public static void q2140s(ScriptManager sm) { + // Quest 2140 - Beginner Thief's First Training Session (START) + // Dark Lord (1052001) - Kerning City + sm.sayNext("Being a Thief doesn't mean you are strong. You're just out of the beginner's level. It's hard for you to fight. You look even worse. Would you like to go for a special training by #p1052001#?"); + + if (sm.askYesNo("I want to be trained.")) { + sm.forceStartQuest(2140); + sm.sayNext("I'm sure that you don't know how to use weapons. It's better to hunt #o130100#. You can do anything after you learn how to hunt. Go and hunt #r20 #o130100#s#k and let me know when you've finished. You can do it easily as #o130100#s are all around Kerning City."); + } else { + sm.sayOk("You're not ready to be trained."); + } + } + + @Script("q2140e") + public static void q2140e(ScriptManager sm) { + // Quest 2140 - Beginner Thief's First Training Session (END) + final int STUMP = 130100; +sm.sayNext("You came back with 20 #o130100#s. It's a piece of cake. Don't be happy with this. It is just first step."); + + if (sm.askYesNo("Are you ready to complete your training?")) { + sm.addExp(590); + sm.addItem(2000000, 30); // Red Potion + sm.addItem(2000003, 30); // Orange Potion + sm.forceCompleteQuest(2140); + sm.sayOk("I'll call you when you need another training session. Until we meet again...keep training hard!"); + } + } + + @Script("q2141s") + public static void q2141s(ScriptManager sm) { + // Quest 2141 - Beginner Thief's Second Training Session (START) + // Dark Lord (1052001) - Kerning City + sm.sayNext("Hmm. You seem to be improved. That doesn't make a big difference though. You have a long way to go. You should be trained by #p1052001#. What do you think? Would you like to go for a training?"); + + if (sm.askYesNo("Will you continue your training?")) { + sm.forceStartQuest(2141); + sm.sayNext("Then hunt #r50 #o130100##k. You have hunted these monsters before. It won't be hard. Keep hunting #o130100# and make sure you master a basic training. Then let me know."); + } else { + sm.sayOk("If you train by yourself, do it. I wonder how long you can do that."); + } + } + + @Script("q2141e") + public static void q2141e(ScriptManager sm) { + // Quest 2141 - Beginner Thief's Second Training Session (END) + final int STUMP = 130100; +sm.sayNext("You killed 50 #o130100#s. That's not bad. You just began and that's good. Hmm? What is that face? Do you believe that you did something great? Don't be proud of yourself. You have a long way to go."); + + if (sm.askYesNo("Are you ready to complete your training?")) { + sm.addExp(610); + sm.addItem(2000001, 30); // Blue Potion + sm.addItem(2000003, 30); // Orange Potion + sm.forceCompleteQuest(2141); + sm.sayOk("I'll call you when you need another training session. Until we meet again...keep training hard!"); + } + } + + @Script("q2142s") + public static void q2142s(ScriptManager sm) { + // Quest 2142 - Beginner Thief's Third Training Session (START) + // Dark Lord (1052001) - Kerning City + sm.sayNext("Hmm...you have definitely improved. That's good. We might have a real Thief here... But you're not good enough. Take another training session for further improvement!"); + + if (sm.askYesNo("Are you ready for more training?")) { + sm.forceStartQuest(2142); + sm.sayNext("Good. Your mission is to hunt #r80 #o130100#s#k. It's a lot, but If you are a Thief, you should fight with concentration and calmness. A cool head and calm heart will always win in the end."); + } else { + sm.sayOk("If you train by yourself, I won't ask you any more."); + } + } + + @Script("q2142e") + public static void q2142e(ScriptManager sm) { + // Quest 2142 - Beginner Thief's Third Training Session (END) + final int STUMP = 130100; +sm.sayNext("You hunted 80 #o130100#s. Now I have to say you're quite good."); + + if (sm.askYesNo("Are you ready to complete your training?")) { + sm.addExp(645); + sm.addItem(2000002, 30); // White Potion + sm.addItem(2000003, 50); // Orange Potion + sm.forceCompleteQuest(2142); + sm.sayOk("I'll call you when you need another training session. Until we meet again...keep training hard!"); + } + } + + @Script("q2143s") + public static void q2143s(ScriptManager sm) { + // Quest 2143 - Beginner Thief's Last Training Session (START) + // Dark Lord (1052001) - Kerning City + sm.sayNext("Beginner Thief. You've been doing a good job till now. I can see some of your weak points. But you're a Thief now. Let me test you."); + + if (sm.askYesNo("Are you ready for the final test?")) { + sm.forceStartQuest(2143); + sm.sayNext("It's simple:Hunt #rten #o1120100##k. They're much stronger than #o0130100# which you've dealt with. If you've been doing well with training, it's not that hard. Now go and get them!"); + } else { + sm.sayOk("Are you afraid of the test? Don't be! I won't give you any test that you can't pass!"); + } + } + + @Script("q2143e") + public static void q2143e(ScriptManager sm) { + // Quest 2143 - Beginner Thief's Last Training Session (END) + final int OCTOPUS = 1120100; +sm.sayNext("You succeeded to eliminate ten #o1120100#. Hahaha. Quite good. You were beginner who can't do anything. Now you made it thanks to Dark Lord. Haha. I'm really happy."); + + if (sm.askYesNo("Are you ready to complete your training?")) { + sm.addExp(840); + sm.addItem(2000002, 50); // White Potion + sm.addItem(2000003, 50); // Orange Potion + sm.addItem(2030000, 10); // Mana Elixir + sm.forceCompleteQuest(2143); + sm.sayOk("But this is only beginning as a Thief. There is a long way to be a real Thief. The way of Thief...It is tough. If you make efforts, you can make it. Be calm and concentrate on your job."); + } + } +} diff --git a/src/main/java/kinoko/script/quest/TutorialQuest.java b/src/main/java/kinoko/script/quest/TutorialQuest.java new file mode 100644 index 00000000..50a8afc7 --- /dev/null +++ b/src/main/java/kinoko/script/quest/TutorialQuest.java @@ -0,0 +1,143 @@ +package kinoko.script.quest; + +import kinoko.script.common.Script; +import kinoko.script.common.ScriptHandler; +import kinoko.script.common.ScriptManager; + +/** + * Tutorial and beginner quest scripts + * Covers quests 0-1099 range for tutorial and job recommendation quests + */ +public final class TutorialQuest extends ScriptHandler { + + // TUTORIAL QUEST SCRIPTS + // q0 - Base tutorial quest + // q1028-q1054 - Job recommendation and beginner quests + + @Script("q0e") + public static void q0e(ScriptManager sm) { + // Quest 0 - Tutorial (END) + // Auto-complete tutorial quest + sm.forceCompleteQuest(0); + } + + @Script("q0s") + public static void q0s(ScriptManager sm) { + // Quest 0 - Tutorial (START) + // Auto-start tutorial quest + sm.forceStartQuest(0); + } + + @Script("q1028s") + public static void q1028s(ScriptManager sm) { + // Quest 1028 - To Lith Harbor! (START) + // NPC: 22000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(1028); + sm.addItem(1042003, 1); // Quest item + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q1048s") + public static void q1048s(ScriptManager sm) { + // Quest 1048 - Job Recommendation (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(1048); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q1049s") + public static void q1049s(ScriptManager sm) { + // Quest 1049 - Becoming a Warrior (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(1049); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q1050s") + public static void q1050s(ScriptManager sm) { + // Quest 1050 - Becoming a Magician (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(1050); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q1051s") + public static void q1051s(ScriptManager sm) { + // Quest 1051 - Becoming a Bowman (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(1051); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q1052s") + public static void q1052s(ScriptManager sm) { + // Quest 1052 - Becoming a Thief (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(1052); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q1053s") + public static void q1053s(ScriptManager sm) { + // Quest 1053 - Becoming a Pirate (START) + // NPC: 9010000 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(1053); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } + + @Script("q1054s") + public static void q1054s(ScriptManager sm) { + // Quest 1054 - Cygnus Knights (START) + // NPC: 1101002 + sm.sayNext("Would you like to begin this quest?"); + + if (sm.askAccept("Are you ready to start?")) { + sm.forceStartQuest(1054); + sm.sayOk("Good luck with your quest!"); + } else { + sm.sayOk("Come back when you're ready."); + } + } +} From 9273e579ea297d1d22557264df35638941e3639d Mon Sep 17 00:00:00 2001 From: MujyKun Date: Wed, 29 Oct 2025 02:03:43 -0400 Subject: [PATCH 56/83] Added support for lynx scripts --- .../kinoko/handler/stage/GachaponHandler.java | 56 +++++ .../kinoko/provider/GachaponProvider.java | 207 ++++++++++++++++++ .../script/common/ScriptManagerImpl.java | 2 +- .../java/kinoko/server/event/EventType.java | 3 +- src/main/java/kinoko/util/Tuple.java | 2 +- src/main/java/kinoko/world/BossConstants.java | 49 +++++ src/main/java/kinoko/world/field/mob/Mob.java | 12 +- .../java/kinoko/world/field/mob/MobType.java | 26 +++ .../kinoko/world/quest/QuestRecordType.java | 15 +- .../java/kinoko/world/user/EventCoolDown.java | 33 +++ src/main/java/kinoko/world/user/User.java | 56 ++++- 11 files changed, 451 insertions(+), 10 deletions(-) create mode 100644 src/main/java/kinoko/handler/stage/GachaponHandler.java create mode 100644 src/main/java/kinoko/provider/GachaponProvider.java create mode 100644 src/main/java/kinoko/world/BossConstants.java create mode 100644 src/main/java/kinoko/world/field/mob/MobType.java create mode 100644 src/main/java/kinoko/world/user/EventCoolDown.java diff --git a/src/main/java/kinoko/handler/stage/GachaponHandler.java b/src/main/java/kinoko/handler/stage/GachaponHandler.java new file mode 100644 index 00000000..ce5c8207 --- /dev/null +++ b/src/main/java/kinoko/handler/stage/GachaponHandler.java @@ -0,0 +1,56 @@ +package kinoko.handler.stage; + +import kinoko.provider.GachaponProvider; +import kinoko.provider.ItemProvider; +import kinoko.provider.item.ItemInfo; +import kinoko.provider.reward.Reward; +import kinoko.util.Tuple; +import kinoko.util.Util; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Comparator; +import java.util.List; +import java.util.Optional; + +public final class GachaponHandler { + private static final Logger log = LogManager.getLogger(GachaponHandler.class); + + public static Tuple rollGachapon(String gachaponName) { + List rewards = GachaponProvider.getGachaponRewards(gachaponName); + if (rewards.isEmpty()) { + throw new IllegalArgumentException("No rewards available for Gachapon: " + gachaponName); + } + + // Calculate the sum of all weights + double totalWeight = rewards.stream() + .mapToDouble(Reward::getProb) + .sum(); + + // Generate a random value between 0 and the total weight + double randomValue = Math.random() * totalWeight; + + // Find the reward that corresponds to the random value + double cumulativeWeight = 0.0; + for (Reward reward : rewards) { + cumulativeWeight += reward.getProb(); + if (randomValue <= cumulativeWeight) { + // We found our reward + final Optional itemInfoResult = ItemProvider.getItemInfo(reward.getItemId()); + if (itemInfoResult.isEmpty()) { + // If the item doesn't exist, try again + return rollGachapon(gachaponName); + } + final int quantity = Util.getRandom(reward.getMin(), reward.getMax()); + return Tuple.of(reward.getItemId(), quantity); + } + } + + // Fallback + Reward mostProbableReward = rewards.stream() + .max(Comparator.comparingDouble(Reward::getProb)) + .orElse(rewards.getFirst()); + return Tuple.of(mostProbableReward.getItemId(), + Util.getRandom(mostProbableReward.getMin(), mostProbableReward.getMax())); + } +} diff --git a/src/main/java/kinoko/provider/GachaponProvider.java b/src/main/java/kinoko/provider/GachaponProvider.java new file mode 100644 index 00000000..8b9c3de1 --- /dev/null +++ b/src/main/java/kinoko/provider/GachaponProvider.java @@ -0,0 +1,207 @@ +package kinoko.provider; + +import kinoko.provider.reward.Reward; +import kinoko.server.ServerConfig; +import org.snakeyaml.engine.v2.api.Load; +import org.snakeyaml.engine.v2.api.LoadSettings; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.file.Files; +import java.nio.file.Path; +import java.util.*; +import java.util.stream.Stream; + +public final class GachaponProvider { + public static final Path GACHAPON_DATA = Path.of(ServerConfig.DATA_DIRECTORY, "gachapon"); + private static final Map> gachaponRewards = new HashMap<>(); // gachaponName -> rewards + private static final Map> gachaponConfigs = new HashMap<>(); // gachaponName -> full config + private static List globalRewards = new ArrayList<>(); // global rewards list + + public static void initialize() { + final Load yamlLoader = new Load(LoadSettings.builder().build()); + + // First, load the global.yaml file if it exists + try { + Path globalPath = GACHAPON_DATA.resolve("global.yaml"); + if (Files.exists(globalPath)) { + try (final InputStream is = Files.newInputStream(globalPath)) { + loadGlobalRewards(yamlLoader.loadFromInputStream(is)); + } + } + } catch (IOException e) { + throw new IllegalArgumentException("Exception caught while loading Global Gachapon Data", e); + } + + // Then load all location-specific gachapon files + try (final Stream paths = Files.list(GACHAPON_DATA)) { + for (Path path : paths.toList()) { + final String fileName = path.getFileName().toString(); + if (!fileName.endsWith(".yaml") || fileName.equals("global.yaml")) { + continue; + } + final String gachaponName = fileName.replace(".yaml", ""); + try (final InputStream is = Files.newInputStream(path)) { + Map config = (Map) yamlLoader.loadFromInputStream(is); + gachaponConfigs.put(gachaponName, config); + processGachaponConfig(gachaponName, config); + } + } + } catch (IOException e) { + throw new IllegalArgumentException("Exception caught while loading Gachapon Data", e); + } + } + + public static List getGachaponRewards(String gachaponName) { + return gachaponRewards.getOrDefault(gachaponName, List.of()); + } + + @SuppressWarnings("unchecked") + private static void loadGlobalRewards(Object yamlObject) { + if (!(yamlObject instanceof Map rewardData)) { + throw new IllegalArgumentException("Could not resolve global reward data"); + } + if (!(rewardData.get("rewards") instanceof List rewardList)) { + return; + } + + for (Object rewardObject : rewardList) { + if (!(rewardObject instanceof List rewardInfo)) { + throw new IllegalArgumentException("Invalid global reward format"); + } + final int itemId = ((Number) rewardInfo.get(0)).intValue(); + final int min = ((Number) rewardInfo.get(1)).intValue(); + final int max = ((Number) rewardInfo.get(2)).intValue(); + final double prob = ((Number) rewardInfo.get(3)).doubleValue(); + globalRewards.add(Reward.item(itemId, min, max, prob, 0)); + } + } + + @SuppressWarnings("unchecked") + private static void processGachaponConfig(String gachaponName, Map config) { + // Process location-specific rewards + List locationRewards = new ArrayList<>(); + if (config.get("rewards") instanceof List rewardList) { + for (Object rewardObject : rewardList) { + if (!(rewardObject instanceof List rewardInfo)) { + throw new IllegalArgumentException("Invalid reward format for Gachapon: " + gachaponName); + } + final int itemId = ((Number) rewardInfo.get(0)).intValue(); + final int min = ((Number) rewardInfo.get(1)).intValue(); + final int max = ((Number) rewardInfo.get(2)).intValue(); + final double prob = ((Number) rewardInfo.get(3)).doubleValue(); + locationRewards.add(Reward.item(itemId, min, max, prob, 0)); + } + } + + // Process global item settings and merge with location rewards + List finalRewards = new ArrayList<>(locationRewards); + + if (config.containsKey("global_item_settings")) { + Map globalSettings = (Map) config.get("global_item_settings"); + boolean enabled = globalSettings.containsKey("enabled") && (boolean) globalSettings.get("enabled"); + + if (enabled) { + double weightModifier = 1.0; // Default: no modification + if (globalSettings.containsKey("weight_modifier")) { + weightModifier = ((Number) globalSettings.get("weight_modifier")).doubleValue(); + } + + // Check if we need to include or exclude specific items + List> itemsToInclude = globalSettings.containsKey("items_to_include") ? + (List>) globalSettings.get("items_to_include") : null; + + List> itemsToExclude = globalSettings.containsKey("items_to_exclude") ? + (List>) globalSettings.get("items_to_exclude") : null; + + // Add modified global rewards + for (Reward globalReward : globalRewards) { + // Skip if item should be excluded + if (shouldExcludeItem(globalReward.getItemId(), itemsToExclude)) { + continue; + } + + // Include only if no specific inclusion rules or item matches inclusion rules + if (itemsToInclude == null || shouldIncludeItem(globalReward.getItemId(), itemsToInclude)) { + double modifiedProb = globalReward.getProb() * weightModifier; + finalRewards.add(Reward.item( + globalReward.getItemId(), + globalReward.getMin(), + globalReward.getMax(), + modifiedProb, + 0 + )); + } + } + } + } + + gachaponRewards.put(gachaponName, Collections.unmodifiableList(finalRewards)); + } + + private static boolean shouldExcludeItem(int itemId, List> exclusionRules) { + if (exclusionRules == null) { + return false; + } + + for (Map rule : exclusionRules) { + // Check for type-based exclusion (e.g., "scroll" items) + if (rule.containsKey("type")) { + String type = (String) rule.get("type"); + if (type.equals("scroll") && (itemId / 10000) == 204) { + return true; + } + } + + // Check for ID range exclusion + if (rule.containsKey("id_range")) { + List range = (List) rule.get("id_range"); + int minId = range.get(0).intValue(); + int maxId = range.get(1).intValue(); + if (itemId >= minId && itemId <= maxId) { + return true; + } + } + + // Check for specific ID exclusion + if (rule.containsKey("id") && ((Number) rule.get("id")).intValue() == itemId) { + return true; + } + } + + return false; + } + + private static boolean shouldIncludeItem(int itemId, List> inclusionRules) { + if (inclusionRules == null) { + return true; + } + + for (Map rule : inclusionRules) { + // Check for type-based inclusion (e.g., "scroll" items) + if (rule.containsKey("type")) { + String type = (String) rule.get("type"); + if (type.equals("scroll") && (itemId / 10000) == 204) { + return true; + } + } + + // Check for ID range inclusion + if (rule.containsKey("id_range")) { + List range = (List) rule.get("id_range"); + int minId = range.get(0).intValue(); + int maxId = range.get(1).intValue(); + if (itemId >= minId && itemId <= maxId) { + return true; + } + } + + // Check for specific ID inclusion + if (rule.containsKey("id") && ((Number) rule.get("id")).intValue() == itemId) { + return true; + } + } + + return false; + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/script/common/ScriptManagerImpl.java b/src/main/java/kinoko/script/common/ScriptManagerImpl.java index 9a4f8607..32a24034 100644 --- a/src/main/java/kinoko/script/common/ScriptManagerImpl.java +++ b/src/main/java/kinoko/script/common/ScriptManagerImpl.java @@ -780,7 +780,7 @@ public String getTimeUntilEventReset(EventType eventType) { public boolean partyHasCoolDown(EventType eventType, int runsPerDay) { final List members = field.getUserPool().getPartyMembers(user.getPartyId()); for (User member : members) { - if (member.getAccount().isGM()) { + if (member.isGM()) { return false; } if (member.getEventAmountDone(eventType) >= runsPerDay) { diff --git a/src/main/java/kinoko/server/event/EventType.java b/src/main/java/kinoko/server/event/EventType.java index 8b5ad469..3c4ea7db 100644 --- a/src/main/java/kinoko/server/event/EventType.java +++ b/src/main/java/kinoko/server/event/EventType.java @@ -7,7 +7,8 @@ public enum EventType { CM_ARIANT, CM_ELEVATOR, CM_SUBWAY, - CM_AIRPORT; + CM_AIRPORT, + PQ_BALROG; public static EventType getByName(String name) { for (EventType type : values()) { diff --git a/src/main/java/kinoko/util/Tuple.java b/src/main/java/kinoko/util/Tuple.java index fcf5cec2..683880c3 100644 --- a/src/main/java/kinoko/util/Tuple.java +++ b/src/main/java/kinoko/util/Tuple.java @@ -48,4 +48,4 @@ public int hashCode() { public static Tuple of(L left, R right) { return new Tuple<>(left, right); } -} +} \ No newline at end of file diff --git a/src/main/java/kinoko/world/BossConstants.java b/src/main/java/kinoko/world/BossConstants.java new file mode 100644 index 00000000..5bc01de7 --- /dev/null +++ b/src/main/java/kinoko/world/BossConstants.java @@ -0,0 +1,49 @@ +package kinoko.world; + +public final class BossConstants { + public static final int BALROG_ENTRY_MAP = 105100100; // Balrog Temple : Bottom of the Temple + public static final short BALROG_TIME_LIMIT = 3600; // 30 min + public static final short BALROG_RELEASE_LEFT_CLAW_INTERVAL = 60; // 10 minutes + public static final int BALROG_RUNS_PER_DAY = 1; + public static final short BALROG_SPAWN_X = 412; + public static final short BALROG_SPAWN_Y = 258; + public static final int BALROG_LEFT_ARM = 8830001; + public static final int BALROG_RIGHT_ARM = 8830002; + public static final int BALROG_FAKE_LEFT_ARM = 8830004; + public static final int BALROG_FAKE_RIGHT_ARM = 8830005; + public static final long BALROG_COOLDOWN = 21600000; // 6 hrs + + // NORMAL BALROG CONSTANTS + public static final Integer[] NORMAL_BALROG_IDS = { // All Normal Balrog Boss Template ID's + 8830010, 8830000, 8830001, 8830002, 8830004, 8830005, + }; + + public static final int BALROG_NORMAL_TREASURE_THIEF = 9402045; // Normal Balrog Loot Mob + public static final int BALROG_NORMAL_TREASURE_THIEF_HP = 1000000; // Normal Balrog Loot Mob HP + + public static final int BALROG_NORMAL_BATTLE_MAP = 105100400; // Normal Balrog Battle Map + public static final int BALROG_NORMAL_WIN_MAP = 105100401; // Normal Balrog Victory Map + + public static final int BALROG_NORMAL_BODY_HP = 3600000; // Normal Balrog Body HP + public static final int BALROG_NORMAL_ARM_HP = 1800000; // Normal Balrog Arm HP + + public static final int BALROG_NORMAL_DAMAGE_SINK = 8830010; // Normal Balrog Damage Sink Template ID + public static final int BALROG_NORMAL_BODY = 8830007; // Normal Balrog Body Template ID + + // MYSTIC BALROG CONSTANTS + public static final Integer[] MYSTIC_BALROG_IDS = { // All Mystic Balrog Boss Template ID's + 8830010, 8830000, 8830001, 8830002, 8830004, 8830005, + }; + + public static final int BALROG_MYSTIC_TREASURE_THIEF = 9402046; // Mystic Balrog Loot Mob + public static final int BALROG_MYSTIC_TREASURE_THIEF_HP = 100000000; // Mystic Balrog Loot Mob HP + + public static final int BALROG_MYSTIC_BATTLE_MAP = 105100300; // Mystic Balrog Battle Map + public static final int BALROG_MYSTIC_WIN_MAP = 105100301; // Mystic Balrog Victory Map + + public static final long BALROG_MYSTIC_BODY_HP = 180000000000L; // Mystic Balrog Body HP + public static final long BALROG_MYSTIC_ARM_HP = 90000000000L; // Mystic Balrog Arm HP + + public static final int BALROG_MYSTIC_DAMAGE_SINK = 8830010; // Mystic Balrog Damage Sink Template ID + public static final int BALROG_MYSTIC_BODY = 8830000; // Mystic Balrog Body Template ID +} \ No newline at end of file diff --git a/src/main/java/kinoko/world/field/mob/Mob.java b/src/main/java/kinoko/world/field/mob/Mob.java index 109befee..0364c7c5 100644 --- a/src/main/java/kinoko/world/field/mob/Mob.java +++ b/src/main/java/kinoko/world/field/mob/Mob.java @@ -53,6 +53,7 @@ public final class Mob extends Life implements ControlledObject, Encodable { private int hp; private int mp; private int summonType; + private int mobType; private int itemDropCount; private boolean slowUsed; private int swallowCharacterId; @@ -76,6 +77,7 @@ public Mob(MobTemplate template, MobSpawnPoint spawnPoint, int x, int y, int fh) this.hp = template.getMaxHp(); this.mp = template.getMaxMp(); this.summonType = MobAppearType.REGEN.getValue(); + this.mobType = MobType.NORMAL.getValue(); this.nextSendMobHp = Instant.MIN; this.nextSkillUse = Instant.MIN; this.nextRecovery = Instant.now().plus(GameConstants.MOB_RECOVER_TIME, ChronoUnit.SECONDS); @@ -200,6 +202,14 @@ public void setSummonType(int summonType) { this.summonType = summonType; } + public int getMobType() { + return mobType; + } + + public void setMobType(int mobType) { + this.mobType = mobType; + } + public int getExp() { if (getMobStat().hasOption(MobTemporaryStat.Showdown)) { final double multiplier = (getMobStat().getOption(MobTemporaryStat.Showdown).nOption + 100) / 100.0; @@ -689,4 +699,4 @@ public void encode(OutPacket outPacket) { outPacket.encodeInt(0); // nEffectItemID outPacket.encodeInt(0); // nPhase } -} +} \ No newline at end of file diff --git a/src/main/java/kinoko/world/field/mob/MobType.java b/src/main/java/kinoko/world/field/mob/MobType.java new file mode 100644 index 00000000..a0c2e674 --- /dev/null +++ b/src/main/java/kinoko/world/field/mob/MobType.java @@ -0,0 +1,26 @@ +package kinoko.world.field.mob; + +public enum MobType { + NORMAL(0), + SUB_MOB(1), + PARENT_MOB(2); + + private final byte value; + + MobType(int value) { + this.value = (byte) value; + } + + public final byte getValue() { + return value; + } + + public static MobType getByValue(int value) { + for (MobType type : values()) { + if (type.getValue() == value) { + return type; + } + } + return null; + } +} diff --git a/src/main/java/kinoko/world/quest/QuestRecordType.java b/src/main/java/kinoko/world/quest/QuestRecordType.java index b7136410..31d4d277 100644 --- a/src/main/java/kinoko/world/quest/QuestRecordType.java +++ b/src/main/java/kinoko/world/quest/QuestRecordType.java @@ -41,7 +41,18 @@ public enum QuestRecordType { EdelsteinUnlockTownQuests(23977), //Not sure what quest is supposed to update this EdelsteinFabioFirebombs(23979), - EdelsteinWonny10PM(23984); + EdelsteinWonny10PM(23984), + + ZakumPreqStageOne(100200), // Zakum Pre-quest Stage 1 tracking + ZakumPreqStageTwo(100201), // Zakum Pre-quest Stage 2 tracking + Zakum(100250), // Zakum daily entry tracking + MuLungDojoTutorial(100300), // Mu Lung Dojo tutorial quest + GachaponEvent(100400), // Gachapon return map tracking + AlcasterAndTheDarkCrystal(100500), // Alcaster Dark Crystal quest + WheresHella(100501), // Where's Hella quest + TheSmallGraveThatsHidden(100502), // Small Grave quest + AcquiringTheFairyDust(100503), // Fairy Dust quest + MemoryKeeper(100504); // Memory Keeper quest private final int questId; @@ -52,4 +63,4 @@ public enum QuestRecordType { public final int getQuestId() { return questId; } -} +} \ No newline at end of file diff --git a/src/main/java/kinoko/world/user/EventCoolDown.java b/src/main/java/kinoko/world/user/EventCoolDown.java new file mode 100644 index 00000000..35d54b5e --- /dev/null +++ b/src/main/java/kinoko/world/user/EventCoolDown.java @@ -0,0 +1,33 @@ +package kinoko.world.user; + +import kinoko.server.event.EventType; + +public final class EventCoolDown { + private EventType eventType; + private int amountDone; + private long nextResetTime; + + public EventCoolDown(EventType eventType, int amountDone, long nextResetTime) { + this.eventType = eventType; + this.amountDone = amountDone; + this.nextResetTime = nextResetTime; + } + public EventType getEventType() { + return eventType; + } + public void setEventType(EventType eventType) { + this.eventType = eventType; + } + public int getAmountDone() { + return amountDone; + } + public void setAmountDone(int amountDone) { + this.amountDone = amountDone; + } + public long getNextResetTime() { + return nextResetTime; + } + public void setNextResetTime(long nextResetTime) { + this.nextResetTime = nextResetTime; + } +} diff --git a/src/main/java/kinoko/world/user/User.java b/src/main/java/kinoko/world/user/User.java index 0ed084d3..3a6980b9 100644 --- a/src/main/java/kinoko/world/user/User.java +++ b/src/main/java/kinoko/world/user/User.java @@ -17,6 +17,7 @@ import kinoko.server.dialog.Dialog; import kinoko.server.dialog.ScriptDialog; import kinoko.server.dialog.miniroom.MiniRoom; +import kinoko.server.event.EventType; import kinoko.server.guild.GuildRank; import kinoko.server.node.ChannelServerNode; import kinoko.server.node.Client; @@ -68,7 +69,6 @@ public final class User extends Life { private final Map schedules = new HashMap<>(); private final AtomicInteger fieldKey = new AtomicInteger(0); - private int messengerId; private PartyInfo partyInfo; private GuildInfo guildInfo; @@ -81,7 +81,9 @@ public final class User extends Life { private int portableChairId; private boolean inCashShop = false; private String adBoard; + private int dojoEnergy; private boolean inTransfer; + private List cooldowns = new ArrayList<>(); private Instant nextCheckItemExpire; public User(Client client, CharacterData characterData) { @@ -98,6 +100,10 @@ public Account getAccount() { return client.getAccount(); } + public boolean isGM() { + return getAdminLevel().isAtLeast(AdminLevel.JR_GM); + } + public ChannelServerNode getConnectedServer() { return (ChannelServerNode) client.getServerNode(); } @@ -310,6 +316,46 @@ public void setTownPortal(TownPortal townPortal) { this.townPortal = townPortal; } + public int getDojoEnergy() { + return dojoEnergy; + } + + public void setDojoEnergy(int newEnergy) { + this.dojoEnergy = newEnergy; + } + + public void resetDojoEnergy() { + this.dojoEnergy = 0; + } + public EventCoolDown getCoolDownByType(EventType eventType) { + return this.cooldowns.stream().filter(eventCoolDown -> eventCoolDown.getEventType() == eventType).toList().getFirst(); + } + + public void addCoolDown(EventType eventType, long time) { + addCoolDown(eventType, 1, System.currentTimeMillis() + time); + } + + public void addCoolDown(EventType eventType, int amountDone, long nextReset) { + EventCoolDown cd = this.cooldowns.stream().filter(eventCoolDown -> eventCoolDown.getEventType() == eventType).findFirst().orElse(null); + if (cd == null) { + cd = new EventCoolDown(eventType, amountDone, nextReset); + this.cooldowns.add(cd); + } else { + cd.setNextResetTime(nextReset); + cd.setAmountDone(amountDone); + } + } + + public int getEventAmountDone(EventType eventType) { + EventCoolDown cd = this.cooldowns.stream().filter(eventCoolDown -> eventCoolDown.getEventType() == eventType).findFirst().orElse(null); + if (cd == null) { + return 0; + } + if (System.currentTimeMillis() > cd.getNextResetTime()) { + cd.setAmountDone(0); + } + return cd.getAmountDone(); + } public int getTownPortalIndex() { return hasParty() ? getPartyMemberIndex() - 1 : 0; } @@ -372,6 +418,10 @@ public int getJob() { return getCharacterStat().getJob(); } + public boolean is4thJob() { + return getCharacterStat().getJob() % 10 == 2; + } + public int getLevel() { return getCharacterStat().getLevel(); } @@ -813,8 +863,7 @@ public void write(OutPacket outPacket) { } public void dispose() { - OutPacket outpacket = WvsContext.statChanged(Map.of(), true); - write(outpacket); + write(WvsContext.statChanged(Map.of(), true)); } public void logout(boolean disconnect) { @@ -868,7 +917,6 @@ public void setInCashShop(boolean inCashShop) { this.inCashShop = inCashShop; } - // OVERRIDES ------------------------------------------------------------------------------------------------------- @Override From 3c71411fb7ee6833305f770497cd643216f97f53 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Wed, 29 Oct 2025 17:35:16 -0400 Subject: [PATCH 57/83] LYNX - Fixed ClassScanner not working when building a JAR and running via bat file. --- src/main/java/kinoko/util/ClassScanner.java | 47 ++++++++++++++++++--- 1 file changed, 40 insertions(+), 7 deletions(-) diff --git a/src/main/java/kinoko/util/ClassScanner.java b/src/main/java/kinoko/util/ClassScanner.java index e1f05730..926c30ed 100644 --- a/src/main/java/kinoko/util/ClassScanner.java +++ b/src/main/java/kinoko/util/ClassScanner.java @@ -1,10 +1,15 @@ package kinoko.util; import java.io.File; +import java.io.IOException; +import java.net.JarURLConnection; import java.net.URL; +import java.util.Enumeration; import java.util.HashSet; import java.util.Objects; import java.util.Set; +import java.util.jar.JarEntry; +import java.util.jar.JarFile; public final class ClassScanner { public static Set> getClasses(String packageName) { @@ -13,17 +18,45 @@ public static Set> getClasses(String packageName) { try { URL resource = Thread.currentThread().getContextClassLoader().getResource(path); if (resource == null) return classes; - File dir = new File(resource.toURI()); - if (!dir.exists()) return classes; - for (File file : Objects.requireNonNull(dir.listFiles())) { - if (file.isFile() && file.getName().endsWith(".class")) { - String className = packageName + "." + file.getName().replace(".class", ""); - classes.add(Class.forName(className)); + + String protocol = resource.getProtocol(); + + if ("file".equals(protocol)) { + // Running from IDE/filesystem + File dir = new File(resource.toURI()); + if (!dir.exists()) return classes; + for (File file : Objects.requireNonNull(dir.listFiles())) { + if (file.isFile() && file.getName().endsWith(".class")) { + String className = packageName + "." + file.getName().replace(".class", ""); + classes.add(Class.forName(className)); + } + } + } else if ("jar".equals(protocol)) { + // Running from JAR file (start.bat) + JarURLConnection connection = (JarURLConnection) resource.openConnection(); + JarFile jarFile = connection.getJarFile(); + Enumeration entries = jarFile.entries(); + String pathPrefix = packageName.replace('.', '/') + "/"; + + while (entries.hasMoreElements()) { + JarEntry entry = entries.nextElement(); + String name = entry.getName(); + + // Check if it's in our package, is a class file, and not a subpackage + if (name.startsWith(pathPrefix) && name.endsWith(".class")) { + String relativePath = name.substring(pathPrefix.length()); + // Only include classes directly in this package (not subpackages) + if (!relativePath.contains("/") && !relativePath.contains("$")) { + String className = name.replace('/', '.').replace(".class", ""); + classes.add(Class.forName(className)); + } + } } + jarFile.close(); } } catch (Exception e) { e.printStackTrace(); } return classes; } -} +} \ No newline at end of file From 3075f4512e88c0b495d428f3a6895a69eec8c017 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Wed, 29 Oct 2025 18:09:13 -0400 Subject: [PATCH 58/83] Fixed quest typo --- src/main/java/kinoko/script/boss/Zakum.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/kinoko/script/boss/Zakum.java b/src/main/java/kinoko/script/boss/Zakum.java index b203af56..11ac3440 100644 --- a/src/main/java/kinoko/script/boss/Zakum.java +++ b/src/main/java/kinoko/script/boss/Zakum.java @@ -194,7 +194,7 @@ public static void zakum00(ScriptManager sm) { sm.forceCompleteQuest(100202); sm.sayOk("Here it is. You will now be able to enter the alter of the Zakum Dungeon when the door on the left is open.. You'll need\\r\\n#b#t4001017##k with you in order to go through the door and enter the stage. Now, let's see how many can enter this place ...?"); } - else if (sm.hasQuestCompleted(3)) { + else if (sm.hasQuestCompleted(100202)) { if (!sm.askYesNo("Hmmm ... aren't you the one who refined #b#t4001017##k before? Then what can I do for you? Are you interested in mixing #b#t4031061##k with #b#t4031062##k again to create #b#t4001017##k?")) { sm.sayOk("I see ... but please be aware that you won't be able to see the boss of Zakum Dungeon without the #b#t4001017##k."); return; From 8e94af598e2f224b9b142ef10c2b975588e81c7f Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 4 Nov 2025 17:54:29 -0500 Subject: [PATCH 59/83] Added schema updater --- .../postgresql/PostgresConnector.java | 5 ++ .../database/postgresql/setup/README.md | 9 +++ .../postgresql/setup/SchemaUpdater.java | 73 +++++++++++++++++++ .../kinoko/database/postgresql/setup/init.sql | 70 +++++++++++++++++- .../postgresql/setup/updates/n.sql.template | 11 +++ 5 files changed, 167 insertions(+), 1 deletion(-) create mode 100644 src/main/java/kinoko/database/postgresql/setup/README.md create mode 100644 src/main/java/kinoko/database/postgresql/setup/SchemaUpdater.java create mode 100644 src/main/java/kinoko/database/postgresql/setup/updates/n.sql.template diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java index 7daa02cf..a735860c 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresConnector.java +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -2,9 +2,11 @@ import kinoko.database.*; +import java.sql.Connection; import java.util.TimeZone; import com.zaxxer.hikari.HikariConfig; import com.zaxxer.hikari.HikariDataSource; +import kinoko.database.postgresql.setup.SchemaUpdater; import kinoko.server.ServerConstants; public final class PostgresConnector implements DatabaseConnector { @@ -49,6 +51,9 @@ public void initialize() { // stmt.execute(sql); // } // } + try (Connection connection = dataSource.getConnection()) { + SchemaUpdater.run(connection); + } // Create Accessors idAccessor = new PostgresIdAccessor(dataSource); diff --git a/src/main/java/kinoko/database/postgresql/setup/README.md b/src/main/java/kinoko/database/postgresql/setup/README.md new file mode 100644 index 00000000..89536ff8 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/setup/README.md @@ -0,0 +1,9 @@ +# Schema Update Instructions + +To create a new migration: + +1. Copy the `updates/n.sql.template` file. +2. Rename the copy to the next schema version, e.g., `1.sql`, `2.sql`, etc in the `updates/` folder. +3. Edit the new `.sql` file to include your schema changes. +4. Ensure each `.sql` file is only a **SINGLE** transaction. +5. Do **not** include `versioning.increment_schema_version` in the SQL - the Java `SchemaUpdater` will handle version increments automatically. diff --git a/src/main/java/kinoko/database/postgresql/setup/SchemaUpdater.java b/src/main/java/kinoko/database/postgresql/setup/SchemaUpdater.java new file mode 100644 index 00000000..c1e4e34d --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/setup/SchemaUpdater.java @@ -0,0 +1,73 @@ +package kinoko.database.postgresql.setup; + +import java.nio.file.*; +import java.sql.*; +import java.io.IOException; + +public class SchemaUpdater { + private static final String UPDATES_DIR = "src/main/java/kinoko/database/postgresql/setup/updates"; + + public static void run(Connection connection) throws SQLException, IOException { + long startTime = System.nanoTime(); // start timing + + // Step 1: get current version + int currentVersion = getSchemaVersion(connection); + + System.out.println("Current schema version: " + currentVersion); + + while (true) { + int nextVersion = currentVersion + 1; + Path nextFile = Path.of(UPDATES_DIR, nextVersion + ".sql"); + + if (!Files.exists(nextFile)) { + System.out.println("No update file found for version " + nextVersion + ". Schema is up-to-date."); + break; + } + + System.out.println("Applying schema update: " + nextFile.getFileName()); + + String sql = Files.readString(nextFile); + + try { + // Step 2: execute migration + connection.setAutoCommit(false); + try (Statement stmt = connection.createStatement()) { + stmt.execute(sql); + } + + // Step 3: increment schema version (inside same transaction) + try (PreparedStatement ps = connection.prepareStatement( + "SELECT versioning.increment_schema_version(?)")) { + ps.setInt(1, currentVersion); + ps.executeQuery(); + } + + connection.commit(); + currentVersion = nextVersion; + + System.out.println("✅ Successfully applied version " + currentVersion); + } catch (SQLException e) { + connection.rollback(); + System.err.println("❌ Failed to apply schema update " + nextFile.getFileName() + ": " + e.getMessage()); + break; + } finally { + connection.setAutoCommit(true); + } + } + + long endTime = System.nanoTime(); // end timing + long durationMs = (endTime - startTime) / 1_000_000; // convert to milliseconds + System.out.println("Final schema version: " + currentVersion); + System.out.println("Schema updater completed in " + durationMs + " ms"); + } + + private static int getSchemaVersion(Connection connection) throws SQLException { + try (Statement stmt = connection.createStatement(); + ResultSet rs = stmt.executeQuery("SELECT versioning.get_schema_version()")) { + if (rs.next()) { + return rs.getInt(1); + } + } + return 0; // fallback default + } +} diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index 631fd3ae..73ce6018 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -506,4 +506,72 @@ VALUES ( 0 ); -COMMIT TRANSACTION; \ No newline at end of file +COMMIT TRANSACTION; + + + + +--------------------------------- +--SCHEMA UPDATES AND VERSIONING-- +--------------------------------- +-- Create schema for version tracking +CREATE SCHEMA IF NOT EXISTS versioning; + +-- Create the version tracking table +CREATE TABLE IF NOT EXISTS versioning.schema_version ( + version INTEGER PRIMARY KEY, + applied_at TIMESTAMP NOT NULL DEFAULT UTC_NOW() +); + +-- Function to get current schema version +CREATE OR REPLACE FUNCTION versioning.get_schema_version() +RETURNS INTEGER AS $$ +DECLARE + v INTEGER; +BEGIN + SELECT version INTO v + FROM versioning.schema_version + ORDER BY version DESC + LIMIT 1; + + IF v IS NULL THEN + RETURN 0; + END IF; + + RETURN v; +END; +$$ LANGUAGE plpgsql; + + +-- Function to increment schema version +CREATE OR REPLACE FUNCTION versioning.increment_schema_version(expected_current_version INTEGER) +RETURNS INTEGER AS $$ +DECLARE + current_version INTEGER; + new_version INTEGER; +BEGIN + -- Get current version, or default to 0 if none exists + SELECT COALESCE( + (SELECT version + FROM versioning.schema_version + ORDER BY version DESC + LIMIT 1), + 0 + ) INTO current_version; + + -- Ensure it matches the expected value + IF current_version IS DISTINCT FROM expected_current_version THEN + RAISE EXCEPTION + 'Schema version mismatch. Expected %, but current version is %.', + expected_current_version, current_version; + END IF; + + -- Increment and insert the new version + new_version := current_version + 1; + + INSERT INTO versioning.schema_version (version) + VALUES (new_version); + + RETURN new_version; +END; +$$ LANGUAGE plpgsql; diff --git a/src/main/java/kinoko/database/postgresql/setup/updates/n.sql.template b/src/main/java/kinoko/database/postgresql/setup/updates/n.sql.template new file mode 100644 index 00000000..003224f7 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/setup/updates/n.sql.template @@ -0,0 +1,11 @@ +-- Migration template for schema version n +-- Replace N with the version you are incrementing to (old version + 1) +-- Example: if current schema is 2, this file should be 3.sql + +BEGIN; + +-- Your SQL updates here +-- Example: +-- ALTER TABLE users ADD COLUMN last_login TIMESTAMPTZ; + +COMMIT; From 151d6d2c9216fedc3dd77f5165cac3f4807ec0e1 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 4 Nov 2025 17:56:35 -0500 Subject: [PATCH 60/83] removed comments --- .../kinoko/database/postgresql/setup/SchemaUpdater.java | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/main/java/kinoko/database/postgresql/setup/SchemaUpdater.java b/src/main/java/kinoko/database/postgresql/setup/SchemaUpdater.java index c1e4e34d..bf0840d2 100644 --- a/src/main/java/kinoko/database/postgresql/setup/SchemaUpdater.java +++ b/src/main/java/kinoko/database/postgresql/setup/SchemaUpdater.java @@ -10,7 +10,7 @@ public class SchemaUpdater { public static void run(Connection connection) throws SQLException, IOException { long startTime = System.nanoTime(); // start timing - // Step 1: get current version + // get current version int currentVersion = getSchemaVersion(connection); System.out.println("Current schema version: " + currentVersion); @@ -29,13 +29,13 @@ public static void run(Connection connection) throws SQLException, IOException { String sql = Files.readString(nextFile); try { - // Step 2: execute migration + // execute migration connection.setAutoCommit(false); try (Statement stmt = connection.createStatement()) { stmt.execute(sql); } - // Step 3: increment schema version (inside same transaction) + // increment schema version (inside same transaction) try (PreparedStatement ps = connection.prepareStatement( "SELECT versioning.increment_schema_version(?)")) { ps.setInt(1, currentVersion); From 8ab30900f1dda1fa26c92d40e0681ab750830278 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 4 Nov 2025 18:15:49 -0500 Subject: [PATCH 61/83] Added commit to end of init.sql --- src/main/java/kinoko/database/postgresql/setup/init.sql | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/src/main/java/kinoko/database/postgresql/setup/init.sql b/src/main/java/kinoko/database/postgresql/setup/init.sql index 73ce6018..abc89a4e 100644 --- a/src/main/java/kinoko/database/postgresql/setup/init.sql +++ b/src/main/java/kinoko/database/postgresql/setup/init.sql @@ -506,10 +506,6 @@ VALUES ( 0 ); -COMMIT TRANSACTION; - - - --------------------------------- --SCHEMA UPDATES AND VERSIONING-- @@ -575,3 +571,7 @@ BEGIN RETURN new_version; END; $$ LANGUAGE plpgsql; + + +COMMIT TRANSACTION; + From d339b8d1359532e290fc372924e005eaf9af13d5 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 4 Nov 2025 20:45:12 -0500 Subject: [PATCH 62/83] Added BanInfo --- .../database/postgresql/setup/updates/1.sql | 18 +++ .../database/postgresql/type/AccountDao.java | 5 + .../database/postgresql/type/BanInfoDao.java | 135 ++++++++++++++++++ .../kinoko/handler/stage/LoginHandler.java | 7 + .../kinoko/server/command/gm/BanCommand.java | 91 ++++++++++++ .../server/command/gm/UnBanCommand.java | 60 ++++++++ src/main/java/kinoko/world/user/Account.java | 8 ++ src/main/java/kinoko/world/user/BanInfo.java | 97 +++++++++++++ 8 files changed, 421 insertions(+) create mode 100644 src/main/java/kinoko/database/postgresql/setup/updates/1.sql create mode 100644 src/main/java/kinoko/database/postgresql/type/BanInfoDao.java create mode 100644 src/main/java/kinoko/server/command/gm/BanCommand.java create mode 100644 src/main/java/kinoko/server/command/gm/UnBanCommand.java create mode 100644 src/main/java/kinoko/world/user/BanInfo.java diff --git a/src/main/java/kinoko/database/postgresql/setup/updates/1.sql b/src/main/java/kinoko/database/postgresql/setup/updates/1.sql new file mode 100644 index 00000000..16f274bf --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/setup/updates/1.sql @@ -0,0 +1,18 @@ +-- 1.sql +-- Migration: Create the account.bans table to store temporary and permanent bans +-- Each row represents a banned account. No row exists if the account is not banned. + + +BEGIN; + +CREATE TABLE account.bans ( + account_id INT NOT NULL + REFERENCES account.accounts(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + reason TEXT NOT NULL, + temp_ban_until TIMESTAMP WITH TIME ZONE, -- UTC timestamp, null if permanent ban + PRIMARY KEY (account_id) +); + +COMMIT; diff --git a/src/main/java/kinoko/database/postgresql/type/AccountDao.java b/src/main/java/kinoko/database/postgresql/type/AccountDao.java index f99083ef..6a9cfc72 100644 --- a/src/main/java/kinoko/database/postgresql/type/AccountDao.java +++ b/src/main/java/kinoko/database/postgresql/type/AccountDao.java @@ -2,12 +2,15 @@ import kinoko.database.DatabaseManager; import kinoko.world.user.Account; +import kinoko.world.user.BanInfo; import org.mindrot.jbcrypt.BCrypt; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; import java.sql.SQLException; +import java.time.Duration; +import java.time.Instant; import java.util.Optional; public class AccountDao { @@ -42,6 +45,7 @@ public static void save(Connection conn, Account account) throws SQLException { TrunkDao.save(conn, accountId, account.getTrunk()); WishlistDao.save(conn, accountId, account.getWishlist()); LockerDao.save(conn, accountId, account.getLocker()); + BanInfoDao.save(conn, accountId, account.getBanInfo()); } /** @@ -93,6 +97,7 @@ public static Account load(Connection conn, ResultSet rs) throws SQLException { account.setTrunk(TrunkDao.load(conn, accountId)); account.setLocker(LockerDao.load(conn, accountId)); account.setWishlist(WishlistDao.load(conn, accountId)); + account.setBanInfo(BanInfoDao.load(conn, accountId)); return account; } diff --git a/src/main/java/kinoko/database/postgresql/type/BanInfoDao.java b/src/main/java/kinoko/database/postgresql/type/BanInfoDao.java new file mode 100644 index 00000000..0592c4c6 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/BanInfoDao.java @@ -0,0 +1,135 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.user.BanInfo; + +import java.sql.*; +import java.time.Instant; + +public class BanInfoDao { + + /** + * Loads the BanInfo for a specific account. + * + * Always returns a BanInfo object. If the account is not banned, + * the returned BanInfo will have a null reason and no temporary ban. + * + * @param conn the active SQL connection + * @param accountId the ID of the account to load ban info for + * @return a BanInfo object representing the account's ban status + * @throws SQLException if a database error occurs + */ + public static BanInfo load(Connection conn, int accountId) throws SQLException { + String sql = "SELECT reason, temp_ban_until FROM account.bans WHERE account_id = ?"; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, accountId); + try (ResultSet rs = stmt.executeQuery()) { + if (rs.next()) { + String reason = rs.getString("reason"); + Timestamp ts = rs.getTimestamp("temp_ban_until"); + Instant tempBanUntil = ts != null ? ts.toInstant() : null; + return new BanInfo(reason, tempBanUntil); + } + } + } + // Not banned → return empty BanInfo + return new BanInfo(null, null); + } + + /** + * Saves or updates the BanInfo for an account. + * + * If the BanInfo indicates the account is banned, this method inserts + * a new row or updates the existing row in the database. + * If the account is not banned, any existing row is deleted. + * + * @param conn the active SQL connection + * @param accountId the ID of the account to save ban info for + * @param banInfo the BanInfo object containing ban data + * @throws SQLException if a database error occurs + */ + public static void save(Connection conn, int accountId, BanInfo banInfo) throws SQLException { + if (banInfo.isBanned()) { + String sql = """ + INSERT INTO account.bans (account_id, reason, temp_ban_until) + VALUES (?, ?, ?) + ON CONFLICT (account_id) DO UPDATE + SET reason = EXCLUDED.reason, + temp_ban_until = EXCLUDED.temp_ban_until + """; + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, accountId); + stmt.setString(2, banInfo.getReason()); + Instant tempBanUntil = banInfo.getTempBanUntil(); + if (tempBanUntil != null) { + stmt.setTimestamp(3, Timestamp.from(tempBanUntil)); + } else { + stmt.setNull(3, Types.TIMESTAMP); + } + stmt.executeUpdate(); + } + } else { + // Not banned → delete any existing ban row + try (PreparedStatement stmt = conn.prepareStatement( + "DELETE FROM account.bans WHERE account_id = ?")) { + stmt.setInt(1, accountId); + stmt.executeUpdate(); + } + } + } + + /** + * Permanently bans an account with a given reason. + * + * @param conn the active SQL connection + * @param accountId the ID of the account to ban + * @param reason the reason for the ban + * @throws SQLException if a database error occurs + */ + public static void permanentBan(Connection conn, int accountId, String reason) throws SQLException { + BanInfo banInfo = new BanInfo(reason, null); + save(conn, accountId, banInfo); + } + + /** + * Temporarily bans an account for a specific duration in minutes. + * + * @param conn the active SQL connection + * @param accountId the ID of the account to ban + * @param reason the reason for the temporary ban + * @param durationMinutes the duration of the temporary ban in minutes + * @throws SQLException if a database error occurs + */ + public static void tempBan(Connection conn, int accountId, String reason, long durationMinutes) throws SQLException { + BanInfo banInfo = new BanInfo(null, null); + banInfo.setTempBan(reason, durationMinutes); + save(conn, accountId, banInfo); + } + + /** + * Lifts any ban on the account. + * + * Deletes the row from the `account.bans` table if it exists. + * + * @param conn the active SQL connection + * @param accountId the ID of the account to unban + * @throws SQLException if a database error occurs + */ + public static void liftBan(Connection conn, int accountId) throws SQLException { + try (PreparedStatement stmt = conn.prepareStatement( + "DELETE FROM account.bans WHERE account_id = ?")) { + stmt.setInt(1, accountId); + stmt.executeUpdate(); + } + } + + /** + * Alias for liftBan; removes any ban on the account. + * + * @param conn the active SQL connection + * @param accountId the ID of the account to unban + * @throws SQLException if a database error occurs + */ + public static void unBan(Connection conn, int accountId) throws SQLException { + liftBan(conn, accountId); + } +} diff --git a/src/main/java/kinoko/handler/stage/LoginHandler.java b/src/main/java/kinoko/handler/stage/LoginHandler.java index cdd2d3bb..9a3c3470 100644 --- a/src/main/java/kinoko/handler/stage/LoginHandler.java +++ b/src/main/java/kinoko/handler/stage/LoginHandler.java @@ -90,6 +90,12 @@ public static void handleCheckPassword(Client c, InPacket inPacket) { return; } + if (account.getBanInfo().isBanned()){ + c.write(LoginPacket.checkPasswordResultBlocked(0, account.getBanInfo().getTempBanUntil())); + return; + } + + c.setAccount(account); c.setMachineId(machineId); c.getServerNode().addClient(c); @@ -238,6 +244,7 @@ public static void handleCreateNewCharacter(Client c, InPacket inPacket) { // let non-relational databases handle IDs here. cs.setId(characterIdResult.get()); } + cs.setName(name); cs.setGender(gender); cs.setSkin((byte) selectedAL[3]); diff --git a/src/main/java/kinoko/server/command/gm/BanCommand.java b/src/main/java/kinoko/server/command/gm/BanCommand.java new file mode 100644 index 00000000..1ec62138 --- /dev/null +++ b/src/main/java/kinoko/server/command/gm/BanCommand.java @@ -0,0 +1,91 @@ +package kinoko.server.command.gm; + +import kinoko.database.DatabaseManager; +import kinoko.packet.stage.LoginPacket; +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Arguments; +import kinoko.server.command.Command; +import kinoko.server.packet.OutPacket; +import kinoko.world.user.Account; +import kinoko.world.user.BanInfo; +import kinoko.world.user.User; +import kinoko.world.user.stat.AdminLevel; + +import java.util.Arrays; +import java.util.Optional; +import java.util.concurrent.Executors; +import java.util.concurrent.ScheduledExecutorService; +import java.util.concurrent.TimeUnit; + +public final class BanCommand { + private static final ScheduledExecutorService scheduler = Executors.newScheduledThreadPool(1); + + /** + * Bans a player's account. + * Usage: !ban + */ + @Command("ban") + @Arguments({"player name", "reason"}) + public static void ban(User user, String[] args) { + if (args.length < 3) { + user.write(MessagePacket.system("Usage: !ban ")); + return; + } + + final String targetName = args[1]; + final String reason = String.join(" ", Arrays.copyOfRange(args, 2, args.length)); + + // Find the target user on this channel + final Optional targetUserResult = user.getConnectedServer().getConnectedUsers().stream() + .filter(u -> u.getCharacterName().equalsIgnoreCase(targetName)) + .findFirst(); + + if (targetUserResult.isEmpty()) { + user.write(MessagePacket.system("Could not find player '%s' on this channel.", targetName)); + return; + } + + final User targetUser = targetUserResult.get(); + final Account targetAccount = targetUser.getAccount(); + + // Prevent self-ban + if (targetUser.getCharacterId() == user.getCharacterId()) { + user.write(MessagePacket.system("You cannot ban yourself!")); + return; + } + + // Prevent banning a GM with a higher status than you. Banning same-level GMs is allowed. + if (!user.getAdminLevel().isAtLeast(targetUser.getAdminLevel())) { + user.write(MessagePacket.system("You cannot ban any GM with a higher status than you.")); + return; + } + + // Check if already banned + BanInfo banInfo = targetAccount.getBanInfo(); + if (banInfo.isBanned()) { + user.write(MessagePacket.system("Player '%s' is already banned.", targetName)); + return; + } + + // Construct full reason including GM name + final String fullReason = user.getCharacterName() + " banned " + targetName + " for " + reason; + // TODO: display this to all users in the server + + banInfo = new BanInfo(reason, null); + targetAccount.setBanInfo(banInfo); + + // Save the account + DatabaseManager.accountAccessor().saveAccount(targetAccount); + + // Notify the player and the GM + targetUser.write(MessagePacket.system("You have been banned by GM %s.", user.getCharacterName())); + targetUser.write(MessagePacket.system("Reason: %s", reason)); + user.write(MessagePacket.system("Banned player '%s' (Account ID: %d).", targetName, targetAccount.getId())); + + // Disconnect after 5 seconds + scheduler.schedule(() -> { + targetUser.logout(true); + targetUser.getClient().close(); + }, 5, TimeUnit.SECONDS); + } +} diff --git a/src/main/java/kinoko/server/command/gm/UnBanCommand.java b/src/main/java/kinoko/server/command/gm/UnBanCommand.java new file mode 100644 index 00000000..f8789d12 --- /dev/null +++ b/src/main/java/kinoko/server/command/gm/UnBanCommand.java @@ -0,0 +1,60 @@ +package kinoko.server.command.gm; + +import kinoko.database.DatabaseManager; +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Arguments; +import kinoko.server.command.Command; +import kinoko.world.user.Account; +import kinoko.world.user.BanInfo; +import kinoko.world.user.CharacterData; +import kinoko.world.user.User; + +import java.util.Optional; + +public final class UnBanCommand { + /** + * Unbans a player's account by username. + * Usage: !unban + */ + @Command("unban") + @Arguments("character username") + public static void unban(User user, String[] args) { + if (args.length < 2) { + user.write(MessagePacket.system("Usage: !unban ")); + return; + } + + final String targetUsername = args[1]; + + // Look up the account by character username + final Optional targetCharacterResult = DatabaseManager.characterAccessor().getCharacterByName(targetUsername); + + if (targetCharacterResult.isEmpty()) { + user.write(MessagePacket.system("Could not find character with username '%s'.", targetUsername)); + return; + } + + final CharacterData targetCharacterData = targetCharacterResult.get(); + final Optional targetAccountResult = DatabaseManager.accountAccessor().getAccountById(targetCharacterData.getAccountId()); + + if (targetAccountResult.isEmpty()) { + user.write(MessagePacket.system("Could not find account with character username '%s'.", targetUsername)); + return; + } + + final Account targetAccount = targetAccountResult.get(); + + BanInfo banInfo = targetAccount.getBanInfo(); + if (!banInfo.isBanned()) { + user.write(MessagePacket.system("Account '%s' is not banned.", targetUsername)); + return; + } + + // Lift the ban using BanInfo + banInfo.liftBan(); + targetAccount.setBanInfo(banInfo); + DatabaseManager.accountAccessor().saveAccount(targetAccount); + + user.write(MessagePacket.system("Unbanned account '%s' (Account ID: %d).", targetUsername, targetAccount.getId())); + } +} diff --git a/src/main/java/kinoko/world/user/Account.java b/src/main/java/kinoko/world/user/Account.java index f3dfb676..4d4da75b 100644 --- a/src/main/java/kinoko/world/user/Account.java +++ b/src/main/java/kinoko/world/user/Account.java @@ -14,6 +14,7 @@ public final class Account { private Trunk trunk; private Locker locker; private List wishlist; + private BanInfo banInfo; // TRANSIENT private int channelId = -1; @@ -89,6 +90,13 @@ public void setWishlist(List wishlist) { this.wishlist = wishlist; } + public void setBanInfo(BanInfo banInfo){ + this.banInfo = banInfo; + } + + public BanInfo getBanInfo(){ + return banInfo; + } // TRANSIENT ------------------------------------------------------------------------------------------------------- diff --git a/src/main/java/kinoko/world/user/BanInfo.java b/src/main/java/kinoko/world/user/BanInfo.java new file mode 100644 index 00000000..35237a19 --- /dev/null +++ b/src/main/java/kinoko/world/user/BanInfo.java @@ -0,0 +1,97 @@ +package kinoko.world.user; + +import java.time.Duration; +import java.time.Instant; + +/** + * Represents the ban status of an account. + * A BanInfo object can represent a permanent ban, a temporary ban, or no ban. + * All timestamps are stored in UTC using Instant. + */ +public final class BanInfo { + + /** The reason for the ban. Null if the account is not banned. */ + private String reason; + + /** The expiration timestamp for a temporary ban, or null for permanent bans. */ + private Instant tempBanUntil; + + /** + * Constructs a BanInfo object with the given reason and temporary ban expiration. + * + * @param reason the reason for the ban, or null if not banned + * @param tempBanUntil the expiration time of a temporary ban; null if permanent or not banned + */ + public BanInfo(String reason, Instant tempBanUntil) { + this.reason = reason; + this.tempBanUntil = tempBanUntil; + } + + /** + * Returns true if the account is currently banned. + * Returns false if reason is null (not banned). + * Returns true if tempBanUntil is null (permanent ban) or in the future (temporary ban still active). + * + * @return true if the account is banned, false otherwise + */ + public boolean isBanned() { + if (reason == null) return false; + if (tempBanUntil != null) return Instant.now().isBefore(tempBanUntil); + return true; + } + + /** + * Returns the reason for the ban. + * + * @return the ban reason, or null if not banned + */ + public String getReason() { + return reason; + } + + /** + * Returns the expiration timestamp of a temporary ban. + * + * @return the Instant representing the end of the temporary ban, or null if permanent or not banned + */ + public Instant getTempBanUntil() { + return tempBanUntil; + } + + /** + * Lifts any ban on the account. + * After calling this, isBanned() will return false. + */ + public void liftBan() { + this.reason = null; + this.tempBanUntil = null; + } + + /** + * Alias for liftBan(). Removes any ban on the account. + */ + public void unBan() { + liftBan(); + } + + /** + * Sets a temporary ban with a duration in minutes from now. + * + * @param reason the reason for the temporary ban + * @param durationMinutes the duration of the ban in minutes + */ + public void setTempBan(String reason, long durationMinutes) { + this.reason = reason; + this.tempBanUntil = Instant.now().plus(Duration.ofMinutes(durationMinutes)); + } + + /** + * Sets a permanent ban. + * + * @param reason the reason for the permanent ban + */ + public void setPermanentBan(String reason) { + this.reason = reason; + this.tempBanUntil = null; + } +} From 8473c569e93d8dca03970d91824b22bcc32dbbfa Mon Sep 17 00:00:00 2001 From: MujyKun Date: Tue, 4 Nov 2025 20:55:54 -0500 Subject: [PATCH 63/83] update comment --- src/main/java/kinoko/server/command/gm/UnBanCommand.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/kinoko/server/command/gm/UnBanCommand.java b/src/main/java/kinoko/server/command/gm/UnBanCommand.java index f8789d12..cf7d048c 100644 --- a/src/main/java/kinoko/server/command/gm/UnBanCommand.java +++ b/src/main/java/kinoko/server/command/gm/UnBanCommand.java @@ -13,7 +13,7 @@ public final class UnBanCommand { /** - * Unbans a player's account by username. + * Unbans a player's account by character username. * Usage: !unban */ @Command("unban") From 38ecd33ebe3666275419acda3f5d7ca28cf6602a Mon Sep 17 00:00:00 2001 From: MujyKun Date: Wed, 5 Nov 2025 02:07:08 -0500 Subject: [PATCH 64/83] Added new commands & modified CentralServerNode Access --- .../handler/stage/MigrationHandler.java | 2 +- .../java/kinoko/provider/RewardProvider.java | 4 ++ src/main/java/kinoko/server/Server.java | 4 ++ .../server/command/gm/HealMapCommand.java | 25 +++++++ .../kinoko/server/command/gm/KillCommand.java | 38 ++++++++++ .../server/command/gm/KillMapCommand.java | 29 ++++++++ .../server/command/gm/NoticeBCommand.java | 21 ++++++ .../server/command/gm/NoticeCommand.java | 22 ++++++ .../server/command/gm/NoticeRCommand.java | 24 +++++++ .../kinoko/server/command/gm/SayCommand.java | 22 ++++++ .../server/command/gm/UnBanCommand.java | 2 +- .../server/command/gm/WarpHereCommand.java | 38 ++++++++++ .../server/command/jrgm/HealCommand.java | 40 +++++++++++ .../server/command/jrgm/HideCommand.java | 24 +++++++ .../server/command/jrgm/UnHideCommand.java | 20 ++++++ .../server/command/jrgm/WarpToCommand.java | 34 +++++++++ .../server/command/player/OnlineCommand.java | 17 +++++ .../server/command/supergm/DcCommand.java | 42 +++++++++++ .../{FindCommand.java => SearchCommand.java} | 13 ++-- .../command/tester/WhoDropsCommand.java | 72 +++++++++++++++++++ .../server/netty/CentralServerHandler.java | 42 +++++------ .../kinoko/server/node/CentralServerNode.java | 72 ++++++++++++++++--- .../kinoko/server/node/ChannelServerNode.java | 22 ++++++ .../kinoko/server/node/ServerStorage.java | 50 ++++++++++--- .../java/kinoko/server/user/UserStorage.java | 12 ++++ .../kinoko/world/field/FieldObjectPool.java | 12 ++++ src/main/java/kinoko/world/user/User.java | 66 +++++++++++++++++ 27 files changed, 723 insertions(+), 46 deletions(-) create mode 100644 src/main/java/kinoko/server/command/gm/HealMapCommand.java create mode 100644 src/main/java/kinoko/server/command/gm/KillCommand.java create mode 100644 src/main/java/kinoko/server/command/gm/KillMapCommand.java create mode 100644 src/main/java/kinoko/server/command/gm/NoticeBCommand.java create mode 100644 src/main/java/kinoko/server/command/gm/NoticeCommand.java create mode 100644 src/main/java/kinoko/server/command/gm/NoticeRCommand.java create mode 100644 src/main/java/kinoko/server/command/gm/SayCommand.java create mode 100644 src/main/java/kinoko/server/command/gm/WarpHereCommand.java create mode 100644 src/main/java/kinoko/server/command/jrgm/HealCommand.java create mode 100644 src/main/java/kinoko/server/command/jrgm/HideCommand.java create mode 100644 src/main/java/kinoko/server/command/jrgm/UnHideCommand.java create mode 100644 src/main/java/kinoko/server/command/jrgm/WarpToCommand.java create mode 100644 src/main/java/kinoko/server/command/player/OnlineCommand.java create mode 100644 src/main/java/kinoko/server/command/supergm/DcCommand.java rename src/main/java/kinoko/server/command/tester/{FindCommand.java => SearchCommand.java} (96%) create mode 100644 src/main/java/kinoko/server/command/tester/WhoDropsCommand.java diff --git a/src/main/java/kinoko/handler/stage/MigrationHandler.java b/src/main/java/kinoko/handler/stage/MigrationHandler.java index a07a4d46..17f7ec2f 100644 --- a/src/main/java/kinoko/handler/stage/MigrationHandler.java +++ b/src/main/java/kinoko/handler/stage/MigrationHandler.java @@ -455,7 +455,7 @@ private static void handleRevive(User user, Field field, boolean premium) { } } - private static void handleTransferChannel(User user, Account account, int targetChannelId) { + public static void handleTransferChannel(User user, Account account, int targetChannelId) { // Submit transfer request final MigrationInfo migrationInfo = MigrationInfo.from(user, targetChannelId); user.getConnectedServer().submitTransferRequest(migrationInfo, (transferResult) -> { diff --git a/src/main/java/kinoko/provider/RewardProvider.java b/src/main/java/kinoko/provider/RewardProvider.java index 78eadb9c..64672a0a 100644 --- a/src/main/java/kinoko/provider/RewardProvider.java +++ b/src/main/java/kinoko/provider/RewardProvider.java @@ -38,6 +38,10 @@ public static List getMobRewards(int mobId) { return mobRewards.getOrDefault(mobId, List.of()); } + public static Map> getAllMobRewards() { + return Collections.unmodifiableMap(mobRewards); + } + private static void loadMobRewards(int mobId, Object yamlObject) throws ProviderError { if (!(yamlObject instanceof Map rewardData)) { throw new ProviderError("Could not resolve reward data for mob ID : %d", mobId); diff --git a/src/main/java/kinoko/server/Server.java b/src/main/java/kinoko/server/Server.java index ab6e3916..3cbab12e 100644 --- a/src/main/java/kinoko/server/Server.java +++ b/src/main/java/kinoko/server/Server.java @@ -115,4 +115,8 @@ private static void shutdown() throws Exception { DatabaseManager.shutdown(); LogManager.shutdown(); } + + public static CentralServerNode getCentralServerNode() { + return centralServerNode; + } } diff --git a/src/main/java/kinoko/server/command/gm/HealMapCommand.java b/src/main/java/kinoko/server/command/gm/HealMapCommand.java new file mode 100644 index 00000000..7d007ed5 --- /dev/null +++ b/src/main/java/kinoko/server/command/gm/HealMapCommand.java @@ -0,0 +1,25 @@ +package kinoko.server.command.gm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.world.field.Field; +import kinoko.world.user.User; + +public final class HealMapCommand { + /** + * Heals all players in the current map to full HP and MP. + * Usage: !healmap + */ + @Command("healmap") + public static void healMap(User user, String[] args) { + final Field field = user.getField(); + int healedCount = 0; + + for (User targetUser : field.getUserPool().values()) { + targetUser.heal(); + healedCount++; + } + + user.write(MessagePacket.system("Healed %d player(s) in the current map.", healedCount)); + } +} diff --git a/src/main/java/kinoko/server/command/gm/KillCommand.java b/src/main/java/kinoko/server/command/gm/KillCommand.java new file mode 100644 index 00000000..3d3f6f97 --- /dev/null +++ b/src/main/java/kinoko/server/command/gm/KillCommand.java @@ -0,0 +1,38 @@ +package kinoko.server.command.gm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Arguments; +import kinoko.server.command.Command; +import kinoko.world.user.User; + +import java.util.Optional; + +public final class KillCommand { + /** + * Kills a specific player. + * Usage: !kill + */ + @Command("kill") + @Arguments("player name") + public static void kill(User user, String[] args) { + final String targetName = args[1]; + + // Find the target user on this channel + final Optional targetUserResult = user.getConnectedServer().getUserByCharacterName(targetName); + + if (targetUserResult.isEmpty()) { + user.systemMessage("Could not find player '%s' on this channel.", targetName); + return; + } + + final User targetUser = targetUserResult.get(); + + if (!user.getAdminLevel().isAtLeast(targetUser.getAdminLevel())){ + user.systemMessage("You cannot kill a GM that is a higher level than you!"); + return; + } + + targetUser.kill(); + user.systemMessage("Killed player: %s", targetName); + } +} diff --git a/src/main/java/kinoko/server/command/gm/KillMapCommand.java b/src/main/java/kinoko/server/command/gm/KillMapCommand.java new file mode 100644 index 00000000..cd6f355e --- /dev/null +++ b/src/main/java/kinoko/server/command/gm/KillMapCommand.java @@ -0,0 +1,29 @@ +package kinoko.server.command.gm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.world.field.Field; +import kinoko.world.user.User; +import kinoko.world.user.stat.AdminLevel; + +public final class KillMapCommand { + /** + * Kills all players in the current map. + * Usage: !killmap + */ + @Command("killmap") + public static void killMap(User user, String[] args) { + final Field field = user.getField(); + final int[] killCount = {0}; // needed here for lambda mutation + + field.getUserPool().forEachExcept(user, targetUser -> { + if (targetUser.getAdminLevel().isAtLeast(AdminLevel.JR_GM)) { + return; // skip GMs and above + } + targetUser.kill(); + killCount[0]++; + }); + + user.systemMessage("Killed %d player(s) in the current map.", killCount[0]); + } +} diff --git a/src/main/java/kinoko/server/command/gm/NoticeBCommand.java b/src/main/java/kinoko/server/command/gm/NoticeBCommand.java new file mode 100644 index 00000000..c0b5a765 --- /dev/null +++ b/src/main/java/kinoko/server/command/gm/NoticeBCommand.java @@ -0,0 +1,21 @@ +package kinoko.server.command.gm; + +import kinoko.server.command.Arguments; +import kinoko.server.command.Command; +import kinoko.world.user.User; + +import java.util.Arrays; + +public final class NoticeBCommand { + /** + * Sends a blue message to all players on the server. + * Usage: !noticeb + */ + @Command("noticeb") + @Arguments("message") + public static void notice(User user, String[] args) { + // Join all args except the command itself + final String message = String.join(" ", Arrays.copyOfRange(args, 1, args.length)); + user.getConnectedServer().broadcastServerNoticeWithoutPrefix(message); + } +} diff --git a/src/main/java/kinoko/server/command/gm/NoticeCommand.java b/src/main/java/kinoko/server/command/gm/NoticeCommand.java new file mode 100644 index 00000000..732e5cfa --- /dev/null +++ b/src/main/java/kinoko/server/command/gm/NoticeCommand.java @@ -0,0 +1,22 @@ +package kinoko.server.command.gm; + +import kinoko.server.command.Arguments; +import kinoko.server.command.Command; +import kinoko.world.user.User; + +import java.util.Arrays; + +public final class NoticeCommand { + /** + * Sends a blue notice message to all players on the server. + * Usage: !notice + */ + @Command("notice") + @Arguments("message") + public static void notice(User user, String[] args) { + // Join all args except the command itself + final String message = String.join(" ", Arrays.copyOfRange(args, 1, args.length)); + final String formattedMessage = "[Notice] " + message; + user.getConnectedServer().broadcastServerNoticeWithoutPrefix(formattedMessage); + } +} diff --git a/src/main/java/kinoko/server/command/gm/NoticeRCommand.java b/src/main/java/kinoko/server/command/gm/NoticeRCommand.java new file mode 100644 index 00000000..ee1d6565 --- /dev/null +++ b/src/main/java/kinoko/server/command/gm/NoticeRCommand.java @@ -0,0 +1,24 @@ +package kinoko.server.command.gm; + +import kinoko.packet.world.BroadcastPacket; +import kinoko.server.command.Arguments; +import kinoko.server.command.Command; +import kinoko.server.packet.OutPacket; +import kinoko.world.user.User; + +import java.util.Arrays; + +public final class NoticeRCommand { + /** + * Broadcasts a pop-up notice message to all players on the channel. + * Usage: !noticer + */ + @Command("noticer") + @Arguments("message") + public static void notice(User user, String[] args) { + // Join all args except the command itself + final String message = String.join(" ", Arrays.copyOfRange(args, 1, args.length)); + user.getConnectedServer().broadcastServerAlert(message); + + } +} diff --git a/src/main/java/kinoko/server/command/gm/SayCommand.java b/src/main/java/kinoko/server/command/gm/SayCommand.java new file mode 100644 index 00000000..e18c699f --- /dev/null +++ b/src/main/java/kinoko/server/command/gm/SayCommand.java @@ -0,0 +1,22 @@ +package kinoko.server.command.gm; + +import kinoko.server.command.Arguments; +import kinoko.server.command.Command; +import kinoko.world.user.User; + +import java.util.Arrays; + +public final class SayCommand { + /** + * Sends a blue message to all players on the server with the GM's Character Name as the prefix. + * Usage: !say + */ + @Command("say") + @Arguments("message") + public static void say(User user, String[] args) { + // Join all args except the command itself + final String message = String.join(" ", Arrays.copyOfRange(args, 1, args.length)); + final String formattedMessage = "[" + user.getCharacterName() +"] " + message; + user.getConnectedServer().broadcastServerNoticeWithoutPrefix(formattedMessage); + } +} diff --git a/src/main/java/kinoko/server/command/gm/UnBanCommand.java b/src/main/java/kinoko/server/command/gm/UnBanCommand.java index cf7d048c..8bbe1802 100644 --- a/src/main/java/kinoko/server/command/gm/UnBanCommand.java +++ b/src/main/java/kinoko/server/command/gm/UnBanCommand.java @@ -16,7 +16,7 @@ public final class UnBanCommand { * Unbans a player's account by character username. * Usage: !unban */ - @Command("unban") + @Command({"unban", "liftban"}) @Arguments("character username") public static void unban(User user, String[] args) { if (args.length < 2) { diff --git a/src/main/java/kinoko/server/command/gm/WarpHereCommand.java b/src/main/java/kinoko/server/command/gm/WarpHereCommand.java new file mode 100644 index 00000000..712423a9 --- /dev/null +++ b/src/main/java/kinoko/server/command/gm/WarpHereCommand.java @@ -0,0 +1,38 @@ +package kinoko.server.command.gm; + +import kinoko.provider.map.PortalInfo; +import kinoko.server.Server; +import kinoko.server.command.Arguments; +import kinoko.server.command.Command; +import kinoko.server.node.CentralServerNode; +import kinoko.server.node.RemoteServerNode; +import kinoko.server.user.RemoteUser; +import kinoko.world.GameConstants; +import kinoko.world.field.Field; +import kinoko.world.user.User; + +import java.util.Optional; + +public final class WarpHereCommand { + /** + * Summons another player to your location. + * Usage: !summon + */ + @Command({"warphere", "summon"}) + @Arguments("player name") + public static void warphere(User user, String[] args) { + final String targetName = args[1]; + + Optional targetUserResult = Server.getCentralServerNode().getUserByCharacterName(targetName); + + if (targetUserResult.isEmpty()) { + user.systemMessage("Could not find player '%s'.", targetName); + return; + } + + final User targetUser = targetUserResult.get(); + targetUser.warpTo(user); + user.systemMessage("Summoned %s to your location.", targetName); + targetUser.systemMessage("You have been summoned by %s.", user.getCharacterName()); + } +} diff --git a/src/main/java/kinoko/server/command/jrgm/HealCommand.java b/src/main/java/kinoko/server/command/jrgm/HealCommand.java new file mode 100644 index 00000000..cfe5d309 --- /dev/null +++ b/src/main/java/kinoko/server/command/jrgm/HealCommand.java @@ -0,0 +1,40 @@ +package kinoko.server.command.jrgm; + +import kinoko.server.command.Command; +import kinoko.world.user.User; + +import java.util.Optional; + +public final class HealCommand { + /** + * Heals one or more players to full HP and MP. + * Usage: !heal [player1] [player2] [...] + * If no names are provided, heals yourself. + */ + @Command("heal") + public static void heal(User user, String[] args) { + // If no player names provided, heal yourself + if (args.length == 1) { + user.heal(); + user.systemMessage("Healed %s.", user.getCharacterName()); + return; + } + + // Loop through all provided player names + for (int i = 1; i < args.length; i++) { + String targetName = args[i]; + + Optional targetUserResult = user.getConnectedServer().getUserByCharacterName(targetName); + + if (targetUserResult.isEmpty()) { + user.systemMessage("Could not find player '%s' on this channel.", targetName); + continue; // skip to next name + } + + User targetUser = targetUserResult.get(); + targetUser.heal(); + + user.systemMessage("Healed %s.", targetUser.getCharacterName()); + } + } +} diff --git a/src/main/java/kinoko/server/command/jrgm/HideCommand.java b/src/main/java/kinoko/server/command/jrgm/HideCommand.java new file mode 100644 index 00000000..ac1dfeca --- /dev/null +++ b/src/main/java/kinoko/server/command/jrgm/HideCommand.java @@ -0,0 +1,24 @@ +package kinoko.server.command.jrgm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.world.user.User; +import kinoko.world.user.stat.CharacterTemporaryStat; +import kinoko.world.user.stat.TemporaryStatOption; + +public final class HideCommand { + /** + * Activates GM Hide mode (makes you invisible to players). + * Usage: !hide + */ + @Command("hide") + public static void hide(User user, String[] args) { + final int GM_HIDE_SKILL_ID = 9101004; + + // Apply the GM Hide buff using DarkSight + // nOption = 1 (value), rOption = skillId, tOption = 0 (permanent until unhide) + final TemporaryStatOption option = TemporaryStatOption.of(1, GM_HIDE_SKILL_ID, 0); + user.setTemporaryStat(CharacterTemporaryStat.DarkSight, option); + user.systemMessage("You are now invisible to players."); + } +} diff --git a/src/main/java/kinoko/server/command/jrgm/UnHideCommand.java b/src/main/java/kinoko/server/command/jrgm/UnHideCommand.java new file mode 100644 index 00000000..d9a62143 --- /dev/null +++ b/src/main/java/kinoko/server/command/jrgm/UnHideCommand.java @@ -0,0 +1,20 @@ +package kinoko.server.command.jrgm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Command; +import kinoko.world.user.User; + +public final class UnHideCommand { + /** + * Deactivates GM Hide mode (makes you visible again). + * Usage: !unhide + */ + @Command("unhide") + public static void unhide(User user, String[] args) { + final int GM_HIDE_SKILL_ID = 9101004; + + // Remove the GM Hide buff + user.resetTemporaryStat(GM_HIDE_SKILL_ID); + user.systemMessage("You are now visible to players."); + } +} diff --git a/src/main/java/kinoko/server/command/jrgm/WarpToCommand.java b/src/main/java/kinoko/server/command/jrgm/WarpToCommand.java new file mode 100644 index 00000000..60c8eafa --- /dev/null +++ b/src/main/java/kinoko/server/command/jrgm/WarpToCommand.java @@ -0,0 +1,34 @@ +package kinoko.server.command.jrgm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.Server; +import kinoko.server.command.Arguments; +import kinoko.server.command.Command; +import kinoko.server.user.RemoteUser; +import kinoko.world.user.User; + +import java.util.Optional; + +public final class WarpToCommand { + /** + * Teleports to another player's location. + * Usage: !warpto + */ + @Command({"warpto", "reach"}) + @Arguments("player name") + public static void warpToPlayer(User user, String[] args) { + final String targetName = args[1]; + + // Find the target user + Optional targetRemoteUser = Server.getCentralServerNode().getRemoteUserByCharacterName(targetName); + + if (targetRemoteUser.isEmpty()) { + user.write(MessagePacket.system("Could not find player '%s'.", targetName)); + return; + } + + final RemoteUser targetUser = targetRemoteUser.get(); + user.warpTo(targetUser); + user.systemMessage("Teleported to %s's location.", targetName); + } +} diff --git a/src/main/java/kinoko/server/command/player/OnlineCommand.java b/src/main/java/kinoko/server/command/player/OnlineCommand.java new file mode 100644 index 00000000..d4da9f1e --- /dev/null +++ b/src/main/java/kinoko/server/command/player/OnlineCommand.java @@ -0,0 +1,17 @@ +package kinoko.server.command.player; + +import kinoko.server.Server; +import kinoko.server.command.Command; +import kinoko.world.user.User; + +public final class OnlineCommand { + /** + * Shows the number of online players. + * Usage: @online + */ + @Command("online") + public static void online(User user, String[] args) { + final int onlineCount = Server.getCentralServerNode().getRemoteUserCount(); + user.systemMessage("There are currently %d player(s) online.", onlineCount); + } +} diff --git a/src/main/java/kinoko/server/command/supergm/DcCommand.java b/src/main/java/kinoko/server/command/supergm/DcCommand.java new file mode 100644 index 00000000..868e7314 --- /dev/null +++ b/src/main/java/kinoko/server/command/supergm/DcCommand.java @@ -0,0 +1,42 @@ +package kinoko.server.command.supergm; + +import kinoko.packet.world.MessagePacket; +import kinoko.server.command.Arguments; +import kinoko.server.command.Command; +import kinoko.world.user.User; + +import java.util.Optional; + +public final class DcCommand { + /** + * Disconnects a player from the server. + * Usage: !dc + */ + @Command({"dc", "disconnect"}) + @Arguments("player name") + public static void dc(User user, String[] args) { + final String targetName = args[1]; + + // Find the target user on this channel + final Optional targetUserResult = user.getConnectedServer().getUserByCharacterName(targetName); + + if (targetUserResult.isEmpty()) { + user.systemMessage("Could not find player '%s' on this channel.", targetName); + return; + } + + final User targetUser = targetUserResult.get(); + + // Prevent self-disconnect + if (targetUser.getCharacterId() == user.getCharacterId()) { + user.systemMessage("You cannot disconnect yourself! Use @dispose or logout normally."); + return; + } + + user.systemMessage("Disconnecting player: %s", targetName); + + // Properly logout the user before closing connection + targetUser.logout(true); + targetUser.getClient().close(); + } +} diff --git a/src/main/java/kinoko/server/command/tester/FindCommand.java b/src/main/java/kinoko/server/command/tester/SearchCommand.java similarity index 96% rename from src/main/java/kinoko/server/command/tester/FindCommand.java rename to src/main/java/kinoko/server/command/tester/SearchCommand.java index 64cfdd45..95364c60 100644 --- a/src/main/java/kinoko/server/command/tester/FindCommand.java +++ b/src/main/java/kinoko/server/command/tester/SearchCommand.java @@ -25,11 +25,16 @@ /** - * Fully detailed FindCommand with helpers for item/map/mob/npc/skill/quest/commodity. + * Fully detailed SearchCommand with helpers for item/map/mob/npc/skill/quest/commodity. */ -public final class FindCommand { - - @Command({ "find", "lookup", "search"}) +public final class SearchCommand { + /** + * Searches for items, maps, mobs, NPCs, skills, quests, or commodities. + * Usage: !search + * !lookup + * !find + */ + @Command({ "search", "find", "lookup"}) @Arguments({ "item/map/mob/npc/skill/quest/commodity", "id or query" }) public static void find(User user, String[] args) { if (args.length < 2) { diff --git a/src/main/java/kinoko/server/command/tester/WhoDropsCommand.java b/src/main/java/kinoko/server/command/tester/WhoDropsCommand.java new file mode 100644 index 00000000..b895ecf6 --- /dev/null +++ b/src/main/java/kinoko/server/command/tester/WhoDropsCommand.java @@ -0,0 +1,72 @@ +package kinoko.server.command.tester; + +import kinoko.packet.world.MessagePacket; +import kinoko.provider.RewardProvider; +import kinoko.provider.StringProvider; +import kinoko.provider.reward.Reward; +import kinoko.server.command.Arguments; +import kinoko.server.command.Command; +import kinoko.world.user.User; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import java.util.Map; + +public final class WhoDropsCommand { + /** + * Finds which mobs drop a specific item. + * Usage: !whodrops + */ + @Command("whodrops") + @Arguments("item ID") + public static void whoDrops(User user, String[] args) { + if (args.length < 2) { + user.systemMessage("Usage: !whodrops "); + return; + } + + final int itemId; + try { + itemId = Integer.parseInt(args[1]); + } catch (NumberFormatException e) { + user.systemMessage("Invalid item ID: '%s'. Please provide a valid number.", args[1]); + return; + } + + final String itemName = StringProvider.getItemName(itemId); + if (itemName.isEmpty()) { + user.systemMessage("Could not find item with ID: %d", itemId); + return; + } + + // Search through all mob rewards + final List> droppedBy = new ArrayList<>(); + for (Map.Entry> entry : RewardProvider.getAllMobRewards().entrySet()) { + final int mobId = entry.getKey(); + for (Reward reward : entry.getValue()) { + if (reward.getItemId() == itemId) { + droppedBy.add(Map.entry(mobId, reward.getProb())); + break; // Only count each mob once + } + } + } + + if (droppedBy.isEmpty()) { + user.systemMessage("No mobs drop '%s' (ID: %d)", itemName, itemId); + return; + } + + // Sort by mob ID + droppedBy.sort(Comparator.comparingInt(Map.Entry::getKey)); + + user.systemMessage("Item: %s (ID: %d)", itemName, itemId); + user.systemMessage("Dropped by %d mob(s):", droppedBy.size()); + for (Map.Entry mobEntry : droppedBy) { + final int mobId = mobEntry.getKey(); + final double dropChance = mobEntry.getValue(); + final String mobName = StringProvider.getMobName(mobId); + user.systemMessage(" %d - %s (%.2f%% chance)", mobId, mobName, dropChance); + } + } +} diff --git a/src/main/java/kinoko/server/netty/CentralServerHandler.java b/src/main/java/kinoko/server/netty/CentralServerHandler.java index 5352077b..4e2d6655 100644 --- a/src/main/java/kinoko/server/netty/CentralServerHandler.java +++ b/src/main/java/kinoko/server/netty/CentralServerHandler.java @@ -168,7 +168,7 @@ private void handleTransferRequest(RemoteServerNode remoteServerNode, InPacket i private void handleUserConnect(RemoteServerNode remoteServerNode, InPacket inPacket) { final RemoteUser remoteUser = RemoteUser.decode(inPacket); - centralServerNode.addUser(remoteUser); + centralServerNode.addRemoteUser(remoteUser); updateMessengerUser(remoteUser); updatePartyMember(remoteUser, false); updateGuildMember(remoteUser, false); @@ -176,7 +176,7 @@ private void handleUserConnect(RemoteServerNode remoteServerNode, InPacket inPac private void handleUserUpdate(RemoteServerNode remoteServerNode, InPacket inPacket) { final RemoteUser remoteUser = RemoteUser.decode(inPacket); - centralServerNode.updateUser(remoteUser); + centralServerNode.updateRemoteUser(remoteUser); updateMessengerUser(remoteUser); updatePartyMember(remoteUser, true); updateGuildMember(remoteUser, true); @@ -184,7 +184,7 @@ private void handleUserUpdate(RemoteServerNode remoteServerNode, InPacket inPack private void handleUserDisconnect(RemoteServerNode remoteServerNode, InPacket inPacket) { final RemoteUser remoteUser = RemoteUser.decode(inPacket); - centralServerNode.removeUser(remoteUser); + centralServerNode.removeRemoteUser(remoteUser); // Check if transfer if (centralServerNode.isMigrating(remoteUser.getAccountId())) { return; @@ -202,7 +202,7 @@ private void handleUserPacketRequest(RemoteServerNode remoteServerNode, InPacket final String characterName = inPacket.decodeString(); final OutPacket remotePacket = OutPacket.decodeRemotePacket(inPacket); // Resolve target user - final Optional targetResult = centralServerNode.getUserByCharacterName(characterName); + final Optional targetResult = centralServerNode.getRemoteUserByCharacterName(characterName); if (targetResult.isEmpty()) { return; } @@ -221,7 +221,7 @@ private void handleUserPacketReceive(RemoteServerNode remoteServerNode, InPacket final int characterId = inPacket.decodeInt(); final OutPacket remotePacket = OutPacket.decodeRemotePacket(inPacket); // Resolve target user - final Optional targetResult = centralServerNode.getUserByCharacterId(characterId); + final Optional targetResult = centralServerNode.getRemoteUserByCharacterId(characterId); if (targetResult.isEmpty()) { return; } @@ -243,7 +243,7 @@ private void handleUserPacketBroadcast(RemoteServerNode remoteServerNode, InPack characterIds.add(inPacket.decodeInt()); } final OutPacket remotePacket = OutPacket.decodeRemotePacket(inPacket); - for (RemoteServerNode serverNode : centralServerNode.getChannelServerNodes()) { + for (RemoteServerNode serverNode : centralServerNode.getRemoteChannelServerNodes()) { serverNode.write(CentralPacket.userPacketBroadcast(characterIds, remotePacket)); } } @@ -254,12 +254,12 @@ private void handleUserQueryRequest(RemoteServerNode remoteServerNode, InPacket final int size = inPacket.decodeInt(); final List remoteUsers; if (size < 0) { - remoteUsers = centralServerNode.getUsers(); // Get all connected users + remoteUsers = centralServerNode.getRemoteUsers(); // Get all connected users } else { remoteUsers = new ArrayList<>(); for (int i = 0; i < size; i++) { final String characterName = inPacket.decodeString(); - centralServerNode.getUserByCharacterName(characterName).ifPresent(remoteUsers::add); + centralServerNode.getRemoteUserByCharacterName(characterName).ifPresent(remoteUsers::add); } } // Reply with queried remote users @@ -270,14 +270,14 @@ private void handleWorldSpeakerRequest(RemoteServerNode remoteServerNode, InPack final int characterId = inPacket.decodeInt(); final boolean avatar = inPacket.decodeBoolean(); final OutPacket remotePacket = OutPacket.decodeRemotePacket(inPacket); - for (RemoteServerNode serverNode : centralServerNode.getChannelServerNodes()) { + for (RemoteServerNode serverNode : centralServerNode.getRemoteChannelServerNodes()) { serverNode.write(CentralPacket.worldSpeakerRequest(characterId, avatar, remotePacket)); } } private void handleServerPacketBroadcast(RemoteServerNode remoteServerNode, InPacket inPacket) { final OutPacket remotePacket = OutPacket.decodeRemotePacket(inPacket); - for (RemoteServerNode serverNode : centralServerNode.getChannelServerNodes()) { + for (RemoteServerNode serverNode : centralServerNode.getRemoteChannelServerNodes()) { serverNode.write(CentralPacket.serverPacketBroadcast(remotePacket)); } } @@ -286,7 +286,7 @@ private void handleMessengerRequest(RemoteServerNode remoteServerNode, InPacket final int characterId = inPacket.decodeInt(); final MessengerRequest messengerRequest = MessengerRequest.decode(inPacket); // Resolve requester user - final Optional remoteUserResult = centralServerNode.getUserByCharacterId(characterId); + final Optional remoteUserResult = centralServerNode.getRemoteUserByCharacterId(characterId); if (remoteUserResult.isEmpty()) { log.error("Failed to resolve user with character ID : {} for MessengerRequest", characterId); return; @@ -422,7 +422,7 @@ private void handlePartyRequest(RemoteServerNode remoteServerNode, InPacket inPa final int characterId = inPacket.decodeInt(); final PartyRequest partyRequest = PartyRequest.decode(inPacket); // Resolve requester user - final Optional remoteUserResult = centralServerNode.getUserByCharacterId(characterId); + final Optional remoteUserResult = centralServerNode.getRemoteUserByCharacterId(characterId); if (remoteUserResult.isEmpty()) { log.error("Failed to resolve user with character ID : {} for PartyRequest", characterId); return; @@ -513,7 +513,7 @@ private void handlePartyRequest(RemoteServerNode remoteServerNode, InPacket inPa } // Resolve inviter final int inviterId = partyRequest.getCharacterId(); - final Optional inviterResult = centralServerNode.getUserByCharacterId(inviterId); + final Optional inviterResult = centralServerNode.getRemoteUserByCharacterId(inviterId); if (inviterResult.isEmpty()) { // The inviter is not online. remoteServerNode.write(CentralPacket.userPacketReceive(remoteUser.getCharacterId(), PartyPacket.of(PartyResultType.JoinParty_Unknown))); // Your request for a party didn't work due to an unexpected error. @@ -568,7 +568,7 @@ private void handlePartyRequest(RemoteServerNode remoteServerNode, InPacket inPa } // Resolve target final String targetName = partyRequest.getCharacterName(); - final Optional targetResult = centralServerNode.getUserByCharacterName(targetName); // target name + final Optional targetResult = centralServerNode.getRemoteUserByCharacterName(targetName); // target name if (targetResult.isEmpty()) { remoteServerNode.write(CentralPacket.userPacketReceive(remoteUser.getCharacterId(), PartyPacket.serverMsg(String.format("Unable to find '%s'", targetName)))); return; @@ -654,7 +654,7 @@ private void handleGuildRequest(RemoteServerNode remoteServerNode, InPacket inPa final int characterId = inPacket.decodeInt(); final GuildRequest guildRequest = GuildRequest.decode(inPacket); // Resolve requester user - final Optional remoteUserResult = centralServerNode.getUserByCharacterId(characterId); + final Optional remoteUserResult = centralServerNode.getRemoteUserByCharacterId(characterId); if (remoteUserResult.isEmpty()) { log.error("Failed to resolve user with character ID : {} for GuildRequest", characterId); return; @@ -709,7 +709,7 @@ private void handleGuildRequest(RemoteServerNode remoteServerNode, InPacket inPa } // Resolve target final String targetName = guildRequest.getTargetName(); - final Optional targetResult = centralServerNode.getUserByCharacterName(targetName); // target name + final Optional targetResult = centralServerNode.getRemoteUserByCharacterName(targetName); // target name if (targetResult.isEmpty()) { remoteServerNode.write(CentralPacket.userPacketReceive(remoteUser.getCharacterId(), GuildPacket.serverMsg(String.format("Unable to find '%s'", targetName)))); return; @@ -747,7 +747,7 @@ private void handleGuildRequest(RemoteServerNode remoteServerNode, InPacket inPa case JoinGuild -> { // Resolve inviter final int inviterId = guildRequest.getInviterId(); - final Optional inviterResult = centralServerNode.getUserByCharacterId(inviterId); + final Optional inviterResult = centralServerNode.getRemoteUserByCharacterId(inviterId); if (inviterResult.isEmpty()) { remoteServerNode.write(CentralPacket.userPacketReceive(remoteUser.getCharacterId(), GuildPacket.joinGuildUnknown())); // The guild request has not been accepted due to unknown reason. return; @@ -858,7 +858,7 @@ private void handleGuildRequest(RemoteServerNode remoteServerNode, InPacket inPa // Save to database DatabaseManager.guildAccessor().saveGuild(guild); // Resolve target - final Optional targetResult = centralServerNode.getUserByCharacterId(guildRequest.getTargetId()); + final Optional targetResult = centralServerNode.getRemoteUserByCharacterId(guildRequest.getTargetId()); if (targetResult.isPresent()) { final RemoteUser targetUser = targetResult.get(); targetUser.setGuildId(0); @@ -1055,7 +1055,7 @@ private void handleBoardRequest(RemoteServerNode remoteServerNode, InPacket inPa final int characterId = inPacket.decodeInt(); final GuildBoardRequest boardRequest = GuildBoardRequest.decode(inPacket); // Resolve requester user - final Optional remoteUserResult = centralServerNode.getUserByCharacterId(characterId); + final Optional remoteUserResult = centralServerNode.getRemoteUserByCharacterId(characterId); if (remoteUserResult.isEmpty()) { log.error("Failed to resolve user with character ID : {} for GuildBoardRequest", characterId); return; @@ -1268,7 +1268,7 @@ private void updateGuildMember(RemoteUser remoteUser, boolean isUserUpdate) { final OutPacket outPacket = isUserUpdate ? GuildPacket.changeLevelOrJob(guild.getGuildId(), remoteUser.getCharacterId(), remoteUser.getLevel(), remoteUser.getJob()) : GuildPacket.notifyLoginOrLogout(guild.getGuildId(), remoteUser.getCharacterId(), isOnline); - for (RemoteServerNode serverNode : centralServerNode.getChannelServerNodes()) { + for (RemoteServerNode serverNode : centralServerNode.getRemoteChannelServerNodes()) { serverNode.write(CentralPacket.userPacketBroadcast(guildMemberIds, outPacket)); } } @@ -1296,7 +1296,7 @@ private void forEachPartyMember(Party party, BiConsumer biConsumer) { for (int memberId : guild.getMemberIds()) { - final Optional remoteMemberResult = centralServerNode.getUserByCharacterId(memberId); + final Optional remoteMemberResult = centralServerNode.getRemoteUserByCharacterId(memberId); if (remoteMemberResult.isEmpty()) { continue; } diff --git a/src/main/java/kinoko/server/node/CentralServerNode.java b/src/main/java/kinoko/server/node/CentralServerNode.java index a0360ed6..3338e028 100644 --- a/src/main/java/kinoko/server/node/CentralServerNode.java +++ b/src/main/java/kinoko/server/node/CentralServerNode.java @@ -22,11 +22,13 @@ import kinoko.server.party.PartyStorage; import kinoko.server.user.RemoteUser; import kinoko.server.user.UserStorage; +import kinoko.world.user.User; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; import java.time.Duration; import java.time.Instant; +import java.util.ArrayList; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; @@ -60,6 +62,10 @@ public synchronized void addServerNode(RemoteServerNode serverNode) { } } + public synchronized void addChannelServerNode(ChannelServerNode serverNode){ + serverStorage.addChannelServerNode(serverNode); + } + public synchronized void removeServerNode(int channelId) { serverStorage.removeServerNode(channelId); if (serverStorage.isEmpty()) { @@ -68,10 +74,14 @@ public synchronized void removeServerNode(int channelId) { } public Optional getChannelServerNodeById(int channelId) { - return serverStorage.getChannelServerNodeById(channelId); + return serverStorage.getRemoteChannelServerNodeById(channelId); + } + + public List getRemoteChannelServerNodes() { + return serverStorage.getRemoteChannelServerNodes(); } - public List getChannelServerNodes() { + public List getChannelServerNodes() { return serverStorage.getChannelServerNodes(); } @@ -94,35 +104,75 @@ public Optional completeMigrationRequest(int channelId, int accou return migrationStorage.completeMigrationRequest(channelId, accountId, characterId, machineId, clientKey); } - // USER METHODS ---------------------------------------------------------------------------------------------------- - public List getUsers() { + /** + * Returns a list of all users currently connected across all channel servers. + */ + public List getUsers() { + List allUsers = new ArrayList<>(); + for (ChannelServerNode channelNode : getChannelServerNodes()) { + allUsers.addAll(channelNode.getConnectedUsers()); + } + return allUsers; + } + + /** + * Returns the actual User object for a character ID, if connected. + * Looks up the RemoteUser by ID, finds their channel server, then fetches the User from that channel. + */ + public Optional getUserByCharacterId(int characterId) { + return getRemoteUserByCharacterId(characterId) + .flatMap(remoteUser -> serverStorage + .getChannelServerNodeById(remoteUser.getChannelId()) + .flatMap(channelNode -> channelNode.getUserByCharacterId(characterId)) + ); + } + + /** + * Returns the actual User object for a character name, if connected. + * Looks up the RemoteUser by name, finds their channel server, then fetches the User from that channel. + */ + public Optional getUserByCharacterName(String characterName) { + return getRemoteUserByCharacterName(characterName) + .flatMap(remoteUser -> serverStorage + .getChannelServerNodeById(remoteUser.getChannelId()) + .flatMap(channelNode -> channelNode.getUserByCharacterName(characterName)) + ); + } + + + // REMOTE USER METHODS ---------------------------------------------------------------------------------------------------- + + public List getRemoteUsers() { return userStorage.getUsers(); } - public Optional getUserByCharacterId(int characterId) { + public Optional getRemoteUserByCharacterId(int characterId) { return userStorage.getByCharacterId(characterId); } - public Optional getUserByCharacterName(String characterName) { + public Optional getRemoteUserByCharacterName(String characterName) { return userStorage.getByCharacterName(characterName); } - public void addUser(RemoteUser remoteUser) { + public void addRemoteUser(RemoteUser remoteUser) { userStorage.putUser(remoteUser); getChannelServerNodeById(remoteUser.getChannelId()).ifPresent(RemoteServerNode::incrementUserCount); } - public void updateUser(RemoteUser remoteUser) { + public void updateRemoteUser(RemoteUser remoteUser) { userStorage.putUser(remoteUser); } - public void removeUser(RemoteUser remoteUser) { + public void removeRemoteUser(RemoteUser remoteUser) { userStorage.removeUser(remoteUser); getChannelServerNodeById(remoteUser.getChannelId()).ifPresent(RemoteServerNode::decrementUserCount); } + public int getRemoteUserCount() { + return userStorage.getUserCount(); + } // MESSENGER METHODS ----------------------------------------------------------------------------------------------- @@ -217,7 +267,7 @@ protected void initChannel(SocketChannel ch) { // Complete initialization for login server node final RemoteServerNode loginServerNode = serverStorage.getLoginServerNode().orElseThrow(); - loginServerNode.write(CentralPacket.initializeComplete(serverStorage.getChannelServerNodes())); + loginServerNode.write(CentralPacket.initializeComplete(serverStorage.getRemoteChannelServerNodes())); } @Override @@ -230,7 +280,7 @@ public void shutdown() throws InterruptedException { serverStorage.getLoginServerNode().ifPresent((serverNode) -> serverNode.write(CentralPacket.shutdownRequest())); // Shutdown channel server nodes - for (RemoteServerNode serverNode : serverStorage.getChannelServerNodes()) { + for (RemoteServerNode serverNode : serverStorage.getRemoteChannelServerNodes()) { serverNode.write(CentralPacket.shutdownRequest()); } shutdownFuture.join(); diff --git a/src/main/java/kinoko/server/node/ChannelServerNode.java b/src/main/java/kinoko/server/node/ChannelServerNode.java index c120f1b8..8b386f14 100644 --- a/src/main/java/kinoko/server/node/ChannelServerNode.java +++ b/src/main/java/kinoko/server/node/ChannelServerNode.java @@ -4,6 +4,8 @@ import io.netty.channel.ChannelInitializer; import io.netty.channel.socket.SocketChannel; import kinoko.packet.CentralPacket; +import kinoko.packet.world.BroadcastPacket; +import kinoko.server.Server; import kinoko.server.ServerConfig; import kinoko.server.ServerConstants; import kinoko.server.event.EventManager; @@ -49,6 +51,7 @@ public final class ChannelServerNode extends ServerNode { public ChannelServerNode(int channelId, int channelPort) { this.channelId = channelId; this.channelPort = channelPort; + Server.getCentralServerNode().addChannelServerNode(this); } public int getChannelId() { @@ -310,4 +313,23 @@ public void shutdown() throws InterruptedException { public boolean isInitialized() { return true; } + + // Utility Functions ----------------------------------------------------------------------------------------------- + public void broadcastServerAlert(String message){ + OutPacket packet = BroadcastPacket.alert(message); + submitServerPacketBroadcast(packet); + } + + public void broadcastServerNoticeWithoutPrefix(String message){ + OutPacket packet = BroadcastPacket.noticeWithoutPrefix(message); + submitServerPacketBroadcast(packet); + } + + public int getServerOnline(){ + return clientStorage.getConnectedUsers().size(); + } + + public int getChannelOnline(){ + return getConnectedUsers().size(); + } } diff --git a/src/main/java/kinoko/server/node/ServerStorage.java b/src/main/java/kinoko/server/node/ServerStorage.java index 2267827f..c0bcd713 100644 --- a/src/main/java/kinoko/server/node/ServerStorage.java +++ b/src/main/java/kinoko/server/node/ServerStorage.java @@ -10,7 +10,8 @@ import java.util.concurrent.atomic.AtomicReference; public final class ServerStorage { - private final ConcurrentHashMap channelServerNodes = new ConcurrentHashMap<>(); // channel id -> remote child node + private final ConcurrentHashMap remoteChannelServerNodes = new ConcurrentHashMap<>(); // channel id -> remote child node + private final ConcurrentHashMap channelServerNodes = new ConcurrentHashMap<>(); // channel id -> channel server node private final AtomicReference loginServerNode = new AtomicReference<>(); public void addServerNode(RemoteServerNode serverNode) { @@ -18,24 +19,45 @@ public void addServerNode(RemoteServerNode serverNode) { if (channelId == GameConstants.CHANNEL_LOGIN) { loginServerNode.set(serverNode); } else if (channelId >= 0 && channelId < ServerConfig.CHANNELS_PER_WORLD) { - channelServerNodes.put(channelId, serverNode); + remoteChannelServerNodes.put(channelId, serverNode); } else { throw new IllegalStateException(String.format("Tried to add remote server node with channel ID : %d", channelId)); } } + /** + * Registers a live ChannelServerNode in the server storage. + * + * Unlike RemoteServerNode, which only contains metadata and a Netty connection, + * a ChannelServerNode holds all the live User objects and server state for that channel. + * + * Storing it here allows the central server or other components to: + * - Access the live users on a specific channel + * - Submit packets or requests directly to a ChannelServerNode + * - Perform operations like warping a player within the same channel + * - Perform cross channel operations + * - Get all live Users across all channels. + * - and much more... + * + * Essentially, this provides a way to map channel IDs to their full server instances, + * enabling operations that cannot be done with just RemoteServerNode metadata. + */ + public void addChannelServerNode(ChannelServerNode serverNode){ + channelServerNodes.put(serverNode.getChannelId(), serverNode); + } + public void removeServerNode(int channelId) { if (channelId == GameConstants.CHANNEL_LOGIN) { loginServerNode.set(null); } else { - channelServerNodes.remove(channelId); + remoteChannelServerNodes.remove(channelId); } } public boolean isFull() { // Check if all channels are connected for (int i = 0; i < ServerConfig.CHANNELS_PER_WORLD; i++) { - if (!channelServerNodes.containsKey(i)) { + if (!remoteChannelServerNodes.containsKey(i)) { return false; } } @@ -44,19 +66,31 @@ public boolean isFull() { public boolean isEmpty() { // Check if all channels are disconnected - return channelServerNodes.isEmpty(); + return remoteChannelServerNodes.isEmpty(); } public Optional getLoginServerNode() { return Optional.ofNullable(loginServerNode.get()); } - public Optional getChannelServerNodeById(int channelId) { - return Optional.ofNullable(channelServerNodes.get(channelId)); + public Optional getRemoteChannelServerNodeById(int channelId) { + return Optional.ofNullable(remoteChannelServerNodes.get(channelId)); } - public List getChannelServerNodes() { + public List getRemoteChannelServerNodes() { final List connectedNodes = new ArrayList<>(); + for (int i = 0; i < ServerConfig.CHANNELS_PER_WORLD; i++) { + getRemoteChannelServerNodeById(i).ifPresent(connectedNodes::add); + } + return connectedNodes; + } + + public Optional getChannelServerNodeById(int channelId) { + return Optional.ofNullable(channelServerNodes.get(channelId)); + } + + public List getChannelServerNodes() { + final List connectedNodes = new ArrayList<>(); for (int i = 0; i < ServerConfig.CHANNELS_PER_WORLD; i++) { getChannelServerNodeById(i).ifPresent(connectedNodes::add); } diff --git a/src/main/java/kinoko/server/user/UserStorage.java b/src/main/java/kinoko/server/user/UserStorage.java index c5992392..ff318681 100644 --- a/src/main/java/kinoko/server/user/UserStorage.java +++ b/src/main/java/kinoko/server/user/UserStorage.java @@ -74,4 +74,16 @@ public Optional getByCharacterName(String characterName) { private static String normalizeName(String name) { return name.toLowerCase(); } + + /** + * Returns the number of online users. + */ + public int getUserCount() { + lock.lock(); + try { + return mapByAccountId.size(); + } finally { + lock.unlock(); + } + } } diff --git a/src/main/java/kinoko/world/field/FieldObjectPool.java b/src/main/java/kinoko/world/field/FieldObjectPool.java index 027bb5ea..5622cbd0 100644 --- a/src/main/java/kinoko/world/field/FieldObjectPool.java +++ b/src/main/java/kinoko/world/field/FieldObjectPool.java @@ -48,6 +48,18 @@ public final void forEach(Consumer consumer) { objects.forEachValue(Long.MAX_VALUE, consumer); } + public void forEachExcept(T except, Consumer action) { + forEach(user -> { + if (!user.equals(except)) { + action.accept(user); + } + }); + } + + public final java.util.Collection values() { + return objects.values(); + } + public final boolean isEmpty() { return objects.isEmpty(); } diff --git a/src/main/java/kinoko/world/user/User.java b/src/main/java/kinoko/world/user/User.java index 3a6980b9..c3717197 100644 --- a/src/main/java/kinoko/world/user/User.java +++ b/src/main/java/kinoko/world/user/User.java @@ -1,11 +1,13 @@ package kinoko.world.user; +import kinoko.handler.stage.MigrationHandler; import kinoko.handler.user.FriendHandler; import kinoko.packet.stage.StagePacket; import kinoko.packet.user.PetPacket; import kinoko.packet.user.UserLocal; import kinoko.packet.user.UserRemote; import kinoko.packet.world.FriendPacket; +import kinoko.packet.world.MessagePacket; import kinoko.packet.world.WvsContext; import kinoko.provider.SkillProvider; import kinoko.provider.WzProvider; @@ -24,6 +26,7 @@ import kinoko.server.node.ServerExecutor; import kinoko.server.packet.OutPacket; import kinoko.server.party.PartyRequest; +import kinoko.server.user.RemoteUser; import kinoko.util.BitFlag; import kinoko.world.GameConstants; import kinoko.world.field.Field; @@ -46,6 +49,8 @@ import kinoko.world.user.friend.Friend; import kinoko.world.user.friend.FriendStatus; import kinoko.world.user.stat.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; import java.time.Instant; import java.time.temporal.ChronoUnit; @@ -55,6 +60,7 @@ import java.util.function.Predicate; public final class User extends Life { + private static final Logger log = LoggerFactory.getLogger(User.class); private final Client client; private final CharacterData characterData; @@ -108,6 +114,14 @@ public ChannelServerNode getConnectedServer() { return (ChannelServerNode) client.getServerNode(); } + public void changeChannels(int channelID){ + if (channelID == getConnectedServer().getChannelId()){ + return; + } + + MigrationHandler.handleTransferChannel(this, this.getAccount(), channelID); + } + public int getChannelId() { return getConnectedServer().getChannelId(); } @@ -439,6 +453,15 @@ public void setHp(int hp) { }); } + public void heal(){ + setHp(getMaxHp()); + setMp(getMaxMp()); + } + + public void kill() { + setHp(0); + } + public void addHp(int hp) { setHp(getHp() + hp); } @@ -852,6 +875,43 @@ public void warp(Field destination, int x, int y, int portalId, boolean isMigrat } } + public void warp(int newFieldId, boolean isMigrate, boolean isRevive) { + if (getFieldId() == newFieldId){ + return; + } + + final Optional fieldResult = getConnectedServer().getFieldById(newFieldId); + if (fieldResult.isEmpty()) { + systemMessage("A system error has occurred when changing maps."); + log.error("Field with ID {} does not exist.", newFieldId); + return; + } + + final Field targetField = fieldResult.get(); + + // get the default portal + final Optional portalResult = targetField.getPortalByName(GameConstants.DEFAULT_PORTAL_NAME); + if (portalResult.isEmpty()) { + systemMessage("A system error has occurred when deciding the entry portal."); + log.error("Portal {} does not exist in Field {}", GameConstants.DEFAULT_PORTAL_NAME, newFieldId); + return; + } + + final PortalInfo targetPortalInfo = portalResult.get(); + + this.warp(targetField, targetPortalInfo, isMigrate, isRevive); + } + + public void warpTo(User user){ + changeChannels(user.getChannelId()); + warp(user.getField(), user.getX(), user.getY(), 0, false, false); + } + + public void warpTo(RemoteUser user){ + changeChannels(user.getChannelId()); + warp(user.getFieldId(), false, false); + } + private void completeWarp(Field destination, boolean isMigrate, boolean isRevive) { write(StagePacket.setField(this, getChannelId(), isMigrate, isRevive)); destination.addUser(this); @@ -928,4 +988,10 @@ public int getId() { public void setId(int id) { throw new IllegalStateException("Tried to modify character ID"); } + + + // UTILITY --------------------------------------------------------------------------------------------------------- + public void systemMessage(String text, Object... args){ + write(MessagePacket.system(text, args)); + } } From 9b11226321cda81c403241bcbb1afd41e3ffd8c0 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Wed, 5 Nov 2025 02:21:57 -0500 Subject: [PATCH 65/83] optimized --- src/main/java/kinoko/server/node/CentralServerNode.java | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/main/java/kinoko/server/node/CentralServerNode.java b/src/main/java/kinoko/server/node/CentralServerNode.java index 3338e028..7f700733 100644 --- a/src/main/java/kinoko/server/node/CentralServerNode.java +++ b/src/main/java/kinoko/server/node/CentralServerNode.java @@ -137,7 +137,7 @@ public Optional getUserByCharacterName(String characterName) { return getRemoteUserByCharacterName(characterName) .flatMap(remoteUser -> serverStorage .getChannelServerNodeById(remoteUser.getChannelId()) - .flatMap(channelNode -> channelNode.getUserByCharacterName(characterName)) + .flatMap(channelNode -> channelNode.getUserByCharacterId(remoteUser.getCharacterId())) ); } From 45df30fc86ea26b44e441943f0490decd3cd7f5d Mon Sep 17 00:00:00 2001 From: MujyKun Date: Wed, 5 Nov 2025 18:02:41 -0500 Subject: [PATCH 66/83] updated user.write(MessagePacket.system to user.systemMessage --- .../kinoko/handler/user/SkillHandler.java | 2 +- .../java/kinoko/handler/user/UserHandler.java | 2 +- .../handler/user/item/CashItemHandler.java | 2 +- .../kinoko/handler/user/item/ItemHandler.java | 10 +- .../handler/user/item/UpgradeItemHandler.java | 2 +- .../script/common/ScriptManagerImpl.java | 4 +- .../server/command/CommandProcessor.java | 8 +- .../command/admin/BattleshipCommand.java | 2 +- .../server/command/admin/MobSkillCommand.java | 8 +- .../server/command/admin/RideCommand.java | 4 +- .../server/command/admin/TestCommand.java | 6 +- .../kinoko/server/command/gm/BanCommand.java | 16 +-- .../server/command/gm/HealMapCommand.java | 2 +- .../kinoko/server/command/gm/MobCommand.java | 4 +- .../server/command/gm/UnBanCommand.java | 10 +- .../kinoko/server/command/jrgm/HpCommand.java | 4 +- .../server/command/jrgm/JobCommand.java | 4 +- .../server/command/jrgm/LevelCommand.java | 4 +- .../server/command/jrgm/LevelUpCommand.java | 4 +- .../kinoko/server/command/jrgm/MpCommand.java | 4 +- .../server/command/jrgm/StatCommand.java | 10 +- .../server/command/jrgm/WarpToCommand.java | 2 +- .../server/command/manager/AvatarCommand.java | 8 +- .../manager/ReloadCashShopCommand.java | 4 +- .../command/manager/ReloadDropsCommand.java | 4 +- .../command/manager/ReloadShopsCommand.java | 4 +- .../command/manager/SetGmLevelCommand.java | 12 +- .../server/command/manager/SkillCommand.java | 4 +- .../server/command/player/DisposeCommand.java | 2 +- .../server/command/player/HelpCommand.java | 10 +- .../command/supergm/ClearLockerCommand.java | 2 +- .../command/supergm/ClearQuestCommand.java | 4 +- .../command/supergm/CompleteQuestCommand.java | 2 +- .../server/command/supergm/ItemCommand.java | 6 +- .../server/command/supergm/MesoCommand.java | 2 +- .../server/command/supergm/MorphCommand.java | 4 +- .../server/command/supergm/NpcCommand.java | 8 +- .../server/command/supergm/NxCommand.java | 4 +- .../command/supergm/QuestExCommand.java | 6 +- .../command/supergm/ReactorCommand.java | 8 +- .../command/supergm/StartQuestCommand.java | 4 +- .../command/supergm/ToggleMobCommand.java | 8 +- .../command/tester/ClearInventoryCommand.java | 8 +- .../server/command/tester/InfoCommand.java | 26 ++-- .../server/command/tester/MapCommand.java | 8 +- .../server/command/tester/MaxCommand.java | 2 +- .../server/command/tester/SearchCommand.java | 118 +++++++++--------- .../server/dialog/miniroom/TradingRoom.java | 8 +- .../kinoko/world/job/explorer/Magician.java | 2 +- 49 files changed, 196 insertions(+), 196 deletions(-) diff --git a/src/main/java/kinoko/handler/user/SkillHandler.java b/src/main/java/kinoko/handler/user/SkillHandler.java index 6d0ca31d..47d9079e 100644 --- a/src/main/java/kinoko/handler/user/SkillHandler.java +++ b/src/main/java/kinoko/handler/user/SkillHandler.java @@ -139,7 +139,7 @@ public static void handleUserSkillUseRequest(User user, InPacket inPacket) { // Mystic Door cooltime to avoid crashes if (skill.skillId == Magician.MYSTIC_DOOR) { if (user.getTownPortal() != null && user.getTownPortal().getWaitTime().isAfter(Instant.now())) { - user.write(MessagePacket.system("Please wait 5 seconds before casting Mystic Door again.")); + user.systemMessage("Please wait 5 seconds before casting Mystic Door again."); user.dispose(); return; } diff --git a/src/main/java/kinoko/handler/user/UserHandler.java b/src/main/java/kinoko/handler/user/UserHandler.java index bfa7419a..82313b45 100644 --- a/src/main/java/kinoko/handler/user/UserHandler.java +++ b/src/main/java/kinoko/handler/user/UserHandler.java @@ -757,7 +757,7 @@ public static void handleUserGivePopularityRequest(User user, InPacket inPacket) final Optional targetResult = user.getField().getUserPool().getById(targetId); if (targetResult.isEmpty()) { - user.write(MessagePacket.system("Unable to find the character.")); + user.systemMessage("Unable to find the character."); return; } final User target = targetResult.get(); diff --git a/src/main/java/kinoko/handler/user/item/CashItemHandler.java b/src/main/java/kinoko/handler/user/item/CashItemHandler.java index aba8f3b4..17608321 100644 --- a/src/main/java/kinoko/handler/user/item/CashItemHandler.java +++ b/src/main/java/kinoko/handler/user/item/CashItemHandler.java @@ -598,7 +598,7 @@ public static void handleUserConsumeCashItemUseRequest(User user, InPacket inPac final ItemRewardInfo itemRewardInfo = itemRewardInfoResult.get(); // Resolve reward if (!itemRewardInfo.canAddReward(user)) { - user.write(MessagePacket.system("You do not have enough inventory space.")); + user.systemMessage("You do not have enough inventory space."); user.dispose(); return; } diff --git a/src/main/java/kinoko/handler/user/item/ItemHandler.java b/src/main/java/kinoko/handler/user/item/ItemHandler.java index 059bbb3d..03b29966 100644 --- a/src/main/java/kinoko/handler/user/item/ItemHandler.java +++ b/src/main/java/kinoko/handler/user/item/ItemHandler.java @@ -426,13 +426,13 @@ public static void handleUserPortalScrollUseRequest(User user, InPacket inPacket // Check portal scroll can be used final Field field = user.getField(); if (field.hasFieldOption(FieldOption.PORTALSCROLLLIMIT)) { - user.write(MessagePacket.system("You can't use it here in this map.")); + user.systemMessage("You can't use it here in this map."); user.dispose(); return; } final int moveTo = itemInfoResult.get().getSpec(ItemSpecType.moveTo); if (moveTo != GameConstants.UNDEFINED_FIELD_ID && field.isConnected(moveTo)) { - user.write(MessagePacket.system("You cannot go to that place.")); + user.systemMessage("You cannot go to that place."); user.dispose(); return; } @@ -442,7 +442,7 @@ public static void handleUserPortalScrollUseRequest(User user, InPacket inPacket final Optional destinationFieldResult = user.getConnectedServer().getFieldById(destinationFieldId); if (destinationFieldResult.isEmpty()) { log.error("Could not resolve field ID : {}", destinationFieldId); - user.write(MessagePacket.system("You cannot go to that place.")); + user.systemMessage("You cannot go to that place."); user.dispose(); return; } @@ -450,7 +450,7 @@ public static void handleUserPortalScrollUseRequest(User user, InPacket inPacket final Optional destinationPortalResult = destinationField.getRandomStartPoint(); if (destinationPortalResult.isEmpty()) { log.error("Could not resolve start point portal for field ID : {}", destinationFieldId); - user.write(MessagePacket.system("You cannot go to that place.")); + user.systemMessage("You cannot go to that place."); user.dispose(); return; } @@ -489,7 +489,7 @@ public static void handleUserLotteryItemUseRequest(User user, InPacket inPacket) // Resolve reward if (!itemRewardInfo.canAddReward(user)) { - user.write(MessagePacket.system("You do not have enough inventory space.")); + user.systemMessage("You do not have enough inventory space."); user.dispose(); return; } diff --git a/src/main/java/kinoko/handler/user/item/UpgradeItemHandler.java b/src/main/java/kinoko/handler/user/item/UpgradeItemHandler.java index 31866dd8..689ca898 100644 --- a/src/main/java/kinoko/handler/user/item/UpgradeItemHandler.java +++ b/src/main/java/kinoko/handler/user/item/UpgradeItemHandler.java @@ -403,7 +403,7 @@ private static void itemUpgradeEffectError(User user, boolean enchantSkill) { if (enchantSkill) { user.write(UserPacket.userItemUpgradeEffectEnchantError(user)); } else { - user.write(MessagePacket.system("That scroll cannot be used on this item.")); + user.systemMessage("That scroll cannot be used on this item."); } user.dispose(); } diff --git a/src/main/java/kinoko/script/common/ScriptManagerImpl.java b/src/main/java/kinoko/script/common/ScriptManagerImpl.java index 32a24034..329e7b67 100644 --- a/src/main/java/kinoko/script/common/ScriptManagerImpl.java +++ b/src/main/java/kinoko/script/common/ScriptManagerImpl.java @@ -105,7 +105,7 @@ public void write(OutPacket outPacket) { @Override public void message(String message) { - user.write(MessagePacket.system(message)); + user.systemMessage(message); } @Override @@ -864,7 +864,7 @@ public void removeNpc(int templateId) { public void spawnReactor(int templateId, int x, int y, boolean isFlip, int reactorTime, boolean originalField) { final Optional reactorTemplateResult = ReactorProvider.getReactorTemplate(templateId); if (reactorTemplateResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve reactor template ID : %d", templateId)); + user.systemMessage("Could not resolve reactor template ID : %d", templateId); return; } final Field targetField = originalField ? field : user.getField(); diff --git a/src/main/java/kinoko/server/command/CommandProcessor.java b/src/main/java/kinoko/server/command/CommandProcessor.java index 16dc392d..890719d0 100644 --- a/src/main/java/kinoko/server/command/CommandProcessor.java +++ b/src/main/java/kinoko/server/command/CommandProcessor.java @@ -160,7 +160,7 @@ public static void tryProcessCommand(User user, String text) { // no registered command found. if (commandResult.isEmpty()) { - user.write(MessagePacket.system("Unknown command : %s", text)); + user.systemMessage("Unknown command : %s", text); return; } @@ -171,7 +171,7 @@ public static void tryProcessCommand(User user, String text) { if (method.isAnnotationPresent(Arguments.class)) { final Arguments annotation = method.getAnnotation(Arguments.class); if (arguments.length < annotation.value().length + 1) { - user.write(MessagePacket.system("Syntax : %s", getHelpString(method))); + user.systemMessage("Syntax : %s", getHelpString(method)); return; } } @@ -180,7 +180,7 @@ public static void tryProcessCommand(User user, String text) { AdminLevel requiredLevel = methodLevelMap.getOrDefault(method, AdminLevel.PLAYER); if (!user.getAdminLevel().isAtLeast(requiredLevel)) { if (ServerConfig.TESPIA) { - user.write(MessagePacket.system("You do not have permission to use this command.")); // We don't want to show this message to a normal player. + user.systemMessage("You do not have permission to use this command."); // We don't want to show this message to a normal player. } return; } @@ -191,7 +191,7 @@ public static void tryProcessCommand(User user, String text) { method.invoke(null, user, arguments); } catch (IllegalAccessException | InvocationTargetException e) { log.error("Exception caught while processing command {}", text, e); - user.write(MessagePacket.system("Failed to process command : %s", text)); + user.systemMessage("Failed to process command : %s", text); e.printStackTrace(); } } diff --git a/src/main/java/kinoko/server/command/admin/BattleshipCommand.java b/src/main/java/kinoko/server/command/admin/BattleshipCommand.java index 7a17a2ce..9b10dd90 100644 --- a/src/main/java/kinoko/server/command/admin/BattleshipCommand.java +++ b/src/main/java/kinoko/server/command/admin/BattleshipCommand.java @@ -13,6 +13,6 @@ public final class BattleshipCommand { @Command({ "battleship", "bship" }) public static void battleship(User user, String[] args) { - user.write(MessagePacket.system("Battleship HP : %d", Pirate.getBattleshipDurability(user))); + user.systemMessage("Battleship HP : %d", Pirate.getBattleshipDurability(user)); } } diff --git a/src/main/java/kinoko/server/command/admin/MobSkillCommand.java b/src/main/java/kinoko/server/command/admin/MobSkillCommand.java index 73aa486b..2a7264c2 100644 --- a/src/main/java/kinoko/server/command/admin/MobSkillCommand.java +++ b/src/main/java/kinoko/server/command/admin/MobSkillCommand.java @@ -28,19 +28,19 @@ public static void mobskill(User user, String[] args) { MobSkillType skillType = MobSkillType.getByValue(skillId); if (skillType == null) { - user.write(MessagePacket.system("Could not resolve mob skill %d", skillId)); + user.systemMessage("Could not resolve mob skill %d", skillId); return; } CharacterTemporaryStat cts = skillType.getCharacterTemporaryStat(); if (cts == null) { - user.write(MessagePacket.system("Mob skill %s does not apply a CTS", skillType)); + user.systemMessage("Mob skill %s does not apply a CTS", skillType); return; } Optional skillInfoResult = SkillProvider.getMobSkillInfoById(skillId); if (skillInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve mob skill info %d", skillId)); + user.systemMessage("Could not resolve mob skill info %d", skillId); return; } @@ -50,7 +50,7 @@ public static void mobskill(User user, String[] args) { user.setTemporaryStat(cts, TemporaryStatOption.ofMobSkill(value, skillId, slv, duration)); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !mobskill ")); + user.systemMessage("Usage: !mobskill "); } } } diff --git a/src/main/java/kinoko/server/command/admin/RideCommand.java b/src/main/java/kinoko/server/command/admin/RideCommand.java index 894e44f0..fe287f00 100644 --- a/src/main/java/kinoko/server/command/admin/RideCommand.java +++ b/src/main/java/kinoko/server/command/admin/RideCommand.java @@ -27,7 +27,7 @@ public static void ride(User user, String[] args) { try { int vehicleId = Integer.parseInt(args[1]); if (ItemProvider.getItemInfo(vehicleId).isEmpty()) { - user.write(MessagePacket.system("Could not resolve item info for vehicle ID : %d", vehicleId)); + user.systemMessage("Could not resolve item info for vehicle ID : %d", vehicleId); return; } @@ -42,7 +42,7 @@ public static void ride(User user, String[] args) { user.write(WvsContext.temporaryStatSet(ss, flag)); user.getField().broadcastPacket(UserRemote.temporaryStatSet(user, ss, flag)); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !ride ")); + user.systemMessage("Usage: !ride "); } } } diff --git a/src/main/java/kinoko/server/command/admin/TestCommand.java b/src/main/java/kinoko/server/command/admin/TestCommand.java index a903ddee..720703c0 100644 --- a/src/main/java/kinoko/server/command/admin/TestCommand.java +++ b/src/main/java/kinoko/server/command/admin/TestCommand.java @@ -12,9 +12,9 @@ public class TestCommand { @Command("test") public static void test(User user, String[] args) { user.getConnectedServer().submitUserQueryRequestAll(queryResult -> { - user.write(MessagePacket.system("Users in world: %d", queryResult.size())); - user.write(MessagePacket.system("Users in field : %d", user.getField().getUserPool().getCount())); - user.write(MessagePacket.system("Party ID : %d (%d)", user.getPartyId(), user.getCharacterData().getPartyId())); + user.systemMessage("Users in world: %d", queryResult.size()); + user.systemMessage("Users in field : %d", user.getField().getUserPool().getCount()); + user.systemMessage("Party ID : %d (%d)", user.getPartyId(), user.getCharacterData().getPartyId()); // Apply item effect (throws if item not found) user.setConsumeItemEffect(ItemProvider.getItemInfo(2022181).orElseThrow()); diff --git a/src/main/java/kinoko/server/command/gm/BanCommand.java b/src/main/java/kinoko/server/command/gm/BanCommand.java index 1ec62138..8ed44a31 100644 --- a/src/main/java/kinoko/server/command/gm/BanCommand.java +++ b/src/main/java/kinoko/server/command/gm/BanCommand.java @@ -28,7 +28,7 @@ public final class BanCommand { @Arguments({"player name", "reason"}) public static void ban(User user, String[] args) { if (args.length < 3) { - user.write(MessagePacket.system("Usage: !ban ")); + user.systemMessage("Usage: !ban "); return; } @@ -41,7 +41,7 @@ public static void ban(User user, String[] args) { .findFirst(); if (targetUserResult.isEmpty()) { - user.write(MessagePacket.system("Could not find player '%s' on this channel.", targetName)); + user.systemMessage("Could not find player '%s' on this channel.", targetName); return; } @@ -50,20 +50,20 @@ public static void ban(User user, String[] args) { // Prevent self-ban if (targetUser.getCharacterId() == user.getCharacterId()) { - user.write(MessagePacket.system("You cannot ban yourself!")); + user.systemMessage("You cannot ban yourself!"); return; } // Prevent banning a GM with a higher status than you. Banning same-level GMs is allowed. if (!user.getAdminLevel().isAtLeast(targetUser.getAdminLevel())) { - user.write(MessagePacket.system("You cannot ban any GM with a higher status than you.")); + user.systemMessage("You cannot ban any GM with a higher status than you."); return; } // Check if already banned BanInfo banInfo = targetAccount.getBanInfo(); if (banInfo.isBanned()) { - user.write(MessagePacket.system("Player '%s' is already banned.", targetName)); + user.systemMessage("Player '%s' is already banned.", targetName); return; } @@ -78,9 +78,9 @@ public static void ban(User user, String[] args) { DatabaseManager.accountAccessor().saveAccount(targetAccount); // Notify the player and the GM - targetUser.write(MessagePacket.system("You have been banned by GM %s.", user.getCharacterName())); - targetUser.write(MessagePacket.system("Reason: %s", reason)); - user.write(MessagePacket.system("Banned player '%s' (Account ID: %d).", targetName, targetAccount.getId())); + targetUser.systemMessage("You have been banned by GM %s.", user.getCharacterName()); + targetUser.systemMessage("Reason: %s", reason); + user.systemMessage("Banned player '%s' (Account ID: %d).", targetName, targetAccount.getId()); // Disconnect after 5 seconds scheduler.schedule(() -> { diff --git a/src/main/java/kinoko/server/command/gm/HealMapCommand.java b/src/main/java/kinoko/server/command/gm/HealMapCommand.java index 7d007ed5..fe056a43 100644 --- a/src/main/java/kinoko/server/command/gm/HealMapCommand.java +++ b/src/main/java/kinoko/server/command/gm/HealMapCommand.java @@ -20,6 +20,6 @@ public static void healMap(User user, String[] args) { healedCount++; } - user.write(MessagePacket.system("Healed %d player(s) in the current map.", healedCount)); + user.systemMessage("Healed %d player(s) in the current map.", healedCount); } } diff --git a/src/main/java/kinoko/server/command/gm/MobCommand.java b/src/main/java/kinoko/server/command/gm/MobCommand.java index 3ff31794..3a97c082 100644 --- a/src/main/java/kinoko/server/command/gm/MobCommand.java +++ b/src/main/java/kinoko/server/command/gm/MobCommand.java @@ -24,7 +24,7 @@ public static void mob(User user, String[] args) { final int templateId = Integer.parseInt(args[1]); final Optional mobTemplateResult = MobProvider.getMobTemplate(templateId); if (mobTemplateResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve mob template ID: %d", templateId)); + user.systemMessage("Could not resolve mob template ID: %d", templateId); return; } @@ -43,7 +43,7 @@ public static void mob(User user, String[] args) { field.getMobPool().addMob(mob); } } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !mob [count]")); + user.systemMessage("Usage: !mob [count]"); } } } diff --git a/src/main/java/kinoko/server/command/gm/UnBanCommand.java b/src/main/java/kinoko/server/command/gm/UnBanCommand.java index 8bbe1802..568cda0d 100644 --- a/src/main/java/kinoko/server/command/gm/UnBanCommand.java +++ b/src/main/java/kinoko/server/command/gm/UnBanCommand.java @@ -20,7 +20,7 @@ public final class UnBanCommand { @Arguments("character username") public static void unban(User user, String[] args) { if (args.length < 2) { - user.write(MessagePacket.system("Usage: !unban ")); + user.systemMessage("Usage: !unban "); return; } @@ -30,7 +30,7 @@ public static void unban(User user, String[] args) { final Optional targetCharacterResult = DatabaseManager.characterAccessor().getCharacterByName(targetUsername); if (targetCharacterResult.isEmpty()) { - user.write(MessagePacket.system("Could not find character with username '%s'.", targetUsername)); + user.systemMessage("Could not find character with username '%s'.", targetUsername); return; } @@ -38,7 +38,7 @@ public static void unban(User user, String[] args) { final Optional targetAccountResult = DatabaseManager.accountAccessor().getAccountById(targetCharacterData.getAccountId()); if (targetAccountResult.isEmpty()) { - user.write(MessagePacket.system("Could not find account with character username '%s'.", targetUsername)); + user.systemMessage("Could not find account with character username '%s'.", targetUsername); return; } @@ -46,7 +46,7 @@ public static void unban(User user, String[] args) { BanInfo banInfo = targetAccount.getBanInfo(); if (!banInfo.isBanned()) { - user.write(MessagePacket.system("Account '%s' is not banned.", targetUsername)); + user.systemMessage("Account '%s' is not banned.", targetUsername); return; } @@ -55,6 +55,6 @@ public static void unban(User user, String[] args) { targetAccount.setBanInfo(banInfo); DatabaseManager.accountAccessor().saveAccount(targetAccount); - user.write(MessagePacket.system("Unbanned account '%s' (Account ID: %d).", targetUsername, targetAccount.getId())); + user.systemMessage("Unbanned account '%s' (Account ID: %d).", targetUsername, targetAccount.getId()); } } diff --git a/src/main/java/kinoko/server/command/jrgm/HpCommand.java b/src/main/java/kinoko/server/command/jrgm/HpCommand.java index ccb5adeb..f513ddd6 100644 --- a/src/main/java/kinoko/server/command/jrgm/HpCommand.java +++ b/src/main/java/kinoko/server/command/jrgm/HpCommand.java @@ -16,9 +16,9 @@ public static void hp(User user, String[] args) { try { int newHp = Integer.parseInt(args[1]); user.setHp(newHp); - user.write(MessagePacket.system("HP set to %d", newHp)); + user.systemMessage("HP set to %d", newHp); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !hp ")); + user.systemMessage("Usage: !hp "); } } } diff --git a/src/main/java/kinoko/server/command/jrgm/JobCommand.java b/src/main/java/kinoko/server/command/jrgm/JobCommand.java index 1275fd9a..16d4c437 100644 --- a/src/main/java/kinoko/server/command/jrgm/JobCommand.java +++ b/src/main/java/kinoko/server/command/jrgm/JobCommand.java @@ -33,7 +33,7 @@ public static void job(User user, String[] args) { int jobId = Integer.parseInt(args[1]); Job job = Job.getById(jobId); if (job == null) { - user.write(MessagePacket.system("Could not change to unknown job : %d", jobId)); + user.systemMessage("Could not change to unknown job : %d", jobId); return; } @@ -77,7 +77,7 @@ public static void job(User user, String[] args) { user.getConnectedServer().notifyUserUpdate(user); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !job ")); + user.systemMessage("Usage: !job "); } } } diff --git a/src/main/java/kinoko/server/command/jrgm/LevelCommand.java b/src/main/java/kinoko/server/command/jrgm/LevelCommand.java index 358026fd..72a4fc8a 100644 --- a/src/main/java/kinoko/server/command/jrgm/LevelCommand.java +++ b/src/main/java/kinoko/server/command/jrgm/LevelCommand.java @@ -21,7 +21,7 @@ public static void level(User user, String[] args) { try { int level = Integer.parseInt(args[1]); if (level < 1 || level > GameConstants.LEVEL_MAX) { - user.write(MessagePacket.system("Could not change level to : %d", level)); + user.systemMessage("Could not change level to : %d", level); return; } @@ -31,7 +31,7 @@ public static void level(User user, String[] args) { user.write(WvsContext.statChanged(Stat.LEVEL, (byte) cs.getLevel(), true)); user.getConnectedServer().notifyUserUpdate(user); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !level ")); + user.systemMessage("Usage: !level "); } } } diff --git a/src/main/java/kinoko/server/command/jrgm/LevelUpCommand.java b/src/main/java/kinoko/server/command/jrgm/LevelUpCommand.java index 07bea323..c799d472 100644 --- a/src/main/java/kinoko/server/command/jrgm/LevelUpCommand.java +++ b/src/main/java/kinoko/server/command/jrgm/LevelUpCommand.java @@ -18,14 +18,14 @@ public static void levelUp(User user, String[] args) { try { int level = Integer.parseInt(args[1]); if (level <= user.getLevel() || level > GameConstants.LEVEL_MAX) { - user.write(MessagePacket.system("Could not level up to : %d", level)); + user.systemMessage("Could not level up to : %d", level); return; } while (user.getLevel() < level) { user.addExp(GameConstants.getNextLevelExp(user.getLevel()) - user.getCharacterStat().getExp()); } } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !levelup ")); + user.systemMessage("Usage: !levelup "); } } } diff --git a/src/main/java/kinoko/server/command/jrgm/MpCommand.java b/src/main/java/kinoko/server/command/jrgm/MpCommand.java index 79d4fa26..6686dde1 100644 --- a/src/main/java/kinoko/server/command/jrgm/MpCommand.java +++ b/src/main/java/kinoko/server/command/jrgm/MpCommand.java @@ -16,9 +16,9 @@ public static void mp(User user, String[] args) { try { int newMp = Integer.parseInt(args[1]); user.setMp(newMp); - user.write(MessagePacket.system("MP set to %d", newMp)); + user.systemMessage("MP set to %d", newMp); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !mp ")); + user.systemMessage("Usage: !mp "); } } } diff --git a/src/main/java/kinoko/server/command/jrgm/StatCommand.java b/src/main/java/kinoko/server/command/jrgm/StatCommand.java index d19aca7d..b248f67b 100644 --- a/src/main/java/kinoko/server/command/jrgm/StatCommand.java +++ b/src/main/java/kinoko/server/command/jrgm/StatCommand.java @@ -67,21 +67,21 @@ public static void stat(User user, String[] args) { } } default -> { - user.write(MessagePacket.system( + user.systemMessage( "Syntax: %sstat hp/mp/str/dex/int/luk/ap/sp ", - ServerConfig.PLAYER_COMMAND_PREFIX)); + ServerConfig.PLAYER_COMMAND_PREFIX); return; } } user.validateStat(); user.write(WvsContext.statChanged(statMap, true)); - user.write(MessagePacket.system("Set %s to %d", stat, value)); + user.systemMessage("Set %s to %d", stat, value); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system( + user.systemMessage( "Syntax: %sstat hp/mp/str/dex/int/luk/ap/sp ", - ServerConfig.PLAYER_COMMAND_PREFIX)); + ServerConfig.PLAYER_COMMAND_PREFIX); } } } diff --git a/src/main/java/kinoko/server/command/jrgm/WarpToCommand.java b/src/main/java/kinoko/server/command/jrgm/WarpToCommand.java index 60c8eafa..32532849 100644 --- a/src/main/java/kinoko/server/command/jrgm/WarpToCommand.java +++ b/src/main/java/kinoko/server/command/jrgm/WarpToCommand.java @@ -23,7 +23,7 @@ public static void warpToPlayer(User user, String[] args) { Optional targetRemoteUser = Server.getCentralServerNode().getRemoteUserByCharacterName(targetName); if (targetRemoteUser.isEmpty()) { - user.write(MessagePacket.system("Could not find player '%s'.", targetName)); + user.systemMessage("Could not find player '%s'.", targetName); return; } diff --git a/src/main/java/kinoko/server/command/manager/AvatarCommand.java b/src/main/java/kinoko/server/command/manager/AvatarCommand.java index 1d5e6924..07da5187 100644 --- a/src/main/java/kinoko/server/command/manager/AvatarCommand.java +++ b/src/main/java/kinoko/server/command/manager/AvatarCommand.java @@ -28,7 +28,7 @@ public static void avatar(User user, String[] args) { user.getField().broadcastPacket(UserRemote.avatarModified(user), user); } else if (look >= GameConstants.FACE_MIN && look <= GameConstants.FACE_MAX) { if (StringProvider.getItemName(look) == null) { - user.write(MessagePacket.system("Tried to change face with invalid ID : %d", look)); + user.systemMessage("Tried to change face with invalid ID : %d", look); return; } user.getCharacterStat().setFace(look); @@ -36,18 +36,18 @@ public static void avatar(User user, String[] args) { user.getField().broadcastPacket(UserRemote.avatarModified(user), user); } else if (look >= GameConstants.HAIR_MIN && look <= GameConstants.HAIR_MAX) { if (StringProvider.getItemName(look) == null) { - user.write(MessagePacket.system("Tried to change hair with invalid ID : %d", look)); + user.systemMessage("Tried to change hair with invalid ID : %d", look); return; } user.getCharacterStat().setHair(look); user.write(WvsContext.statChanged(Stat.HAIR, user.getCharacterStat().getHair(), false)); user.getField().broadcastPacket(UserRemote.avatarModified(user), user); } else { - user.write(MessagePacket.system("Tried to change avatar with invalid ID : %d", look)); + user.systemMessage("Tried to change avatar with invalid ID : %d", look); } } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !avatar ")); + user.systemMessage("Usage: !avatar "); } } } diff --git a/src/main/java/kinoko/server/command/manager/ReloadCashShopCommand.java b/src/main/java/kinoko/server/command/manager/ReloadCashShopCommand.java index 4d4d8b13..11830f56 100644 --- a/src/main/java/kinoko/server/command/manager/ReloadCashShopCommand.java +++ b/src/main/java/kinoko/server/command/manager/ReloadCashShopCommand.java @@ -18,9 +18,9 @@ public final class ReloadCashShopCommand { public static void reloadCashShop(User user, String[] args) { try { CashShop.initialize(); - user.write(MessagePacket.system("Cash shop reloaded successfully.")); + user.systemMessage("Cash shop reloaded successfully."); } catch (Exception e) { - user.write(MessagePacket.system("Failed to reload cash shop: %s", e.getMessage())); + user.systemMessage("Failed to reload cash shop: %s", e.getMessage()); } } } \ No newline at end of file diff --git a/src/main/java/kinoko/server/command/manager/ReloadDropsCommand.java b/src/main/java/kinoko/server/command/manager/ReloadDropsCommand.java index 601c2595..8e6d48dd 100644 --- a/src/main/java/kinoko/server/command/manager/ReloadDropsCommand.java +++ b/src/main/java/kinoko/server/command/manager/ReloadDropsCommand.java @@ -18,9 +18,9 @@ public final class ReloadDropsCommand { public static void reloadDrops(User user, String[] args) { try { RewardProvider.initialize(); - user.write(MessagePacket.system("Drops reloaded successfully.")); + user.systemMessage("Drops reloaded successfully."); } catch (Exception e) { - user.write(MessagePacket.system("Failed to reload drops: %s", e.getMessage())); + user.systemMessage("Failed to reload drops: %s", e.getMessage()); } } } diff --git a/src/main/java/kinoko/server/command/manager/ReloadShopsCommand.java b/src/main/java/kinoko/server/command/manager/ReloadShopsCommand.java index bbb3790b..75859492 100644 --- a/src/main/java/kinoko/server/command/manager/ReloadShopsCommand.java +++ b/src/main/java/kinoko/server/command/manager/ReloadShopsCommand.java @@ -18,9 +18,9 @@ public final class ReloadShopsCommand { public static void reloadShops(User user, String[] args) { try { ShopProvider.initialize(); - user.write(MessagePacket.system("Shops reloaded successfully.")); + user.systemMessage("Shops reloaded successfully."); } catch (Exception e) { - user.write(MessagePacket.system("Failed to reload shops: %s", e.getMessage())); + user.systemMessage("Failed to reload shops: %s", e.getMessage()); } } } diff --git a/src/main/java/kinoko/server/command/manager/SetGmLevelCommand.java b/src/main/java/kinoko/server/command/manager/SetGmLevelCommand.java index 0c50ea76..493ca52a 100644 --- a/src/main/java/kinoko/server/command/manager/SetGmLevelCommand.java +++ b/src/main/java/kinoko/server/command/manager/SetGmLevelCommand.java @@ -23,7 +23,7 @@ public class SetGmLevelCommand { @Arguments({ "Character Name", "Admin Level" }) public static void setGMLevel(User user, String[] args) { if (args.length < 3) { - user.write(MessagePacket.system("Usage: !setadmin ")); + user.systemMessage("Usage: !setadmin "); return; } @@ -34,18 +34,18 @@ public static void setGMLevel(User user, String[] args) { try { level = Integer.parseInt(levelStr); } catch (NumberFormatException e) { - user.write(MessagePacket.system("Invalid level: %s", levelStr)); + user.systemMessage("Invalid level: %s", levelStr); return; } if (level < 0 || level > 6) { - user.write(MessagePacket.system("Admin level must be between 0 (Admin) and 6 (Player).")); + user.systemMessage("Admin level must be between 0 (Admin) and 6 (Player)."); return; } Optional target = user.getConnectedServer().getUserByCharacterName(targetName); if (target.isEmpty()) { - user.write(MessagePacket.system("User not found: %s", targetName)); + user.systemMessage("User not found: %s", targetName); return; } @@ -54,7 +54,7 @@ public static void setGMLevel(User user, String[] args) { AdminLevel adminLevel = AdminLevel.fromValue((short) level); targetUser.getCharacterStat().setAdminLevel(adminLevel); - user.write(MessagePacket.system("Set admin level of %s to %s (%d)", targetName, adminLevel.name(), level)); - targetUser.write(MessagePacket.system("Your admin level has been set to %s by %s", adminLevel.name(), user.getCharacterName())); + user.systemMessage("Set admin level of %s to %s (%d)", targetName, adminLevel.name(), level); + targetUser.systemMessage("Your admin level has been set to %s by %s", adminLevel.name(), user.getCharacterName()); } } diff --git a/src/main/java/kinoko/server/command/manager/SkillCommand.java b/src/main/java/kinoko/server/command/manager/SkillCommand.java index 88c8c016..7fc68036 100644 --- a/src/main/java/kinoko/server/command/manager/SkillCommand.java +++ b/src/main/java/kinoko/server/command/manager/SkillCommand.java @@ -27,7 +27,7 @@ public static void skill(User user, String[] args) { Optional skillInfoResult = SkillProvider.getSkillInfoById(skillId); if (skillInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not find skill : %d", skillId)); + user.systemMessage("Could not find skill : %d", skillId); return; } @@ -42,7 +42,7 @@ public static void skill(User user, String[] args) { user.validateStat(); user.write(WvsContext.changeSkillRecordResult(skillRecord, true)); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !skill ")); + user.systemMessage("Usage: !skill "); } } } diff --git a/src/main/java/kinoko/server/command/player/DisposeCommand.java b/src/main/java/kinoko/server/command/player/DisposeCommand.java index 06e7eb24..46621612 100644 --- a/src/main/java/kinoko/server/command/player/DisposeCommand.java +++ b/src/main/java/kinoko/server/command/player/DisposeCommand.java @@ -12,6 +12,6 @@ public class DisposeCommand { public static void dispose(User user, String[] args) { user.closeDialog(); user.dispose(); - user.write(MessagePacket.system("You have been disposed.")); + user.systemMessage("You have been disposed."); } } diff --git a/src/main/java/kinoko/server/command/player/HelpCommand.java b/src/main/java/kinoko/server/command/player/HelpCommand.java index 8f1e5e8d..b0fb34c7 100644 --- a/src/main/java/kinoko/server/command/player/HelpCommand.java +++ b/src/main/java/kinoko/server/command/player/HelpCommand.java @@ -28,7 +28,7 @@ public static void help(User user, String[] args) { // If just "!help" or "@help" -> list all accessible commands if (args.length == 1) { - user.write(MessagePacket.system("Available Commands:")); + user.systemMessage("Available Commands:"); // Use a set to avoid duplicate methods caused by multiple aliases Set uniqueMethods = new HashSet<>(CommandProcessor.getCommandMap().values()); @@ -37,7 +37,7 @@ public static void help(User user, String[] args) { AdminLevel requiredLevel = CommandProcessor.getRequiredLevel(method); if (!userLevel.isAtLeast(requiredLevel)) continue; - user.write(MessagePacket.system("%s", CommandProcessor.getHelpString(method))); + user.systemMessage("%s", CommandProcessor.getHelpString(method)); } } // "!help " -> show syntax only @@ -46,18 +46,18 @@ public static void help(User user, String[] args) { Optional commandResult = CommandProcessor.getCommand(commandName); if (commandResult.isEmpty()) { - user.write(MessagePacket.system("Unknown command: %s", commandName)); + user.systemMessage("Unknown command: %s", commandName); return; } Method method = commandResult.get(); AdminLevel requiredLevel = CommandProcessor.getRequiredLevel(method); if (!userLevel.isAtLeast(requiredLevel)) { - user.write(MessagePacket.system("Unknown command: %s", commandName)); + user.systemMessage("Unknown command: %s", commandName); return; } - user.write(MessagePacket.system("Syntax: %s", CommandProcessor.getHelpString(method))); + user.systemMessage("Syntax: %s", CommandProcessor.getHelpString(method)); } } } diff --git a/src/main/java/kinoko/server/command/supergm/ClearLockerCommand.java b/src/main/java/kinoko/server/command/supergm/ClearLockerCommand.java index 7c464475..9e5057d4 100644 --- a/src/main/java/kinoko/server/command/supergm/ClearLockerCommand.java +++ b/src/main/java/kinoko/server/command/supergm/ClearLockerCommand.java @@ -12,6 +12,6 @@ public final class ClearLockerCommand { @Command("clearlocker") public static void clearLocker(User user, String[] args) { user.getAccount().getLocker().getCashItems().clear(); - user.write(MessagePacket.system("Locker inventory cleared!")); + user.systemMessage("Locker inventory cleared!"); } } diff --git a/src/main/java/kinoko/server/command/supergm/ClearQuestCommand.java b/src/main/java/kinoko/server/command/supergm/ClearQuestCommand.java index 4a1e9ee5..f338fc70 100644 --- a/src/main/java/kinoko/server/command/supergm/ClearQuestCommand.java +++ b/src/main/java/kinoko/server/command/supergm/ClearQuestCommand.java @@ -23,7 +23,7 @@ public static void clearQuest(User user, String[] args) { Optional questRecordResult = user.getQuestManager().getQuestRecord(questId); if (questRecordResult.isEmpty()) { - user.write(MessagePacket.system("Could not find quest record : %d", questId)); + user.systemMessage("Could not find quest record : %d", questId); return; } @@ -32,7 +32,7 @@ public static void clearQuest(User user, String[] args) { user.write(MessagePacket.questRecord(qr)); user.validateStat(); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !clearquest ")); + user.systemMessage("Usage: !clearquest "); } } } diff --git a/src/main/java/kinoko/server/command/supergm/CompleteQuestCommand.java b/src/main/java/kinoko/server/command/supergm/CompleteQuestCommand.java index a25e029d..bcfc0a8d 100644 --- a/src/main/java/kinoko/server/command/supergm/CompleteQuestCommand.java +++ b/src/main/java/kinoko/server/command/supergm/CompleteQuestCommand.java @@ -21,7 +21,7 @@ public static void completeQuest(User user, String[] args) { user.write(MessagePacket.questRecord(qr)); user.validateStat(); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !completequest ")); + user.systemMessage("Usage: !completequest "); } } } diff --git a/src/main/java/kinoko/server/command/supergm/ItemCommand.java b/src/main/java/kinoko/server/command/supergm/ItemCommand.java index 0ab71539..27e7702c 100644 --- a/src/main/java/kinoko/server/command/supergm/ItemCommand.java +++ b/src/main/java/kinoko/server/command/supergm/ItemCommand.java @@ -28,7 +28,7 @@ public static void item(User user, String[] args) { final Optional itemInfoResult = ItemProvider.getItemInfo(itemId); if (itemInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve item ID: %d", itemId)); + user.systemMessage("Could not resolve item ID: %d", itemId); return; } @@ -43,11 +43,11 @@ public static void item(User user, String[] args) { user.write(WvsContext.inventoryOperation(addItemResult.get(), true)); user.write(UserLocal.effect(Effect.gainItem(item))); } else { - user.write(MessagePacket.system("Failed to add item ID %d (%d) to inventory", itemId, quantity)); + user.systemMessage("Failed to add item ID %d (%d) to inventory", itemId, quantity); } } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !item [quantity]")); + user.systemMessage("Usage: !item [quantity]"); } } } diff --git a/src/main/java/kinoko/server/command/supergm/MesoCommand.java b/src/main/java/kinoko/server/command/supergm/MesoCommand.java index e3bebe1f..43777e31 100644 --- a/src/main/java/kinoko/server/command/supergm/MesoCommand.java +++ b/src/main/java/kinoko/server/command/supergm/MesoCommand.java @@ -28,7 +28,7 @@ public static void meso(User user, String[] args) { im.setMoney(money); user.write(WvsContext.statChanged(Stat.MONEY, im.getMoney(), true)); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !meso ")); + user.systemMessage("Usage: !meso "); } } } \ No newline at end of file diff --git a/src/main/java/kinoko/server/command/supergm/MorphCommand.java b/src/main/java/kinoko/server/command/supergm/MorphCommand.java index 932ddae9..9f064b22 100644 --- a/src/main/java/kinoko/server/command/supergm/MorphCommand.java +++ b/src/main/java/kinoko/server/command/supergm/MorphCommand.java @@ -26,7 +26,7 @@ public static void morph(User user, String[] args) { try { int morphId = Integer.parseInt(args[1]); if (SkillProvider.getMorphInfoById(morphId).isEmpty()) { - user.write(MessagePacket.system("Could not resolve morph info for morph ID : %d", morphId)); + user.systemMessage("Could not resolve morph info for morph ID : %d", morphId); return; } @@ -36,7 +36,7 @@ public static void morph(User user, String[] args) { user.write(WvsContext.temporaryStatSet(ss, flag)); user.getField().broadcastPacket(UserRemote.temporaryStatSet(user, ss, flag)); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !morph ")); + user.systemMessage("Usage: !morph "); } } } diff --git a/src/main/java/kinoko/server/command/supergm/NpcCommand.java b/src/main/java/kinoko/server/command/supergm/NpcCommand.java index 92bb31c9..3a9493dd 100644 --- a/src/main/java/kinoko/server/command/supergm/NpcCommand.java +++ b/src/main/java/kinoko/server/command/supergm/NpcCommand.java @@ -23,21 +23,21 @@ public static void npc(User user, String[] args) { final Optional npcTemplateResult = NpcProvider.getNpcTemplate(templateId); if (npcTemplateResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve npc ID: %d", templateId)); + user.systemMessage("Could not resolve npc ID: %d", templateId); return; } final String scriptName = npcTemplateResult.get().getScript(); if (scriptName == null || scriptName.isEmpty()) { - user.write(MessagePacket.system("Could not find script for npc ID: %d", templateId)); + user.systemMessage("Could not find script for npc ID: %d", templateId); return; } - user.write(MessagePacket.system("Starting script for npc ID: %d, script: %s", templateId, scriptName)); + user.systemMessage("Starting script for npc ID: %d, script: %s", templateId, scriptName); ScriptDispatcher.startNpcScript(user, user, scriptName, templateId); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !npc ")); + user.systemMessage("Usage: !npc "); } } } diff --git a/src/main/java/kinoko/server/command/supergm/NxCommand.java b/src/main/java/kinoko/server/command/supergm/NxCommand.java index 46fcf142..09444438 100644 --- a/src/main/java/kinoko/server/command/supergm/NxCommand.java +++ b/src/main/java/kinoko/server/command/supergm/NxCommand.java @@ -16,9 +16,9 @@ public static void nx(User user, String[] args) { try { int nx = Integer.parseInt(args[1]); user.getAccount().setNxPrepaid(nx); - user.write(MessagePacket.system("Set NX prepaid to %d", nx)); + user.systemMessage("Set NX prepaid to %d", nx); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !nx ")); + user.systemMessage("Usage: !nx "); } } } diff --git a/src/main/java/kinoko/server/command/supergm/QuestExCommand.java b/src/main/java/kinoko/server/command/supergm/QuestExCommand.java index 44a19be8..448b96f4 100644 --- a/src/main/java/kinoko/server/command/supergm/QuestExCommand.java +++ b/src/main/java/kinoko/server/command/supergm/QuestExCommand.java @@ -24,15 +24,15 @@ public static void questex(User user, String[] args) { if (newValue == null) { Optional questRecordResult = user.getQuestManager().getQuestRecord(questId); String value = questRecordResult.map(QuestRecord::getValue).orElse(""); - user.write(MessagePacket.system("Get QR value for quest ID %d : %s", questId, value)); + user.systemMessage("Get QR value for quest ID %d : %s", questId, value); } else { QuestRecord qr = user.getQuestManager().setQuestInfoEx(questId, newValue); user.write(MessagePacket.questRecord(qr)); user.validateStat(); - user.write(MessagePacket.system("Set QR value for quest ID %d : %s", questId, newValue)); + user.systemMessage("Set QR value for quest ID %d : %s", questId, newValue); } } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !questex [value]")); + user.systemMessage("Usage: !questex [value]"); } } } diff --git a/src/main/java/kinoko/server/command/supergm/ReactorCommand.java b/src/main/java/kinoko/server/command/supergm/ReactorCommand.java index f9e18868..c75e6de0 100644 --- a/src/main/java/kinoko/server/command/supergm/ReactorCommand.java +++ b/src/main/java/kinoko/server/command/supergm/ReactorCommand.java @@ -25,7 +25,7 @@ public static void reactor(User user, String[] args) { final Optional reactorTemplateResult = ReactorProvider.getReactorTemplate(templateId); if (reactorTemplateResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve reactor template ID: %d", templateId)); + user.systemMessage("Could not resolve reactor template ID: %d", templateId); return; } @@ -34,7 +34,7 @@ public static void reactor(User user, String[] args) { field.getReactorPool().addReactor(Reactor.from(reactorTemplateResult.get(), reactorInfo)); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !reactor ")); + user.systemMessage("Usage: !reactor "); } } @@ -47,7 +47,7 @@ public static void hitReactor(User user, String[] args) { final Optional reactorResult = field.getReactorPool().getByTemplateId(templateId); if (reactorResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve reactor with template ID: %d", templateId)); + user.systemMessage("Could not resolve reactor with template ID: %d", templateId); return; } @@ -56,7 +56,7 @@ public static void hitReactor(User user, String[] args) { field.getReactorPool().hitReactor(user, reactor, 0); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !hitreactor ")); + user.systemMessage("Usage: !hitreactor "); } } } diff --git a/src/main/java/kinoko/server/command/supergm/StartQuestCommand.java b/src/main/java/kinoko/server/command/supergm/StartQuestCommand.java index 0a99311f..eb2b59ad 100644 --- a/src/main/java/kinoko/server/command/supergm/StartQuestCommand.java +++ b/src/main/java/kinoko/server/command/supergm/StartQuestCommand.java @@ -23,7 +23,7 @@ public static void startQuest(User user, String[] args) { int questId = Integer.parseInt(args[1]); Optional questInfoResult = QuestProvider.getQuestInfo(questId); if (questInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not find quest : %d", questId)); + user.systemMessage("Could not find quest : %d", questId); return; } @@ -31,7 +31,7 @@ public static void startQuest(User user, String[] args) { user.write(MessagePacket.questRecord(qr)); user.validateStat(); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !startquest ")); + user.systemMessage("Usage: !startquest "); } } } diff --git a/src/main/java/kinoko/server/command/supergm/ToggleMobCommand.java b/src/main/java/kinoko/server/command/supergm/ToggleMobCommand.java index 75228671..50da4e4e 100644 --- a/src/main/java/kinoko/server/command/supergm/ToggleMobCommand.java +++ b/src/main/java/kinoko/server/command/supergm/ToggleMobCommand.java @@ -16,15 +16,15 @@ public static void disableMob(User user, String[] args) { try { if (args[1].equalsIgnoreCase("true")) { user.getField().setMobSpawn(true); - user.write(MessagePacket.system("Enabled mob spawns")); + user.systemMessage("Enabled mob spawns"); } else if (args[1].equalsIgnoreCase("false")) { user.getField().setMobSpawn(false); - user.write(MessagePacket.system("Disabled mob spawns")); + user.systemMessage("Disabled mob spawns"); } else { - user.write(MessagePacket.system("Usage: !togglemob ")); + user.systemMessage("Usage: !togglemob "); } } catch (ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !togglemob ")); + user.systemMessage("Usage: !togglemob "); } } } diff --git a/src/main/java/kinoko/server/command/tester/ClearInventoryCommand.java b/src/main/java/kinoko/server/command/tester/ClearInventoryCommand.java index 0bf02f22..1898fba8 100644 --- a/src/main/java/kinoko/server/command/tester/ClearInventoryCommand.java +++ b/src/main/java/kinoko/server/command/tester/ClearInventoryCommand.java @@ -27,8 +27,8 @@ public static void clearInventory(User user, String[] args) { .findFirst(); if (inventoryTypeResult.isEmpty()) { - user.write(MessagePacket.system( - "Please specify a valid inventory type: EQUIP | CONSUME | INSTALL | ETC | CASH")); + user.systemMessage( + "Please specify a valid inventory type: EQUIP | CONSUME | INSTALL | ETC | CASH"); return; } @@ -44,10 +44,10 @@ public static void clearInventory(User user, String[] args) { } user.write(WvsContext.inventoryOperation(removeOperations, true)); - user.write(MessagePacket.system("%s inventory cleared!", inventoryType)); + user.systemMessage("%s inventory cleared!", inventoryType); } catch (ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !clearinventory ")); + user.systemMessage("Usage: !clearinventory "); } } } diff --git a/src/main/java/kinoko/server/command/tester/InfoCommand.java b/src/main/java/kinoko/server/command/tester/InfoCommand.java index 27bf8083..9d190391 100644 --- a/src/main/java/kinoko/server/command/tester/InfoCommand.java +++ b/src/main/java/kinoko/server/command/tester/InfoCommand.java @@ -21,18 +21,18 @@ public static void info(User user, String[] args) { final var charStats = user.getCharacterStat(); // Basic stats - user.write(MessagePacket.system("HP : %d / %d, MP : %d / %d", user.getHp(), user.getMaxHp(), user.getMp(), user.getMaxMp())); - user.write(MessagePacket.system("STR : %d, DEX : %d, INT : %d, LUK : %d", stats.getStr(), stats.getDex(), stats.getInt(), stats.getLuk())); - user.write(MessagePacket.system("AP : %d", charStats.getAp())); - user.write(MessagePacket.system("SP : %s", charStats.getSp().getMap())); - user.write(MessagePacket.system("Damage : %d ~ %d", (int) CalcDamage.calcDamageMin(user), (int) CalcDamage.calcDamageMax(user))); - user.write(MessagePacket.system("Field ID : %d (%s)", field.getFieldId(), field.getFieldType())); + user.systemMessage("HP : %d / %d, MP : %d / %d", user.getHp(), user.getMaxHp(), user.getMp(), user.getMaxMp()); + user.systemMessage("STR : %d, DEX : %d, INT : %d, LUK : %d", stats.getStr(), stats.getDex(), stats.getInt(), stats.getLuk()); + user.systemMessage("AP : %d", charStats.getAp()); + user.systemMessage("SP : %s", charStats.getSp().getMap()); + user.systemMessage("Damage : %d ~ %d", (int) CalcDamage.calcDamageMin(user), (int) CalcDamage.calcDamageMax(user)); + user.systemMessage("Field ID : %d (%s)", field.getFieldId(), field.getFieldType()); // Foothold below final String footholdBelow = field.getFootholdBelow(user.getX(), user.getY()) .map(fh -> String.valueOf(fh.getSn())) .orElse("unk"); - user.write(MessagePacket.system(" x : %d, y : %d, fh : %d (%s)", user.getX(), user.getY(), user.getFoothold(), footholdBelow)); + user.systemMessage(" x : %d, y : %d, fh : %d (%s)", user.getX(), user.getY(), user.getFoothold(), footholdBelow); // Nearest portal final PortalInfo nearestPortal = field.getMapInfo().getPortalInfos().stream() @@ -41,8 +41,8 @@ public static void info(User user, String[] args) { .orElse(null); if (nearestPortal != null && Util.distance(user.getX(), user.getY(), nearestPortal.getX(), nearestPortal.getY()) < 200) { - user.write(MessagePacket.system("Portal name : %s (%d)", nearestPortal.getPortalName(), nearestPortal.getPortalId())); - user.write(MessagePacket.system(" x : %d, y : %d, script : %s", nearestPortal.getX(), nearestPortal.getY(), nearestPortal.getScript())); + user.systemMessage("Portal name : %s (%d)", nearestPortal.getPortalName(), nearestPortal.getPortalId()); + user.systemMessage(" x : %d, y : %d, script : %s", nearestPortal.getX(), nearestPortal.getY(), nearestPortal.getScript()); } // Detection rectangle for nearby objects @@ -51,16 +51,16 @@ public static void info(User user, String[] args) { // Nearest mob user.getNearestObject(field.getMobPool().getInsideRect(user.getRelativeRect(detectRect))) .ifPresent(mob -> { - user.write(MessagePacket.system(mob.toString())); - user.write(MessagePacket.system(" Controller : %s", mob.getController().getCharacterName())); + user.systemMessage(mob.toString()); + user.systemMessage(" Controller : %s", mob.getController().getCharacterName()); }); // Nearest NPC user.getNearestObject(field.getNpcPool().getInsideRect(user.getRelativeRect(detectRect))) - .ifPresent(npc -> user.write(MessagePacket.system(npc.toString()))); + .ifPresent(npc -> user.systemMessage(npc.toString())); // Nearest Reactor user.getNearestObject(field.getReactorPool().getInsideRect(user.getRelativeRect(detectRect))) - .ifPresent(reactor -> user.write(MessagePacket.system(reactor.toString()))); + .ifPresent(reactor -> user.systemMessage(reactor.toString())); } } diff --git a/src/main/java/kinoko/server/command/tester/MapCommand.java b/src/main/java/kinoko/server/command/tester/MapCommand.java index e4ab0c63..c5f235c3 100644 --- a/src/main/java/kinoko/server/command/tester/MapCommand.java +++ b/src/main/java/kinoko/server/command/tester/MapCommand.java @@ -17,7 +17,7 @@ public final class MapCommand { @Command("whereami") public static void whereAmI(User user, String[] args) { - user.write(MessagePacket.system("You are in map: %d", user.getField().getFieldId())); + user.systemMessage("You are in map: %d", user.getField().getFieldId()); } @Command({ "map", "warp" }) @@ -33,7 +33,7 @@ public static void map(User user, String[] args) { // Get the field final Optional fieldResult = user.getConnectedServer().getFieldById(fieldId); if (fieldResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve field ID: %d", fieldId)); + user.systemMessage("Could not resolve field ID: %d", fieldId); return; } final Field targetField = fieldResult.get(); @@ -41,7 +41,7 @@ public static void map(User user, String[] args) { // Get the portal by name final Optional portalResult = targetField.getPortalByName(portalName); if (portalResult.isEmpty()) { - user.write(MessagePacket.system("Could not resolve portal '%s' for field ID: %d", portalName, fieldId)); + user.systemMessage("Could not resolve portal '%s' for field ID: %d", portalName, fieldId); return; } @@ -49,7 +49,7 @@ public static void map(User user, String[] args) { user.warp(targetField, portalResult.get(), false, false); } catch (NumberFormatException | ArrayIndexOutOfBoundsException e) { - user.write(MessagePacket.system("Usage: !map [portal name]")); + user.systemMessage("Usage: !map [portal name]"); } } diff --git a/src/main/java/kinoko/server/command/tester/MaxCommand.java b/src/main/java/kinoko/server/command/tester/MaxCommand.java index 6a9d59e3..155503a9 100644 --- a/src/main/java/kinoko/server/command/tester/MaxCommand.java +++ b/src/main/java/kinoko/server/command/tester/MaxCommand.java @@ -86,7 +86,7 @@ public static void max(User user, String[] args) { user.setMp(user.getMaxMp()); } catch (Exception e) { - user.write(MessagePacket.system("Failed to max your character: %s", e.getMessage())); + user.systemMessage("Failed to max your character: %s", e.getMessage()); e.printStackTrace(); } } diff --git a/src/main/java/kinoko/server/command/tester/SearchCommand.java b/src/main/java/kinoko/server/command/tester/SearchCommand.java index 95364c60..a6acbdeb 100644 --- a/src/main/java/kinoko/server/command/tester/SearchCommand.java +++ b/src/main/java/kinoko/server/command/tester/SearchCommand.java @@ -38,7 +38,7 @@ public final class SearchCommand { @Arguments({ "item/map/mob/npc/skill/quest/commodity", "id or query" }) public static void find(User user, String[] args) { if (args.length < 2) { - user.write(MessagePacket.system("Usage: !find ")); + user.systemMessage("Usage: !find "); return; } @@ -55,10 +55,10 @@ public static void find(User user, String[] args) { case "skill" -> findSkill(user, query, isNumber); case "quest" -> findQuest(user, query, isNumber); case "commodity" -> findCommodity(user, query, isNumber); - default -> user.write(MessagePacket.system("Unknown type: %s", type)); + default -> user.systemMessage("Unknown type: %s", type); } } catch (NumberFormatException e) { - user.write(MessagePacket.system("Invalid number: %s", query)); + user.systemMessage("Invalid number: %s", query); } } @@ -72,32 +72,32 @@ private static void findItem(User user, String query, boolean isNumber) { .sorted(Map.Entry.comparingByKey()) .toList(); if (results.isEmpty()) { - user.write(MessagePacket.system("No item found for name: %s", query)); + user.systemMessage("No item found for name: %s", query); return; } else if (results.size() == 1) { itemId = results.get(0).getKey(); } else { - user.write(MessagePacket.system("Results for item name: \"%s\"", query)); - results.forEach(entry -> user.write(MessagePacket.system(" %d : %s", entry.getKey(), entry.getValue()))); + user.systemMessage("Results for item name: \"%s\"", query); + results.forEach(entry -> user.systemMessage(" %d : %s", entry.getKey(), entry.getValue())); return; } } Optional itemInfoResult = ItemProvider.getItemInfo(itemId); if (itemInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not find item with %s: %s", isNumber ? "ID" : "name", query)); + user.systemMessage("Could not find item with %s: %s", isNumber ? "ID" : "name", query); return; } ItemInfo ii = itemInfoResult.get(); - user.write(MessagePacket.system("Item: %s (%d)", StringProvider.getItemName(itemId), itemId)); + user.systemMessage("Item: %s (%d)", StringProvider.getItemName(itemId), itemId); if (!ii.getItemInfos().isEmpty()) { - user.write(MessagePacket.system(" info:")); - ii.getItemInfos().forEach((key, value) -> user.write(MessagePacket.system(" %s : %s", key.name(), value))); + user.systemMessage(" info:"); + ii.getItemInfos().forEach((key, value) -> user.systemMessage(" %s : %s", key.name(), value)); } if (!ii.getItemSpecs().isEmpty()) { - user.write(MessagePacket.system(" spec:")); - ii.getItemSpecs().forEach((key, value) -> user.write(MessagePacket.system(" %s : %s", key.name(), value))); + user.systemMessage(" spec:"); + ii.getItemSpecs().forEach((key, value) -> user.systemMessage(" %s : %s", key.name(), value)); } } @@ -111,13 +111,13 @@ private static void findMap(User user, String query, boolean isNumber) { .sorted(Map.Entry.comparingByKey()) .toList(); if (results.isEmpty()) { - user.write(MessagePacket.system("No map found for name: %s", query)); + user.systemMessage("No map found for name: %s", query); return; } else if (results.size() == 1) { mapId = results.get(0).getKey(); } else { - user.write(MessagePacket.system("Results for map name: \"%s\"", query)); - results.forEach(entry -> user.write(MessagePacket.system(" %d : %s", entry.getKey(), entry.getValue()))); + user.systemMessage("Results for map name: \"%s\"", query); + results.forEach(entry -> user.systemMessage(" %d : %s", entry.getKey(), entry.getValue())); return; } } @@ -126,7 +126,7 @@ private static void findMap(User user, String query, boolean isNumber) { Optional mapInfoResult = MapProvider.getMapInfo(mapIdFinal); if (mapInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not find map with %s: %s", isNumber ? "ID" : "name", query)); + user.systemMessage("Could not find map with %s: %s", isNumber ? "ID" : "name", query); return; } @@ -137,23 +137,23 @@ private static void findMap(User user, String query, boolean isNumber) { .sorted(Comparator.comparingInt(MapInfo::getMapId)) .toList(); - user.write(MessagePacket.system("Map: %s (%d)", StringProvider.getMapName(mapIdFinal), mapIdFinal)); - user.write(MessagePacket.system(" type: %s", mapInfo.getFieldType())); - user.write(MessagePacket.system(" returnMap: %d", mapInfo.getReturnMap())); - user.write(MessagePacket.system(" forcedReturn: %d", mapInfo.getForcedReturn())); - user.write(MessagePacket.system(" onFirstUserEnter: %s", mapInfo.getOnFirstUserEnter())); - user.write(MessagePacket.system(" onUserEnter: %s", mapInfo.getOnUserEnter())); + user.systemMessage("Map: %s (%d)", StringProvider.getMapName(mapIdFinal), mapIdFinal); + user.systemMessage(" type: %s", mapInfo.getFieldType()); + user.systemMessage(" returnMap: %d", mapInfo.getReturnMap()); + user.systemMessage(" forcedReturn: %d", mapInfo.getForcedReturn()); + user.systemMessage(" onFirstUserEnter: %s", mapInfo.getOnFirstUserEnter()); + user.systemMessage(" onUserEnter: %s", mapInfo.getOnUserEnter()); if (!mapInfo.getPortalInfos().isEmpty()) { - user.write(MessagePacket.system(" portals:")); + user.systemMessage(" portals:"); mapInfo.getPortalInfos().stream() .sorted(Comparator.comparingInt(PortalInfo::getPortalId)) - .forEach(p -> user.write(MessagePacket.system(" %s (%d, %d)", p.getPortalName(), p.getX(), p.getY()))); + .forEach(p -> user.systemMessage(" %s (%d, %d)", p.getPortalName(), p.getX(), p.getY())); } if (!connectedMaps.isEmpty()) { - user.write(MessagePacket.system(" connectedMaps:")); - connectedMaps.forEach(m -> user.write(MessagePacket.system(" %s (%d)", StringProvider.getMapName(m.getMapId()), m.getMapId()))); + user.systemMessage(" connectedMaps:"); + connectedMaps.forEach(m -> user.systemMessage(" %s (%d)", StringProvider.getMapName(m.getMapId()), m.getMapId())); } } @@ -166,26 +166,26 @@ private static void findMob(User user, String query, boolean isNumber) { .sorted(Map.Entry.comparingByKey()) .toList(); if (results.isEmpty()) { - user.write(MessagePacket.system("No mob found for name: %s", query)); + user.systemMessage("No mob found for name: %s", query); return; } else if (results.size() == 1) { mobId = results.get(0).getKey(); } else { - user.write(MessagePacket.system("Results for mob name: \"%s\"", query)); - results.forEach(entry -> user.write(MessagePacket.system(" %d : %s", entry.getKey(), entry.getValue()))); + user.systemMessage("Results for mob name: \"%s\"", query); + results.forEach(entry -> user.systemMessage(" %d : %s", entry.getKey(), entry.getValue())); return; } } Optional mobTemplateResult = MobProvider.getMobTemplate(mobId); if (mobTemplateResult.isEmpty()) { - user.write(MessagePacket.system("Could not find mob with %s: %s", isNumber ? "ID" : "name", query)); + user.systemMessage("Could not find mob with %s: %s", isNumber ? "ID" : "name", query); return; } MobTemplate mob = mobTemplateResult.get(); - user.write(MessagePacket.system("Mob: %s (%d)", StringProvider.getMobName(mobId), mobId)); - user.write(MessagePacket.system(" level: %d", mob.getLevel())); + user.systemMessage("Mob: %s (%d)", StringProvider.getMobName(mobId), mobId); + user.systemMessage(" level: %d", mob.getLevel()); } private static void findNpc(User user, String query, boolean isNumber) { @@ -196,20 +196,20 @@ private static void findNpc(User user, String query, boolean isNumber) { .sorted(Map.Entry.comparingByKey()) .toList(); if (results.isEmpty()) { - user.write(MessagePacket.system("No npc found for name: %s", query)); + user.systemMessage("No npc found for name: %s", query); return; } else if (results.size() == 1) { npcId = results.get(0).getKey(); } else { - user.write(MessagePacket.system("Results for npc name: \"%s\"", query)); - results.forEach(entry -> user.write(MessagePacket.system(" %d : %s", entry.getKey(), entry.getValue()))); + user.systemMessage("Results for npc name: \"%s\"", query); + results.forEach(entry -> user.systemMessage(" %d : %s", entry.getKey(), entry.getValue())); return; } } Optional npcTemplateResult = NpcProvider.getNpcTemplate(npcId); if (npcTemplateResult.isEmpty()) { - user.write(MessagePacket.system("Could not find npc with %s: %s", isNumber ? "ID" : "name", query)); + user.systemMessage("Could not find npc with %s: %s", isNumber ? "ID" : "name", query); return; } @@ -219,9 +219,9 @@ private static void findNpc(User user, String query, boolean isNumber) { .sorted(Comparator.comparingInt(MapInfo::getMapId)) .toList(); - user.write(MessagePacket.system("Npc: %s (%d)", StringProvider.getNpcName(npcId), npcId)); - user.write(MessagePacket.system(" script: %s", npc.getScript())); - npcFields.forEach(f -> user.write(MessagePacket.system(" field: %s (%d)", StringProvider.getMapName(f.getMapId()), f.getMapId()))); + user.systemMessage("Npc: %s (%d)", StringProvider.getNpcName(npcId), npcId); + user.systemMessage(" script: %s", npc.getScript()); + npcFields.forEach(f -> user.systemMessage(" field: %s (%d)", StringProvider.getMapName(f.getMapId()), f.getMapId())); } private static void findSkill(User user, String query, boolean isNumber) { @@ -232,24 +232,24 @@ private static void findSkill(User user, String query, boolean isNumber) { .sorted(Map.Entry.comparingByKey()) .toList(); if (results.isEmpty()) { - user.write(MessagePacket.system("No skill found for name: %s", query)); + user.systemMessage("No skill found for name: %s", query); return; } else if (results.size() == 1) { skillId = results.get(0).getKey(); } else { - user.write(MessagePacket.system("Results for skill name: \"%s\"", query)); - results.forEach(e -> user.write(MessagePacket.system(" %d : %s", e.getKey(), e.getValue().getName()))); + user.systemMessage("Results for skill name: \"%s\"", query); + results.forEach(e -> user.systemMessage(" %d : %s", e.getKey(), e.getValue().getName())); return; } } Optional skillInfoResult = SkillProvider.getSkillInfoById(skillId); if (skillInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not find skill with %s: %s", isNumber ? "ID" : "name", query)); + user.systemMessage("Could not find skill with %s: %s", isNumber ? "ID" : "name", query); return; } - user.write(MessagePacket.system("Skill: %s (%d)", StringProvider.getSkillName(skillId), skillId)); + user.systemMessage("Skill: %s (%d)", StringProvider.getSkillName(skillId), skillId); } private static void findQuest(User user, String query, boolean isNumber) { @@ -261,51 +261,51 @@ private static void findQuest(User user, String query, boolean isNumber) { .sorted(Comparator.comparingInt(QuestInfo::getQuestId)) .toList(); if (results.isEmpty()) { - user.write(MessagePacket.system("No quest found for name: %s", query)); + user.systemMessage("No quest found for name: %s", query); return; } else if (results.size() == 1) { questId = results.get(0).getQuestId(); } else { - user.write(MessagePacket.system("Results for quest name: \"%s\"", query)); - results.forEach(q -> user.write(MessagePacket.system(" %d : %s%s", + user.systemMessage("Results for quest name: \"%s\"", query); + results.forEach(q -> user.systemMessage(" %d : %s%s", q.getQuestId(), q.getQuestParent().isEmpty() ? "" : q.getQuestParent() + " : ", - q.getQuestName()))); + q.getQuestName())); return; } } Optional questInfoResult = QuestProvider.getQuestInfo(questId); if (questInfoResult.isEmpty()) { - user.write(MessagePacket.system("Could not find quest with %s: %s", isNumber ? "ID" : "name", query)); + user.systemMessage("Could not find quest with %s: %s", isNumber ? "ID" : "name", query); return; } QuestInfo quest = questInfoResult.get(); - user.write(MessagePacket.system("Quest %d : %s%s", quest.getQuestId(), + user.systemMessage("Quest %d : %s%s", quest.getQuestId(), quest.getQuestParent().isEmpty() ? "" : quest.getQuestParent() + " : ", - quest.getQuestName())); + quest.getQuestName()); } private static void findCommodity(User user, String query, boolean isNumber) { if (!isNumber) { - user.write(MessagePacket.system("Can only lookup commodity by ID")); + user.systemMessage("Can only lookup commodity by ID"); return; } int commodityId = Integer.parseInt(query); Optional commodityResult = CashShop.getCommodity(commodityId); if (commodityResult.isEmpty()) { - user.write(MessagePacket.system("Could not find commodity with ID: %d", commodityId)); + user.systemMessage("Could not find commodity with ID: %d", commodityId); return; } Commodity commodity = commodityResult.get(); - user.write(MessagePacket.system("Commodity: %d", commodityId)); - user.write(MessagePacket.system(" itemId : %d (%s)", commodity.getItemId(), StringProvider.getItemName(commodity.getItemId()))); - user.write(MessagePacket.system(" count : %d", commodity.getCount())); - user.write(MessagePacket.system(" price : %d", commodity.getPrice())); - user.write(MessagePacket.system(" period : %d", commodity.getPeriod())); - user.write(MessagePacket.system(" gender : %d", commodity.getGender())); + user.systemMessage("Commodity: %d", commodityId); + user.systemMessage(" itemId : %d (%s)", commodity.getItemId(), StringProvider.getItemName(commodity.getItemId())); + user.systemMessage(" count : %d", commodity.getCount()); + user.systemMessage(" price : %d", commodity.getPrice()); + user.systemMessage(" period : %d", commodity.getPeriod()); + user.systemMessage(" gender : %d", commodity.getGender()); } } diff --git a/src/main/java/kinoko/server/dialog/miniroom/TradingRoom.java b/src/main/java/kinoko/server/dialog/miniroom/TradingRoom.java index fcc1a3b6..0bfe8e81 100644 --- a/src/main/java/kinoko/server/dialog/miniroom/TradingRoom.java +++ b/src/main/java/kinoko/server/dialog/miniroom/TradingRoom.java @@ -215,12 +215,12 @@ private boolean completeTrade(User user) { final Set itemsForUser = items.getOrDefault(other, Map.of()).values().stream().collect(Collectors.toUnmodifiableSet()); final int moneyForUser = GameConstants.getTradeTax(money.getOrDefault(other, 0)); if (!user.getInventoryManager().canAddItems(itemsForUser)) { - user.write(MessagePacket.system("You do not have enough inventory space.")); + user.systemMessage("You do not have enough inventory space."); other.write(MessagePacket.system(user.getCharacterName() + " does not have enough inventory space.")); return false; } if (!user.getInventoryManager().canAddMoney(moneyForUser)) { - user.write(MessagePacket.system("You cannot hold any more mesos.")); + user.systemMessage("You cannot hold any more mesos."); other.write(MessagePacket.system(user.getCharacterName() + " cannot hold any more mesos.")); return false; } @@ -229,12 +229,12 @@ private boolean completeTrade(User user) { final int moneyForOther = GameConstants.getTradeTax(money.getOrDefault(user, 0)); if (!other.getInventoryManager().canAddItems(itemsForOther)) { other.write(MessagePacket.system("You do not have enough inventory space.")); - user.write(MessagePacket.system(user.getCharacterName() + " does not have enough inventory space.")); + user.systemMessage(user.getCharacterName() + " does not have enough inventory space."); return false; } if (!other.getInventoryManager().canAddMoney(moneyForOther)) { other.write(MessagePacket.system("You cannot hold any more mesos.")); - user.write(MessagePacket.system(user.getCharacterName() + " cannot hold any more mesos.")); + user.systemMessage(user.getCharacterName() + " cannot hold any more mesos."); return false; } // Process items diff --git a/src/main/java/kinoko/world/job/explorer/Magician.java b/src/main/java/kinoko/world/job/explorer/Magician.java index 6635ceac..fbdaaeb8 100644 --- a/src/main/java/kinoko/world/job/explorer/Magician.java +++ b/src/main/java/kinoko/world/job/explorer/Magician.java @@ -253,7 +253,7 @@ public static void handleSkill(User user, Skill skill) { user.write(WvsContext.townPortal(townPortal)); user.getConnectedServer().notifyUserUpdate(user); } else { - user.write(MessagePacket.system("You cannot use the Mystic Door skill here.")); + user.systemMessage("You cannot use the Mystic Door skill here."); } return; } From cbca8ee8220531a2152b87b827e1572590e05c63 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 6 Nov 2025 01:09:39 -0500 Subject: [PATCH 67/83] Added generic goto command --- .../server/command/player/GotoCommand.java | 78 +++++++++++++++++++ 1 file changed, 78 insertions(+) create mode 100644 src/main/java/kinoko/server/command/player/GotoCommand.java diff --git a/src/main/java/kinoko/server/command/player/GotoCommand.java b/src/main/java/kinoko/server/command/player/GotoCommand.java new file mode 100644 index 00000000..0c0b220d --- /dev/null +++ b/src/main/java/kinoko/server/command/player/GotoCommand.java @@ -0,0 +1,78 @@ +package kinoko.server.command.player; + +import kinoko.server.command.Arguments; +import kinoko.server.command.Command; +import kinoko.world.user.User; + +import java.util.HashMap; + +public final class GotoCommand { + + public static final HashMap GOTO_TOWNS = new HashMap() {{ + put("amherst", 1000000); + put("amoria", 680000000); + put("aqua", 230000000); + put("ariant", 260000000); + put("cbd", 540000000); + put("china", 702050000); + put("ellin", 300000000); + put("ellinia", 101000000); + put("elnath", 211000000); + put("ereve", 130000000); + put("florina", 110000000); + put("happy", 209000000); + put("henesys", 100000000); + put("herb", 251000000); + put("kampung", 551000000); + put("kerning", 103000000); + put("korea", 222000000); + put("kft", 222000000); + put("krex", 541020700); + put("krexel", 541020700); + put("leafre", 240000000); + put("lith", 104000000); + put("ludi", 220000000); + put("magatia", 261000000); + put("malaysia", 551000000); + put("mulung", 250000000); + put("mushking", 106020000); + put("naut", 120000000); + put("nautilus", 120000000); + put("neo", 240070000); + put("nlc", 600000000); + put("omega", 221000000); + put("orbis", 200000000); + put("perion", 102000000); + put("quay", 541000000); + put("rien", 140000000); + put("showa", 801000000); + put("shrine", 800000000); + put("singapore", 540000000); + put("sleepywood", 105040300); + put("southperry", 2000000); + put("square", 103040000); + put("temple", 270000000); + put("tot", 270000000); + }}; + + @Command("goto") + @Arguments("location") + public static void goTo(User user, String[] args) { + if (args.length < 2) { + user.systemMessage("Usage: !goto "); + return; + } + + String target = args[1].toLowerCase(); + Integer mapId = GOTO_TOWNS.get(target); + + if (mapId == null) { + user.systemMessage("Unknown location: " + target); + user.systemMessage(("Available towns: " + String.join(", ", GOTO_TOWNS.keySet()))); + return; + } + + user.warp(mapId, false, false); + user.systemMessage(("Warped to " + target + " (" + mapId + ")")); + } +} \ No newline at end of file From d3dac16495caf9641b904ba40cbb9536c1c41449 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 6 Nov 2025 02:09:34 -0500 Subject: [PATCH 68/83] added general gotos --- .../server/command/player/GotoCommand.java | 57 ++++++++++++++++++- 1 file changed, 55 insertions(+), 2 deletions(-) diff --git a/src/main/java/kinoko/server/command/player/GotoCommand.java b/src/main/java/kinoko/server/command/player/GotoCommand.java index 0c0b220d..711332da 100644 --- a/src/main/java/kinoko/server/command/player/GotoCommand.java +++ b/src/main/java/kinoko/server/command/player/GotoCommand.java @@ -14,12 +14,10 @@ public final class GotoCommand { put("aqua", 230000000); put("ariant", 260000000); put("cbd", 540000000); - put("china", 702050000); put("ellin", 300000000); put("ellinia", 101000000); put("elnath", 211000000); put("ereve", 130000000); - put("florina", 110000000); put("happy", 209000000); put("henesys", 100000000); put("herb", 251000000); @@ -53,6 +51,61 @@ public final class GotoCommand { put("square", 103040000); put("temple", 270000000); put("tot", 270000000); + + + put("apq", 670010000); + put("boat", 541000000); + put("bosspq", 970030000); + put("bpq", 105100100); + put("cht", 240050400); + put("chimney", 682000200); + put("coconut", 109080000); + put("cwkpq", 610030020); + put("dojo", 925020001); + put("dragonoir", 240080000); + put("epq", 300030100); + put("fitness", 109040000); + put("fm", 910000000); + put("ghq", 200000301); + put("glass", 109020005); + put("gm", 180000000); + put("griffey", 240020101); + put("guild", 200000301); + put("haunted", 682000000); + put("horseman", 682000001); + put("horntail", 240050400); + put("hpq", 100000200); + put("ht", 240050400); + put("juliet", 261000021); + put("keep", 610020006); + put("lab", 926120000); + put("manon", 240020401); + put("mushroom", 106020900); + put("mvpq", 674030100); + put("nath", 211000000); + put("ninja", 800040000); + put("ola", 109030301); + put("opq", 200080101); + put("ox", 109020001); + put("pap", 220080000); + put("papu", 220080000); + put("pb", 270050000); + put("pianus", 230040420); + put("ppq", 251010404); + put("priest", 240010501); + put("quiz", 109020001); + put("romeo", 261000011); + put("scarga", 551030100); + put("skelegon", 240040511); + put("snowball", 109060000); + put("tag", 109020001); + put("theboss", 801040004); + put("toad", 800040211); + put("ulo", 541020000); + put("xo", 109020001); + put("zakum", 211042400); + put("zip", 800000000); + put("zipangu", 800000000); }}; @Command("goto") From b588a1c34c45ad1a6f0e0fd24c11476476fdfad9 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sun, 16 Nov 2025 19:00:47 -0500 Subject: [PATCH 69/83] data update --- data/reward/100005.yaml | 2 +- data/reward/100200.yaml | 4 + data/reward/1110101.yaml | 3 +- data/reward/1110130.yaml | 3 + data/reward/1120100.yaml | 1 + data/reward/1210100.yaml | 1 + data/reward/1210103.yaml | 1 + data/reward/2000.yaml | 5 + data/reward/2040001.yaml | 11 ++ data/reward/2040002.yaml | 11 ++ data/reward/2040003.yaml | 11 ++ data/reward/2040004.yaml | 11 ++ data/reward/2040005.yaml | 11 ++ data/reward/2040006.yaml | 11 ++ data/reward/2040007.yaml | 11 ++ data/reward/2040008.yaml | 11 ++ data/reward/2040009.yaml | 11 ++ data/reward/2040010.yaml | 11 ++ data/reward/2040011.yaml | 11 ++ data/reward/2040012.yaml | 11 ++ data/reward/2040013.yaml | 11 ++ data/reward/2040015.yaml | 11 ++ data/reward/2040017.yaml | 11 ++ data/reward/2040018.yaml | 11 ++ data/reward/2040022.yaml | 11 ++ data/reward/2040023.yaml | 11 ++ data/reward/2040024.yaml | 11 ++ data/reward/2040025.yaml | 11 ++ data/reward/2040026.yaml | 11 ++ data/reward/2040027.yaml | 11 ++ data/reward/2040029.yaml | 11 ++ data/reward/2040034.yaml | 11 ++ data/reward/2040049.yaml | 11 ++ data/reward/2040051.yaml | 11 ++ data/reward/2041011.yaml | 10 ++ data/reward/2041012.yaml | 10 ++ data/reward/2041016.yaml | 10 ++ data/reward/2041018.yaml | 10 ++ data/reward/2041019.yaml | 10 ++ data/reward/2041020.yaml | 10 ++ data/reward/2041021.yaml | 10 ++ data/reward/2041023.yaml | 10 ++ data/reward/2041024.yaml | 10 ++ data/reward/2041026.yaml | 10 ++ data/reward/2041029.yaml | 10 ++ data/reward/2050002.yaml | 10 ++ data/reward/2050006.yaml | 10 ++ data/reward/2050007.yaml | 10 ++ data/reward/2050014.yaml | 10 ++ data/reward/2050015.yaml | 10 ++ data/reward/2050016.yaml | 10 ++ data/reward/2050017.yaml | 10 ++ data/reward/2050018.yaml | 10 ++ data/reward/2050019.yaml | 10 ++ data/reward/2050020.yaml | 10 ++ data/reward/2071000.yaml | 10 ++ data/reward/2071001.yaml | 10 ++ data/reward/2071002.yaml | 10 ++ data/reward/2071003.yaml | 10 ++ data/reward/2071004.yaml | 10 ++ data/reward/2120100.yaml | 10 ++ data/reward/2120101.yaml | 10 ++ data/reward/2150000.yaml | 4 + data/reward/2150001.yaml | 4 + data/reward/2150002.yaml | 4 + data/reward/2150003.yaml | 25 ++++ data/reward/2200000.yaml | 10 ++ data/reward/2200001.yaml | 10 ++ data/reward/2208000.yaml | 10 ++ data/reward/2208001.yaml | 10 ++ data/reward/2208002.yaml | 10 ++ data/reward/2208003.yaml | 10 ++ data/reward/2212000.yaml | 10 ++ data/reward/2212001.yaml | 10 ++ data/reward/2212002.yaml | 10 ++ data/reward/2212003.yaml | 10 ++ data/reward/2221007.yaml | 10 ++ data/reward/2221008.yaml | 10 ++ data/reward/2221009.yaml | 10 ++ data/reward/2221010.yaml | 10 ++ data/reward/2222000.yaml | 10 ++ data/reward/2230101.yaml | 3 +- data/reward/2230113.yaml | 4 + data/reward/2230114.yaml | 4 + data/reward/2298000.yaml | 10 ++ data/reward/2300100.yaml | 2 + data/reward/3110102.yaml | 2 +- data/reward/3150000.yaml | 16 +++ data/reward/3150001.yaml | 12 ++ data/reward/3150002.yaml | 17 +++ data/reward/3210100.yaml | 1 + data/reward/3210206.yaml | 2 +- data/reward/3230101.yaml | 1 + data/reward/3230307.yaml | 2 +- data/reward/3230308.yaml | 2 +- data/reward/3400003.yaml | 20 +++ data/reward/3400004.yaml | 21 +++ data/reward/3400005.yaml | 9 ++ data/reward/3400006.yaml | 4 + data/reward/3400007.yaml | 7 + data/reward/3400008.yaml | 20 +++ data/reward/4220001.yaml | 5 + data/reward/4230116.yaml | 2 +- data/reward/4300015.yaml | 4 + data/reward/4300016.yaml | 4 + data/reward/5160000.yaml | 5 + data/reward/5160001.yaml | 4 + data/reward/5160002.yaml | 4 + data/reward/5160003.yaml | 4 + data/reward/5160004.yaml | 4 + data/reward/6090002.yaml | 4 + data/reward/6100300.yaml | 4 + data/reward/6130201.yaml | 10 ++ data/reward/6140703.yaml | 4 + data/reward/6150000.yaml | 16 +++ data/reward/6160000.yaml | 4 + data/reward/6160001.yaml | 4 + data/reward/6160002.yaml | 4 + data/reward/6160003.yaml | 11 ++ data/reward/6230500.yaml | 2 +- data/reward/7120103.yaml | 64 +-------- data/reward/7120104.yaml | 44 +----- data/reward/7120105.yaml | 31 +--- data/reward/7120106.yaml | 3 +- data/reward/7120107.yaml | 3 +- data/reward/7130102.yaml | 61 ++++++++ data/reward/7140002.yaml | 4 + data/reward/7150000.yaml | 19 +++ data/reward/7150001.yaml | 23 +++ data/reward/7150002.yaml | 23 +++ data/reward/7150003.yaml | 19 +++ data/reward/7150004.yaml | 27 ++++ data/reward/8105000.yaml | 20 +++ data/reward/8105001.yaml | 33 +++++ data/reward/8105002.yaml | 19 +++ data/reward/8105003.yaml | 27 ++++ data/reward/8105004.yaml | 29 ++++ data/reward/8105005.yaml | 31 ++++ data/reward/8140100.yaml | 60 ++++++++ data/reward/8140104.yaml | 4 + data/reward/8140200.yaml | 1 + data/reward/8140300.yaml | 1 + data/reward/8140503.yaml | 4 + data/reward/8180018.yaml | 4 + data/reward/8180122.yaml | 4 + data/reward/8190009.yaml | 4 + data/reward/8210000.yaml | 7 + data/reward/8210001.yaml | 12 ++ data/reward/8210002.yaml | 13 ++ data/reward/8210003.yaml | 14 ++ data/reward/8210004.yaml | 14 ++ data/reward/8210005.yaml | 13 ++ data/reward/8210010.yaml | 5 + data/reward/8210011.yaml | 6 + data/reward/8210012.yaml | 7 + data/reward/8210013.yaml | 9 ++ data/reward/8220010.yaml | 3 +- data/reward/8220011.yaml | 1 + data/reward/8220012.yaml | 3 +- data/reward/8220015.yaml | 304 +++++++++++++++++++++++++++++++++++++++ data/reward/8230300.yaml | 4 + data/reward/8400006.yaml | 4 + data/reward/8600000.yaml | 15 ++ data/reward/8600001.yaml | 12 ++ data/reward/8600002.yaml | 14 ++ data/reward/8600003.yaml | 12 ++ data/reward/8600004.yaml | 10 ++ data/reward/8600005.yaml | 13 ++ data/reward/8600006.yaml | 8 ++ data/reward/8610000.yaml | 16 +++ data/reward/8610001.yaml | 14 ++ data/reward/8610002.yaml | 16 +++ data/reward/8610003.yaml | 16 +++ data/reward/8610004.yaml | 11 ++ data/reward/8610005.yaml | 10 ++ data/reward/8610006.yaml | 11 ++ data/reward/8610007.yaml | 11 ++ data/reward/8610008.yaml | 18 +++ data/reward/8610009.yaml | 9 ++ data/reward/8610010.yaml | 9 ++ data/reward/8610011.yaml | 13 ++ data/reward/8610012.yaml | 12 ++ data/reward/8610013.yaml | 14 ++ data/reward/8610014.yaml | 13 ++ data/reward/8610016.yaml | 4 + data/reward/8610017.yaml | 4 + data/reward/8610018.yaml | 4 + data/reward/8610019.yaml | 4 + data/reward/8610020.yaml | 4 + data/reward/8610021.yaml | 4 + data/reward/8610022.yaml | 5 + data/reward/8800102.yaml | 106 ++++++++++++++ data/reward/8810122.yaml | 198 +++++++++++++++++++++++++ data/reward/8820001.yaml | 205 ++++++++++++++++++++++++++ data/reward/8840000.yaml | 43 ++++++ data/reward/8850011.yaml | 107 ++++++++++++++ data/reward/8850012.yaml | 4 + data/reward/9000000.yaml | 4 + data/reward/9000068.yaml | 10 ++ data/reward/9000084.yaml | 10 ++ data/reward/9001004.yaml | 5 + data/reward/9001005.yaml | 4 + data/reward/9001006.yaml | 4 + data/reward/9001009.yaml | 9 ++ data/reward/9001010.yaml | 4 + data/reward/9001011.yaml | 9 ++ data/reward/9001012.yaml | 4 + data/reward/9001013.yaml | 4 + data/reward/9001023.yaml | 4 + data/reward/9101005.yaml | 4 + data/reward/9240549.yaml | 5 + data/reward/9300004.yaml | 4 + data/reward/9300095.yaml | 6 + data/reward/9300147.yaml | 4 + data/reward/9300148.yaml | 4 + data/reward/9300169.yaml | 4 + data/reward/9300170.yaml | 4 + data/reward/9300171.yaml | 4 + data/reward/9300173.yaml | 4 + data/reward/9300217.yaml | 7 + data/reward/9300218.yaml | 7 + data/reward/9300219.yaml | 7 + data/reward/9300220.yaml | 7 + data/reward/9300221.yaml | 7 + data/reward/9300222.yaml | 7 + data/reward/9300223.yaml | 7 + data/reward/9300224.yaml | 7 + data/reward/9300225.yaml | 7 + data/reward/9300226.yaml | 7 + data/reward/9300227.yaml | 7 + data/reward/9300228.yaml | 7 + data/reward/9300229.yaml | 8 ++ data/reward/9300230.yaml | 7 + data/reward/9300231.yaml | 7 + data/reward/9300232.yaml | 7 + data/reward/9300233.yaml | 7 + data/reward/9300234.yaml | 7 + data/reward/9300235.yaml | 7 + data/reward/9300236.yaml | 7 + data/reward/9300237.yaml | 7 + data/reward/9300238.yaml | 7 + data/reward/9300239.yaml | 7 + data/reward/9300240.yaml | 7 + data/reward/9300241.yaml | 7 + data/reward/9300242.yaml | 7 + data/reward/9300243.yaml | 7 + data/reward/9300244.yaml | 7 + data/reward/9300245.yaml | 7 + data/reward/9300246.yaml | 7 + data/reward/9300247.yaml | 7 + data/reward/9300248.yaml | 7 + data/reward/9300249.yaml | 7 + data/reward/9300250.yaml | 7 + data/reward/9300251.yaml | 7 + data/reward/9300252.yaml | 7 + data/reward/9300253.yaml | 7 + data/reward/9300254.yaml | 7 + data/reward/9300255.yaml | 7 + data/reward/9300256.yaml | 7 + data/reward/9300257.yaml | 7 + data/reward/9300258.yaml | 7 + data/reward/9300259.yaml | 7 + data/reward/9300260.yaml | 7 + data/reward/9300261.yaml | 7 + data/reward/9300262.yaml | 7 + data/reward/9300263.yaml | 7 + data/reward/9300264.yaml | 7 + data/reward/9300265.yaml | 7 + data/reward/9300266.yaml | 7 + data/reward/9300267.yaml | 7 + data/reward/9300270.yaml | 7 + data/reward/9300274.yaml | 2 + data/reward/9300287.yaml | 4 + data/reward/9300288.yaml | 6 + data/reward/9300289.yaml | 4 + data/reward/9300290.yaml | 4 + data/reward/9300291.yaml | 4 + data/reward/9300292.yaml | 5 + data/reward/9300293.yaml | 4 + data/reward/9300294.yaml | 4 + data/reward/9300315.yaml | 19 +++ data/reward/9300316.yaml | 19 +++ data/reward/9300317.yaml | 19 +++ data/reward/9300318.yaml | 19 +++ data/reward/9300319.yaml | 19 +++ data/reward/9300320.yaml | 19 +++ data/reward/9300321.yaml | 19 +++ data/reward/9300322.yaml | 19 +++ data/reward/9300323.yaml | 19 +++ data/reward/9300324.yaml | 19 +++ data/reward/9300344.yaml | 4 + data/reward/9300347.yaml | 5 + data/reward/9300367.yaml | 7 + data/reward/9300368.yaml | 7 + data/reward/9300369.yaml | 7 + data/reward/9300370.yaml | 7 + data/reward/9300371.yaml | 7 + data/reward/9300372.yaml | 7 + data/reward/9300373.yaml | 7 + data/reward/9300374.yaml | 7 + data/reward/9300375.yaml | 7 + data/reward/9300376.yaml | 7 + data/reward/9300377.yaml | 7 + data/reward/9300378.yaml | 4 + data/reward/9300384.yaml | 6 + data/reward/9300414.yaml | 5 + data/reward/9300446.yaml | 4 + data/reward/9300447.yaml | 4 + data/reward/9302010.yaml | 4 + data/reward/9400010.yaml | 4 + data/reward/9400201.yaml | 4 + data/reward/9400202.yaml | 6 + data/reward/9400253.yaml | 26 ++++ data/reward/9400254.yaml | 25 ++++ data/reward/9400255.yaml | 24 ++++ data/reward/9400256.yaml | 25 ++++ data/reward/9400257.yaml | 25 ++++ data/reward/9400258.yaml | 24 ++++ data/reward/9400259.yaml | 22 +++ data/reward/9400260.yaml | 23 +++ data/reward/9400262.yaml | 6 + data/reward/9400265.yaml | 72 ++++++++++ data/reward/9400266.yaml | 72 ++++++++++ data/reward/9400270.yaml | 74 ++++++++++ data/reward/9400273.yaml | 79 ++++++++++ data/reward/9400274.yaml | 4 + data/reward/9400276.yaml | 6 + data/reward/9400287.yaml | 12 ++ data/reward/9400288.yaml | 12 ++ data/reward/9400289.yaml | 17 +++ data/reward/9400294.yaml | 17 +++ data/reward/9400296.yaml | 4 + data/reward/9400300.yaml | 14 ++ data/reward/9400382.yaml | 6 + data/reward/9400407.yaml | 4 + data/reward/9400409.yaml | 157 ++++++++++++++++++++ data/reward/9400533.yaml | 5 + data/reward/9400534.yaml | 5 + data/reward/9400574.yaml | 6 + data/reward/9400575.yaml | 5 + data/reward/9400576.yaml | 6 + data/reward/9400578.yaml | 7 + data/reward/9400579.yaml | 4 + data/reward/9400580.yaml | 8 ++ data/reward/9400581.yaml | 4 + data/reward/9400582.yaml | 7 + data/reward/9400583.yaml | 13 ++ data/reward/9400584.yaml | 4 + data/reward/9400585.yaml | 4 + data/reward/9400586.yaml | 4 + data/reward/9400587.yaml | 4 + data/reward/9400588.yaml | 4 + data/reward/9400589.yaml | 4 + data/reward/9400590.yaml | 5 + data/reward/9400591.yaml | 5 + data/reward/9400592.yaml | 5 + data/reward/9400593.yaml | 4 + data/reward/9400601.yaml | 4 + data/reward/9400602.yaml | 4 + data/reward/9400605.yaml | 4 + data/reward/9400606.yaml | 4 + data/reward/9400609.yaml | 5 + data/reward/9400610.yaml | 5 + data/reward/9400611.yaml | 5 + data/reward/9400612.yaml | 5 + data/reward/9400613.yaml | 5 + data/reward/9400614.yaml | 5 + data/reward/9400615.yaml | 5 + data/reward/9400616.yaml | 5 + data/reward/9400617.yaml | 6 + data/reward/9400618.yaml | 4 + data/reward/9400619.yaml | 4 + data/reward/9400620.yaml | 4 + data/reward/9400621.yaml | 4 + data/reward/9400622.yaml | 4 + data/reward/9400623.yaml | 5 + data/reward/9400633.yaml | 16 +++ data/reward/9400640.yaml | 22 +++ data/reward/9400648.yaml | 4 + data/reward/9400649.yaml | 4 + data/reward/9400651.yaml | 4 + data/reward/9400652.yaml | 5 + data/reward/9400653.yaml | 4 + data/reward/9400655.yaml | 4 + data/reward/9400656.yaml | 4 + data/reward/9400657.yaml | 4 + data/reward/9400661.yaml | 4 + data/reward/9400700.yaml | 14 ++ data/reward/9400701.yaml | 5 + data/reward/9400702.yaml | 5 + data/reward/9400703.yaml | 5 + data/reward/9400734.yaml | 14 ++ data/reward/9400737.yaml | 5 + data/reward/9400738.yaml | 5 + data/reward/9400739.yaml | 4 + data/reward/9400740.yaml | 4 + data/reward/9400748.yaml | 16 +++ data/reward/9400805.yaml | 5 + data/reward/9400807.yaml | 4 + data/reward/9400902.yaml | 4 + data/reward/9409015.yaml | 5 + data/reward/9409016.yaml | 5 + data/reward/9409017.yaml | 4 + data/reward/9409018.yaml | 4 + data/reward/9410009.yaml | 4 + data/reward/9410011.yaml | 4 + data/reward/9410066.yaml | 26 ++++ data/reward/9410067.yaml | 5 + data/reward/9420001.yaml | 4 + data/reward/9420002.yaml | 4 + data/reward/9420003.yaml | 6 + data/reward/9420005.yaml | 5 + data/reward/9420015.yaml | 110 ++++++++++++++ data/reward/9420065.yaml | 29 ++++ data/reward/9420066.yaml | 104 ++++++++++++++ data/reward/9420067.yaml | 29 ++++ data/reward/9420500.yaml | 19 +++ data/reward/9420501.yaml | 28 ++++ data/reward/9420502.yaml | 12 ++ data/reward/9420503.yaml | 9 ++ data/reward/9420504.yaml | 8 ++ data/reward/9420505.yaml | 9 ++ data/reward/9420506.yaml | 5 + data/reward/9420507.yaml | 18 +++ data/reward/9420508.yaml | 9 ++ data/reward/9420509.yaml | 18 +++ data/reward/9420510.yaml | 13 ++ data/reward/9420511.yaml | 11 ++ data/reward/9420512.yaml | 22 +++ data/reward/9420513.yaml | 22 +++ data/reward/9420514.yaml | 30 ++++ data/reward/9420515.yaml | 25 ++++ data/reward/9420516.yaml | 19 +++ data/reward/9420517.yaml | 28 ++++ data/reward/9420518.yaml | 24 ++++ data/reward/9420519.yaml | 24 ++++ data/reward/9420522.yaml | 27 ++++ data/reward/9420527.yaml | 15 ++ data/reward/9420528.yaml | 14 ++ data/reward/9420529.yaml | 24 ++++ data/reward/9420530.yaml | 17 +++ data/reward/9420531.yaml | 28 ++++ data/reward/9420532.yaml | 31 ++++ data/reward/9420533.yaml | 28 ++++ data/reward/9420534.yaml | 27 ++++ data/reward/9420535.yaml | 31 ++++ data/reward/9420536.yaml | 34 +++++ data/reward/9420537.yaml | 32 +++++ data/reward/9420538.yaml | 31 ++++ data/reward/9420539.yaml | 29 ++++ data/reward/9420540.yaml | 27 ++++ data/reward/9420544.yaml | 68 +++++++++ data/reward/9420549.yaml | 67 +++++++++ data/reward/9500109.yaml | 2 +- data/reward/9500117.yaml | 2 +- data/reward/9500118.yaml | 2 +- data/reward/9500127.yaml | 2 +- data/reward/9500165.yaml | 27 +--- data/reward/9500197.yaml | 10 ++ data/reward/9500379.yaml | 4 + data/reward/9500387.yaml | 5 + data/reward/9500388.yaml | 5 + data/reward/9500389.yaml | 4 + data/reward/9500391.yaml | 89 ++++++++++++ data/reward/9500392.yaml | 92 ++++++++++++ data/reward/9500393.yaml | 4 + data/reward/9500394.yaml | 4 + data/reward/9500395.yaml | 4 + data/reward/9600001.yaml | 4 + data/reward/9600002.yaml | 5 + data/reward/9600003.yaml | 5 + data/reward/9600004.yaml | 5 + data/reward/9600005.yaml | 5 + data/reward/9600006.yaml | 5 + data/reward/9600007.yaml | 5 + data/reward/9600008.yaml | 4 + data/reward/9600009.yaml | 15 ++ data/reward/9600010.yaml | 15 ++ data/reward/9600037.yaml | 4 + data/reward/9600038.yaml | 4 + data/reward/9600039.yaml | 4 + data/reward/9600040.yaml | 4 + data/reward/9600041.yaml | 4 + data/reward/9600042.yaml | 4 + data/reward/9600043.yaml | 4 + data/reward/9600044.yaml | 4 + data/reward/9600045.yaml | 4 + data/reward/9600046.yaml | 4 + data/reward/9600047.yaml | 4 + data/reward/9600048.yaml | 4 + data/reward/9600049.yaml | 4 + data/reward/9600050.yaml | 4 + data/reward/9600051.yaml | 4 + data/reward/9600052.yaml | 4 + data/reward/9600053.yaml | 4 + data/reward/9600054.yaml | 4 + data/reward/9600055.yaml | 4 + data/reward/9600056.yaml | 4 + data/reward/9600057.yaml | 4 + data/reward/9600058.yaml | 4 + data/reward/9600059.yaml | 4 + data/reward/9600060.yaml | 4 + data/reward/9600061.yaml | 4 + data/reward/9600062.yaml | 4 + data/reward/9700019.yaml | 4 + data/reward/9700020.yaml | 4 + data/reward/9700029.yaml | 4 + data/shop/1011000.yaml | 26 ++-- data/shop/1052116.yaml | 22 +++ data/shop/1055000.yaml | 63 ++++++++ data/shop/1055002.yaml | 43 ++++++ data/shop/1091000.yaml | 16 +++ data/shop/11000.yaml | 8 +- data/shop/1100001.yaml | 20 +++ data/shop/1100002.yaml | 25 ++++ data/shop/1301000.yaml | 25 ++++ data/shop/2152016.yaml | 66 +++++++++ data/shop/9000081.yaml | 31 ++++ data/shop/9090000.yaml | 43 ++++++ data/shop/9110100.yaml | 17 +++ data/shop/9110101.yaml | 15 ++ data/shop/9110102.yaml | 14 ++ data/shop/9201099.yaml | 22 +++ data/shop/9270019.yaml | 19 +++ data/shop/9270020.yaml | 90 ++++++++++++ data/shop/9270021.yaml | 34 +++++ data/shop/9270022.yaml | 30 ++++ data/shop/9270027.yaml | 14 ++ data/shop/9270055.yaml | 53 +++++++ data/shop/9270056.yaml | 86 +++++++++++ data/shop/9270057.yaml | 38 +++++ data/shop/9270065.yaml | 26 ++++ data/shop/9991000.yaml | 28 ++++ data/shop/9991001.yaml | 18 +++ 534 files changed, 7310 insertions(+), 182 deletions(-) create mode 100644 data/reward/100200.yaml create mode 100644 data/reward/2000.yaml create mode 100644 data/reward/2040001.yaml create mode 100644 data/reward/2040002.yaml create mode 100644 data/reward/2040003.yaml create mode 100644 data/reward/2040004.yaml create mode 100644 data/reward/2040005.yaml create mode 100644 data/reward/2040006.yaml create mode 100644 data/reward/2040007.yaml create mode 100644 data/reward/2040008.yaml create mode 100644 data/reward/2040009.yaml create mode 100644 data/reward/2040010.yaml create mode 100644 data/reward/2040011.yaml create mode 100644 data/reward/2040012.yaml create mode 100644 data/reward/2040013.yaml create mode 100644 data/reward/2040015.yaml create mode 100644 data/reward/2040017.yaml create mode 100644 data/reward/2040018.yaml create mode 100644 data/reward/2040022.yaml create mode 100644 data/reward/2040023.yaml create mode 100644 data/reward/2040024.yaml create mode 100644 data/reward/2040025.yaml create mode 100644 data/reward/2040026.yaml create mode 100644 data/reward/2040027.yaml create mode 100644 data/reward/2040029.yaml create mode 100644 data/reward/2040034.yaml create mode 100644 data/reward/2040049.yaml create mode 100644 data/reward/2040051.yaml create mode 100644 data/reward/2041011.yaml create mode 100644 data/reward/2041012.yaml create mode 100644 data/reward/2041016.yaml create mode 100644 data/reward/2041018.yaml create mode 100644 data/reward/2041019.yaml create mode 100644 data/reward/2041020.yaml create mode 100644 data/reward/2041021.yaml create mode 100644 data/reward/2041023.yaml create mode 100644 data/reward/2041024.yaml create mode 100644 data/reward/2041026.yaml create mode 100644 data/reward/2041029.yaml create mode 100644 data/reward/2050002.yaml create mode 100644 data/reward/2050006.yaml create mode 100644 data/reward/2050007.yaml create mode 100644 data/reward/2050014.yaml create mode 100644 data/reward/2050015.yaml create mode 100644 data/reward/2050016.yaml create mode 100644 data/reward/2050017.yaml create mode 100644 data/reward/2050018.yaml create mode 100644 data/reward/2050019.yaml create mode 100644 data/reward/2050020.yaml create mode 100644 data/reward/2071000.yaml create mode 100644 data/reward/2071001.yaml create mode 100644 data/reward/2071002.yaml create mode 100644 data/reward/2071003.yaml create mode 100644 data/reward/2071004.yaml create mode 100644 data/reward/2120100.yaml create mode 100644 data/reward/2120101.yaml create mode 100644 data/reward/2150000.yaml create mode 100644 data/reward/2150001.yaml create mode 100644 data/reward/2150002.yaml create mode 100644 data/reward/2150003.yaml create mode 100644 data/reward/2200000.yaml create mode 100644 data/reward/2200001.yaml create mode 100644 data/reward/2208000.yaml create mode 100644 data/reward/2208001.yaml create mode 100644 data/reward/2208002.yaml create mode 100644 data/reward/2208003.yaml create mode 100644 data/reward/2212000.yaml create mode 100644 data/reward/2212001.yaml create mode 100644 data/reward/2212002.yaml create mode 100644 data/reward/2212003.yaml create mode 100644 data/reward/2221007.yaml create mode 100644 data/reward/2221008.yaml create mode 100644 data/reward/2221009.yaml create mode 100644 data/reward/2221010.yaml create mode 100644 data/reward/2222000.yaml create mode 100644 data/reward/2230113.yaml create mode 100644 data/reward/2230114.yaml create mode 100644 data/reward/2298000.yaml create mode 100644 data/reward/3150000.yaml create mode 100644 data/reward/3150001.yaml create mode 100644 data/reward/3150002.yaml create mode 100644 data/reward/3400003.yaml create mode 100644 data/reward/3400004.yaml create mode 100644 data/reward/3400005.yaml create mode 100644 data/reward/3400006.yaml create mode 100644 data/reward/3400007.yaml create mode 100644 data/reward/3400008.yaml create mode 100644 data/reward/4220001.yaml create mode 100644 data/reward/4300015.yaml create mode 100644 data/reward/4300016.yaml create mode 100644 data/reward/5160000.yaml create mode 100644 data/reward/5160001.yaml create mode 100644 data/reward/5160002.yaml create mode 100644 data/reward/5160003.yaml create mode 100644 data/reward/5160004.yaml create mode 100644 data/reward/6090002.yaml create mode 100644 data/reward/6100300.yaml create mode 100644 data/reward/6130201.yaml create mode 100644 data/reward/6140703.yaml create mode 100644 data/reward/6150000.yaml create mode 100644 data/reward/6160000.yaml create mode 100644 data/reward/6160001.yaml create mode 100644 data/reward/6160002.yaml create mode 100644 data/reward/6160003.yaml create mode 100644 data/reward/7130102.yaml create mode 100644 data/reward/7140002.yaml create mode 100644 data/reward/7150000.yaml create mode 100644 data/reward/7150001.yaml create mode 100644 data/reward/7150002.yaml create mode 100644 data/reward/7150003.yaml create mode 100644 data/reward/7150004.yaml create mode 100644 data/reward/8105000.yaml create mode 100644 data/reward/8105001.yaml create mode 100644 data/reward/8105002.yaml create mode 100644 data/reward/8105003.yaml create mode 100644 data/reward/8105004.yaml create mode 100644 data/reward/8105005.yaml create mode 100644 data/reward/8140100.yaml create mode 100644 data/reward/8140104.yaml create mode 100644 data/reward/8140503.yaml create mode 100644 data/reward/8180018.yaml create mode 100644 data/reward/8180122.yaml create mode 100644 data/reward/8190009.yaml create mode 100644 data/reward/8210000.yaml create mode 100644 data/reward/8210001.yaml create mode 100644 data/reward/8210002.yaml create mode 100644 data/reward/8210003.yaml create mode 100644 data/reward/8210004.yaml create mode 100644 data/reward/8210005.yaml create mode 100644 data/reward/8210010.yaml create mode 100644 data/reward/8210011.yaml create mode 100644 data/reward/8210012.yaml create mode 100644 data/reward/8210013.yaml create mode 100644 data/reward/8220015.yaml create mode 100644 data/reward/8230300.yaml create mode 100644 data/reward/8400006.yaml create mode 100644 data/reward/8600000.yaml create mode 100644 data/reward/8600001.yaml create mode 100644 data/reward/8600002.yaml create mode 100644 data/reward/8600003.yaml create mode 100644 data/reward/8600004.yaml create mode 100644 data/reward/8600005.yaml create mode 100644 data/reward/8600006.yaml create mode 100644 data/reward/8610000.yaml create mode 100644 data/reward/8610001.yaml create mode 100644 data/reward/8610002.yaml create mode 100644 data/reward/8610003.yaml create mode 100644 data/reward/8610004.yaml create mode 100644 data/reward/8610005.yaml create mode 100644 data/reward/8610006.yaml create mode 100644 data/reward/8610007.yaml create mode 100644 data/reward/8610008.yaml create mode 100644 data/reward/8610009.yaml create mode 100644 data/reward/8610010.yaml create mode 100644 data/reward/8610011.yaml create mode 100644 data/reward/8610012.yaml create mode 100644 data/reward/8610013.yaml create mode 100644 data/reward/8610014.yaml create mode 100644 data/reward/8610016.yaml create mode 100644 data/reward/8610017.yaml create mode 100644 data/reward/8610018.yaml create mode 100644 data/reward/8610019.yaml create mode 100644 data/reward/8610020.yaml create mode 100644 data/reward/8610021.yaml create mode 100644 data/reward/8610022.yaml create mode 100644 data/reward/8800102.yaml create mode 100644 data/reward/8810122.yaml create mode 100644 data/reward/8820001.yaml create mode 100644 data/reward/8840000.yaml create mode 100644 data/reward/8850011.yaml create mode 100644 data/reward/8850012.yaml create mode 100644 data/reward/9000000.yaml create mode 100644 data/reward/9000068.yaml create mode 100644 data/reward/9000084.yaml create mode 100644 data/reward/9001004.yaml create mode 100644 data/reward/9001005.yaml create mode 100644 data/reward/9001006.yaml create mode 100644 data/reward/9001009.yaml create mode 100644 data/reward/9001010.yaml create mode 100644 data/reward/9001011.yaml create mode 100644 data/reward/9001012.yaml create mode 100644 data/reward/9001013.yaml create mode 100644 data/reward/9001023.yaml create mode 100644 data/reward/9101005.yaml create mode 100644 data/reward/9240549.yaml create mode 100644 data/reward/9300004.yaml create mode 100644 data/reward/9300095.yaml create mode 100644 data/reward/9300147.yaml create mode 100644 data/reward/9300148.yaml create mode 100644 data/reward/9300169.yaml create mode 100644 data/reward/9300170.yaml create mode 100644 data/reward/9300171.yaml create mode 100644 data/reward/9300173.yaml create mode 100644 data/reward/9300217.yaml create mode 100644 data/reward/9300218.yaml create mode 100644 data/reward/9300219.yaml create mode 100644 data/reward/9300220.yaml create mode 100644 data/reward/9300221.yaml create mode 100644 data/reward/9300222.yaml create mode 100644 data/reward/9300223.yaml create mode 100644 data/reward/9300224.yaml create mode 100644 data/reward/9300225.yaml create mode 100644 data/reward/9300226.yaml create mode 100644 data/reward/9300227.yaml create mode 100644 data/reward/9300228.yaml create mode 100644 data/reward/9300229.yaml create mode 100644 data/reward/9300230.yaml create mode 100644 data/reward/9300231.yaml create mode 100644 data/reward/9300232.yaml create mode 100644 data/reward/9300233.yaml create mode 100644 data/reward/9300234.yaml create mode 100644 data/reward/9300235.yaml create mode 100644 data/reward/9300236.yaml create mode 100644 data/reward/9300237.yaml create mode 100644 data/reward/9300238.yaml create mode 100644 data/reward/9300239.yaml create mode 100644 data/reward/9300240.yaml create mode 100644 data/reward/9300241.yaml create mode 100644 data/reward/9300242.yaml create mode 100644 data/reward/9300243.yaml create mode 100644 data/reward/9300244.yaml create mode 100644 data/reward/9300245.yaml create mode 100644 data/reward/9300246.yaml create mode 100644 data/reward/9300247.yaml create mode 100644 data/reward/9300248.yaml create mode 100644 data/reward/9300249.yaml create mode 100644 data/reward/9300250.yaml create mode 100644 data/reward/9300251.yaml create mode 100644 data/reward/9300252.yaml create mode 100644 data/reward/9300253.yaml create mode 100644 data/reward/9300254.yaml create mode 100644 data/reward/9300255.yaml create mode 100644 data/reward/9300256.yaml create mode 100644 data/reward/9300257.yaml create mode 100644 data/reward/9300258.yaml create mode 100644 data/reward/9300259.yaml create mode 100644 data/reward/9300260.yaml create mode 100644 data/reward/9300261.yaml create mode 100644 data/reward/9300262.yaml create mode 100644 data/reward/9300263.yaml create mode 100644 data/reward/9300264.yaml create mode 100644 data/reward/9300265.yaml create mode 100644 data/reward/9300266.yaml create mode 100644 data/reward/9300267.yaml create mode 100644 data/reward/9300270.yaml create mode 100644 data/reward/9300287.yaml create mode 100644 data/reward/9300288.yaml create mode 100644 data/reward/9300289.yaml create mode 100644 data/reward/9300290.yaml create mode 100644 data/reward/9300291.yaml create mode 100644 data/reward/9300292.yaml create mode 100644 data/reward/9300293.yaml create mode 100644 data/reward/9300294.yaml create mode 100644 data/reward/9300315.yaml create mode 100644 data/reward/9300316.yaml create mode 100644 data/reward/9300317.yaml create mode 100644 data/reward/9300318.yaml create mode 100644 data/reward/9300319.yaml create mode 100644 data/reward/9300320.yaml create mode 100644 data/reward/9300321.yaml create mode 100644 data/reward/9300322.yaml create mode 100644 data/reward/9300323.yaml create mode 100644 data/reward/9300324.yaml create mode 100644 data/reward/9300344.yaml create mode 100644 data/reward/9300347.yaml create mode 100644 data/reward/9300367.yaml create mode 100644 data/reward/9300368.yaml create mode 100644 data/reward/9300369.yaml create mode 100644 data/reward/9300370.yaml create mode 100644 data/reward/9300371.yaml create mode 100644 data/reward/9300372.yaml create mode 100644 data/reward/9300373.yaml create mode 100644 data/reward/9300374.yaml create mode 100644 data/reward/9300375.yaml create mode 100644 data/reward/9300376.yaml create mode 100644 data/reward/9300377.yaml create mode 100644 data/reward/9300378.yaml create mode 100644 data/reward/9300384.yaml create mode 100644 data/reward/9300414.yaml create mode 100644 data/reward/9300446.yaml create mode 100644 data/reward/9300447.yaml create mode 100644 data/reward/9302010.yaml create mode 100644 data/reward/9400010.yaml create mode 100644 data/reward/9400201.yaml create mode 100644 data/reward/9400202.yaml create mode 100644 data/reward/9400253.yaml create mode 100644 data/reward/9400254.yaml create mode 100644 data/reward/9400255.yaml create mode 100644 data/reward/9400256.yaml create mode 100644 data/reward/9400257.yaml create mode 100644 data/reward/9400258.yaml create mode 100644 data/reward/9400259.yaml create mode 100644 data/reward/9400260.yaml create mode 100644 data/reward/9400262.yaml create mode 100644 data/reward/9400265.yaml create mode 100644 data/reward/9400266.yaml create mode 100644 data/reward/9400270.yaml create mode 100644 data/reward/9400273.yaml create mode 100644 data/reward/9400274.yaml create mode 100644 data/reward/9400276.yaml create mode 100644 data/reward/9400287.yaml create mode 100644 data/reward/9400288.yaml create mode 100644 data/reward/9400289.yaml create mode 100644 data/reward/9400294.yaml create mode 100644 data/reward/9400296.yaml create mode 100644 data/reward/9400300.yaml create mode 100644 data/reward/9400382.yaml create mode 100644 data/reward/9400407.yaml create mode 100644 data/reward/9400409.yaml create mode 100644 data/reward/9400533.yaml create mode 100644 data/reward/9400534.yaml create mode 100644 data/reward/9400574.yaml create mode 100644 data/reward/9400575.yaml create mode 100644 data/reward/9400576.yaml create mode 100644 data/reward/9400578.yaml create mode 100644 data/reward/9400579.yaml create mode 100644 data/reward/9400580.yaml create mode 100644 data/reward/9400581.yaml create mode 100644 data/reward/9400582.yaml create mode 100644 data/reward/9400583.yaml create mode 100644 data/reward/9400584.yaml create mode 100644 data/reward/9400585.yaml create mode 100644 data/reward/9400586.yaml create mode 100644 data/reward/9400587.yaml create mode 100644 data/reward/9400588.yaml create mode 100644 data/reward/9400589.yaml create mode 100644 data/reward/9400590.yaml create mode 100644 data/reward/9400591.yaml create mode 100644 data/reward/9400592.yaml create mode 100644 data/reward/9400593.yaml create mode 100644 data/reward/9400601.yaml create mode 100644 data/reward/9400602.yaml create mode 100644 data/reward/9400605.yaml create mode 100644 data/reward/9400606.yaml create mode 100644 data/reward/9400609.yaml create mode 100644 data/reward/9400610.yaml create mode 100644 data/reward/9400611.yaml create mode 100644 data/reward/9400612.yaml create mode 100644 data/reward/9400613.yaml create mode 100644 data/reward/9400614.yaml create mode 100644 data/reward/9400615.yaml create mode 100644 data/reward/9400616.yaml create mode 100644 data/reward/9400617.yaml create mode 100644 data/reward/9400618.yaml create mode 100644 data/reward/9400619.yaml create mode 100644 data/reward/9400620.yaml create mode 100644 data/reward/9400621.yaml create mode 100644 data/reward/9400622.yaml create mode 100644 data/reward/9400623.yaml create mode 100644 data/reward/9400633.yaml create mode 100644 data/reward/9400640.yaml create mode 100644 data/reward/9400648.yaml create mode 100644 data/reward/9400649.yaml create mode 100644 data/reward/9400651.yaml create mode 100644 data/reward/9400652.yaml create mode 100644 data/reward/9400653.yaml create mode 100644 data/reward/9400655.yaml create mode 100644 data/reward/9400656.yaml create mode 100644 data/reward/9400657.yaml create mode 100644 data/reward/9400661.yaml create mode 100644 data/reward/9400700.yaml create mode 100644 data/reward/9400701.yaml create mode 100644 data/reward/9400702.yaml create mode 100644 data/reward/9400703.yaml create mode 100644 data/reward/9400734.yaml create mode 100644 data/reward/9400737.yaml create mode 100644 data/reward/9400738.yaml create mode 100644 data/reward/9400739.yaml create mode 100644 data/reward/9400740.yaml create mode 100644 data/reward/9400748.yaml create mode 100644 data/reward/9400805.yaml create mode 100644 data/reward/9400807.yaml create mode 100644 data/reward/9400902.yaml create mode 100644 data/reward/9409015.yaml create mode 100644 data/reward/9409016.yaml create mode 100644 data/reward/9409017.yaml create mode 100644 data/reward/9409018.yaml create mode 100644 data/reward/9410009.yaml create mode 100644 data/reward/9410011.yaml create mode 100644 data/reward/9410066.yaml create mode 100644 data/reward/9410067.yaml create mode 100644 data/reward/9420001.yaml create mode 100644 data/reward/9420002.yaml create mode 100644 data/reward/9420003.yaml create mode 100644 data/reward/9420005.yaml create mode 100644 data/reward/9420015.yaml create mode 100644 data/reward/9420065.yaml create mode 100644 data/reward/9420066.yaml create mode 100644 data/reward/9420067.yaml create mode 100644 data/reward/9420500.yaml create mode 100644 data/reward/9420501.yaml create mode 100644 data/reward/9420502.yaml create mode 100644 data/reward/9420503.yaml create mode 100644 data/reward/9420504.yaml create mode 100644 data/reward/9420505.yaml create mode 100644 data/reward/9420506.yaml create mode 100644 data/reward/9420507.yaml create mode 100644 data/reward/9420508.yaml create mode 100644 data/reward/9420509.yaml create mode 100644 data/reward/9420510.yaml create mode 100644 data/reward/9420511.yaml create mode 100644 data/reward/9420512.yaml create mode 100644 data/reward/9420513.yaml create mode 100644 data/reward/9420514.yaml create mode 100644 data/reward/9420515.yaml create mode 100644 data/reward/9420516.yaml create mode 100644 data/reward/9420517.yaml create mode 100644 data/reward/9420518.yaml create mode 100644 data/reward/9420519.yaml create mode 100644 data/reward/9420522.yaml create mode 100644 data/reward/9420527.yaml create mode 100644 data/reward/9420528.yaml create mode 100644 data/reward/9420529.yaml create mode 100644 data/reward/9420530.yaml create mode 100644 data/reward/9420531.yaml create mode 100644 data/reward/9420532.yaml create mode 100644 data/reward/9420533.yaml create mode 100644 data/reward/9420534.yaml create mode 100644 data/reward/9420535.yaml create mode 100644 data/reward/9420536.yaml create mode 100644 data/reward/9420537.yaml create mode 100644 data/reward/9420538.yaml create mode 100644 data/reward/9420539.yaml create mode 100644 data/reward/9420540.yaml create mode 100644 data/reward/9420544.yaml create mode 100644 data/reward/9420549.yaml create mode 100644 data/reward/9500197.yaml create mode 100644 data/reward/9500379.yaml create mode 100644 data/reward/9500387.yaml create mode 100644 data/reward/9500388.yaml create mode 100644 data/reward/9500389.yaml create mode 100644 data/reward/9500391.yaml create mode 100644 data/reward/9500392.yaml create mode 100644 data/reward/9500393.yaml create mode 100644 data/reward/9500394.yaml create mode 100644 data/reward/9500395.yaml create mode 100644 data/reward/9600001.yaml create mode 100644 data/reward/9600002.yaml create mode 100644 data/reward/9600003.yaml create mode 100644 data/reward/9600004.yaml create mode 100644 data/reward/9600005.yaml create mode 100644 data/reward/9600006.yaml create mode 100644 data/reward/9600007.yaml create mode 100644 data/reward/9600008.yaml create mode 100644 data/reward/9600009.yaml create mode 100644 data/reward/9600010.yaml create mode 100644 data/reward/9600037.yaml create mode 100644 data/reward/9600038.yaml create mode 100644 data/reward/9600039.yaml create mode 100644 data/reward/9600040.yaml create mode 100644 data/reward/9600041.yaml create mode 100644 data/reward/9600042.yaml create mode 100644 data/reward/9600043.yaml create mode 100644 data/reward/9600044.yaml create mode 100644 data/reward/9600045.yaml create mode 100644 data/reward/9600046.yaml create mode 100644 data/reward/9600047.yaml create mode 100644 data/reward/9600048.yaml create mode 100644 data/reward/9600049.yaml create mode 100644 data/reward/9600050.yaml create mode 100644 data/reward/9600051.yaml create mode 100644 data/reward/9600052.yaml create mode 100644 data/reward/9600053.yaml create mode 100644 data/reward/9600054.yaml create mode 100644 data/reward/9600055.yaml create mode 100644 data/reward/9600056.yaml create mode 100644 data/reward/9600057.yaml create mode 100644 data/reward/9600058.yaml create mode 100644 data/reward/9600059.yaml create mode 100644 data/reward/9600060.yaml create mode 100644 data/reward/9600061.yaml create mode 100644 data/reward/9600062.yaml create mode 100644 data/reward/9700019.yaml create mode 100644 data/reward/9700020.yaml create mode 100644 data/reward/9700029.yaml create mode 100644 data/shop/1052116.yaml create mode 100644 data/shop/1055000.yaml create mode 100644 data/shop/1055002.yaml create mode 100644 data/shop/1091000.yaml create mode 100644 data/shop/1100001.yaml create mode 100644 data/shop/1100002.yaml create mode 100644 data/shop/1301000.yaml create mode 100644 data/shop/2152016.yaml create mode 100644 data/shop/9000081.yaml create mode 100644 data/shop/9090000.yaml create mode 100644 data/shop/9110100.yaml create mode 100644 data/shop/9110101.yaml create mode 100644 data/shop/9110102.yaml create mode 100644 data/shop/9201099.yaml create mode 100644 data/shop/9270019.yaml create mode 100644 data/shop/9270020.yaml create mode 100644 data/shop/9270021.yaml create mode 100644 data/shop/9270022.yaml create mode 100644 data/shop/9270027.yaml create mode 100644 data/shop/9270055.yaml create mode 100644 data/shop/9270056.yaml create mode 100644 data/shop/9270057.yaml create mode 100644 data/shop/9270065.yaml create mode 100644 data/shop/9991000.yaml create mode 100644 data/shop/9991001.yaml diff --git a/data/reward/100005.yaml b/data/reward/100005.yaml index 6c3f885a..2a2b02b9 100644 --- a/data/reward/100005.yaml +++ b/data/reward/100005.yaml @@ -16,4 +16,4 @@ rewards: - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow - [ 4000003, 1, 1, 0.400000 ] # Tree Branch - [ 4010003, 1, 1, 0.002000 ] # Adamantium Ore - - [ 4020003, 1, 1, 0.002000 ] # Emerald Ore + - [ 4020003, 1, 1, 0.002000 ] # Emerald Ore \ No newline at end of file diff --git a/data/reward/100200.yaml b/data/reward/100200.yaml new file mode 100644 index 00000000..4e946a64 --- /dev/null +++ b/data/reward/100200.yaml @@ -0,0 +1,4 @@ +# Mob 100200 (100200) + +rewards: +- [4033072, 1, 1, 0.999999] diff --git a/data/reward/1110101.yaml b/data/reward/1110101.yaml index 93072cb2..f6b9e41c 100644 --- a/data/reward/1110101.yaml +++ b/data/reward/1110101.yaml @@ -27,4 +27,5 @@ rewards: - [ 4032460, 1, 1, 0.050000, 22529 ] # Refreshing Stump Sap - [ 4032374, 1, 1, 0.035000, 2405 ] # Letter of Commendation - Warrior Adventurer - [ 4032376, 1, 1, 0.035000, 2406 ] # Letter of Commendation - Magician Adventurer - - [ 4001371, 1, 1, 0.035000, 28261, 101010102 ] # Ripped Paper 5 \ No newline at end of file + - [ 4001371, 1, 1, 0.035000, 28261, 101010102 ] # Ripped Paper 5 + - [ 4032624, 1, 1, 0.500000, 2380 ] # Dark Stump's Dual Blade Seal (Quest 2380 - Sixth Mission: Destroying the Evidence 4) \ No newline at end of file diff --git a/data/reward/1110130.yaml b/data/reward/1110130.yaml index bf3bfa43..53422e73 100644 --- a/data/reward/1110130.yaml +++ b/data/reward/1110130.yaml @@ -32,3 +32,6 @@ rewards: - [ 4010004, 1, 1, 0.002000 ] # Silver Ore - [ 4020002, 1, 1, 0.002000 ] # AquaMarine Ore - [ 4020003, 1, 1, 0.002000 ] # Emerald Ore + - [ 4032316, 1, 1, 1.000000, 21714 ] # Green Mushroom Puppet (Quest 21714 - Aran) + - [ 4032317, 1, 1, 1.000000, 21717 ] # Green Mushroom Puppet (Quest 21717 - Rowen's Request 1 - Aran) + - [ 4032318, 1, 1, 1.000000, 21718 ] # Green Mushroom Puppet (Quest 21718 - Rowen's Request 2 - Aran) diff --git a/data/reward/1120100.yaml b/data/reward/1120100.yaml index 06e6ae19..b7e9c515 100644 --- a/data/reward/1120100.yaml +++ b/data/reward/1120100.yaml @@ -19,3 +19,4 @@ rewards: - [ 4010003, 1, 1, 0.002000 ] # Adamantium Ore - [ 4020002, 1, 1, 0.002000 ] # AquaMarine Ore - [ 4032378, 1, 1, 0.035000, 2408 ] # Letter of Commendation - Thief Adventurer + - [ 4032622, 1, 1, 0.500000, 2359 ] # Octopus Dual Blade Seal (Quest 2359 - Sixth Mission: Destroying the Evidence) diff --git a/data/reward/1210100.yaml b/data/reward/1210100.yaml index 478d7746..b5490da1 100644 --- a/data/reward/1210100.yaml +++ b/data/reward/1210100.yaml @@ -20,5 +20,6 @@ rewards: - [ 4000021, 1, 1, 0.040000 ] # Leather - [ 4010006, 1, 1, 0.002000 ] # Gold Ore - [ 4020006, 1, 1, 0.002000 ] # Topaz Ore + - [ 4032340, 1, 1, 1.000000, 21710 ] # Pig tail (Quest 21710: A Monster War?) - [ 4032453, 1, 1, 0.400000, 22503 ] # Pork - [ 4032379, 1, 1, 0.035000, 2409 ] # Letter of Commendation - Pirate Adventurer diff --git a/data/reward/1210103.yaml b/data/reward/1210103.yaml index 8689177f..d9538173 100644 --- a/data/reward/1210103.yaml +++ b/data/reward/1210103.yaml @@ -27,3 +27,4 @@ rewards: - [ 4006000, 1, 1, 0.000700 ] # The Magic Rock - [ 4010002, 1, 1, 0.002000 ] # Mithril Ore - [ 4020005, 1, 1, 0.002000 ] # Sapphire Ore + - [ 4032621, 1, 1, 0.500000, 2378 ] # Bubbling's Dual Blade Seal (Quest 2378 - Sixth Mission: Destroying the Evidence 2) diff --git a/data/reward/2000.yaml b/data/reward/2000.yaml new file mode 100644 index 00000000..f7d55825 --- /dev/null +++ b/data/reward/2000.yaml @@ -0,0 +1,5 @@ +# Mob 2000 (2000) + +rewards: +- [4031161, 1, 1, 1.0, 1008] +- [4031162, 1, 1, 1.0, 1008] diff --git a/data/reward/2040001.yaml b/data/reward/2040001.yaml new file mode 100644 index 00000000..b7f1c366 --- /dev/null +++ b/data/reward/2040001.yaml @@ -0,0 +1,11 @@ +# Mob 2040001 + +rewards: + - [ 0, 110, 165, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020001, 1, 1, 0.002000 ] # Amethyst Ore diff --git a/data/reward/2040002.yaml b/data/reward/2040002.yaml new file mode 100644 index 00000000..646809dc --- /dev/null +++ b/data/reward/2040002.yaml @@ -0,0 +1,11 @@ +# Mob 2040002 + +rewards: + - [ 0, 115, 172, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020002, 1, 1, 0.002000 ] # AquaMarine Ore diff --git a/data/reward/2040003.yaml b/data/reward/2040003.yaml new file mode 100644 index 00000000..87d9a57e --- /dev/null +++ b/data/reward/2040003.yaml @@ -0,0 +1,11 @@ +# Mob 2040003 + +rewards: + - [ 0, 118, 177, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020003, 1, 1, 0.002000 ] # Emerald Ore diff --git a/data/reward/2040004.yaml b/data/reward/2040004.yaml new file mode 100644 index 00000000..d50abdb9 --- /dev/null +++ b/data/reward/2040004.yaml @@ -0,0 +1,11 @@ +# Mob 2040004 + +rewards: + - [ 0, 120, 180, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020004, 1, 1, 0.002000 ] # Opal Ore diff --git a/data/reward/2040005.yaml b/data/reward/2040005.yaml new file mode 100644 index 00000000..7864a835 --- /dev/null +++ b/data/reward/2040005.yaml @@ -0,0 +1,11 @@ +# Mob 2040005 + +rewards: + - [ 0, 125, 187, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020005, 1, 1, 0.002000 ] # Sapphire Ore diff --git a/data/reward/2040006.yaml b/data/reward/2040006.yaml new file mode 100644 index 00000000..8888caa0 --- /dev/null +++ b/data/reward/2040006.yaml @@ -0,0 +1,11 @@ +# Mob 2040006 + +rewards: + - [ 0, 127, 190, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020006, 1, 1, 0.002000 ] # Topaz Ore diff --git a/data/reward/2040007.yaml b/data/reward/2040007.yaml new file mode 100644 index 00000000..0de36599 --- /dev/null +++ b/data/reward/2040007.yaml @@ -0,0 +1,11 @@ +# Mob 2040007 + +rewards: + - [ 0, 130, 195, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020007, 1, 1, 0.002000 ] # Diamond Ore diff --git a/data/reward/2040008.yaml b/data/reward/2040008.yaml new file mode 100644 index 00000000..addd722a --- /dev/null +++ b/data/reward/2040008.yaml @@ -0,0 +1,11 @@ +# Mob 2040008 + +rewards: + - [ 0, 132, 198, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020008, 1, 1, 0.002000 ] # Black Crystal Ore diff --git a/data/reward/2040009.yaml b/data/reward/2040009.yaml new file mode 100644 index 00000000..89be8e6b --- /dev/null +++ b/data/reward/2040009.yaml @@ -0,0 +1,11 @@ +# Mob 2040009 + +rewards: + - [ 0, 135, 202, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020001, 1, 1, 0.002000 ] # Amethyst Ore diff --git a/data/reward/2040010.yaml b/data/reward/2040010.yaml new file mode 100644 index 00000000..69a53a4f --- /dev/null +++ b/data/reward/2040010.yaml @@ -0,0 +1,11 @@ +# Mob 2040010 + +rewards: + - [ 0, 138, 207, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020002, 1, 1, 0.002000 ] # AquaMarine Ore diff --git a/data/reward/2040011.yaml b/data/reward/2040011.yaml new file mode 100644 index 00000000..bdfe1a6e --- /dev/null +++ b/data/reward/2040011.yaml @@ -0,0 +1,11 @@ +# Mob 2040011 + +rewards: + - [ 0, 140, 210, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020003, 1, 1, 0.002000 ] # Emerald Ore diff --git a/data/reward/2040012.yaml b/data/reward/2040012.yaml new file mode 100644 index 00000000..c4aa2aa4 --- /dev/null +++ b/data/reward/2040012.yaml @@ -0,0 +1,11 @@ +# Mob 2040012 + +rewards: + - [ 0, 142, 213, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020004, 1, 1, 0.002000 ] # Opal Ore diff --git a/data/reward/2040013.yaml b/data/reward/2040013.yaml new file mode 100644 index 00000000..bde99945 --- /dev/null +++ b/data/reward/2040013.yaml @@ -0,0 +1,11 @@ +# Mob 2040013 + +rewards: + - [ 0, 145, 217, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020005, 1, 1, 0.002000 ] # Sapphire Ore diff --git a/data/reward/2040015.yaml b/data/reward/2040015.yaml new file mode 100644 index 00000000..83bfb16b --- /dev/null +++ b/data/reward/2040015.yaml @@ -0,0 +1,11 @@ +# Mob 2040015 + +rewards: + - [ 0, 148, 222, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020006, 1, 1, 0.002000 ] # Topaz Ore diff --git a/data/reward/2040017.yaml b/data/reward/2040017.yaml new file mode 100644 index 00000000..76da9aee --- /dev/null +++ b/data/reward/2040017.yaml @@ -0,0 +1,11 @@ +# Mob 2040017 + +rewards: + - [ 0, 150, 225, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020007, 1, 1, 0.002000 ] # Diamond Ore diff --git a/data/reward/2040018.yaml b/data/reward/2040018.yaml new file mode 100644 index 00000000..79370d96 --- /dev/null +++ b/data/reward/2040018.yaml @@ -0,0 +1,11 @@ +# Mob 2040018 + +rewards: + - [ 0, 152, 228, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020008, 1, 1, 0.002000 ] # Black Crystal Ore diff --git a/data/reward/2040022.yaml b/data/reward/2040022.yaml new file mode 100644 index 00000000..8d5523cd --- /dev/null +++ b/data/reward/2040022.yaml @@ -0,0 +1,11 @@ +# Mob 2040022 + +rewards: + - [ 0, 155, 232, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020001, 1, 1, 0.002000 ] # Amethyst Ore diff --git a/data/reward/2040023.yaml b/data/reward/2040023.yaml new file mode 100644 index 00000000..d40b6f23 --- /dev/null +++ b/data/reward/2040023.yaml @@ -0,0 +1,11 @@ +# Mob 2040023 + +rewards: + - [ 0, 157, 235, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020002, 1, 1, 0.002000 ] # AquaMarine Ore diff --git a/data/reward/2040024.yaml b/data/reward/2040024.yaml new file mode 100644 index 00000000..a7736f2b --- /dev/null +++ b/data/reward/2040024.yaml @@ -0,0 +1,11 @@ +# Mob 2040024 + +rewards: + - [ 0, 160, 240, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020003, 1, 1, 0.002000 ] # Emerald Ore diff --git a/data/reward/2040025.yaml b/data/reward/2040025.yaml new file mode 100644 index 00000000..bddbee8a --- /dev/null +++ b/data/reward/2040025.yaml @@ -0,0 +1,11 @@ +# Mob 2040025 + +rewards: + - [ 0, 162, 243, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020004, 1, 1, 0.002000 ] # Opal Ore diff --git a/data/reward/2040026.yaml b/data/reward/2040026.yaml new file mode 100644 index 00000000..51273bd8 --- /dev/null +++ b/data/reward/2040026.yaml @@ -0,0 +1,11 @@ +# Mob 2040026 + +rewards: + - [ 0, 165, 247, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020005, 1, 1, 0.002000 ] # Sapphire Ore diff --git a/data/reward/2040027.yaml b/data/reward/2040027.yaml new file mode 100644 index 00000000..aad7fc09 --- /dev/null +++ b/data/reward/2040027.yaml @@ -0,0 +1,11 @@ +# Mob 2040027 + +rewards: + - [ 0, 167, 250, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020006, 1, 1, 0.002000 ] # Topaz Ore diff --git a/data/reward/2040029.yaml b/data/reward/2040029.yaml new file mode 100644 index 00000000..98e2263b --- /dev/null +++ b/data/reward/2040029.yaml @@ -0,0 +1,11 @@ +# Mob 2040029 + +rewards: + - [ 0, 170, 255, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020007, 1, 1, 0.002000 ] # Diamond Ore diff --git a/data/reward/2040034.yaml b/data/reward/2040034.yaml new file mode 100644 index 00000000..c5140357 --- /dev/null +++ b/data/reward/2040034.yaml @@ -0,0 +1,11 @@ +# Mob 2040034 + +rewards: + - [ 0, 172, 258, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020008, 1, 1, 0.002000 ] # Black Crystal Ore diff --git a/data/reward/2040049.yaml b/data/reward/2040049.yaml new file mode 100644 index 00000000..f169fb0e --- /dev/null +++ b/data/reward/2040049.yaml @@ -0,0 +1,11 @@ +# Mob 2040049 + +rewards: + - [ 0, 175, 262, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020001, 1, 1, 0.002000 ] # Amethyst Ore diff --git a/data/reward/2040051.yaml b/data/reward/2040051.yaml new file mode 100644 index 00000000..fb542a80 --- /dev/null +++ b/data/reward/2040051.yaml @@ -0,0 +1,11 @@ +# Mob 2040051 + +rewards: + - [ 0, 177, 265, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020002, 1, 1, 0.002000 ] # AquaMarine Ore diff --git a/data/reward/2041011.yaml b/data/reward/2041011.yaml new file mode 100644 index 00000000..1c00c1a0 --- /dev/null +++ b/data/reward/2041011.yaml @@ -0,0 +1,10 @@ +# Mob 2041011 + +rewards: + - [ 0, 180, 270, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020003, 1, 1, 0.002000 ] # Emerald Ore diff --git a/data/reward/2041012.yaml b/data/reward/2041012.yaml new file mode 100644 index 00000000..e1132558 --- /dev/null +++ b/data/reward/2041012.yaml @@ -0,0 +1,10 @@ +# Mob 2041012 + +rewards: + - [ 0, 185, 277, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020004, 1, 1, 0.002000 ] # Opal Ore diff --git a/data/reward/2041016.yaml b/data/reward/2041016.yaml new file mode 100644 index 00000000..0dcffd6f --- /dev/null +++ b/data/reward/2041016.yaml @@ -0,0 +1,10 @@ +# Mob 2041016 + +rewards: + - [ 0, 190, 285, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020005, 1, 1, 0.002000 ] # Sapphire Ore diff --git a/data/reward/2041018.yaml b/data/reward/2041018.yaml new file mode 100644 index 00000000..0593d8ee --- /dev/null +++ b/data/reward/2041018.yaml @@ -0,0 +1,10 @@ +# Mob 2041018 + +rewards: + - [ 0, 195, 292, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020006, 1, 1, 0.002000 ] # Topaz Ore diff --git a/data/reward/2041019.yaml b/data/reward/2041019.yaml new file mode 100644 index 00000000..c362d1fc --- /dev/null +++ b/data/reward/2041019.yaml @@ -0,0 +1,10 @@ +# Mob 2041019 + +rewards: + - [ 0, 200, 300, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020007, 1, 1, 0.002000 ] # Diamond Ore diff --git a/data/reward/2041020.yaml b/data/reward/2041020.yaml new file mode 100644 index 00000000..c144941a --- /dev/null +++ b/data/reward/2041020.yaml @@ -0,0 +1,10 @@ +# Mob 2041020 + +rewards: + - [ 0, 205, 307, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020008, 1, 1, 0.002000 ] # Black Crystal Ore diff --git a/data/reward/2041021.yaml b/data/reward/2041021.yaml new file mode 100644 index 00000000..42661c34 --- /dev/null +++ b/data/reward/2041021.yaml @@ -0,0 +1,10 @@ +# Mob 2041021 + +rewards: + - [ 0, 210, 315, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020001, 1, 1, 0.002000 ] # Amethyst Ore diff --git a/data/reward/2041023.yaml b/data/reward/2041023.yaml new file mode 100644 index 00000000..d1c8951b --- /dev/null +++ b/data/reward/2041023.yaml @@ -0,0 +1,10 @@ +# Mob 2041023 + +rewards: + - [ 0, 215, 322, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020002, 1, 1, 0.002000 ] # AquaMarine Ore diff --git a/data/reward/2041024.yaml b/data/reward/2041024.yaml new file mode 100644 index 00000000..7aae37d5 --- /dev/null +++ b/data/reward/2041024.yaml @@ -0,0 +1,10 @@ +# Mob 2041024 + +rewards: + - [ 0, 220, 330, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020003, 1, 1, 0.002000 ] # Emerald Ore diff --git a/data/reward/2041026.yaml b/data/reward/2041026.yaml new file mode 100644 index 00000000..8cdb77e5 --- /dev/null +++ b/data/reward/2041026.yaml @@ -0,0 +1,10 @@ +# Mob 2041026 + +rewards: + - [ 0, 225, 337, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020004, 1, 1, 0.002000 ] # Opal Ore diff --git a/data/reward/2041029.yaml b/data/reward/2041029.yaml new file mode 100644 index 00000000..2cea16d8 --- /dev/null +++ b/data/reward/2041029.yaml @@ -0,0 +1,10 @@ +# Mob 2041029 + +rewards: + - [ 0, 230, 345, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020005, 1, 1, 0.002000 ] # Sapphire Ore diff --git a/data/reward/2050002.yaml b/data/reward/2050002.yaml new file mode 100644 index 00000000..158d0fbd --- /dev/null +++ b/data/reward/2050002.yaml @@ -0,0 +1,10 @@ +# Mob 2050002 + +rewards: + - [ 0, 240, 360, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020001, 1, 1, 0.002000 ] # Amethyst Ore diff --git a/data/reward/2050006.yaml b/data/reward/2050006.yaml new file mode 100644 index 00000000..a184d5e0 --- /dev/null +++ b/data/reward/2050006.yaml @@ -0,0 +1,10 @@ +# Mob 2050006 + +rewards: + - [ 0, 250, 375, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020002, 1, 1, 0.002000 ] # AquaMarine Ore diff --git a/data/reward/2050007.yaml b/data/reward/2050007.yaml new file mode 100644 index 00000000..5188cf12 --- /dev/null +++ b/data/reward/2050007.yaml @@ -0,0 +1,10 @@ +# Mob 2050007 + +rewards: + - [ 0, 255, 382, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020003, 1, 1, 0.002000 ] # Emerald Ore diff --git a/data/reward/2050014.yaml b/data/reward/2050014.yaml new file mode 100644 index 00000000..fe373fe8 --- /dev/null +++ b/data/reward/2050014.yaml @@ -0,0 +1,10 @@ +# Mob 2050014 + +rewards: + - [ 0, 260, 390, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020004, 1, 1, 0.002000 ] # Opal Ore diff --git a/data/reward/2050015.yaml b/data/reward/2050015.yaml new file mode 100644 index 00000000..e4545090 --- /dev/null +++ b/data/reward/2050015.yaml @@ -0,0 +1,10 @@ +# Mob 2050015 + +rewards: + - [ 0, 265, 397, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020005, 1, 1, 0.002000 ] # Sapphire Ore diff --git a/data/reward/2050016.yaml b/data/reward/2050016.yaml new file mode 100644 index 00000000..bdab7950 --- /dev/null +++ b/data/reward/2050016.yaml @@ -0,0 +1,10 @@ +# Mob 2050016 + +rewards: + - [ 0, 270, 405, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020006, 1, 1, 0.002000 ] # Topaz Ore diff --git a/data/reward/2050017.yaml b/data/reward/2050017.yaml new file mode 100644 index 00000000..f5988503 --- /dev/null +++ b/data/reward/2050017.yaml @@ -0,0 +1,10 @@ +# Mob 2050017 + +rewards: + - [ 0, 275, 412, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020000, 1, 1, 0.002000 ] # Garnet Ore diff --git a/data/reward/2050018.yaml b/data/reward/2050018.yaml new file mode 100644 index 00000000..3007926b --- /dev/null +++ b/data/reward/2050018.yaml @@ -0,0 +1,10 @@ +# Mob 2050018 + +rewards: + - [ 0, 280, 420, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020001, 1, 1, 0.002000 ] # Amethyst Ore diff --git a/data/reward/2050019.yaml b/data/reward/2050019.yaml new file mode 100644 index 00000000..b0d6ab9f --- /dev/null +++ b/data/reward/2050019.yaml @@ -0,0 +1,10 @@ +# Mob 2050019 + +rewards: + - [ 0, 285, 427, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020002, 1, 1, 0.002000 ] # AquaMarine Ore diff --git a/data/reward/2050020.yaml b/data/reward/2050020.yaml new file mode 100644 index 00000000..162f72c5 --- /dev/null +++ b/data/reward/2050020.yaml @@ -0,0 +1,10 @@ +# Mob 2050020 + +rewards: + - [ 0, 290, 435, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020003, 1, 1, 0.002000 ] # Emerald Ore diff --git a/data/reward/2071000.yaml b/data/reward/2071000.yaml new file mode 100644 index 00000000..b0298036 --- /dev/null +++ b/data/reward/2071000.yaml @@ -0,0 +1,10 @@ +# Mob 2071000 + +rewards: + - [ 0, 300, 450, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020001, 1, 1, 0.002000 ] # Amethyst Ore diff --git a/data/reward/2071001.yaml b/data/reward/2071001.yaml new file mode 100644 index 00000000..ca21f37c --- /dev/null +++ b/data/reward/2071001.yaml @@ -0,0 +1,10 @@ +# Mob 2071001 + +rewards: + - [ 0, 305, 457, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020002, 1, 1, 0.002000 ] # AquaMarine Ore diff --git a/data/reward/2071002.yaml b/data/reward/2071002.yaml new file mode 100644 index 00000000..37171016 --- /dev/null +++ b/data/reward/2071002.yaml @@ -0,0 +1,10 @@ +# Mob 2071002 + +rewards: + - [ 0, 310, 465, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020003, 1, 1, 0.002000 ] # Emerald Ore diff --git a/data/reward/2071003.yaml b/data/reward/2071003.yaml new file mode 100644 index 00000000..6fb32ce6 --- /dev/null +++ b/data/reward/2071003.yaml @@ -0,0 +1,10 @@ +# Mob 2071003 + +rewards: + - [ 0, 315, 472, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020004, 1, 1, 0.002000 ] # Opal Ore diff --git a/data/reward/2071004.yaml b/data/reward/2071004.yaml new file mode 100644 index 00000000..1375d569 --- /dev/null +++ b/data/reward/2071004.yaml @@ -0,0 +1,10 @@ +# Mob 2071004 + +rewards: + - [ 0, 320, 480, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020005, 1, 1, 0.002000 ] # Sapphire Ore diff --git a/data/reward/2120100.yaml b/data/reward/2120100.yaml new file mode 100644 index 00000000..03cd87a3 --- /dev/null +++ b/data/reward/2120100.yaml @@ -0,0 +1,10 @@ +# Mob 2120100 + +rewards: + - [ 0, 150, 225, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020000, 1, 1, 0.002000 ] # Garnet Ore diff --git a/data/reward/2120101.yaml b/data/reward/2120101.yaml new file mode 100644 index 00000000..9b8e1659 --- /dev/null +++ b/data/reward/2120101.yaml @@ -0,0 +1,10 @@ +# Mob 2120101 + +rewards: + - [ 0, 155, 232, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020001, 1, 1, 0.002000 ] # Amethyst Ore diff --git a/data/reward/2150000.yaml b/data/reward/2150000.yaml new file mode 100644 index 00000000..bcd8916a --- /dev/null +++ b/data/reward/2150000.yaml @@ -0,0 +1,4 @@ +# Mob 2150000 (2150000) + +rewards: +- [4000601, 1, 1, 0.5] diff --git a/data/reward/2150001.yaml b/data/reward/2150001.yaml new file mode 100644 index 00000000..dcac2a13 --- /dev/null +++ b/data/reward/2150001.yaml @@ -0,0 +1,4 @@ +# Mob 2150001 (2150001) + +rewards: +- [4000602, 1, 1, 0.5] diff --git a/data/reward/2150002.yaml b/data/reward/2150002.yaml new file mode 100644 index 00000000..db760d53 --- /dev/null +++ b/data/reward/2150002.yaml @@ -0,0 +1,4 @@ +# Mob 2150002 (2150002) + +rewards: +- [4000603, 1, 1, 0.5] diff --git a/data/reward/2150003.yaml b/data/reward/2150003.yaml new file mode 100644 index 00000000..6cbfd705 --- /dev/null +++ b/data/reward/2150003.yaml @@ -0,0 +1,25 @@ +# Patrol Robot S (2150003) + +# Command Emblem - didn't add check +# Zebra Stripe Ticket Piece - didn't add check +# Equip Enhancement Scroll - not added check +# Potential Scroll - not added check +# Twin Angels - can't find + +rewards: + - [ 0, 67, 88, 0.600000 ] + - [ 4000604, 1, 1, 0.400000 ] # Patrol Robot S Memory Chip + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 2044805, 1, 1, 0.000100 ] # Scroll for Knuckle for Accuracy 100% + - [ 2040515, 1, 1, 0.000100 ] # Scroll for Overall Armor for LUK 100% + - [ 2040901, 1, 1, 0.010000 ] # Scroll for Shield for DEF 60% + - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow + - [ 1072012, 1, 1, 0.000100 ] # Red Whitebottom Boots + - [ 1312005, 1, 1, 0.000100 ] # Fireman's Axe + - [ 1382005, 1, 1, 0.000100 ] # Emerald Staff + - [ 1462000, 1, 1, 0.000200 ] # Mountain Crossbow + - [ 1082048, 1, 1, 0.000100 ] # Brown Marker + - [ 1060046, 1, 1, 0.000100 ] # Silver / Black Stealer Pants + - [ 1472007, 1, 1, 0.000200 ] # Meba + - [ 1002148, 1, 1, 0.000100 ] # Green Tiberian + - [ 1472008, 1, 1, 0.002000 ] # Steel Guards \ No newline at end of file diff --git a/data/reward/2200000.yaml b/data/reward/2200000.yaml new file mode 100644 index 00000000..7d5563d9 --- /dev/null +++ b/data/reward/2200000.yaml @@ -0,0 +1,10 @@ +# Mob 2200000 + +rewards: + - [ 0, 130, 195, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020001, 1, 1, 0.002000 ] # Amethyst Ore diff --git a/data/reward/2200001.yaml b/data/reward/2200001.yaml new file mode 100644 index 00000000..4b592779 --- /dev/null +++ b/data/reward/2200001.yaml @@ -0,0 +1,10 @@ +# Mob 2200001 + +rewards: + - [ 0, 135, 202, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020002, 1, 1, 0.002000 ] # AquaMarine Ore diff --git a/data/reward/2208000.yaml b/data/reward/2208000.yaml new file mode 100644 index 00000000..f9bf04a9 --- /dev/null +++ b/data/reward/2208000.yaml @@ -0,0 +1,10 @@ +# Mob 2208000 + +rewards: + - [ 0, 140, 210, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020003, 1, 1, 0.002000 ] # Emerald Ore diff --git a/data/reward/2208001.yaml b/data/reward/2208001.yaml new file mode 100644 index 00000000..a936d4c1 --- /dev/null +++ b/data/reward/2208001.yaml @@ -0,0 +1,10 @@ +# Mob 2208001 + +rewards: + - [ 0, 145, 217, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020004, 1, 1, 0.002000 ] # Opal Ore diff --git a/data/reward/2208002.yaml b/data/reward/2208002.yaml new file mode 100644 index 00000000..dd68b0d5 --- /dev/null +++ b/data/reward/2208002.yaml @@ -0,0 +1,10 @@ +# Mob 2208002 + +rewards: + - [ 0, 150, 225, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020005, 1, 1, 0.002000 ] # Sapphire Ore diff --git a/data/reward/2208003.yaml b/data/reward/2208003.yaml new file mode 100644 index 00000000..67b89330 --- /dev/null +++ b/data/reward/2208003.yaml @@ -0,0 +1,10 @@ +# Mob 2208003 + +rewards: + - [ 0, 155, 232, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020006, 1, 1, 0.002000 ] # Topaz Ore diff --git a/data/reward/2212000.yaml b/data/reward/2212000.yaml new file mode 100644 index 00000000..53d0e8a9 --- /dev/null +++ b/data/reward/2212000.yaml @@ -0,0 +1,10 @@ +# Mob 2212000 + +rewards: + - [ 0, 160, 240, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020000, 1, 1, 0.002000 ] # Garnet Ore diff --git a/data/reward/2212001.yaml b/data/reward/2212001.yaml new file mode 100644 index 00000000..2cb49856 --- /dev/null +++ b/data/reward/2212001.yaml @@ -0,0 +1,10 @@ +# Mob 2212001 + +rewards: + - [ 0, 165, 247, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020001, 1, 1, 0.002000 ] # Amethyst Ore diff --git a/data/reward/2212002.yaml b/data/reward/2212002.yaml new file mode 100644 index 00000000..094b3ed5 --- /dev/null +++ b/data/reward/2212002.yaml @@ -0,0 +1,10 @@ +# Mob 2212002 + +rewards: + - [ 0, 170, 255, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020002, 1, 1, 0.002000 ] # AquaMarine Ore diff --git a/data/reward/2212003.yaml b/data/reward/2212003.yaml new file mode 100644 index 00000000..8a7b61ac --- /dev/null +++ b/data/reward/2212003.yaml @@ -0,0 +1,10 @@ +# Mob 2212003 + +rewards: + - [ 0, 175, 262, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020003, 1, 1, 0.002000 ] # Emerald Ore diff --git a/data/reward/2221007.yaml b/data/reward/2221007.yaml new file mode 100644 index 00000000..a36a7d01 --- /dev/null +++ b/data/reward/2221007.yaml @@ -0,0 +1,10 @@ +# Mob 2221007 + +rewards: + - [ 0, 180, 270, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020004, 1, 1, 0.002000 ] # Opal Ore diff --git a/data/reward/2221008.yaml b/data/reward/2221008.yaml new file mode 100644 index 00000000..ade0951d --- /dev/null +++ b/data/reward/2221008.yaml @@ -0,0 +1,10 @@ +# Mob 2221008 + +rewards: + - [ 0, 185, 277, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020005, 1, 1, 0.002000 ] # Sapphire Ore diff --git a/data/reward/2221009.yaml b/data/reward/2221009.yaml new file mode 100644 index 00000000..1aa76333 --- /dev/null +++ b/data/reward/2221009.yaml @@ -0,0 +1,10 @@ +# Mob 2221009 + +rewards: + - [ 0, 190, 285, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020006, 1, 1, 0.002000 ] # Topaz Ore diff --git a/data/reward/2221010.yaml b/data/reward/2221010.yaml new file mode 100644 index 00000000..f1fece6a --- /dev/null +++ b/data/reward/2221010.yaml @@ -0,0 +1,10 @@ +# Mob 2221010 + +rewards: + - [ 0, 195, 292, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020000, 1, 1, 0.002000 ] # Garnet Ore diff --git a/data/reward/2222000.yaml b/data/reward/2222000.yaml new file mode 100644 index 00000000..b82c4171 --- /dev/null +++ b/data/reward/2222000.yaml @@ -0,0 +1,10 @@ +# Mob 2222000 + +rewards: + - [ 0, 200, 300, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020001, 1, 1, 0.002000 ] # Amethyst Ore diff --git a/data/reward/2230101.yaml b/data/reward/2230101.yaml index deb86816..52420c51 100644 --- a/data/reward/2230101.yaml +++ b/data/reward/2230101.yaml @@ -33,4 +33,5 @@ rewards: - [ 4010002, 1, 1, 0.002000 ] # Mithril Ore - [ 4010003, 1, 1, 0.002000 ] # Adamantium Ore - [ 4020003, 1, 1, 0.002000 ] # Emerald Ore - - [ 4032461, 1, 1, 0.050000, 22531 ] # Zombie Mushroom Doll \ No newline at end of file + - [ 4032321, 1, 1, 1.000000, 21727 ] # Zombie Mushroom Puppet (Quest 21727 - Aran) + - [ 4032461, 1, 1, 0.050000, 22531 ] # Zombie Mushroom Doll (Quest 22531 - Evan) \ No newline at end of file diff --git a/data/reward/2230113.yaml b/data/reward/2230113.yaml new file mode 100644 index 00000000..aca146cc --- /dev/null +++ b/data/reward/2230113.yaml @@ -0,0 +1,4 @@ +# Mob 2230113 (2230113) + +rewards: +- [4000621, 1, 1, 0.5] diff --git a/data/reward/2230114.yaml b/data/reward/2230114.yaml new file mode 100644 index 00000000..0d622877 --- /dev/null +++ b/data/reward/2230114.yaml @@ -0,0 +1,4 @@ +# Mob 2230114 (2230114) + +rewards: +- [4000623, 1, 1, 0.4] diff --git a/data/reward/2298000.yaml b/data/reward/2298000.yaml new file mode 100644 index 00000000..4c2a70ab --- /dev/null +++ b/data/reward/2298000.yaml @@ -0,0 +1,10 @@ +# Mob 2298000 + +rewards: + - [ 0, 400, 600, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020002, 1, 1, 0.002000 ] # AquaMarine Ore diff --git a/data/reward/2300100.yaml b/data/reward/2300100.yaml index 9a0e62a9..e30c8590 100644 --- a/data/reward/2300100.yaml +++ b/data/reward/2300100.yaml @@ -27,3 +27,5 @@ rewards: - [ 4003004, 1, 1, 0.040000 ] # Stiff Feather - [ 4010001, 1, 1, 0.002000 ] # Steel Ore - [ 4020000, 1, 1, 0.002000 ] # Garnet Ore + - [ 4032620, 1, 1, 0.500000, 2357 ] # Secret Map (Quest 2357 - Falsified Remnant) + - [ 4032623, 1, 1, 0.500000, 2379 ] # Stirge's Dual Blade Seal (Quest 2379 - Sixth Mission: Destroying the Evidence 3) diff --git a/data/reward/3110102.yaml b/data/reward/3110102.yaml index 6a7a1cf8..c19e8b24 100644 --- a/data/reward/3110102.yaml +++ b/data/reward/3110102.yaml @@ -20,7 +20,7 @@ rewards: - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow - [ 4000021, 1, 1, 0.040000 ] # Leather - - [ 4000095, 1, 1, 0.400000 ] # Rat Trap + - [ 4000095, 1, 1, 0.400000, 3209 ] # Rat Trap (Quest 3209) - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore - [ 4007001, 1, 1, 0.000300 ] # Magic Powder (White) - [ 4007007, 1, 1, 0.000300 ] # Magic Powder (Black) diff --git a/data/reward/3150000.yaml b/data/reward/3150000.yaml new file mode 100644 index 00000000..1e0da545 --- /dev/null +++ b/data/reward/3150000.yaml @@ -0,0 +1,16 @@ +# Safety First (3150000) + +# The Argents - didn't add check + +rewards: + - [ 0, 88, 104, 0.600000 ] + - [ 4000605, 1, 1, 0.400000 ] # Safety First Sign + - [ 4032764, 1, 1, 0.200000 ] # Rue Battery + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 2000001, 1, 1, 0.010000 ] # Orange Potion + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow + - [ 1302004, 1, 1, 0.000100 ] # Cutlus + - [ 1002152, 1, 1, 0.000100 ] # Blue Guiltian + - [ 1002136, 1, 1, 0.000100 ] # Dark Pole-Feather Hat \ No newline at end of file diff --git a/data/reward/3150001.yaml b/data/reward/3150001.yaml new file mode 100644 index 00000000..c5d84986 --- /dev/null +++ b/data/reward/3150001.yaml @@ -0,0 +1,12 @@ +# Baby Boulder Muncher (3150001) + +rewards: + - [ 0, 32, 48, 0.600000 ] + - [ 2000001, 1, 1, 0.015000 ] # Orange Potion + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 2000006, 1, 1, 0.005000 ] # Mana Elixir + - [ 4032765, 1, 1, 0.500000, 23943 ] # Rock Shield (only drops with quest 23943) + - [ 4032764, 1, 1, 0.100000, 23941 ] # Rue Battery (only drops with quest 23941) + - [ 4010004, 1, 1, 0.002000 ] # Silver Ore + - [ 4020005, 1, 1, 0.002000 ] # Sapphire Ore diff --git a/data/reward/3150002.yaml b/data/reward/3150002.yaml new file mode 100644 index 00000000..6e7b80a6 --- /dev/null +++ b/data/reward/3150002.yaml @@ -0,0 +1,17 @@ +# Big Boulder Muncher (3150002) + +# Proud Blossoms - didn't add check + +rewards: + - [ 0, 84, 126, 0.600000 ] + - [ 4000607, 1, 1, 0.400000 ] # Boulder Fragment + - [ 2000001, 1, 1, 0.010000 ] # Orange Potion + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2040300, 1, 1, 0.001000 ] # Scroll for Earring for INT 100% + - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow + - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow + - [ 1322015, 1, 1, 0.000100 ] # Heavy Hammer + - [ 1002098, 1, 1, 0.000200 ] # Gold Nordic Helm + - [ 1472015, 1, 1, 0.000100 ] # Blood Avarice + - [ 1072107, 1, 1, 0.000200 ] # Black Red-Lined Shoes + - [ 1060052, 1, 1, 0.000100 ] # Black Knucklevest Pants \ No newline at end of file diff --git a/data/reward/3210100.yaml b/data/reward/3210100.yaml index 07929347..0671d197 100644 --- a/data/reward/3210100.yaml +++ b/data/reward/3210100.yaml @@ -34,3 +34,4 @@ rewards: - [ 4130016, 1, 1, 0.000300 ] # Knuckler Production Stimulator - [ 4130017, 1, 1, 0.000300 ] # Gun Production Stimulator - [ 4130022, 1, 1, 0.000300 ] # Shield Production Stimulator + - [ 4032629, 1, 1, 0.200000, 2371 ] # Tulip (Quest 2371 - Yun) diff --git a/data/reward/3210206.yaml b/data/reward/3210206.yaml index a1fe269e..4dd64666 100644 --- a/data/reward/3210206.yaml +++ b/data/reward/3210206.yaml @@ -25,7 +25,7 @@ rewards: - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow - [ 2070003, 1, 1, 0.000400 ] # Kumbi Throwing-Stars - - [ 4000103, 1, 1, 0.400000 ] # Propeller + - [ 4000103, 1, 1, 0.400000, 3203 ] # Propeller (Quest 3203) - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore - [ 4007002, 1, 1, 0.000300 ] # Magic Powder (Blue) - [ 4007003, 1, 1, 0.000300 ] # Magic Powder (Green) diff --git a/data/reward/3230101.yaml b/data/reward/3230101.yaml index fb36724d..26bd3cb7 100644 --- a/data/reward/3230101.yaml +++ b/data/reward/3230101.yaml @@ -27,3 +27,4 @@ rewards: - [ 4130007, 1, 1, 0.000300 ] # Two-Handed Mace Forging Stimulator - [ 4130014, 1, 1, 0.000300 ] # Dagger Forging Stimulator - [ 4130018, 1, 1, 0.000300 ] # Armor Production Stimulator + - [ 4032620, 1, 1, 0.500000, 2357 ] # Secret Map (Quest 2357 - Falsified Remnant) diff --git a/data/reward/3230307.yaml b/data/reward/3230307.yaml index 11a9ee82..c85227e7 100644 --- a/data/reward/3230307.yaml +++ b/data/reward/3230307.yaml @@ -23,7 +23,7 @@ rewards: - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow - [ 4000003, 1, 1, 0.100000 ] # Tree Branch - - [ 4000123, 1, 1, 0.400000 ] # Worn-Out Goggle + - [ 4000123, 1, 1, 0.400000, 3203 ] # Worn-Out Goggle (Quest 3203) - [ 4003004, 1, 1, 0.040000 ] # Stiff Feather - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore - [ 4007001, 1, 1, 0.000300 ] # Magic Powder (White) diff --git a/data/reward/3230308.yaml b/data/reward/3230308.yaml index 8fccd9f1..c2c6c96d 100644 --- a/data/reward/3230308.yaml +++ b/data/reward/3230308.yaml @@ -26,7 +26,7 @@ rewards: - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow - [ 4000003, 1, 1, 0.100000 ] # Tree Branch - - [ 4000116, 1, 1, 0.400000 ] # Small Egg + - [ 4000116, 1, 1, 0.400000, 3208 ] # Small Egg (Quest 3208) - [ 4003005, 1, 1, 0.040000 ] # Soft Feather - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore - [ 4007000, 1, 1, 0.000300 ] # Magic Powder (Brown) diff --git a/data/reward/3400003.yaml b/data/reward/3400003.yaml new file mode 100644 index 00000000..ee9c8b29 --- /dev/null +++ b/data/reward/3400003.yaml @@ -0,0 +1,20 @@ +# Yeti Doll Claw Game (3400003) + +rewards: + - [ 0, 81, 122, 0.600000 ] + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow + - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow + - [ 2041021, 1, 1, 0.000100 ] # Scroll for Cape for LUK 100% + - [ 1092007, 1, 1, 0.000100 ] # Battle Shield + - [ 1072116, 1, 1, 0.000100 ] # Gold Moon Shoes + - [ 1462006, 1, 1, 0.000100 ] # Silver Crow + - [ 1082069, 1, 1, 0.000100 ] # Mithril Scaler + - [ 1060063, 1, 1, 0.000100 ] # Green Legolier Pants + - [ 1060064, 1, 1, 0.000100 ] # Dark Legolier Pants + - [ 1061060, 1, 1, 0.000100 ] # Red Legolia Pants + - [ 1060069, 1, 1, 0.000100 ] # Brown Piette Pants + - [ 1040061, 1, 1, 0.000100 ] # Green Knucklevest + - [ 4032508, 1, 1, 1.000000, 2273 ] # Secret Recipe | The Secret Recipe \ No newline at end of file diff --git a/data/reward/3400004.yaml b/data/reward/3400004.yaml new file mode 100644 index 00000000..2c9e032c --- /dev/null +++ b/data/reward/3400004.yaml @@ -0,0 +1,21 @@ +# Yeti Doll (3400004) + +# Equip Enhancement Scroll - not added check +# Potential Scroll - not added check + +rewards: + - [ 0, 81, 122, 0.600000 ] + - [ 4000542, 1, 1, 0.400000 ] # Yeti Keychain + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2000001, 1, 1, 0.010000 ] # Orange Potion + - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow + - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow + - [ 1302004, 1, 1, 0.000100 ] # Cutlus + - [ 1050022, 1, 1, 0.000100 ] # Dark Crusader Chainmail + - [ 1082036, 1, 1, 0.000100 ] # Dark Briggon + - [ 1452006, 1, 1, 0.000100 ] # Red Viper + - [ 1060065, 1, 1, 0.000100 ] # Brown Legolier Pants + - [ 1060063, 1, 1, 0.000100 ] # Green Legolier Pants + - [ 1002625, 1, 1, 0.000100 ] # Blue Den Marine + - [ 1052110, 1, 1, 0.000100 ] # Blue Brace Look \ No newline at end of file diff --git a/data/reward/3400005.yaml b/data/reward/3400005.yaml new file mode 100644 index 00000000..6c0b59df --- /dev/null +++ b/data/reward/3400005.yaml @@ -0,0 +1,9 @@ +# Jr. Pepe Doll Claw Game (3400005) + +# Twin Angels - can't find + +rewards: + - [ 0, 80, 112, 0.600000 ] + - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow + - [ 1061071, 1, 1, 0.000100 ] # Dark Shadow Pants + - [ 4032508, 1, 1, 1.000000, 2273 ] # Secret Recipe | The Secret Recipe \ No newline at end of file diff --git a/data/reward/3400006.yaml b/data/reward/3400006.yaml new file mode 100644 index 00000000..fa918bfd --- /dev/null +++ b/data/reward/3400006.yaml @@ -0,0 +1,4 @@ +# Jr. Pepe Doll (3400006) + +rewards: + - [4000543, 1, 1, 0.4] diff --git a/data/reward/3400007.yaml b/data/reward/3400007.yaml new file mode 100644 index 00000000..e0b32768 --- /dev/null +++ b/data/reward/3400007.yaml @@ -0,0 +1,7 @@ +# Transformed Doll Claw Game (3400007) [1] + +rewards: + - [ 0, 86, 125, 0.600000 ] + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow + - [ 4032508, 1, 1, 1.000000, 2273 ] # Secret Recipe | The Secret Recipe \ No newline at end of file diff --git a/data/reward/3400008.yaml b/data/reward/3400008.yaml new file mode 100644 index 00000000..f7efb1c9 --- /dev/null +++ b/data/reward/3400008.yaml @@ -0,0 +1,20 @@ +# Transformed Doll Claw Game (3400008) [2] + +rewards: + - [ 0, 96, 125, 0.600000 ] + - [ 4000544, 1, 1, 0.400000] # Orange Mushroom Doll + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 2000001, 1, 1, 0.010000 ] # Orange Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow + - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow + - [ 1412004, 1, 1, 0.000100 ] # Niam + - [ 1050000, 1, 1, 0.000100 ] # White Crusader Chainmail + - [ 1060075, 1, 1, 0.000100 ] # Brown Jangoon Pants + - [ 1041068, 1, 1, 0.000100 ] # Dark Legolia + - [ 1040074, 1, 1, 0.000100 ] # Green Legolier + - [ 1040079, 1, 1, 0.000100 ] # Brown Piette + - [ 1332020, 1, 1, 0.000100 ] # Korean Fan + - [ 1072171, 1, 1, 0.000100 ] # Black Snowshoes + - [ 1072110, 1, 1, 0.000100 ] # Black Blue-Lines Shoes \ No newline at end of file diff --git a/data/reward/4220001.yaml b/data/reward/4220001.yaml new file mode 100644 index 00000000..79c4de34 --- /dev/null +++ b/data/reward/4220001.yaml @@ -0,0 +1,5 @@ +# Seruf (4220001) + +rewards: +- [2510260, 1, 1, 0.005] +- [2512260, 1, 1, 0.005] diff --git a/data/reward/4230116.yaml b/data/reward/4230116.yaml index e924fcce..ffe09b9b 100644 --- a/data/reward/4230116.yaml +++ b/data/reward/4230116.yaml @@ -22,7 +22,7 @@ rewards: - [ 2041005, 1, 1, 0.000100 ] # Scroll for Cape for Weapon DEF 10% - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow - - [ 4000117, 1, 1, 0.400000 ] # Space Food + - [ 4000117, 1, 1, 0.400000, 3204 ] # Space Food (Quest 3204) - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore - [ 4007000, 1, 1, 0.000300 ] # Magic Powder (Brown) - [ 4007005, 1, 1, 0.000300 ] # Magic Powder (Purple) diff --git a/data/reward/4300015.yaml b/data/reward/4300015.yaml new file mode 100644 index 00000000..be600524 --- /dev/null +++ b/data/reward/4300015.yaml @@ -0,0 +1,4 @@ +# Cheap Amplifier (4300015) + +rewards: + - [ 4032509, 1, 1, 0.999999, 2286 ] # Latest Model MP3 : Request for a New Song diff --git a/data/reward/4300016.yaml b/data/reward/4300016.yaml new file mode 100644 index 00000000..dfad28c8 --- /dev/null +++ b/data/reward/4300016.yaml @@ -0,0 +1,4 @@ +# Fancy Amplifier (4300016) + +rewards: +- [4000537, 1, 1, 0.4] diff --git a/data/reward/5160000.yaml b/data/reward/5160000.yaml new file mode 100644 index 00000000..44218dce --- /dev/null +++ b/data/reward/5160000.yaml @@ -0,0 +1,5 @@ +# Mob 5160000 (5160000) + +rewards: +- [4032912, 1, 1, 0.999999, 31001] +- [4000634, 1, 1, 0.2] diff --git a/data/reward/5160001.yaml b/data/reward/5160001.yaml new file mode 100644 index 00000000..dade4276 --- /dev/null +++ b/data/reward/5160001.yaml @@ -0,0 +1,4 @@ +# Mob 5160001 (5160001) + +rewards: +- [4000635, 1, 1, 0.2] diff --git a/data/reward/5160002.yaml b/data/reward/5160002.yaml new file mode 100644 index 00000000..a4bcf480 --- /dev/null +++ b/data/reward/5160002.yaml @@ -0,0 +1,4 @@ +# Mob 5160002 (5160002) + +rewards: +- [4000638, 1, 1, 0.2] diff --git a/data/reward/5160003.yaml b/data/reward/5160003.yaml new file mode 100644 index 00000000..5f07c0b1 --- /dev/null +++ b/data/reward/5160003.yaml @@ -0,0 +1,4 @@ +# Mob 5160003 (5160003) + +rewards: +- [4000639, 1, 1, 0.2] diff --git a/data/reward/5160004.yaml b/data/reward/5160004.yaml new file mode 100644 index 00000000..7b6df5d1 --- /dev/null +++ b/data/reward/5160004.yaml @@ -0,0 +1,4 @@ +# Mob 5160004 (5160004) + +rewards: +- [4000636, 1, 1, 0.2] diff --git a/data/reward/6090002.yaml b/data/reward/6090002.yaml new file mode 100644 index 00000000..729337c2 --- /dev/null +++ b/data/reward/6090002.yaml @@ -0,0 +1,4 @@ +# Bamboo Warrior (6090002) + +rewards: +- [2290159, 1, 1, 0.005] diff --git a/data/reward/6100300.yaml b/data/reward/6100300.yaml new file mode 100644 index 00000000..5a5867e2 --- /dev/null +++ b/data/reward/6100300.yaml @@ -0,0 +1,4 @@ +# Mob 6100300 (6100300) + +rewards: +- [2512258, 1, 1, 0.001] diff --git a/data/reward/6130201.yaml b/data/reward/6130201.yaml new file mode 100644 index 00000000..5dc6a852 --- /dev/null +++ b/data/reward/6130201.yaml @@ -0,0 +1,10 @@ +# Mob 6130201 + +rewards: + - [ 0, 120, 180, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020001, 1, 1, 0.002000 ] # Amethyst Ore diff --git a/data/reward/6140703.yaml b/data/reward/6140703.yaml new file mode 100644 index 00000000..ce44d0b9 --- /dev/null +++ b/data/reward/6140703.yaml @@ -0,0 +1,4 @@ +# Mob 6140703 (6140703) + +rewards: +- [2511044, 1, 1, 0.001] diff --git a/data/reward/6150000.yaml b/data/reward/6150000.yaml new file mode 100644 index 00000000..fe896de7 --- /dev/null +++ b/data/reward/6150000.yaml @@ -0,0 +1,16 @@ +# Guard Robot (6150000) + +rewards: + - [ 0, 85, 128, 0.600000 ] + - [ 2000001, 1, 1, 0.020000 ] # Orange Potion + - [ 2000002, 1, 1, 0.020000 ] # White Potion + - [ 2000003, 1, 1, 0.020000 ] # Blue Potion + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2022003, 1, 1, 0.005000 ] # All Cure Potion + - [ 4000608, 1, 1, 0.600000, 23951 ] # Guard Robot Baton (only drops with quest 23951) + - [ 4010000, 1, 1, 0.003000 ] # Bronze Ore + - [ 4010001, 1, 1, 0.003000 ] # Steel Ore + - [ 4010004, 1, 1, 0.002000 ] # Silver Ore + - [ 4010005, 1, 1, 0.001000 ] # Orihalcon Ore + - [ 4020005, 1, 1, 0.002000 ] # Sapphire Ore + - [ 4020007, 1, 1, 0.001000 ] # Diamond Ore diff --git a/data/reward/6160000.yaml b/data/reward/6160000.yaml new file mode 100644 index 00000000..8796380c --- /dev/null +++ b/data/reward/6160000.yaml @@ -0,0 +1,4 @@ +# Mob 6160000 (6160000) + +rewards: +- [4000637, 1, 1, 0.2] diff --git a/data/reward/6160001.yaml b/data/reward/6160001.yaml new file mode 100644 index 00000000..f0820980 --- /dev/null +++ b/data/reward/6160001.yaml @@ -0,0 +1,4 @@ +# Mob 6160001 (6160001) + +rewards: +- [4000640, 1, 1, 0.2] diff --git a/data/reward/6160002.yaml b/data/reward/6160002.yaml new file mode 100644 index 00000000..0788b51f --- /dev/null +++ b/data/reward/6160002.yaml @@ -0,0 +1,4 @@ +# Mob 6160002 (6160002) + +rewards: +- [4000641, 1, 1, 0.2] diff --git a/data/reward/6160003.yaml b/data/reward/6160003.yaml new file mode 100644 index 00000000..2a316c66 --- /dev/null +++ b/data/reward/6160003.yaml @@ -0,0 +1,11 @@ +# Mob 6160003 (6160003) + +rewards: +- [1022114, 1, 1, 0.0015] +- [1022115, 1, 1, 0.0015] +- [4032911, 1, 1, 0.999999, 31014] +- [2028033, 1, 1, 0.999999] +- [2028034, 1, 1, 0.999999] +- [2028035, 1, 1, 0.999999] +- [2028036, 1, 1, 0.999999] +- [2028037, 1, 1, 0.999999] diff --git a/data/reward/6230500.yaml b/data/reward/6230500.yaml index 6c6ac6aa..79f1e1a4 100644 --- a/data/reward/6230500.yaml +++ b/data/reward/6230500.yaml @@ -24,7 +24,7 @@ rewards: - [ 2041022, 1, 1, 0.000100 ] # Scroll for Cape for LUK 60% - [ 2043301, 1, 1, 0.000100 ] # Scroll for Dagger for ATT 60% - [ 4000021, 1, 1, 0.040000 ] # Leather - - [ 4000144, 1, 1, 0.400000 ] # Free Spirit + - [ 4000144, 1, 1, 0.400000, 3445 ] # Soul Teddy's Spirit (quest 3445 only) - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore - [ 4006000, 1, 1, 0.000700 ] # The Magic Rock - [ 4007002, 1, 1, 0.000300 ] # Magic Powder (Blue) diff --git a/data/reward/7120103.yaml b/data/reward/7120103.yaml index 1d140758..ac2c8968 100644 --- a/data/reward/7120103.yaml +++ b/data/reward/7120103.yaml @@ -1,61 +1,7 @@ -# Red Slime (7120103) +# Mob 7120103 - Red Slime (Year 2021) +# Drops items for quests 3720, 3721, and 3722 rewards: - - [ 0, 630, 945, 0.600000 ] - - [ 1002095, 1, 1, 0.000100 ] # Mithril Planet - - [ 1002246, 1, 1, 0.000100 ] # Dark Seraphis - - [ 1032014, 1, 1, 0.000100 ] # Pink-Flowered Earrings - - [ 1032016, 1, 1, 0.000100 ] # Metal Heart Earrings - - [ 1032020, 1, 1, 0.000100 ] # Gold Drop Earrings - - [ 1041095, 1, 1, 0.000100 ] # Bloody Mantis - - [ 1041102, 1, 1, 0.000100 ] # Pink Mystique - - [ 1050069, 1, 1, 0.000100 ] # Brown Requiem - - [ 1050070, 1, 1, 0.000100 ] # Dark Requiem - - [ 1051054, 1, 1, 0.000100 ] # Brown Requierre - - [ 1061094, 1, 1, 0.000100 ] # Bloody Mantis Pants - - [ 1061101, 1, 1, 0.000100 ] # Pink Mystique Pants - - [ 1072146, 1, 1, 0.000100 ] # Green Gore Boots - - [ 1072163, 1, 1, 0.000100 ] # Red Mystique Shoes - - [ 1072165, 1, 1, 0.000100 ] # Baige Elf Shoes - - [ 1082105, 1, 1, 0.000100 ] # Dark Husk - - [ 1082108, 1, 1, 0.000100 ] # Dark Eyes - - [ 1082110, 1, 1, 0.000100 ] # Blue Cordon - - [ 1102023, 1, 1, 0.000100 ] # White Gaia Cape - - [ 1312009, 1, 1, 0.000100 ] # Hawkhead - - [ 1332015, 1, 1, 0.000100 ] # Deadly Fin - - [ 1332018, 1, 1, 0.000100 ] # Kandine - - [ 1372015, 1, 1, 0.000100 ] # Angel Wings - - [ 1372016, 1, 1, 0.000100 ] # Phoenix Wand - - [ 1402011, 1, 1, 0.000100 ] # Sparta - - [ 1422010, 1, 1, 0.000100 ] # Gigantic Sledge - - [ 1472028, 1, 1, 0.000100 ] # Blue Scarab - - [ 1492009, 1, 1, 0.000100 ] # Abyss Shooter - - [ 2000002, 1, 1, 0.010000 ] # White Potion - - [ 2000004, 1, 1, 0.001000 ] # Elixir - - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir - - [ 2040002, 1, 1, 0.000100 ] # Scroll for Helmet for DEF 10% - - [ 2040427, 1, 1, 0.000100 ] # Scroll for Topwear for LUK 10% - - [ 2040618, 1, 1, 0.000100 ] # Scroll for Bottomwear for Jump 60% - - [ 2040705, 1, 1, 0.000100 ] # Scroll for Shoes for Jump 10% - - [ 2040824, 1, 1, 0.000100 ] # Scroll for Gloves for HP 60% - - [ 2041021, 1, 1, 0.000100 ] # Scroll for Cape for LUK 100% - - [ 2044301, 1, 1, 0.000100 ] # Scroll for Spear for ATT 60% - - [ 2049000, 1, 1, 0.000100 ] # Clean Slate Scroll 1% - - [ 2049100, 1, 1, 0.000001 ] # Chaos Scroll 60% - - [ 2384058, 1, 1, 0.020000 ] # Red Slime Card - - [ 4000545, 1, 1, 0.400000 ] # Red Squishy Liquid - - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore - - [ 4006001, 1, 1, 0.000700 ] # The Summoning Rock - - [ 4007004, 1, 1, 0.000300 ] # Magic Powder (Yellow) - - [ 4007006, 1, 1, 0.000300 ] # Magic Powder (Red) - - [ 4007007, 1, 1, 0.000300 ] # Magic Powder (Black) - - [ 4010004, 1, 1, 0.002000 ] # Silver Ore - - [ 4010005, 1, 1, 0.002000 ] # Orihalcon Ore - - [ 4020000, 1, 1, 0.002000 ] # Garnet Ore - - [ 4020007, 1, 1, 0.002000 ] # Diamond Ore - - [ 4130000, 1, 1, 0.000300 ] # Gloves Production Stimulator - - [ 4130002, 1, 1, 0.000300 ] # One-Handed Sword Forging Stimulator - - [ 4130003, 1, 1, 0.000300 ] # One-Handed Axe Forging Stimulator - - [ 4130017, 1, 1, 0.000300 ] # Gun Production Stimulator - - [ 4130020, 1, 1, 0.000300 ] # Bottomwear Production Stimulator - - [ 4130021, 1, 1, 0.000300 ] # Overall Production Stimulator + - [ 4032512, 1, 1, 0.1, 3720 ] # Scribbled Paper - Quest 3720 (10% drop when quest active) + - [ 4000545, 1, 1, 1.0, 3721 ] # Red Slime Sample - Quest 3721 (100% drop when quest active) + - [ 4032513, 1, 1, 1.0, 3722 ] # Sketchbook Page - Quest 3722 (100% drop when quest active) diff --git a/data/reward/7120104.yaml b/data/reward/7120104.yaml index 0039f2d3..8e227ed9 100644 --- a/data/reward/7120104.yaml +++ b/data/reward/7120104.yaml @@ -1,41 +1,7 @@ -# Silver Slime (7120104) +# Mob 7120104 - Silver Slime (Year 2021) +# Drops items for quests 3720, 3721, and 3722 rewards: - - [ 0, 636, 954, 0.600000 ] - - [ 1002095, 1, 1, 0.000100 ] # Mithril Planet - - [ 1002246, 1, 1, 0.000100 ] # Dark Seraphis - - [ 1032014, 1, 1, 0.000100 ] # Pink-Flowered Earrings - - [ 1032016, 1, 1, 0.000100 ] # Metal Heart Earrings - - [ 1041095, 1, 1, 0.000100 ] # Bloody Mantis - - [ 1041102, 1, 1, 0.000100 ] # Pink Mystique - - [ 1050069, 1, 1, 0.000100 ] # Brown Requiem - - [ 1050070, 1, 1, 0.000100 ] # Dark Requiem - - [ 1051054, 1, 1, 0.000100 ] # Brown Requierre - - [ 1061094, 1, 1, 0.000100 ] # Bloody Mantis Pants - - [ 1061101, 1, 1, 0.000100 ] # Pink Mystique Pants - - [ 1072146, 1, 1, 0.000100 ] # Green Gore Boots - - [ 1072165, 1, 1, 0.000100 ] # Baige Elf Shoes - - [ 1082105, 1, 1, 0.000100 ] # Dark Husk - - [ 1082108, 1, 1, 0.000100 ] # Dark Eyes - - [ 1312009, 1, 1, 0.000100 ] # Hawkhead - - [ 1332015, 1, 1, 0.000100 ] # Deadly Fin - - [ 1372015, 1, 1, 0.000100 ] # Angel Wings - - [ 1372016, 1, 1, 0.000100 ] # Phoenix Wand - - [ 1402011, 1, 1, 0.000100 ] # Sparta - - [ 1492009, 1, 1, 0.000100 ] # Abyss Shooter - - [ 2000004, 1, 1, 0.001000 ] # Elixir - - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir - - [ 2040618, 1, 1, 0.000100 ] # Scroll for Bottomwear for Jump 60% - - [ 2041021, 1, 1, 0.000100 ] # Scroll for Cape for LUK 100% - - [ 2044301, 1, 1, 0.000100 ] # Scroll for Spear for ATT 60% - - [ 2049000, 1, 1, 0.000100 ] # Clean Slate Scroll 1% - - [ 2050000, 1, 1, 0.010000 ] # Antidote - - [ 2384059, 1, 1, 0.020000 ] # Silver Slime Card - - [ 4000546, 1, 1, 0.400000 ] # Silver Squishy Liquid - - [ 4007001, 1, 1, 0.000300 ] # Magic Powder (White) - - [ 4007007, 1, 1, 0.000300 ] # Magic Powder (Black) - - [ 4010004, 1, 1, 0.002000 ] # Silver Ore - - [ 4020007, 1, 1, 0.002000 ] # Diamond Ore - - [ 4130002, 1, 1, 0.000300 ] # One-Handed Sword Forging Stimulator - - [ 4130003, 1, 1, 0.000300 ] # One-Handed Axe Forging Stimulator - - [ 4130021, 1, 1, 0.000300 ] # Overall Production Stimulator + - [ 4032512, 1, 1, 0.2, 3720 ] # Scribbled Paper - Quest 3720 (20% drop when quest active) + - [ 4000546, 1, 1, 1.0, 3721 ] # Silver Slime Sample - Quest 3721 (100% drop when quest active) + - [ 4032513, 1, 1, 1.0, 3722 ] # Sketchbook Page - Quest 3722 (100% drop when quest active) diff --git a/data/reward/7120105.yaml b/data/reward/7120105.yaml index fe0b3595..302dfe1e 100644 --- a/data/reward/7120105.yaml +++ b/data/reward/7120105.yaml @@ -1,28 +1,7 @@ -# Gold Slime (7120105) +# Mob 7120105 - Gold Slime (Year 2021) +# Drops items for quests 3720, 3721, and 3722 rewards: - - [ 0, 642, 963, 0.600000 ] - - [ 1002287, 1, 1, 0.000100 ] # Beige Patriot - - [ 1051062, 1, 1, 0.000100 ] # Blue Lineros - - [ 1072211, 1, 1, 0.000100 ] # Blue Rivers Boots - - [ 1082119, 1, 1, 0.000100 ] # Purple Larceny - - [ 1332019, 1, 1, 0.000100 ] # Golden River - - [ 1452009, 1, 1, 0.000100 ] # Red Hinkel - - [ 2000004, 1, 1, 0.001000 ] # Elixir - - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir - - [ 2040512, 1, 1, 0.000100 ] # Scroll for Overall Armor for INT 100% - - [ 2041008, 1, 1, 0.000100 ] # Scroll for Cape for HP 10% - - [ 2044010, 1, 1, 0.000100 ] # Scroll for Two-Handed Sword for Accuracy 100% - - [ 2044101, 1, 1, 0.000100 ] # Scroll for Two-handed Axe for ATT 60% - - [ 2049000, 1, 1, 0.000100 ] # Clean Slate Scroll 1% - - [ 2384060, 1, 1, 0.020000 ] # Gold Slime Card - - [ 4000547, 1, 1, 0.400000 ] # Gold Squishy Liquid - - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore - - [ 4006001, 1, 1, 0.000700 ] # The Summoning Rock - - [ 4007000, 1, 1, 0.000300 ] # Magic Powder (Brown) - - [ 4007004, 1, 1, 0.000300 ] # Magic Powder (Yellow) - - [ 4010006, 1, 1, 0.002000 ] # Gold Ore - - [ 4020006, 1, 1, 0.002000 ] # Topaz Ore - - [ 4130001, 1, 1, 0.000300 ] # Shoes Production Stimulator - - [ 4130005, 1, 1, 0.000300 ] # Two-Handed Sword Forging Stimulator - - [ 4130017, 1, 1, 0.000300 ] # Gun Production Stimulator + - [ 4032512, 1, 1, 0.3, 3720 ] # Scribbled Paper - Quest 3720 (30% drop when quest active) + - [ 4000547, 1, 1, 1.0, 3721 ] # Gold Slime Sample - Quest 3721 (100% drop when quest active) + - [ 4032513, 1, 1, 1.0, 3722 ] # Sketchbook Page - Quest 3722 (100% drop when quest active) diff --git a/data/reward/7120106.yaml b/data/reward/7120106.yaml index 13b4ffe0..1c7d98b5 100644 --- a/data/reward/7120106.yaml +++ b/data/reward/7120106.yaml @@ -26,7 +26,8 @@ rewards: - [ 2040516, 1, 1, 0.000100 ] # Scroll for Overall Armor for LUK 60% - [ 2049000, 1, 1, 0.000100 ] # Clean Slate Scroll 1% - [ 2384062, 1, 1, 0.020000 ] # Overlord B Card - - [ 4000548, 1, 1, 0.400000 ] # Overlord A Radar Device + - [ 4000548, 1, 1, 1.0, 3728 ] # Overlord A Radar Device - Quest 3728 (100% drop when quest active) + - [ 4032514, 1, 1, 0.4, 3727 ] # Shelter Key - Quest 3727 (40% drop when quest active) - [ 4006001, 1, 1, 0.000700 ] # The Summoning Rock - [ 4007002, 1, 1, 0.000300 ] # Magic Powder (Blue) - [ 4007004, 1, 1, 0.000300 ] # Magic Powder (Yellow) diff --git a/data/reward/7120107.yaml b/data/reward/7120107.yaml index 6ac942c8..c77e1e65 100644 --- a/data/reward/7120107.yaml +++ b/data/reward/7120107.yaml @@ -36,7 +36,8 @@ rewards: - [ 2070005, 1, 1, 0.000400 ] # Steely Throwing-Knives - [ 2330004, 1, 1, 0.000400 ] # Shiny Bullet - [ 2384062, 1, 1, 0.020000 ] # Overlord B Card - - [ 4000549, 1, 1, 0.400000 ] # Overlord B Radar Device + - [ 4000549, 1, 1, 1.0, 3728 ] # Overlord B Radar Device - Quest 3728 (100% drop when quest active) + - [ 4032514, 1, 1, 0.4, 3727 ] # Shelter Key - Quest 3727 (40% drop when quest active) - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore - [ 4006000, 1, 1, 0.000700 ] # The Magic Rock - [ 4007004, 1, 1, 0.000300 ] # Magic Powder (Yellow) diff --git a/data/reward/7130102.yaml b/data/reward/7130102.yaml new file mode 100644 index 00000000..769de2e1 --- /dev/null +++ b/data/reward/7130102.yaml @@ -0,0 +1,61 @@ +# Yeti and Pepe (7130102) + +rewards: +- [4000050, 1, 1, 0.6] +- [2041020, 1, 1, 0.0003] +- [2000006, 1, 1, 0.1] +- [2000004, 1, 1, 0.02] +- [4020008, 1, 1, 0.009] +- [2043801, 1, 1, 0.0003] +- [2044702, 1, 1, 0.0003] +- [2070010, 1, 1, 0.001] +- [4004002, 1, 1, 0.01] +- [2041005, 1, 1, 0.0003] +- [1002185, 1, 1, 0.0015] +- [1041081, 1, 1, 0.0008] +- [1061080, 1, 1, 0.0008] +- [1050047, 1, 1, 0.0007] +- [1002028, 1, 1, 0.0015] +- [1072124, 1, 1, 0.0008] +- [1072128, 1, 1, 0.0008] +- [1082081, 1, 1, 0.001] +- [1082010, 1, 1, 0.001] +- [1432006, 1, 1, 0.0005] +- [1472022, 1, 1, 0.0005] +- [1082091, 1, 1, 0.001] +- [1442010, 1, 1, 0.0007] +- [1102023, 1, 1, 0.001] +- [1072137, 1, 1, 0.0008] +- [1050068, 1, 1, 0.0007] +- [1002030, 1, 1, 0.0015] +- [1002282, 1, 1, 0.0015] +- [1050063, 1, 1, 0.0007] +- [1492008, 1, 1, 0.0005] +- [2044901, 1, 1, 0.0003] +- [2040625, 1, 1, 0.0003] +- [4130010, 1, 1, 0.006] +- [4130017, 1, 1, 0.006] +- [4000049, 1, 1, 0.6] +- [4130003, 1, 1, 0.006] +- [4003005, 1, 1, 0.2] +- [2041023, 1, 1, 0.0003] +- [4000021, 1, 1, 0.05] +- [4020005, 1, 1, 0.009] +- [1050069, 1, 1, 0.0007] +- [1051054, 1, 1, 0.0007] +- [2044101, 1, 1, 0.0003] +- [1402012, 1, 1, 0.0007] +- [1422009, 1, 1, 0.0007] +- [2070005, 1, 1, 0.0008] +- [1050074, 1, 1, 0.0007] +- [1051058, 1, 1, 0.0007] +- [1032011, 1, 1, 0.001] +- [1092006, 1, 1, 0.0007] +- [1332015, 1, 1, 0.0005] +- [1002084, 1, 1, 0.0015] +- [4004000, 1, 1, 0.01] +- [1452011, 1, 1, 0.0005] +- [2040025, 1, 1, 0.0003] +- [2043017, 1, 1, 0.0003] +- [2043210, 1, 1, 0.0003] +- [2044214, 1, 1, 0.0003] diff --git a/data/reward/7140002.yaml b/data/reward/7140002.yaml new file mode 100644 index 00000000..50016a98 --- /dev/null +++ b/data/reward/7140002.yaml @@ -0,0 +1,4 @@ +# Mob 7140002 (7140002) + +rewards: +- [2512198, 1, 1, 0.001] diff --git a/data/reward/7150000.yaml b/data/reward/7150000.yaml new file mode 100644 index 00000000..d66b940d --- /dev/null +++ b/data/reward/7150000.yaml @@ -0,0 +1,19 @@ +# Racoco (7150000) + +rewards: + - [ 0, 280, 490, 0.600000 ] + - [ 4000609, 1, 1, 0.400000 ] # Racoco's Shovel + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2022000, 1, 1, 0.100000 ] # Pure Water + - [ 2022003, 1, 1, 0.010000 ] # Unagi + - [ 2001001, 1, 1, 0.010000 ] # Ice Cream Pop + - [ 2070001, 1, 1, 0.000400 ] # Wolbi Throwing-Stars + - [ 1002095, 1, 1, 0.000100 ] # Mithril Planet + - [ 1432007, 1, 1, 0.000100 ] # Redemption + - [ 1082086, 1, 1, 0.000100 ] # Steel Manute + - [ 1051042, 1, 1, 0.000100 ] # Blue Choro + - [ 1060093, 1, 1, 0.000100 ] # Brown Studded Pants + - [ 1002281, 1, 1, 0.000100 ] # Brown Nightfox + - [ 1472029, 1, 1, 0.000100 ] # Black Scarab + - [ 1652001, 1, 1, 0.000100 ] # Bronze Transistor + - [ 1492008, 1, 1, 0.000100 ] # Burning Hell \ No newline at end of file diff --git a/data/reward/7150001.yaml b/data/reward/7150001.yaml new file mode 100644 index 00000000..85e72c65 --- /dev/null +++ b/data/reward/7150001.yaml @@ -0,0 +1,23 @@ +# Big Spider (7150001) + +rewards: + - [ 0, 350, 530, 0.600000 ] + - [ 4000610, 1, 1, 0.400000 ] # Big Spider Tentacle + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2022003, 1, 1, 0.010000 ] # Unagi + - [ 2001001, 1, 1, 0.010000 ] # Ice Cream Pop + - [ 2000004, 1, 1, 0.001000 ] # Elixir + - [ 2043401, 1, 1, 0.010000 ] # Scroll for Katara for ATT 60% + - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow + - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow + - [ 1061096, 1, 1, 0.000100 ] # Aqua Platina Pants + - [ 1050082, 1, 1, 0.000100 ] # Blue Battle Lord + - [ 1382010, 1, 1, 0.000100 ] # Dark Ritual + - [ 1072164, 1, 1, 0.000100 ] # Blue Elf Shoes + - [ 1462013, 1, 1, 0.000100 ] # Dark Raven + - [ 1452009, 1, 1, 0.000100 ] # Red Hinkel + - [ 1462010, 1, 1, 0.000100 ] # Marine Raven + - [ 1061099, 1, 1, 0.000100 ] # Purple Mystique Pants + - [ 1072174, 1, 1, 0.000100 ] # Dark Pirate Boots + - [ 1082095, 1, 1, 0.000100 ] # Bronze Rover + - [ 1492010, 1, 1, 0.000100 ] # Infinity's Wrath \ No newline at end of file diff --git a/data/reward/7150002.yaml b/data/reward/7150002.yaml new file mode 100644 index 00000000..39301463 --- /dev/null +++ b/data/reward/7150002.yaml @@ -0,0 +1,23 @@ +# Cart Bear (7150002) + +# Equip Enhancement Scroll - not added check +# Potential Scroll - not added check + +rewards: + - [ 0, 385, 576, 0.600000 ] + - [ 4000611, 1, 1, 0.400000 ] # Cart Bear's Cart + - [ 4032770, 1, 1, 0.200000 ] # Cart Bear Meat + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2022000, 1, 1, 0.100000 ] # Pure Water + - [ 2001002, 1, 1, 0.100000 ] # Red Bean Sundae + - [ 2044012, 1, 1, 0.010000 ] # Scroll for Two-Handed Sword for Accuracy 60% + - [ 2330002, 1, 1, 0.000400 ] # Mighty Bullet + - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow + - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow + - [ 1302012, 1, 1, 0.000100 ] # Red Katana + - [ 1082117, 1, 1, 0.000100 ] # Dark Emperor + - [ 1452010, 1, 1, 0.000100 ] # Blue Hinkel + - [ 1452009, 1, 1, 0.000100 ] # Red Hinkel + - [ 1082095, 1, 1, 0.000100 ] # Bronze Rover + - [ 1060095, 1, 1, 0.000100 ] # Dark Studded Pants \ No newline at end of file diff --git a/data/reward/7150003.yaml b/data/reward/7150003.yaml new file mode 100644 index 00000000..34f05b5d --- /dev/null +++ b/data/reward/7150003.yaml @@ -0,0 +1,19 @@ +# Racaroni (7150003) + +rewards: + - [ 0, 395, 591, 0.600000 ] + - [ 400612, 1, 1, 0.400000 ] # Racaroni Tail + - [ 4032771 , 1, 1, 0.200000 ] # Racaroni Heart + - [ 2022000, 1, 1, 0.100000 ] # Pure Water + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2000004, 1, 1, 0.001000 ] # Elixir + - [ 2022003, 1, 1, 0.010000 ] # Unagi + - [ 1422012, 1, 1, 0.000100 ] # The Morningstar + - [ 1050083, 1, 1, 0.000100 ] # Dark Battle Lord + - [ 1372016, 1, 1, 0.000100 ] # Phoenix Wand + - [ 1050072, 1, 1, 0.000100 ] # Green Enigmatic + - [ 1082111, 1, 1, 0.000100 ] # Green Cordon + - [ 1452011, 1, 1, 0.000100 ] # Golden Hinkel + - [ 1061102, 1, 1, 0.000100 ] # Red Mystique Pants + - [ 1472028, 1, 1, 0.000100 ] # Blue Scarab + - [ 1040105, 1, 1, 0.000100 ] # Brown Studded Top \ No newline at end of file diff --git a/data/reward/7150004.yaml b/data/reward/7150004.yaml new file mode 100644 index 00000000..32e5eb42 --- /dev/null +++ b/data/reward/7150004.yaml @@ -0,0 +1,27 @@ +# Guard Robot L (7150004) + +# Equip Enhancement Scroll - not added check +# Potential Scroll - not added check + +rewards: + - [ 0, 413, 612, 0.600000 ] + - [ 4000613, 1, 1, 0.400000 ] # Guard Robot L's Electric Racket + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2001001, 1, 1, 0.010000 ] # Ice Cream Pop + - [ 2022000, 1, 1, 0.100000 ] # Pure Water + - [ 2043301, 1, 1, 0.000100 ] # Scroll for Dagger for ATT 60% + - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow + - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow + - [ 1322020, 1, 1, 0.000100 ] # Bent Judgement + - [ 1432010, 1, 1, 0.000100 ] # Omega Spear + - [ 1402004, 1, 1, 0.000100 ] # Blue Screamer + - [ 1072159, 1, 1, 0.000100 ] # Brown Lapiz Sandals + - [ 1051065, 1, 1, 0.000200 ] # Dark Lineros + - [ 1462010, 1, 1, 0.000100 ] # Marine Raven + - [ 1002278, 1, 1, 0.000100 ] # Dark Falcon + - [ 1342006, 1, 1, 0.002500 ] # Blazing Dragon Katara + - [ 1082118, 1, 1, 0.000100 ] # Green Larceny + - [ 1061104, 1, 1, 0.000100 ] # Green Pirate Skirt + - [ 1492010, 1, 1, 0.000100 ] # Infinity's Wrath + - [ 1642000, 1, 1, 0.000100 ] # Bronze Body Frame \ No newline at end of file diff --git a/data/reward/8105000.yaml b/data/reward/8105000.yaml new file mode 100644 index 00000000..ce818afa --- /dev/null +++ b/data/reward/8105000.yaml @@ -0,0 +1,20 @@ +# Raco (8105000) + +rewards: + - [ 0, 467, 661, 0.600000 ] + - [ 4000614, 1, 1, 0.400000 ] # Raco's Safety Hat + - [ 4032772, 1, 1, 0.200000 ] # Raco Heart + - [ 2001001, 1, 1, 0.010000 ] # Ice Cream Pop + - [ 2000004, 1, 1, 0.001000 ] # Elixir + - [ 2022000, 1, 1, 0.100000 ] # Pure Water + - [ 2022003, 1, 1, 0.010000 ] # Unagi + - [ 2048004, 1, 1, 0.000100 ] # Scroll for Pet Equip. for Jump 60% + - [ 2040901, 1, 1, 0.000100 ] # Scroll for Shield for DEF 60% + - [ 1060102, 1, 1, 0.000100 ] # Dark Commodore Pants + - [ 1040111, 1, 1, 0.000100 ] # Green Commodore + - [ 1092026, 1, 1, 0.000100 ] # Bronze Kalkan + - [ 1092028, 1, 1, 0.000100 ] # Gold Kalkan + - [ 1002276, 1, 1, 0.000100 ] # Red Falcon + - [ 1452011, 1, 1, 0.000100 ] # Golden Hinkel + - [ 1612002, 1, 1, 0.000100 ] # Iron Engine + - [ 1492010, 1, 1, 0.000100 ] # Infinity's Wrath \ No newline at end of file diff --git a/data/reward/8105001.yaml b/data/reward/8105001.yaml new file mode 100644 index 00000000..6a0826ce --- /dev/null +++ b/data/reward/8105001.yaml @@ -0,0 +1,33 @@ +# Security System (8105001) + +# Very Special Sundae - didn't add check +# Equip Enhancement Scroll - not added check +# Potential Scroll - not added check +# Strength Boost Potion Recipe - not added check + +rewards: + - [ 0, 488, 694, 0.600000 ] + - [ 4000615, 1, 1, 0.400000 ] # Outer Handle + - [ 4000618, 1, 1, 0.200000 ] # Broken Parts + - [ 4032782, 1, 1, 0.050000 ] # Security System Key Card + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2022000, 1, 1, 0.100000 ] # Pure Water + - [ 2022003, 1, 1, 0.010000 ] # Unagi + - [ 2020014, 1, 1, 0.010000 ] # Sunrise Dew + - [ 2000004, 1, 1, 0.001000 ] # Elixir + - [ 2001001, 1, 1, 0.010000 ] # Ice Cream Pop + - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow + - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow + - [ 2040801, 1, 1, 0.001000 ] # Scroll for Gloves for DEX 60% + - [ 2044901, 1, 1, 0.000100 ] # Scroll for Gun for Attack 60% + - [ 1432011, 1, 1, 0.000100 ] # Fairfrozen + - [ 1072179, 1, 1, 0.000100 ] # Dark Enigma Shoes + - [ 1372009, 1, 1, 0.000200 ] # Magicodar + - [ 1462010, 1, 1, 0.000100 ] # Marine Raven + - [ 1040116, 1, 1, 0.000100 ] # Brown Osfa Suit + - [ 1342007, 1, 1, 0.002500 ] # Bloodsoaked Katara + - [ 1082120, 1, 1, 0.000100 ] # Blood Larceny + - [ 1040109, 1, 1, 0.000100 ] # Red Pirate Top + - [ 1040118, 1, 1, 0.000100 ] # Red Osfa Suit + - [ 1072193, 1, 1, 0.000100 ] # Brown Osfa Boots + - [ 1492011, 1, 1, 0.000100 ] # The Peacemaker \ No newline at end of file diff --git a/data/reward/8105002.yaml b/data/reward/8105002.yaml new file mode 100644 index 00000000..77f63df3 --- /dev/null +++ b/data/reward/8105002.yaml @@ -0,0 +1,19 @@ +# Enhanced Security System (8105002) + +# Very Special Sundae - didn't add check +# Mana Boost Potion - didn't add check + +rewards: + - [ 0, 499, 714, 0.600000 ] + - [ 4000616, 1, 1, 0.400000 ] # Vent Pipe + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 1312015, 1, 1, 0.000100 ] # Vifennis + - [ 1402015, 1, 1, 0.000100 ] # Heaven's Gate + - [ 1452017, 1, 1, 0.000200 ] # Metus + - [ 1051069, 1, 1, 0.000100 ] # Dark Pria + - [ 1060098, 1, 1, 0.000100 ] # Red Pirate Pants + - [ 1472031, 1, 1, 0.000100 ] # Black Mamba + - [ 1472033, 1, 1, 0.000200 ] # Casters + - [ 1002323, 1, 1, 0.000100 ] # Green Osfa Hat + - [ 1072193, 1, 1, 0.000100 ] # Brown Osfa Boots + - [ 1482010, 1, 1, 0.000100 ] # Steelno \ No newline at end of file diff --git a/data/reward/8105003.yaml b/data/reward/8105003.yaml new file mode 100644 index 00000000..fa9caf3f --- /dev/null +++ b/data/reward/8105003.yaml @@ -0,0 +1,27 @@ +# AF Android (8105003) + +# Mystery Mastery Book - didn't add check +# Advanced Luck Pill II Recipe - didn't add check + +rewards: + - [ 0, 588, 833, 0.600000 ] + - [ 4000617, 1, 1, 0.400000 ] # Android Bulb + - [ 4020008, 1, 1, 0.002000 ] # Black Crystal Ore + - [ 4020007, 1, 1, 0.002000 ] # Diamond Ore + - [ 2001001, 1, 1, 0.010000 ] # Ice Cream Pop + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2000004, 1, 1, 0.001000 ] # Elixir + - [ 2022003, 1, 1, 0.010000 ] # Unagi + - [ 2020012, 1, 1, 0.000750 ] # Melting Cheese + - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow + - [ 2044412, 1, 1, 0.010000 ] # Scroll for Pole-Arm for Accuracy 60%s + - [ 2044212, 1, 1, 0.000100 ] # Scroll for Two-Handed BW for Accuracy 60% + - [ 1442020, 1, 1, 0.000100 ] # Hellslayer + - [ 1322029, 1, 1, 0.000100 ] # Ruin Hammer + - [ 1041124, 1, 1, 0.000100 ] # Dark Lucida + - [ 1051101, 1, 1, 0.000100 ] # Green Bazura + - [ 1072207, 1, 1, 0.000100 ] # Green Neli Shoes + - [ 1382035, 1, 1, 0.000100 ] # Blue Marine + - [ 1462015, 1, 1, 0.000100 ] # White Neschere + - [ 1072215, 1, 1, 0.000100 ] # Red Katina Boots + - [ 1082143, 1, 1, 0.000100 ] # Purple Mystra \ No newline at end of file diff --git a/data/reward/8105004.yaml b/data/reward/8105004.yaml new file mode 100644 index 00000000..5f120c59 --- /dev/null +++ b/data/reward/8105004.yaml @@ -0,0 +1,29 @@ +# Broken DF Android (8105004) + +# Very Special Sundae - didn't add check +# Scroll for Hand Cannon for ATT 60% - didn't add check +# Mystery Mastery Book - didn't add check +# Advanced Defense Pill VII Recipe - didn't add check + +rewards: + - [ 0, 578, 822, 0.600000 ] + - [ 4000617, 1, 1, 0.400000 ] # Android Bulb + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2022000, 1, 1, 0.100000 ] # Pure Water + - [ 2020014, 1, 1, 0.010000 ] # Sunrise Dew + - [ 2000004, 1, 1, 0.001000 ] # Elixir + - [ 2044701, 1, 1, 0.000100 ] # Scroll for Claw for ATT 60% + - [ 2044902, 1, 1, 0.001000 ] # Scroll for Gun for ATT 10% + - [ 2044401, 1, 1, 0.010000 ] # Scroll for Pole Arm for ATT 60% + - [ 2043801, 1, 1, 0.000100 ] # Scroll for Staff for Magic Attack 60% + - [ 1412021, 1, 1, 0.000100 ] # Tavar + - [ 1402016, 1, 1, 0.000200 ] # Devil's Sunrise + - [ 1082140, 1, 1, 0.000200 ] # Blue Korben + - [ 1051104, 1, 1, 0.000200 ] # Dark Bazura + - [ 1072224, 1, 1, 0.000100 ] # Blue Varr Shoes + - [ 1072205, 1, 1, 0.000100 ] # Dark Ades Shoes + - [ 1462015, 1, 1, 0.000100 ] # White Neschere + - [ 1332052, 1, 1, 0.000100 ] # Blood Dagger + - [ 1332027, 1, 1, 0.000100 ] # Varkit + - [ 1482012, 1, 1, 0.000200 ] # King Cent + - [ 1082210, 1, 1, 0.000100 ] # Red Martier \ No newline at end of file diff --git a/data/reward/8105005.yaml b/data/reward/8105005.yaml new file mode 100644 index 00000000..7fc3d376 --- /dev/null +++ b/data/reward/8105005.yaml @@ -0,0 +1,31 @@ +# Ore Muncher (8105005) + +# Very Special Sundae - didn't add check +# Mystery Mastery Book - didn't add check +# Advanced Dexterity Potion I Recipe - didn't add check +# Dragon Shiner Cross Recipe - didn't add check +# Dragonfire Revolver Recipe - didn't add check +# Purple Bear Pendant Recipe - didn't add check +# Tenacious Royal Pauldron Recipe - didn't add check +# Bilge Breaker - didn't add check + +rewards: + - [ 0, 620, 800, 0.600000 ] + - [ 4000619, 1, 1, 0.400000 ] # Rue Ore + - [ 2022000, 1, 1, 0.100000 ] # Pure Water + - [ 2020014, 1, 1, 0.010000 ] # Sunrise Dew + - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow + - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow + - [ 2040621, 1, 1, 0.010000 ] # Scroll for Bottomwear for HP 60% + - [ 2043212, 1, 1, 0.000100 ] # Scroll for One-Handed BW for Accuracy 60% + - [ 2043701, 1, 1, 0.000100 ] # Scroll for Wand for Magic Attack 60% + - [ 1402011, 1, 1, 0.000200 ] # Sparta + - [ 1060102, 1, 1, 0.000100 ] # Dark Commodore Pants + - [ 1382035, 1, 1, 0.000100 ] # Blue Marine + - [ 1082163, 1, 1, 0.000200 ] # Red Hunter Gloves + - [ 1002326, 1, 1, 0.000100 ] # Red Osfa Hat + - [ 1002325, 1, 1, 0.000100 ] # Purple Osfa Hat + - [ 1332051, 1, 1, 0.000100 ] # Gold Double Knife + - [ 1072272, 1, 1, 0.000200 ] # Black Garina Shoes + - [ 1332052, 1, 1, 0.000100 ] # Blood Dagger + - [ 1482012, 1, 1, 0.000100 ] # King Cent \ No newline at end of file diff --git a/data/reward/8140100.yaml b/data/reward/8140100.yaml new file mode 100644 index 00000000..5634dd34 --- /dev/null +++ b/data/reward/8140100.yaml @@ -0,0 +1,60 @@ +# Dark Yeti and Pepe (8140100) + +rewards: +- [4000057, 1, 1, 0.6] +- [4004004, 1, 1, 0.01] +- [2000006, 1, 1, 0.1] +- [2000004, 1, 1, 0.02] +- [4020007, 1, 1, 0.009] +- [2070004, 1, 1, 0.001] +- [4004003, 1, 1, 0.01] +- [4006000, 1, 1, 0.01] +- [1002217, 1, 1, 0.0015] +- [1032013, 1, 1, 0.001] +- [1072131, 1, 1, 0.0008] +- [1050055, 1, 1, 0.0007] +- [1422009, 1, 1, 0.0007] +- [1072152, 1, 1, 0.0008] +- [1002267, 1, 1, 0.0015] +- [1041092, 1, 1, 0.0008] +- [1061091, 1, 1, 0.0008] +- [1092009, 1, 1, 0.0007] +- [1102021, 1, 1, 0.001] +- [1032020, 1, 1, 0.001] +- [1462009, 1, 1, 0.0005] +- [1302012, 1, 1, 0.0007] +- [1082095, 1, 1, 0.001] +- [1082099, 1, 1, 0.001] +- [1002283, 1, 1, 0.0015] +- [2040328, 1, 1, 0.0003] +- [2040512, 1, 1, 0.0003] +- [4130002, 1, 1, 0.006] +- [4130003, 1, 1, 0.006] +- [4130009, 1, 1, 0.006] +- [4000056, 1, 1, 0.6] +- [4003004, 1, 1, 0.2] +- [4004004, 1, 1, 0.01] +- [4020002, 1, 1, 0.009] +- [1072127, 1, 1, 0.0008] +- [1412007, 1, 1, 0.0007] +- [1332019, 1, 1, 0.0005] +- [1472022, 1, 1, 0.0005] +- [1302011, 1, 1, 0.0007] +- [2044001, 1, 1, 0.0003] +- [2043801, 1, 1, 0.0003] +- [1051030, 1, 1, 0.0007] +- [1051031, 1, 1, 0.0007] +- [1051034, 1, 1, 0.0007] +- [1412003, 1, 1, 0.0007] +- [1302018, 1, 1, 0.0007] +- [4004001, 1, 1, 0.01] +- [1040100, 1, 1, 0.0008] +- [1060089, 1, 1, 0.0008] +- [1492009, 1, 1, 0.0005] +- [2044802, 1, 1, 0.0003] +- [2040318, 1, 1, 0.0003] +- [2040619, 1, 1, 0.0003] +- [2040927, 1, 1, 0.0003] +- [2044012, 1, 1, 0.0003] +- [4130004, 1, 1, 0.006] +- [2512156, 1, 1, 0.001] diff --git a/data/reward/8140104.yaml b/data/reward/8140104.yaml new file mode 100644 index 00000000..c618b66d --- /dev/null +++ b/data/reward/8140104.yaml @@ -0,0 +1,4 @@ +# Mob 8140104 (8140104) + +rewards: +- [1612004, 1, 1, 0.001] diff --git a/data/reward/8140200.yaml b/data/reward/8140200.yaml index f5d5ca95..90f4d6ad 100644 --- a/data/reward/8140200.yaml +++ b/data/reward/8140200.yaml @@ -31,6 +31,7 @@ rewards: - [ 2040802, 1, 1, 0.000100 ] # Scroll for Gloves for DEX 10% - [ 2040925, 1, 1, 0.000100 ] # Scroll for Shield for LUK 10% - [ 4000145, 1, 1, 0.400000 ] # Sealed-up Grandpa Clock + - [ 4000460, 1, 1, 0.500000, 3250 ] # Springy Worm (quest 3250 only) - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore - [ 4006001, 1, 1, 0.000700 ] # The Summoning Rock - [ 4007001, 1, 1, 0.000300 ] # Magic Powder (White) diff --git a/data/reward/8140300.yaml b/data/reward/8140300.yaml index 3a411acb..6c0c722d 100644 --- a/data/reward/8140300.yaml +++ b/data/reward/8140300.yaml @@ -34,6 +34,7 @@ rewards: - [ 2044602, 1, 1, 0.000100 ] # Scroll for Crossbow for ATT 10% - [ 2070005, 1, 1, 0.000400 ] # Steely Throwing-Knives - [ 4000146, 1, 1, 0.400000 ] # Evil Spirit + - [ 4000460, 1, 1, 0.500000, 3250 ] # Springy Worm (quest 3250 only) - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore - [ 4006000, 1, 1, 0.000700 ] # The Magic Rock - [ 4007001, 1, 1, 0.000300 ] # Magic Powder (White) diff --git a/data/reward/8140503.yaml b/data/reward/8140503.yaml new file mode 100644 index 00000000..1e7b6b40 --- /dev/null +++ b/data/reward/8140503.yaml @@ -0,0 +1,4 @@ +# Mob 8140503 (8140503) + +rewards: +- [2290052, 1, 1, 0.0005] diff --git a/data/reward/8180018.yaml b/data/reward/8180018.yaml new file mode 100644 index 00000000..598ba14d --- /dev/null +++ b/data/reward/8180018.yaml @@ -0,0 +1,4 @@ +# Mob 8180018 (8180018) + +rewards: +- [2290065, 1, 1, 0.1] diff --git a/data/reward/8180122.yaml b/data/reward/8180122.yaml new file mode 100644 index 00000000..a25b7cc1 --- /dev/null +++ b/data/reward/8180122.yaml @@ -0,0 +1,4 @@ +# Mob 8180122 (8180122) + +rewards: +- [2290065, 1, 1, 0.2] diff --git a/data/reward/8190009.yaml b/data/reward/8190009.yaml new file mode 100644 index 00000000..72451911 --- /dev/null +++ b/data/reward/8190009.yaml @@ -0,0 +1,4 @@ +# Mob 8190009 (8190009) + +rewards: +- [2290283, 1, 1, 0.0005] diff --git a/data/reward/8210000.yaml b/data/reward/8210000.yaml new file mode 100644 index 00000000..824d1ad9 --- /dev/null +++ b/data/reward/8210000.yaml @@ -0,0 +1,7 @@ +# Crocky the Gatekeeper (8210000) + +rewards: + - [ 4000625, 1, 1, 0.4 ] # Crocky's Helmet + - [ 4000630, 1, 1, 0.4 ] + - [ 1342007, 1, 1, 0.002500 ] # Bloodsoaked Katara + - [ 4032838, 1, 1, 0.999999, 3177 ] \ No newline at end of file diff --git a/data/reward/8210001.yaml b/data/reward/8210001.yaml new file mode 100644 index 00000000..b8d01d02 --- /dev/null +++ b/data/reward/8210001.yaml @@ -0,0 +1,12 @@ +# Mob 8210001 (8210001) + +rewards: +- [4000626, 1, 1, 0.4] +- [4000630, 1, 1, 0.4] +- [4032838, 1, 1, 0.999999, 3177] +- [2510113, 1, 1, 0.005] +- [2511049, 1, 1, 0.005] +- [2511089, 1, 1, 0.005] +- [2512116, 1, 1, 0.005] +- [2512132, 1, 1, 0.005] +- [2512248, 1, 1, 0.005] diff --git a/data/reward/8210002.yaml b/data/reward/8210002.yaml new file mode 100644 index 00000000..4390cc38 --- /dev/null +++ b/data/reward/8210002.yaml @@ -0,0 +1,13 @@ +# Mob 8210002 (8210002) + +rewards: +- [4000626, 1, 1, 0.4] +- [4000630, 1, 1, 0.4] +- [4032838, 1, 1, 0.999999, 3177] +- [2510116, 1, 1, 0.005] +- [2510116, 1, 1, 0.005] +- [2510162, 1, 1, 0.001] +- [2510164, 1, 1, 0.005] +- [2510241, 1, 1, 0.005] +- [2512004, 1, 1, 0.005] +- [2512175, 1, 1, 0.005] diff --git a/data/reward/8210003.yaml b/data/reward/8210003.yaml new file mode 100644 index 00000000..05d521a2 --- /dev/null +++ b/data/reward/8210003.yaml @@ -0,0 +1,14 @@ +# Mob 8210003 (8210003) + +rewards: +- [4000627, 1, 1, 0.4] +- [4000630, 1, 1, 0.4] +- [4032838, 1, 1, 0.999999, 3177] +- [2510061, 1, 1, 0.005] +- [2510125, 1, 1, 0.005] +- [2511099, 1, 1, 0.005] +- [2512091, 1, 1, 0.005] +- [2512092, 1, 1, 0.005] +- [2512107, 1, 1, 0.005] +- [2512116, 1, 1, 0.005] +- [2512248, 1, 1, 0.005] diff --git a/data/reward/8210004.yaml b/data/reward/8210004.yaml new file mode 100644 index 00000000..fac20e4c --- /dev/null +++ b/data/reward/8210004.yaml @@ -0,0 +1,14 @@ +# Mob 8210004 (8210004) + +rewards: +- [4000628, 1, 1, 0.4] +- [4000630, 1, 1, 0.4] +- [4032836, 1, 1, 0.999999, 3174] +- [4032838, 1, 1, 0.999999, 3177] +- [2510013, 1, 1, 0.005] +- [2510120, 1, 1, 0.005] +- [2511007, 1, 1, 0.005] +- [2511018, 1, 1, 0.005] +- [2511035, 1, 1, 0.005] +- [2511053, 1, 1, 0.005] +- [2512195, 1, 1, 0.005] diff --git a/data/reward/8210005.yaml b/data/reward/8210005.yaml new file mode 100644 index 00000000..9ac1865c --- /dev/null +++ b/data/reward/8210005.yaml @@ -0,0 +1,13 @@ +# Mob 8210005 (8210005) + +rewards: +- [4000629, 1, 1, 0.4] +- [4000630, 1, 1, 0.4] +- [4032835, 1, 1, 0.999999, 3170] +- [4032838, 1, 1, 0.999999, 3177] +- [2510034, 1, 1, 0.005] +- [2510059, 1, 1, 0.005] +- [2510143, 1, 1, 0.005] +- [2511049, 1, 1, 0.005] +- [2512007, 1, 1, 0.005] +- [2512031, 1, 1, 0.005] diff --git a/data/reward/8210010.yaml b/data/reward/8210010.yaml new file mode 100644 index 00000000..d9c4a281 --- /dev/null +++ b/data/reward/8210010.yaml @@ -0,0 +1,5 @@ +# Mob 8210010 (8210010) + +rewards: +- [2022719, 1, 1, 0.999999] +- [2022719, 1, 1, 0.999999] diff --git a/data/reward/8210011.yaml b/data/reward/8210011.yaml new file mode 100644 index 00000000..6f9997f7 --- /dev/null +++ b/data/reward/8210011.yaml @@ -0,0 +1,6 @@ +# Mob 8210011 (8210011) + +rewards: +- [2022719, 1, 1, 0.999999] +- [2022719, 1, 1, 0.999999] +- [2022719, 1, 1, 0.999999] diff --git a/data/reward/8210012.yaml b/data/reward/8210012.yaml new file mode 100644 index 00000000..ab8f1568 --- /dev/null +++ b/data/reward/8210012.yaml @@ -0,0 +1,7 @@ +# Mob 8210012 (8210012) + +rewards: +- [2022719, 1, 1, 0.999999] +- [2022719, 1, 1, 0.999999] +- [2022719, 1, 1, 0.999999] +- [2022719, 1, 1, 0.999999] diff --git a/data/reward/8210013.yaml b/data/reward/8210013.yaml new file mode 100644 index 00000000..7d0754b2 --- /dev/null +++ b/data/reward/8210013.yaml @@ -0,0 +1,9 @@ +# Mob 8210013 (8210013) + +rewards: +- [2022719, 1, 1, 0.999999] +- [2022719, 1, 1, 0.999999] +- [2022719, 1, 1, 0.999999] +- [2022719, 1, 1, 0.999999] +- [2022719, 1, 1, 0.999999] +- [2022719, 1, 1, 0.999999] diff --git a/data/reward/8220010.yaml b/data/reward/8220010.yaml index 1b7b5723..50cd041a 100644 --- a/data/reward/8220010.yaml +++ b/data/reward/8220010.yaml @@ -2,4 +2,5 @@ rewards: - [ 0, 702, 1053, 0.600000 ] - - [ 2388077, 1, 1, 0.020000 ] # Dunas Card + - [ 2388077, 1, 1, 0.020000 ] # Dunas Card + - [ 4032516, 1, 1, 1.0, 3735 ] # Time Sand - Quest 3735 (100% drop when quest active) diff --git a/data/reward/8220011.yaml b/data/reward/8220011.yaml index c2f0a4a1..e29313f2 100644 --- a/data/reward/8220011.yaml +++ b/data/reward/8220011.yaml @@ -3,3 +3,4 @@ rewards: - [ 0, 702, 1053, 0.600000 ] - [ 2388078, 1, 1, 0.020000 ] # Aufheben Card + - [ 4032517, 1, 1, 1.0, 3740 ] # Time Sand - Quest 3740 (100% drop when quest active) diff --git a/data/reward/8220012.yaml b/data/reward/8220012.yaml index 7402fa26..782d6182 100644 --- a/data/reward/8220012.yaml +++ b/data/reward/8220012.yaml @@ -2,4 +2,5 @@ rewards: - [ 0, 732, 1098, 0.600000 ] - - [ 2388079, 1, 1, 0.020000 ] # Oberon Card + - [ 2388079, 1, 1, 0.020000 ] # Oberon Card + - [ 4032518, 1, 1, 1.0, 3743 ] # Time Sand - Quest 3743 (100% drop when quest active) diff --git a/data/reward/8220015.yaml b/data/reward/8220015.yaml new file mode 100644 index 00000000..f29050c0 --- /dev/null +++ b/data/reward/8220015.yaml @@ -0,0 +1,304 @@ +# Nibelung (8220015) + +rewards: +- [2290088, 1, 1, 0.005] +- [2430144, 1, 2, 0.025] +- [2430144, 1, 2, 0.025] +- [2000005, 1, 1, 0.999999] +- [1452009, 1, 1, 0.005] +- [1382007, 1, 1, 0.005] +- [1462009, 1, 1, 0.005] +- [1322019, 1, 1, 0.007] +- [1312010, 1, 1, 0.007] +- [1412008, 1, 1, 0.005] +- [1422010, 1, 1, 0.005] +- [1472026, 1, 1, 0.005] +- [1432007, 1, 1, 0.005] +- [1442008, 1, 1, 0.005] +- [1302012, 1, 1, 0.007] +- [1402012, 1, 1, 0.005] +- [1332018, 1, 1, 0.005] +- [1332019, 1, 1, 0.005] +- [2043001, 1, 1, 0.003] +- [2043101, 1, 1, 0.003] +- [2043201, 1, 1, 0.003] +- [2043301, 1, 1, 0.003] +- [2043701, 1, 1, 0.003] +- [2043801, 1, 1, 0.003] +- [2044001, 1, 1, 0.003] +- [2044101, 1, 1, 0.003] +- [2044201, 1, 1, 0.003] +- [2044301, 1, 1, 0.003] +- [2044401, 1, 1, 0.003] +- [2044501, 1, 1, 0.003] +- [2044601, 1, 1, 0.003] +- [2000004, 1, 1, 0.999999] +- [1092017, 1, 1, 0.007] +- [1372059, 1, 1, 0.007] +- [1402074, 1, 1, 0.005] +- [1442091, 1, 1, 0.005] +- [1452090, 1, 1, 0.005] +- [1472101, 1, 1, 0.005] +- [1482052, 1, 1, 0.005] +- [2047000, 1, 1, 0.003] +- [2047001, 1, 1, 0.003] +- [2047002, 1, 1, 0.003] +- [2047100, 1, 1, 0.003] +- [2047101, 1, 1, 0.003] +- [2047102, 1, 1, 0.003] +- [2000005, 1, 1, 0.999999] +- [1452009, 1, 1, 0.005] +- [1382007, 1, 1, 0.005] +- [1462009, 1, 1, 0.005] +- [1322019, 1, 1, 0.007] +- [1312010, 1, 1, 0.007] +- [1412008, 1, 1, 0.005] +- [1422010, 1, 1, 0.005] +- [1472026, 1, 1, 0.005] +- [1432007, 1, 1, 0.005] +- [1442008, 1, 1, 0.005] +- [1302012, 1, 1, 0.007] +- [1402012, 1, 1, 0.005] +- [1332018, 1, 1, 0.005] +- [1332019, 1, 1, 0.005] +- [2043001, 1, 1, 0.003] +- [2043101, 1, 1, 0.003] +- [2043201, 1, 1, 0.003] +- [2043301, 1, 1, 0.003] +- [2043701, 1, 1, 0.003] +- [2043801, 1, 1, 0.003] +- [2044001, 1, 1, 0.003] +- [2044101, 1, 1, 0.003] +- [2044201, 1, 1, 0.003] +- [2044301, 1, 1, 0.003] +- [2044401, 1, 1, 0.003] +- [2044501, 1, 1, 0.003] +- [2044601, 1, 1, 0.003] +- [2000004, 1, 1, 0.999999] +- [1092017, 1, 1, 0.007] +- [1372059, 1, 1, 0.007] +- [1402074, 1, 1, 0.005] +- [1442091, 1, 1, 0.005] +- [1452090, 1, 1, 0.005] +- [1472101, 1, 1, 0.005] +- [1482052, 1, 1, 0.005] +- [2047000, 1, 1, 0.003] +- [2047001, 1, 1, 0.003] +- [2047002, 1, 1, 0.003] +- [2047100, 1, 1, 0.003] +- [2047101, 1, 1, 0.003] +- [2047102, 1, 1, 0.003] +- [2000005, 1, 1, 0.999999] +- [1452009, 1, 1, 0.005] +- [1382007, 1, 1, 0.005] +- [1462009, 1, 1, 0.005] +- [1322019, 1, 1, 0.007] +- [1312010, 1, 1, 0.007] +- [1412008, 1, 1, 0.005] +- [1422010, 1, 1, 0.005] +- [1472026, 1, 1, 0.005] +- [1432007, 1, 1, 0.005] +- [1442008, 1, 1, 0.005] +- [1302012, 1, 1, 0.007] +- [1402012, 1, 1, 0.005] +- [1332018, 1, 1, 0.005] +- [1332019, 1, 1, 0.005] +- [2043001, 1, 1, 0.003] +- [2043101, 1, 1, 0.003] +- [2043201, 1, 1, 0.003] +- [2043301, 1, 1, 0.003] +- [2043701, 1, 1, 0.003] +- [2043801, 1, 1, 0.003] +- [2044001, 1, 1, 0.003] +- [2044101, 1, 1, 0.003] +- [2044201, 1, 1, 0.003] +- [2044301, 1, 1, 0.003] +- [2044401, 1, 1, 0.003] +- [2044501, 1, 1, 0.003] +- [2044601, 1, 1, 0.003] +- [2000004, 1, 1, 0.999999] +- [1092017, 1, 1, 0.007] +- [1372059, 1, 1, 0.007] +- [1402074, 1, 1, 0.005] +- [1442091, 1, 1, 0.005] +- [1452090, 1, 1, 0.005] +- [1472101, 1, 1, 0.005] +- [1482052, 1, 1, 0.005] +- [2047000, 1, 1, 0.003] +- [2047001, 1, 1, 0.003] +- [2047002, 1, 1, 0.003] +- [2047100, 1, 1, 0.003] +- [2047101, 1, 1, 0.003] +- [2047102, 1, 1, 0.003] +- [2000005, 1, 1, 0.999999] +- [1452009, 1, 1, 0.005] +- [1382007, 1, 1, 0.005] +- [1462009, 1, 1, 0.005] +- [1322019, 1, 1, 0.007] +- [1312010, 1, 1, 0.007] +- [1412008, 1, 1, 0.005] +- [1422010, 1, 1, 0.005] +- [1472026, 1, 1, 0.005] +- [1432007, 1, 1, 0.005] +- [1442008, 1, 1, 0.005] +- [1302012, 1, 1, 0.007] +- [1402012, 1, 1, 0.005] +- [1332018, 1, 1, 0.005] +- [1332019, 1, 1, 0.005] +- [2043001, 1, 1, 0.003] +- [2043101, 1, 1, 0.003] +- [2043201, 1, 1, 0.003] +- [2043301, 1, 1, 0.003] +- [2043701, 1, 1, 0.003] +- [2043801, 1, 1, 0.003] +- [2044001, 1, 1, 0.003] +- [2044101, 1, 1, 0.003] +- [2044201, 1, 1, 0.003] +- [2044301, 1, 1, 0.003] +- [2044401, 1, 1, 0.003] +- [2044501, 1, 1, 0.003] +- [2044601, 1, 1, 0.003] +- [2000004, 1, 1, 0.999999] +- [1092017, 1, 1, 0.007] +- [1372059, 1, 1, 0.007] +- [1402074, 1, 1, 0.005] +- [1442091, 1, 1, 0.005] +- [1452090, 1, 1, 0.005] +- [1472101, 1, 1, 0.005] +- [1482052, 1, 1, 0.005] +- [2047000, 1, 1, 0.003] +- [2047001, 1, 1, 0.003] +- [2047002, 1, 1, 0.003] +- [2047100, 1, 1, 0.003] +- [2047101, 1, 1, 0.003] +- [2047102, 1, 1, 0.003] +- [2000005, 1, 1, 0.999999] +- [1452009, 1, 1, 0.005] +- [1382007, 1, 1, 0.005] +- [1462009, 1, 1, 0.005] +- [1322019, 1, 1, 0.007] +- [1312010, 1, 1, 0.007] +- [1412008, 1, 1, 0.005] +- [1422010, 1, 1, 0.005] +- [1472026, 1, 1, 0.005] +- [1432007, 1, 1, 0.005] +- [1442008, 1, 1, 0.005] +- [1302012, 1, 1, 0.007] +- [1402012, 1, 1, 0.005] +- [1332018, 1, 1, 0.005] +- [1332019, 1, 1, 0.005] +- [2043001, 1, 1, 0.003] +- [2043101, 1, 1, 0.003] +- [2043201, 1, 1, 0.003] +- [2043301, 1, 1, 0.003] +- [2043701, 1, 1, 0.003] +- [2043801, 1, 1, 0.003] +- [2044001, 1, 1, 0.003] +- [2044101, 1, 1, 0.003] +- [2044201, 1, 1, 0.003] +- [2044301, 1, 1, 0.003] +- [2044401, 1, 1, 0.003] +- [2044501, 1, 1, 0.003] +- [2044601, 1, 1, 0.003] +- [2000004, 1, 1, 0.999999] +- [1092017, 1, 1, 0.007] +- [1372059, 1, 1, 0.007] +- [1402074, 1, 1, 0.005] +- [1442091, 1, 1, 0.005] +- [1452090, 1, 1, 0.005] +- [1472101, 1, 1, 0.005] +- [1482052, 1, 1, 0.005] +- [2047000, 1, 1, 0.003] +- [2047001, 1, 1, 0.003] +- [2047002, 1, 1, 0.003] +- [2047100, 1, 1, 0.003] +- [2047101, 1, 1, 0.003] +- [2047102, 1, 1, 0.003] +- [2000005, 1, 1, 0.999999] +- [1452009, 1, 1, 0.005] +- [1382007, 1, 1, 0.005] +- [1462009, 1, 1, 0.005] +- [1322019, 1, 1, 0.007] +- [1312010, 1, 1, 0.007] +- [1412008, 1, 1, 0.005] +- [1422010, 1, 1, 0.005] +- [1472026, 1, 1, 0.005] +- [1432007, 1, 1, 0.005] +- [1442008, 1, 1, 0.005] +- [1302012, 1, 1, 0.007] +- [1402012, 1, 1, 0.005] +- [1332018, 1, 1, 0.005] +- [1332019, 1, 1, 0.005] +- [2043001, 1, 1, 0.003] +- [2043101, 1, 1, 0.003] +- [2043201, 1, 1, 0.003] +- [2043301, 1, 1, 0.003] +- [2043701, 1, 1, 0.003] +- [2043801, 1, 1, 0.003] +- [2044001, 1, 1, 0.003] +- [2044101, 1, 1, 0.003] +- [2044201, 1, 1, 0.003] +- [2044301, 1, 1, 0.003] +- [2044401, 1, 1, 0.003] +- [2044501, 1, 1, 0.003] +- [2044601, 1, 1, 0.003] +- [2000004, 1, 1, 0.999999] +- [1092017, 1, 1, 0.007] +- [1372059, 1, 1, 0.007] +- [1402074, 1, 1, 0.005] +- [1442091, 1, 1, 0.005] +- [1452090, 1, 1, 0.005] +- [1472101, 1, 1, 0.005] +- [1482052, 1, 1, 0.005] +- [2047000, 1, 1, 0.003] +- [2047001, 1, 1, 0.003] +- [2047002, 1, 1, 0.003] +- [2047100, 1, 1, 0.003] +- [2047101, 1, 1, 0.003] +- [2047102, 1, 1, 0.003] +- [2000005, 1, 1, 0.999999] +- [1452009, 1, 1, 0.005] +- [1382007, 1, 1, 0.005] +- [1462009, 1, 1, 0.005] +- [1322019, 1, 1, 0.007] +- [1312010, 1, 1, 0.007] +- [1412008, 1, 1, 0.005] +- [1422010, 1, 1, 0.005] +- [1472026, 1, 1, 0.005] +- [1432007, 1, 1, 0.005] +- [1442008, 1, 1, 0.005] +- [1302012, 1, 1, 0.007] +- [1402012, 1, 1, 0.005] +- [1332018, 1, 1, 0.005] +- [1332019, 1, 1, 0.005] +- [2043001, 1, 1, 0.003] +- [2043101, 1, 1, 0.003] +- [2043201, 1, 1, 0.003] +- [2043301, 1, 1, 0.003] +- [2043701, 1, 1, 0.003] +- [2043801, 1, 1, 0.003] +- [2044001, 1, 1, 0.003] +- [2044101, 1, 1, 0.003] +- [2044201, 1, 1, 0.003] +- [2044301, 1, 1, 0.003] +- [2044401, 1, 1, 0.003] +- [2044501, 1, 1, 0.003] +- [2044601, 1, 1, 0.003] +- [2000004, 1, 1, 0.999999] +- [1092017, 1, 1, 0.007] +- [1372059, 1, 1, 0.007] +- [1402074, 1, 1, 0.005] +- [1442091, 1, 1, 0.005] +- [1452090, 1, 1, 0.005] +- [1472101, 1, 1, 0.005] +- [1482052, 1, 1, 0.005] +- [2047000, 1, 1, 0.003] +- [2047001, 1, 1, 0.003] +- [2047002, 1, 1, 0.003] +- [2047100, 1, 1, 0.003] +- [2047101, 1, 1, 0.003] +- [2047102, 1, 1, 0.003] +- [2290007, 1, 1, 0.005] +- [2290004, 1, 1, 0.005] +- [2290113, 1, 1, 0.005] +- [ 2044601, 1, 1, 0.003 ] diff --git a/data/reward/8230300.yaml b/data/reward/8230300.yaml new file mode 100644 index 00000000..b3f697c2 --- /dev/null +++ b/data/reward/8230300.yaml @@ -0,0 +1,4 @@ +# Mob 8230300 (8230300) + +rewards: +- [2290130, 1, 1, 0.0005] diff --git a/data/reward/8400006.yaml b/data/reward/8400006.yaml new file mode 100644 index 00000000..724e381b --- /dev/null +++ b/data/reward/8400006.yaml @@ -0,0 +1,4 @@ +# Mob 8400006 (8400006) + +rewards: +- [2512195, 1, 1, 0.005] diff --git a/data/reward/8600000.yaml b/data/reward/8600000.yaml new file mode 100644 index 00000000..472d42dc --- /dev/null +++ b/data/reward/8600000.yaml @@ -0,0 +1,15 @@ +# Mob 8600000 (8600000) + +rewards: +- [4032930, 1, 1, 0.999999, 31114] +- [4032921, 1, 1, 0.999999, 31110] +- [4000642, 1, 1, 0.4] +- [2510131, 1, 1, 0.005] +- [2510249, 1, 1, 0.001] +- [2511039, 1, 1, 0.005] +- [2512090, 1, 1, 0.005] +- [2512096, 1, 1, 0.005] +- [2512099, 1, 1, 0.005] +- [2512102, 1, 1, 0.005] +- [2512192, 1, 1, 0.005] +- [2512216, 1, 1, 0.005] diff --git a/data/reward/8600001.yaml b/data/reward/8600001.yaml new file mode 100644 index 00000000..96e3104e --- /dev/null +++ b/data/reward/8600001.yaml @@ -0,0 +1,12 @@ +# Mob 8600001 (8600001) + +rewards: +- [4032930, 1, 1, 0.999999, 31114] +- [4000643, 1, 1, 0.4] +- [2510010, 1, 1, 0.005] +- [2510014, 1, 1, 0.005] +- [2510137, 1, 1, 0.005] +- [2510143, 1, 1, 0.005] +- [2510261, 1, 1, 0.005] +- [2512023, 1, 1, 0.005] +- [2512125, 1, 1, 0.005] diff --git a/data/reward/8600002.yaml b/data/reward/8600002.yaml new file mode 100644 index 00000000..033c321f --- /dev/null +++ b/data/reward/8600002.yaml @@ -0,0 +1,14 @@ +# Mob 8600002 (8600002) + +rewards: +- [4032930, 1, 1, 0.999999, 31114] +- [4000644, 1, 1, 0.4] +- [2510040, 1, 1, 0.001] +- [2511014, 1, 1, 0.005] +- [2511035, 1, 1, 0.005] +- [2511075, 1, 1, 0.005] +- [2512092, 1, 1, 0.005] +- [2512156, 1, 1, 0.005] +- [2512177, 1, 1, 0.001] +- [2512212, 1, 1, 0.005] +- [2512247, 1, 1, 0.001] diff --git a/data/reward/8600003.yaml b/data/reward/8600003.yaml new file mode 100644 index 00000000..c214b3b7 --- /dev/null +++ b/data/reward/8600003.yaml @@ -0,0 +1,12 @@ +# Mob 8600003 (8600003) + +rewards: +- [4032940, 1, 1, 0.999999, 31116] +- [4032930, 1, 1, 0.999999, 31114] +- [4000645, 1, 1, 0.4] +- [2510139, 1, 1, 0.005] +- [2510261, 1, 1, 0.005] +- [2511002, 1, 1, 0.005] +- [2511098, 1, 1, 0.005] +- [2511099, 1, 1, 0.005] +- [2512012, 1, 1, 0.005] diff --git a/data/reward/8600004.yaml b/data/reward/8600004.yaml new file mode 100644 index 00000000..caaacd53 --- /dev/null +++ b/data/reward/8600004.yaml @@ -0,0 +1,10 @@ +# Mob 8600004 (8600004) + +rewards: +- [4000646, 1, 1, 0.4] +- [2511101, 1, 1, 0.001] +- [2512013, 1, 1, 0.005] +- [2512091, 1, 1, 0.001] +- [2512119, 1, 1, 0.005] +- [2512133, 1, 1, 0.005] +- [2512135, 1, 1, 0.005] diff --git a/data/reward/8600005.yaml b/data/reward/8600005.yaml new file mode 100644 index 00000000..5b201c8f --- /dev/null +++ b/data/reward/8600005.yaml @@ -0,0 +1,13 @@ +# Mob 8600005 (8600005) + +rewards: +- [4000647, 1, 1, 0.4] +- [2510164, 1, 1, 0.005] +- [2510168, 1, 1, 0.001] +- [2511003, 1, 1, 0.005] +- [2511073, 1, 1, 0.005] +- [2512040, 1, 1, 0.005] +- [2512109, 1, 1, 0.005] +- [2512122, 1, 1, 0.005] +- [2512129, 1, 1, 0.005] +- [2512172, 1, 1, 0.005] diff --git a/data/reward/8600006.yaml b/data/reward/8600006.yaml new file mode 100644 index 00000000..adce48bc --- /dev/null +++ b/data/reward/8600006.yaml @@ -0,0 +1,8 @@ +# Mob 8600006 (8600006) + +rewards: +- [4000648, 1, 1, 0.4] +- [2510068, 1, 1, 0.005] +- [2510162, 1, 1, 0.001] +- [2511049, 1, 1, 0.005] +- [2512029, 1, 1, 0.005] diff --git a/data/reward/8610000.yaml b/data/reward/8610000.yaml new file mode 100644 index 00000000..c90e9efb --- /dev/null +++ b/data/reward/8610000.yaml @@ -0,0 +1,16 @@ +# Mob 8610000 (8610000) + +rewards: +- [4000660, 1, 1, 0.01] +- [2510038, 1, 1, 0.005] +- [2510255, 1, 1, 0.005] +- [2510256, 1, 1, 0.005] +- [2510257, 1, 1, 0.005] +- [2510258, 1, 1, 0.005] +- [2510259, 1, 1, 0.005] +- [2511010, 1, 1, 0.005] +- [2511056, 1, 1, 0.005] +- [2512127, 1, 1, 0.005] +- [2512136, 1, 1, 0.005] +- [2512154, 1, 1, 0.005] +- [2512175, 1, 1, 0.005] diff --git a/data/reward/8610001.yaml b/data/reward/8610001.yaml new file mode 100644 index 00000000..3a9c9609 --- /dev/null +++ b/data/reward/8610001.yaml @@ -0,0 +1,14 @@ +# Mob 8610001 (8610001) + +rewards: +- [4000661, 1, 1, 0.01] +- [2510140, 1, 1, 0.005] +- [2510255, 1, 1, 0.005] +- [2510256, 1, 1, 0.005] +- [2510257, 1, 1, 0.005] +- [2510258, 1, 1, 0.005] +- [2510259, 1, 1, 0.005] +- [2511048, 1, 1, 0.005] +- [2512015, 1, 1, 0.005] +- [2512193, 1, 1, 0.005] +- [2512195, 1, 1, 0.005] diff --git a/data/reward/8610002.yaml b/data/reward/8610002.yaml new file mode 100644 index 00000000..f6692900 --- /dev/null +++ b/data/reward/8610002.yaml @@ -0,0 +1,16 @@ +# Mob 8610002 (8610002) + +rewards: +- [4000662, 1, 1, 0.01] +- [2510059, 1, 1, 0.005] +- [2510141, 1, 1, 0.005] +- [2510142, 1, 1, 0.005] +- [2510255, 1, 1, 0.005] +- [2510256, 1, 1, 0.005] +- [2510257, 1, 1, 0.005] +- [2510258, 1, 1, 0.005] +- [2510259, 1, 1, 0.005] +- [2511031, 1, 1, 0.005] +- [2512022, 1, 1, 0.005] +- [2512118, 1, 1, 0.005] +- [2512214, 1, 1, 0.005] diff --git a/data/reward/8610003.yaml b/data/reward/8610003.yaml new file mode 100644 index 00000000..4b4f703b --- /dev/null +++ b/data/reward/8610003.yaml @@ -0,0 +1,16 @@ +# Mob 8610003 (8610003) + +rewards: +- [4000663, 1, 1, 0.01] +- [2510135, 1, 1, 0.005] +- [2510255, 1, 1, 0.005] +- [2510256, 1, 1, 0.005] +- [2510257, 1, 1, 0.005] +- [2510258, 1, 1, 0.005] +- [2510259, 1, 1, 0.005] +- [2510261, 1, 1, 0.005] +- [2511019, 1, 1, 0.005] +- [2512005, 1, 1, 0.005] +- [2512106, 1, 1, 0.005] +- [2512173, 1, 1, 0.005] +- [2512248, 1, 1, 0.005] diff --git a/data/reward/8610004.yaml b/data/reward/8610004.yaml new file mode 100644 index 00000000..469f0400 --- /dev/null +++ b/data/reward/8610004.yaml @@ -0,0 +1,11 @@ +# Mob 8610004 (8610004) + +rewards: +- [2430200, 1, 1, 0.4] +- [2510255, 1, 1, 0.005] +- [2510256, 1, 1, 0.005] +- [2510257, 1, 1, 0.005] +- [2510258, 1, 1, 0.005] +- [2510259, 1, 1, 0.005] +- [2510261, 1, 1, 0.005] +- [2512178, 1, 1, 0.001] diff --git a/data/reward/8610005.yaml b/data/reward/8610005.yaml new file mode 100644 index 00000000..5b8fab21 --- /dev/null +++ b/data/reward/8610005.yaml @@ -0,0 +1,10 @@ +# Mob 8610005 (8610005) + +rewards: +- [4000649, 1, 1, 0.4] +- [2510134, 1, 1, 0.005] +- [2511057, 1, 1, 0.005] +- [2511078, 1, 1, 0.005] +- [2512004, 1, 1, 0.005] +- [2512014, 1, 1, 0.005] +- [2512258, 1, 1, 0.005] diff --git a/data/reward/8610006.yaml b/data/reward/8610006.yaml new file mode 100644 index 00000000..d89edaa7 --- /dev/null +++ b/data/reward/8610006.yaml @@ -0,0 +1,11 @@ +# Mob 8610006 (8610006) + +rewards: +- [4032941, 1, 1, 0.999999, 31133] +- [4000650, 1, 1, 0.4] +- [2510013, 1, 1, 0.005] +- [2510056, 1, 1, 0.005] +- [2510058, 1, 1, 0.005] +- [2511085, 1, 1, 0.005] +- [2512107, 1, 1, 0.005] +- [2512124, 1, 1, 0.005] diff --git a/data/reward/8610007.yaml b/data/reward/8610007.yaml new file mode 100644 index 00000000..57ed893f --- /dev/null +++ b/data/reward/8610007.yaml @@ -0,0 +1,11 @@ +# Mob 8610007 (8610007) + +rewards: +- [4032926, 1, 1, 0.999999, 31132] +- [4000651, 1, 1, 0.4] +- [2510060, 1, 1, 0.005] +- [2511044, 1, 1, 0.005] +- [2512006, 1, 1, 0.005] +- [2512021, 1, 1, 0.005] +- [2512217, 1, 1, 0.005] +- [2512218, 1, 1, 0.005] diff --git a/data/reward/8610008.yaml b/data/reward/8610008.yaml new file mode 100644 index 00000000..07b5359e --- /dev/null +++ b/data/reward/8610008.yaml @@ -0,0 +1,18 @@ +# Mob 8610008 (8610008) + +rewards: +- [4000652, 1, 1, 0.4] +- [2510012, 1, 1, 0.005] +- [2510129, 1, 1, 0.005] +- [2511090, 1, 1, 0.005] +- [2511092, 1, 1, 0.005] +- [2511100, 1, 1, 0.005] +- [2512031, 1, 1, 0.005] +- [2512101, 1, 1, 0.005] +- [2512104, 1, 1, 0.005] +- [2512116, 1, 1, 0.005] +- [2512131, 1, 1, 0.005] +- [2512132, 1, 1, 0.005] +- [2512137, 1, 1, 0.001] +- [2512158, 1, 1, 0.005] +- [2512194, 1, 1, 0.005] diff --git a/data/reward/8610009.yaml b/data/reward/8610009.yaml new file mode 100644 index 00000000..aef041ec --- /dev/null +++ b/data/reward/8610009.yaml @@ -0,0 +1,9 @@ +# Mob 8610009 (8610009) + +rewards: +- [4000653, 1, 1, 0.4] +- [2511034, 1, 1, 0.005] +- [2512041, 1, 1, 0.005] +- [2512097, 1, 1, 0.005] +- [2512103, 1, 1, 0.005] +- [2512115, 1, 1, 0.005] diff --git a/data/reward/8610010.yaml b/data/reward/8610010.yaml new file mode 100644 index 00000000..8461954c --- /dev/null +++ b/data/reward/8610010.yaml @@ -0,0 +1,9 @@ +# Mob 8610010 (8610010) + +rewards: +- [4032925, 1, 1, 0.999999, 31141] +- [4000654, 1, 1, 0.4] +- [2511007, 1, 1, 0.005] +- [2511018, 1, 1, 0.005] +- [2512030, 1, 1, 0.005] +- [2512128, 1, 1, 0.005] diff --git a/data/reward/8610011.yaml b/data/reward/8610011.yaml new file mode 100644 index 00000000..3ebe7578 --- /dev/null +++ b/data/reward/8610011.yaml @@ -0,0 +1,13 @@ +# Mob 8610011 (8610011) + +rewards: +- [4032925, 1, 1, 0.999999, 31141] +- [4000655, 1, 1, 0.4] +- [2510130, 1, 1, 0.005] +- [2510140, 1, 1, 0.005] +- [2511072, 1, 1, 0.005] +- [2512030, 1, 1, 0.005] +- [2512036, 1, 1, 0.005] +- [2512119, 1, 1, 0.005] +- [2512131, 1, 1, 0.005] +- [2512132, 1, 1, 0.005] diff --git a/data/reward/8610012.yaml b/data/reward/8610012.yaml new file mode 100644 index 00000000..ff75e0c8 --- /dev/null +++ b/data/reward/8610012.yaml @@ -0,0 +1,12 @@ +# Mob 8610012 (8610012) + +rewards: +- [4032925, 1, 1, 0.999999, 31141] +- [4000656, 1, 1, 0.4] +- [2510067, 1, 1, 0.005] +- [2511056, 1, 1, 0.005] +- [2511073, 1, 1, 0.005] +- [2512021, 1, 1, 0.005] +- [2512022, 1, 1, 0.005] +- [2512096, 1, 1, 0.005] +- [2512152, 1, 1, 0.005] diff --git a/data/reward/8610013.yaml b/data/reward/8610013.yaml new file mode 100644 index 00000000..fde62977 --- /dev/null +++ b/data/reward/8610013.yaml @@ -0,0 +1,14 @@ +# Mob 8610013 (8610013) + +rewards: +- [4032928, 1, 1, 0.999999, 31142] +- [4032925, 1, 1, 0.999999, 31141] +- [4000657, 1, 1, 0.4] +- [2510164, 1, 1, 0.005] +- [2510248, 1, 1, 0.005] +- [2510261, 1, 1, 0.005] +- [2511027, 1, 1, 0.005] +- [2511035, 1, 1, 0.005] +- [2512100, 1, 1, 0.005] +- [2512173, 1, 1, 0.005] +- [2512174, 1, 1, 0.005] diff --git a/data/reward/8610014.yaml b/data/reward/8610014.yaml new file mode 100644 index 00000000..046f335f --- /dev/null +++ b/data/reward/8610014.yaml @@ -0,0 +1,13 @@ +# Mob 8610014 (8610014) + +rewards: +- [4032927, 1, 1, 0.999999, 31143] +- [4032925, 1, 1, 0.999999, 31141] +- [4000658, 1, 1, 0.4] +- [2510057, 1, 1, 0.005] +- [2510170, 1, 1, 0.005] +- [2511026, 1, 1, 0.005] +- [2511030, 1, 1, 0.005] +- [2511031, 1, 1, 0.005] +- [2511048, 1, 1, 0.005] +- [2511098, 1, 1, 0.005] diff --git a/data/reward/8610016.yaml b/data/reward/8610016.yaml new file mode 100644 index 00000000..b5350e16 --- /dev/null +++ b/data/reward/8610016.yaml @@ -0,0 +1,4 @@ +# Mob 8610016 (8610016) + +rewards: +- [4032922, 1, 1, 0.999999, 31125] diff --git a/data/reward/8610017.yaml b/data/reward/8610017.yaml new file mode 100644 index 00000000..bad75e8d --- /dev/null +++ b/data/reward/8610017.yaml @@ -0,0 +1,4 @@ +# Mob 8610017 (8610017) + +rewards: +- [4032922, 1, 1, 0.999999, 31125] diff --git a/data/reward/8610018.yaml b/data/reward/8610018.yaml new file mode 100644 index 00000000..6843279e --- /dev/null +++ b/data/reward/8610018.yaml @@ -0,0 +1,4 @@ +# Mob 8610018 (8610018) + +rewards: +- [4032922, 1, 1, 0.999999, 31125] diff --git a/data/reward/8610019.yaml b/data/reward/8610019.yaml new file mode 100644 index 00000000..3cfe7a94 --- /dev/null +++ b/data/reward/8610019.yaml @@ -0,0 +1,4 @@ +# Mob 8610019 (8610019) + +rewards: +- [4032922, 1, 1, 0.999999, 31125] diff --git a/data/reward/8610020.yaml b/data/reward/8610020.yaml new file mode 100644 index 00000000..8184bd33 --- /dev/null +++ b/data/reward/8610020.yaml @@ -0,0 +1,4 @@ +# Mob 8610020 (8610020) + +rewards: +- [4032922, 1, 1, 0.999999, 31125] diff --git a/data/reward/8610021.yaml b/data/reward/8610021.yaml new file mode 100644 index 00000000..146a9cf7 --- /dev/null +++ b/data/reward/8610021.yaml @@ -0,0 +1,4 @@ +# Mob 8610021 (8610021) + +rewards: +- [4032922, 1, 1, 0.999999, 31125] diff --git a/data/reward/8610022.yaml b/data/reward/8610022.yaml new file mode 100644 index 00000000..2c4fed0f --- /dev/null +++ b/data/reward/8610022.yaml @@ -0,0 +1,5 @@ +# Mob 8610022 (8610022) + +rewards: +- [4032922, 1, 1, 0.999999, 31125] +- [4000649, 1, 1, 0.4] diff --git a/data/reward/8800102.yaml b/data/reward/8800102.yaml new file mode 100644 index 00000000..cd9c1626 --- /dev/null +++ b/data/reward/8800102.yaml @@ -0,0 +1,106 @@ +# Mob 8800102 (8800102) + +rewards: +- [2430144, 1, 3, 0.1] +- [4001083, 1, 1, 0.999999] +- [1372049, 1, 1, 0.999999] +- [2020013, 1, 1, 0.999999] +- [2020015, 1, 1, 0.999999] +- [2000005, 1, 1, 0.999999] +- [1003112, 1, 1, 0.999999] +- [1003112, 1, 1, 0.999999] +- [1003112, 1, 1, 0.3] +- [1003112, 1, 1, 0.3] +- [1003112, 1, 1, 0.3] +- [2280007, 1, 1, 1.0] +- [2280008, 1, 1, 1.0] +- [2280009, 1, 1, 1.0] +- [2280010, 1, 1, 1.0] +- [2290020, 1, 1, 0.0225] +- [1302056, 1, 1, 0.0315] +- [1312030, 1, 1, 0.0315] +- [1322045, 1, 1, 0.0315] +- [1332051, 1, 1, 0.0225] +- [1332052, 1, 1, 0.0225] +- [1372010, 1, 1, 0.0315] +- [1382035, 1, 1, 0.0315] +- [1402035, 1, 1, 0.0315] +- [1412021, 1, 1, 0.0315] +- [1422027, 1, 1, 0.0315] +- [1432030, 1, 1, 0.0225] +- [1442044, 1, 1, 0.0315] +- [1452019, 1, 1, 0.0225] +- [1452020, 1, 1, 0.0225] +- [1462015, 1, 1, 0.0225] +- [1462016, 1, 1, 0.0225] +- [1472053, 1, 1, 0.0225] +- [2000004, 1, 1, 0.999999] +- [1482012, 1, 1, 0.0225] +- [1492012, 1, 1, 0.0225] +- [2040026, 1, 1, 0.0135] +- [2040031, 1, 1, 0.0135] +- [2040321, 1, 1, 0.0135] +- [2040328, 1, 1, 0.0135] +- [2040512, 1, 1, 0.0135] +- [2049000, 1, 1, 0.00675] +- [2049100, 1, 1, 0.0135] +- [2280013, 1, 1, 0.03] +- [2280014, 1, 1, 0.03] +- [2280015, 1, 1, 0.03] +- [2280016, 1, 1, 0.03] +- [2280026, 1, 1, 0.03] +- [2280027, 1, 1, 0.03] +- [2280028, 1, 1, 0.03] +- [2280029, 1, 1, 0.03] +- [2280030, 1, 1, 0.03] +- [2280031, 1, 1, 0.03] +- [2049300, 1, 1, 0.01] +- [2049400, 1, 1, 0.01] +- [2049301, 1, 1, 0.02] +- [2049401, 1, 1, 0.02] +- [3010127, 1, 1, 0.02] +- [2510171, 1, 1, 0.02] +- [2510172, 1, 1, 0.02] +- [2510173, 1, 1, 0.02] +- [2510174, 1, 1, 0.02] +- [2511001, 1, 1, 0.1] +- [2511005, 1, 1, 0.1] +- [2511009, 1, 1, 0.1] +- [2511013, 1, 1, 0.1] +- [2511017, 1, 1, 0.1] +- [2511025, 1, 1, 0.1] +- [2511029, 1, 1, 0.1] +- [2511033, 1, 1, 0.1] +- [2511037, 1, 1, 0.1] +- [2511043, 1, 1, 0.1] +- [2511047, 1, 1, 0.1] +- [2511051, 1, 1, 0.1] +- [2511055, 1, 1, 0.1] +- [2511064, 1, 1, 0.1] +- [2511065, 1, 1, 0.1] +- [2511066, 1, 1, 0.1] +- [2511067, 1, 1, 0.1] +- [2511068, 1, 1, 0.1] +- [2511069, 1, 1, 0.1] +- [2511103, 1, 1, 0.01] +- [2511104, 1, 1, 0.1] +- [2511106, 1, 1, 0.05] +- [2511107, 1, 1, 0.01] +- [2512053, 1, 1, 0.02] +- [2512054, 1, 1, 0.02] +- [2512055, 1, 1, 0.02] +- [2512056, 1, 1, 0.02] +- [2512057, 1, 1, 0.02] +- [2512058, 1, 1, 0.02] +- [2512059, 1, 1, 0.02] +- [2512060, 1, 1, 0.02] +- [2512061, 1, 1, 0.02] +- [2512062, 1, 1, 0.02] +- [2512063, 1, 1, 0.02] +- [2512064, 1, 1, 0.02] +- [2512065, 1, 1, 0.02] +- [2512066, 1, 1, 0.02] +- [2512067, 1, 1, 0.02] +- [2512068, 1, 1, 0.02] +- [2512261, 1, 1, 0.01] +- [2028062, 1, 1, 0.1] diff --git a/data/reward/8810122.yaml b/data/reward/8810122.yaml new file mode 100644 index 00000000..1ee81148 --- /dev/null +++ b/data/reward/8810122.yaml @@ -0,0 +1,198 @@ +# Mob 8810122 (8810122) + +rewards: +- [2290161, 1, 1, 0.2] +- [2290160, 1, 1, 0.2] +- [2290158, 1, 1, 0.2] +- [2290095, 1, 1, 0.2] +- [2290085, 1, 1, 0.2] +- [2290075, 1, 1, 0.2] +- [2290049, 1, 1, 0.2] +- [2290047, 1, 1, 0.2] +- [2430144, 1, 4, 0.4] +- [2290041, 1, 1, 0.2] +- [2290021, 1, 1, 0.2] +- [2290017, 1, 1, 0.2] +- [2041200, 1, 1, 0.999999] +- [2290125, 1, 1, 0.1] +- [2020013, 1, 1, 0.999999] +- [2020015, 1, 1, 0.999999] +- [2000005, 1, 1, 0.999999] +- [2000006, 1, 1, 1.0] +- [2000004, 1, 1, 0.999999] +- [1302056, 1, 1, 0.016695] +- [1312030, 1, 1, 0.016695] +- [1322045, 1, 1, 0.016695] +- [1332051, 1, 1, 0.011925] +- [1332052, 1, 1, 0.011925] +- [1372010, 1, 1, 0.016695] +- [1382035, 1, 1, 0.016695] +- [1402035, 1, 1, 0.016695] +- [1412021, 1, 1, 0.016695] +- [1422027, 1, 1, 0.016695] +- [1432030, 1, 1, 0.011925] +- [1442044, 1, 1, 0.016695] +- [1452019, 1, 1, 0.011925] +- [1452020, 1, 1, 0.011925] +- [1452021, 1, 1, 0.011925] +- [1462015, 1, 1, 0.011925] +- [1462016, 1, 1, 0.011925] +- [1462017, 1, 1, 0.011925] +- [1472053, 1, 1, 0.011925] +- [1372032, 1, 1, 0.016695] +- [1302059, 1, 1, 0.016695] +- [1312031, 1, 1, 0.016695] +- [1322052, 1, 1, 0.016695] +- [1332049, 1, 1, 0.011925] +- [1332050, 1, 1, 0.011925] +- [1382036, 1, 1, 0.016695] +- [1402036, 1, 1, 0.016695] +- [1412026, 1, 1, 0.016695] +- [1422028, 1, 1, 0.016695] +- [1432038, 1, 1, 0.011925] +- [1442045, 1, 1, 0.016695] +- [1452044, 1, 1, 0.011925] +- [1462039, 1, 1, 0.011925] +- [1472051, 1, 1, 0.011925] +- [1472052, 1, 1, 0.011925] +- [1122076, 1, 1, 0.999999] +- [1122076, 1, 1, 0.999999] +- [1122076, 1, 1, 0.03] +- [2290096, 1, 1, 0.8] +- [1482012, 1, 1, 0.011925] +- [1492012, 1, 1, 0.011925] +- [1482013, 1, 1, 0.011925] +- [1492013, 1, 1, 0.011925] +- [2040317, 1, 1, 0.7155] +- [2040418, 1, 1, 0.7155] +- [2040421, 1, 1, 0.7155] +- [2040512, 1, 1, 0.7155] +- [2040515, 1, 1, 0.7155] +- [2040625, 1, 1, 0.7155] +- [2049000, 1, 1, 0.35775] +- [2049100, 1, 1, 0.7155] +- [2290157, 1, 1, 0.2] +- [2049300, 1, 1, 0.01] +- [2049400, 1, 1, 0.01] +- [2049301, 1, 1, 0.04] +- [2049401, 1, 1, 0.04] +- [1342010, 1, 1, 0.02] +- [3010128, 1, 1, 0.02] +- [2290133, 1, 1, 0.2] +- [2290137, 1, 1, 0.2] +- [2290139, 1, 1, 0.2] +- [2290147, 1, 1, 0.2] +- [2290152, 1, 1, 0.2] +- [2290229, 1, 1, 0.2] +- [2290232, 1, 1, 0.2] +- [2290233, 1, 1, 0.2] +- [2290235, 1, 1, 0.2] +- [2290236, 1, 1, 0.2] +- [2290238, 1, 1, 0.2] +- [2290239, 1, 1, 0.2] +- [2290243, 1, 1, 0.2] +- [2290247, 1, 1, 0.2] +- [2290276, 1, 1, 0.2] +- [1382049, 1, 1, 0.0015] +- [1382050, 1, 1, 0.0015] +- [1382051, 1, 1, 0.0015] +- [1382052, 1, 1, 0.0015] +- [1372039, 1, 1, 0.005] +- [1372040, 1, 1, 0.005] +- [1372041, 1, 1, 0.005] +- [1372042, 1, 1, 0.005] +- [2510005, 1, 1, 0.2] +- [2510006, 1, 1, 0.2] +- [2510007, 1, 1, 0.2] +- [2510008, 1, 1, 0.2] +- [2510009, 1, 1, 0.2] +- [2510031, 1, 1, 0.2] +- [2510032, 1, 1, 0.2] +- [2510033, 1, 1, 0.2] +- [2510034, 1, 1, 0.2] +- [2510035, 1, 1, 0.2] +- [2510051, 1, 1, 0.2] +- [2510052, 1, 1, 0.2] +- [2510053, 1, 1, 0.2] +- [2510054, 1, 1, 0.2] +- [2510055, 1, 1, 0.2] +- [2510061, 1, 1, 0.2] +- [2510062, 1, 1, 0.2] +- [2510063, 1, 1, 0.2] +- [2510064, 1, 1, 0.2] +- [2510108, 1, 1, 0.2] +- [2510109, 1, 1, 0.2] +- [2510110, 1, 1, 0.2] +- [2510111, 1, 1, 0.2] +- [2510112, 1, 1, 0.2] +- [2510113, 1, 1, 0.2] +- [2510114, 1, 1, 0.2] +- [2510115, 1, 1, 0.2] +- [2510116, 1, 1, 0.2] +- [2510117, 1, 1, 0.2] +- [2510118, 1, 1, 0.2] +- [2510119, 1, 1, 0.2] +- [2510120, 1, 1, 0.2] +- [2510121, 1, 1, 0.2] +- [2510122, 1, 1, 0.2] +- [2510123, 1, 1, 0.2] +- [2510124, 1, 1, 0.2] +- [2510125, 1, 1, 0.2] +- [2510161, 1, 1, 0.2] +- [2510162, 1, 1, 0.2] +- [2510173, 1, 1, 0.03] +- [2510174, 1, 1, 0.03] +- [2510261, 1, 1, 0.02] +- [2510239, 1, 1, 0.02] +- [2510240, 1, 1, 0.02] +- [2510241, 1, 1, 0.02] +- [2510264, 1, 1, 0.02] +- [2511001, 1, 1, 0.2] +- [2511005, 1, 1, 0.2] +- [2511009, 1, 1, 0.2] +- [2511013, 1, 1, 0.2] +- [2511017, 1, 1, 0.2] +- [2511025, 1, 1, 0.2] +- [2511029, 1, 1, 0.2] +- [2511033, 1, 1, 0.2] +- [2511037, 1, 1, 0.2] +- [2511043, 1, 1, 0.2] +- [2511047, 1, 1, 0.2] +- [2511051, 1, 1, 0.2] +- [2511055, 1, 1, 0.2] +- [2511064, 1, 1, 0.2] +- [2511065, 1, 1, 0.2] +- [2511066, 1, 1, 0.2] +- [2511067, 1, 1, 0.2] +- [2511068, 1, 1, 0.2] +- [2511069, 1, 1, 0.2] +- [2511103, 1, 1, 0.02] +- [2511104, 1, 1, 0.2] +- [2511106, 1, 1, 0.1] +- [2511107, 1, 1, 0.02] +- [2512029, 1, 1, 0.2] +- [2512053, 1, 1, 0.04] +- [2512054, 1, 1, 0.04] +- [2512055, 1, 1, 0.04] +- [2512056, 1, 1, 0.04] +- [2512057, 1, 1, 0.04] +- [2512058, 1, 1, 0.04] +- [2512059, 1, 1, 0.04] +- [2512060, 1, 1, 0.04] +- [2512061, 1, 1, 0.04] +- [2512062, 1, 1, 0.04] +- [2512063, 1, 1, 0.04] +- [2512064, 1, 1, 0.04] +- [2512065, 1, 1, 0.04] +- [2512066, 1, 1, 0.04] +- [2512067, 1, 1, 0.04] +- [2512068, 1, 1, 0.04] +- [2512261, 1, 1, 0.02] +- [2028062, 1, 1, 0.2] +- [1532014, 1, 1, 0.01] +- [2290323, 1, 1, 0.05] +- [2290325, 1, 1, 0.05] +- [2290327, 1, 1, 0.05] +- [2290330, 1, 1, 0.05] +- [2290332, 1, 1, 0.1] +- [2290334, 1, 1, 0.05] diff --git a/data/reward/8820001.yaml b/data/reward/8820001.yaml new file mode 100644 index 00000000..a2824d15 --- /dev/null +++ b/data/reward/8820001.yaml @@ -0,0 +1,205 @@ +# Pink Bean (8820001) + +rewards: +- [4020009, 1, 3, 0.625] +- [4021010, 1, 3, 0.1] +- [4020009, 1, 3, 0.625] +- [4021010, 1, 3, 0.1] +- [4020009, 1, 3, 0.625] +- [4021010, 1, 3, 0.1] +- [4020009, 1, 3, 0.625] +- [4021010, 1, 3, 0.1] +- [4020009, 1, 3, 0.625] +- [4021010, 1, 3, 0.1] +- [4020009, 1, 3, 0.625] +- [4021010, 1, 3, 0.1] +- [4020009, 1, 3, 0.625] +- [4021010, 1, 3, 0.1] +- [1102172, 1, 1, 0.005] +- [1102172, 1, 1, 0.005] +- [1102172, 1, 1, 0.005] +- [1032031, 1, 1, 0.005] +- [1032031, 1, 1, 0.005] +- [1032031, 1, 1, 0.005] +- [1002790, 1, 1, 0.01] +- [1002791, 1, 1, 0.01] +- [1002792, 1, 1, 0.01] +- [1002793, 1, 1, 0.01] +- [1002794, 1, 1, 0.01] +- [1082239, 1, 1, 0.01] +- [1082240, 1, 1, 0.01] +- [1082241, 1, 1, 0.01] +- [1082242, 1, 1, 0.01] +- [1082243, 1, 1, 0.01] +- [1052160, 1, 1, 0.01] +- [1052161, 1, 1, 0.01] +- [1052162, 1, 1, 0.01] +- [1052163, 1, 1, 0.01] +- [1052164, 1, 1, 0.01] +- [1072361, 1, 1, 0.01] +- [1072362, 1, 1, 0.01] +- [1072363, 1, 1, 0.01] +- [1072364, 1, 1, 0.01] +- [1072365, 1, 1, 0.01] +- [1302086, 1, 1, 0.01] +- [1312038, 1, 1, 0.01] +- [1322061, 1, 1, 0.01] +- [1332075, 1, 1, 0.01] +- [1332076, 1, 1, 0.01] +- [1372045, 1, 1, 0.01] +- [1382059, 1, 1, 0.01] +- [1402047, 1, 1, 0.01] +- [1412034, 1, 1, 0.01] +- [1422038, 1, 1, 0.01] +- [1432049, 1, 1, 0.01] +- [1442067, 1, 1, 0.01] +- [1452059, 1, 1, 0.01] +- [1462051, 1, 1, 0.01] +- [1472071, 1, 1, 0.01] +- [1482024, 1, 1, 0.01] +- [1492025, 1, 1, 0.01] +- [1002790, 1, 1, 0.01] +- [1002791, 1, 1, 0.01] +- [1002792, 1, 1, 0.01] +- [1002793, 1, 1, 0.01] +- [1002794, 1, 1, 0.01] +- [1082239, 1, 1, 0.01] +- [1082240, 1, 1, 0.01] +- [1082241, 1, 1, 0.01] +- [1082242, 1, 1, 0.01] +- [1082243, 1, 1, 0.01] +- [1052160, 1, 1, 0.01] +- [1052161, 1, 1, 0.01] +- [1052162, 1, 1, 0.01] +- [1052163, 1, 1, 0.01] +- [1052164, 1, 1, 0.01] +- [1072361, 1, 1, 0.01] +- [1072362, 1, 1, 0.01] +- [1072363, 1, 1, 0.01] +- [1072364, 1, 1, 0.01] +- [1072365, 1, 1, 0.01] +- [1302086, 1, 1, 0.01] +- [1312038, 1, 1, 0.01] +- [1322061, 1, 1, 0.01] +- [1332075, 1, 1, 0.01] +- [1332076, 1, 1, 0.01] +- [1372045, 1, 1, 0.01] +- [1382059, 1, 1, 0.01] +- [1402047, 1, 1, 0.01] +- [1412034, 1, 1, 0.01] +- [1422038, 1, 1, 0.01] +- [1432049, 1, 1, 0.01] +- [1442067, 1, 1, 0.01] +- [1452059, 1, 1, 0.01] +- [1462051, 1, 1, 0.01] +- [1472071, 1, 1, 0.01] +- [1482024, 1, 1, 0.01] +- [1492025, 1, 1, 0.01] +- [1002776, 1, 1, 0.0025] +- [1002777, 1, 1, 0.0025] +- [1002778, 1, 1, 0.0025] +- [1002779, 1, 1, 0.0025] +- [1002780, 1, 1, 0.0025] +- [1082234, 1, 1, 0.0025] +- [1082235, 1, 1, 0.0025] +- [1082236, 1, 1, 0.0025] +- [1082237, 1, 1, 0.0025] +- [1082238, 1, 1, 0.0025] +- [1052155, 1, 1, 0.0025] +- [1052156, 1, 1, 0.0025] +- [1052157, 1, 1, 0.0025] +- [1052158, 1, 1, 0.0025] +- [1052159, 1, 1, 0.0025] +- [1072355, 1, 1, 0.0025] +- [1072356, 1, 1, 0.0025] +- [1072357, 1, 1, 0.0025] +- [1072358, 1, 1, 0.0025] +- [1072359, 1, 1, 0.0025] +- [1092057, 1, 1, 0.0025] +- [1092058, 1, 1, 0.0025] +- [1092059, 1, 1, 0.0025] +- [1302081, 1, 1, 0.0025] +- [1312037, 1, 1, 0.0025] +- [1322060, 1, 1, 0.0025] +- [1332073, 1, 1, 0.0025] +- [1332074, 1, 1, 0.0025] +- [1372044, 1, 1, 0.0025] +- [1382057, 1, 1, 0.0025] +- [1402046, 1, 1, 0.0025] +- [1412033, 1, 1, 0.0025] +- [1422037, 1, 1, 0.0025] +- [1432047, 1, 1, 0.0025] +- [1442063, 1, 1, 0.0025] +- [1452057, 1, 1, 0.0025] +- [1462050, 1, 1, 0.0025] +- [1472068, 1, 1, 0.0025] +- [1482023, 1, 1, 0.0025] +- [1492023, 1, 1, 0.0025] +- [2430144, 2, 5, 0.5] +- [1122080, 1, 1, 0.999999] +- [4020009, 1, 3, 0.625] +- [4021010, 1, 3, 0.1] +- [4020009, 1, 3, 0.625] +- [4021010, 1, 3, 0.1] +- [4020009, 1, 3, 0.625] +- [4021010, 1, 3, 0.1] +- [4020009, 1, 3, 0.625] +- [4021010, 1, 3, 0.1] +- [4020009, 1, 3, 0.625] +- [4021010, 1, 3, 0.1] +- [4020009, 1, 3, 0.625] +- [4021010, 1, 3, 0.1] +- [4020009, 1, 3, 0.625] +- [4021010, 1, 3, 0.1] +- [1102172, 1, 1, 0.005] +- [1102172, 1, 1, 0.005] +- [1102172, 1, 1, 0.005] +- [1032031, 1, 1, 0.005] +- [1032031, 1, 1, 0.005] +- [1032031, 1, 1, 0.005] +- [2290125, 1, 1, 0.25] +- [2290125, 1, 1, 0.25] +- [2290125, 1, 1, 0.25] +- [2000006, 1, 5, 0.03125] +- [2000006, 1, 5, 0.03125] +- [2000018, 1, 5, 0.0625] +- [2000019, 1, 5, 0.0375] +- [2000019, 1, 5, 0.0375] +- [2000019, 1, 5, 0.0375] +- [2000019, 1, 5, 0.0375] +- [2000019, 1, 5, 0.0375] +- [2020013, 1, 5, 0.0375] +- [2020013, 1, 5, 0.0375] +- [2020013, 1, 5, 0.0375] +- [2020014, 1, 3, 0.0375] +- [2020014, 1, 3, 0.0375] +- [2020014, 1, 3, 0.0375] +- [2020014, 1, 3, 0.0375] +- [2020014, 1, 3, 0.0375] +- [2020015, 1, 5, 0.0375] +- [2020015, 1, 5, 0.0375] +- [2020015, 1, 5, 0.0375] +- [2000012, 1, 3, 0.0375] +- [2000012, 1, 3, 0.0375] +- [2000012, 1, 3, 0.0375] +- [2049300, 1, 1, 0.015] +- [2049400, 1, 1, 0.015] +- [2049301, 1, 1, 0.15] +- [2049401, 1, 1, 0.15] +- [1342011, 1, 1, 0.0025] +- [1342012, 1, 1, 0.01] +- [3010073, 1, 1, 0.005] +- [1122080, 1, 1, 0.999999] +- [2041213, 1, 1, 0.999999] +- [2041214, 1, 1, 0.999999] +- [1382049, 1, 1, 0.0025] +- [1382050, 1, 1, 0.0025] +- [1382051, 1, 1, 0.0025] +- [1382052, 1, 1, 0.0025] +- [1372039, 1, 1, 0.01] +- [1372040, 1, 1, 0.01] +- [1372041, 1, 1, 0.01] +- [1372042, 1, 1, 0.01] +- [2028062, 1, 1, 0.5] +- [1532016, 1, 1, 0.01] +- [1532015, 1, 1, 0.005] diff --git a/data/reward/8840000.yaml b/data/reward/8840000.yaml new file mode 100644 index 00000000..64e18c69 --- /dev/null +++ b/data/reward/8840000.yaml @@ -0,0 +1,43 @@ +# Mob 8840000 (8840000) + +rewards: +- [2430144, 1, 4, 0.2] +- [2430158, 1, 1, 0.999999] +- [2430158, 1, 1, 0.999999] +- [2430158, 1, 1, 0.999999] +- [2430158, 1, 1, 0.2] +- [2430158, 1, 1, 0.2] +- [4310010, 1, 1, 0.1] +- [4310009, 1, 1, 0.1] +- [4310009, 1, 1, 0.1] +- [4000630, 50, 100, 0.999999] +- [4000630, 50, 100, 0.999999] +- [4000630, 50, 100, 0.999999] +- [1462094, 1, 1, 0.01] +- [1492081, 1, 1, 0.01] +- [1382102, 1, 1, 0.01] +- [1372080, 1, 1, 0.01] +- [1442113, 1, 1, 0.01] +- [1452107, 1, 1, 0.01] +- [1432084, 1, 1, 0.01] +- [1402091, 1, 1, 0.01] +- [1332126, 1, 1, 0.01] +- [1302149, 1, 1, 0.01] +- [1472118, 1, 1, 0.01] +- [1482080, 1, 1, 0.01] +- [2510133, 1, 1, 0.01] +- [2510168, 1, 1, 0.01] +- [2510261, 1, 1, 0.01] +- [2510239, 1, 1, 0.01] +- [2512091, 1, 1, 0.01] +- [2512104, 1, 1, 0.01] +- [2512124, 1, 1, 0.01] +- [2512264, 1, 1, 0.1] +- [2028062, 1, 1, 0.2] +- [1362020, 1, 1, 0.01] +- [3010188, 1, 1, 0.01] +- [1522019, 1, 1, 0.01] +- [1532019, 1, 1, 0.01] +- [5010020, 1, 1, 0.0007] +- [5010020, 1, 1, 0.0007] +- [5010020, 1, 1, 0.0007] diff --git a/data/reward/8850011.yaml b/data/reward/8850011.yaml new file mode 100644 index 00000000..33c76d1a --- /dev/null +++ b/data/reward/8850011.yaml @@ -0,0 +1,107 @@ +# Mob 8850011 (8850011) + +rewards: +- [1082303, 1, 1, 0.01] +- [1082302, 1, 1, 0.01] +- [1082301, 1, 1, 0.01] +- [1082300, 1, 1, 0.01] +- [1082299, 1, 1, 0.0025] +- [1082298, 1, 1, 0.0025] +- [1082297, 1, 1, 0.0025] +- [1082296, 1, 1, 0.0025] +- [1082295, 1, 1, 0.0025] +- [1102284, 1, 1, 0.01] +- [1102283, 1, 1, 0.01] +- [1102282, 1, 1, 0.01] +- [1102281, 1, 1, 0.01] +- [1102280, 1, 1, 0.01] +- [1102279, 1, 1, 0.0025] +- [1102278, 1, 1, 0.0025] +- [1102277, 1, 1, 0.0025] +- [1102276, 1, 1, 0.0025] +- [1102275, 1, 1, 0.0025] +- [1003181, 1, 1, 0.01] +- [1003180, 1, 1, 0.01] +- [1003179, 1, 1, 0.01] +- [1003178, 1, 1, 0.01] +- [1003177, 1, 1, 0.01] +- [1003176, 1, 1, 0.0025] +- [1003175, 1, 1, 0.0025] +- [1003174, 1, 1, 0.0025] +- [1003173, 1, 1, 0.0025] +- [1003172, 1, 1, 0.0025] +- [1492086, 1, 1, 0.01] +- [1482085, 1, 1, 0.01] +- [1472123, 1, 1, 0.01] +- [1462100, 1, 1, 0.01] +- [1452112, 1, 1, 0.01] +- [1442117, 1, 1, 0.01] +- [1432087, 1, 1, 0.01] +- [1422067, 1, 1, 0.01] +- [1072485, 1, 1, 0.0025] +- [1052323, 1, 1, 0.01] +- [1052322, 1, 1, 0.01] +- [1052321, 1, 1, 0.01] +- [1052320, 1, 1, 0.01] +- [1052319, 1, 1, 0.01] +- [1052318, 1, 1, 0.025] +- [1052317, 1, 1, 0.0025] +- [1052316, 1, 1, 0.0025] +- [1052315, 1, 1, 0.0025] +- [1052314, 1, 1, 0.0025] +- [1082304, 1, 1, 0.01] +- [1412066, 1, 1, 0.01] +- [1402096, 1, 1, 0.01] +- [1382105, 1, 1, 0.01] +- [1372085, 1, 1, 0.01] +- [1342036, 1, 1, 0.01] +- [1332131, 1, 1, 0.01] +- [1322097, 1, 1, 0.01] +- [1312066, 1, 1, 0.01] +- [1302153, 1, 1, 0.01] +- [1492085, 1, 1, 0.0025] +- [1482084, 1, 1, 0.0025] +- [1472122, 1, 1, 0.0025] +- [1462099, 1, 1, 0.0025] +- [1452111, 1, 1, 0.0025] +- [1442116, 1, 1, 0.0025] +- [1432086, 1, 1, 0.0025] +- [1422066, 1, 1, 0.0025] +- [1412065, 1, 1, 0.0025] +- [1402095, 1, 1, 0.0025] +- [1382104, 1, 1, 0.0025] +- [1372084, 1, 1, 0.0025] +- [1342035, 1, 1, 0.0025] +- [1332130, 1, 1, 0.0025] +- [1322096, 1, 1, 0.0025] +- [1312065, 1, 1, 0.0025] +- [1302152, 1, 1, 0.0025] +- [4000659, 1, 1, 0.4] +- [1072486, 1, 1, 0.0025] +- [1072487, 1, 1, 0.0025] +- [1072488, 1, 1, 0.0025] +- [1072489, 1, 1, 0.0025] +- [1072490, 1, 1, 0.01] +- [1072491, 1, 1, 0.01] +- [1072492, 1, 1, 0.01] +- [1072493, 1, 1, 0.01] +- [1072494, 1, 1, 0.01] +- [2430144, 5, 10, 0.999999] +- [2049300, 1, 1, 0.03] +- [2049400, 1, 1, 0.03] +- [2049301, 1, 1, 0.3] +- [2049401, 1, 1, 0.3] +- [1382049, 1, 1, 0.005] +- [1382050, 1, 1, 0.005] +- [1382051, 1, 1, 0.005] +- [1382052, 1, 1, 0.005] +- [1372039, 1, 1, 0.02] +- [1372040, 1, 1, 0.02] +- [1372041, 1, 1, 0.02] +- [1372042, 1, 1, 0.02] +- [2290125, 2, 3, 0.999999] +- [2290125, 2, 3, 0.999999] +- [2290125, 2, 3, 0.999999] +- [2028062, 1, 1, 0.999999] +- [1532017, 1, 1, 0.01] +- [1532018, 1, 1, 0.005] diff --git a/data/reward/8850012.yaml b/data/reward/8850012.yaml new file mode 100644 index 00000000..e39e049f --- /dev/null +++ b/data/reward/8850012.yaml @@ -0,0 +1,4 @@ +# Mob 8850012 (8850012) + +rewards: +- [4000659, 1, 1, 0.4] diff --git a/data/reward/9000000.yaml b/data/reward/9000000.yaml new file mode 100644 index 00000000..e7409bbf --- /dev/null +++ b/data/reward/9000000.yaml @@ -0,0 +1,4 @@ +# Mob 9000000 (9000000) + +rewards: +- [4031013, 1, 1, 0.3] diff --git a/data/reward/9000068.yaml b/data/reward/9000068.yaml new file mode 100644 index 00000000..5a6b0ab7 --- /dev/null +++ b/data/reward/9000068.yaml @@ -0,0 +1,10 @@ +# Mob 9000068 + +rewards: + - [ 0, 160, 240, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004001, 1, 1, 0.001000 ] # Wisdom Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020002, 1, 1, 0.002000 ] # AquaMarine Ore diff --git a/data/reward/9000084.yaml b/data/reward/9000084.yaml new file mode 100644 index 00000000..63d4d19d --- /dev/null +++ b/data/reward/9000084.yaml @@ -0,0 +1,10 @@ +# Mob 9000084 + +rewards: + - [ 0, 170, 255, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020003, 1, 1, 0.002000 ] # Emerald Ore diff --git a/data/reward/9001004.yaml b/data/reward/9001004.yaml new file mode 100644 index 00000000..6311496d --- /dev/null +++ b/data/reward/9001004.yaml @@ -0,0 +1,5 @@ +# Shadow Kyrin (9001004) + +rewards: +- [0, 200, 400, 0.2] +- [4031059, 1, 1, 1.0] diff --git a/data/reward/9001005.yaml b/data/reward/9001005.yaml new file mode 100644 index 00000000..3f50bd4e --- /dev/null +++ b/data/reward/9001005.yaml @@ -0,0 +1,4 @@ +# OctoPirate (9001005) + +rewards: + - [ 4031857, 1, 1, 0.999999 ] diff --git a/data/reward/9001006.yaml b/data/reward/9001006.yaml new file mode 100644 index 00000000..6f75c028 --- /dev/null +++ b/data/reward/9001006.yaml @@ -0,0 +1,4 @@ +# OctoPirate (9001006) + +rewards: + - [ 4031856, 1, 1, 0.999999 ] diff --git a/data/reward/9001009.yaml b/data/reward/9001009.yaml new file mode 100644 index 00000000..b82092d3 --- /dev/null +++ b/data/reward/9001009.yaml @@ -0,0 +1,9 @@ +# Baroq (9001009) - Master of Disguise Boss (3rd Job Advancement) +# Drops quest-specific items based on which Cygnus class you are + +rewards: + - [ 4032101, 1, 1, 1.000000, 20301 ] # Shinsoo's Teardrop (Dawn Warrior) - Quest 20301 + - [ 4032102, 1, 1, 1.000000, 20302 ] # Shinsoo's Teardrop (Blaze Wizard) - Quest 20302 + - [ 4032103, 1, 1, 1.000000, 20303 ] # Shinsoo's Teardrop (Wind Archer) - Quest 20303 + - [ 4032104, 1, 1, 1.000000, 20304 ] # Shinsoo's Teardrop (Night Walker) - Quest 20304 + - [ 4032105, 1, 1, 1.000000, 20305 ] # Shinsoo's Teardrop (Thunder Breaker) - Quest 20305 diff --git a/data/reward/9001010.yaml b/data/reward/9001010.yaml new file mode 100644 index 00000000..b7bc14e2 --- /dev/null +++ b/data/reward/9001010.yaml @@ -0,0 +1,4 @@ +# Black Witch (9001010) - 4th Job Advancement Boss + +rewards: + - [ 4001158, 1, 1, 1.000000 ] # Black Witch's Curse (4th job proof item) diff --git a/data/reward/9001011.yaml b/data/reward/9001011.yaml new file mode 100644 index 00000000..fde1dcdc --- /dev/null +++ b/data/reward/9001011.yaml @@ -0,0 +1,9 @@ +# Mimis (9001011) - 2nd Job Advancement Exam +# Drops quest-specific items based on which Cygnus class you are + +rewards: + - [ 4032096, 1, 1, 1.000000, 20201 ] # Proof of Test (Dawn Warrior) - Quest 20201 + - [ 4032097, 1, 1, 1.000000, 20202 ] # Proof of Test (Blaze Wizard) - Quest 20202 + - [ 4032098, 1, 1, 1.000000, 20203 ] # Proof of Test (Wind Archer) - Quest 20203 + - [ 4032099, 1, 1, 1.000000, 20204 ] # Proof of Test (Night Walker) - Quest 20204 + - [ 4032100, 1, 1, 1.000000, 20205 ] # Proof of Test (Thunder Breaker) - Quest 20205 \ No newline at end of file diff --git a/data/reward/9001012.yaml b/data/reward/9001012.yaml new file mode 100644 index 00000000..7ad0cb43 --- /dev/null +++ b/data/reward/9001012.yaml @@ -0,0 +1,4 @@ +# Scarred Bear (9001012) + +rewards: +- [4032311, 1, 1, 0.999999] diff --git a/data/reward/9001013.yaml b/data/reward/9001013.yaml new file mode 100644 index 00000000..a21058c0 --- /dev/null +++ b/data/reward/9001013.yaml @@ -0,0 +1,4 @@ +# Thief Crow (9001013) - Quest Boss for Aran 3rd Job Quest 21301 + +rewards: + - [ 4032339, 1, 1, 1.000000, 21301 ] # Red Jade / Stolen Gem (Quest 21301 - Catch that Thief!) diff --git a/data/reward/9001023.yaml b/data/reward/9001023.yaml new file mode 100644 index 00000000..e0b3053e --- /dev/null +++ b/data/reward/9001023.yaml @@ -0,0 +1,4 @@ +# Mob 9001023 (9001023) + +rewards: +- [4000591, 1, 1, 0.999999] diff --git a/data/reward/9101005.yaml b/data/reward/9101005.yaml new file mode 100644 index 00000000..bfa90968 --- /dev/null +++ b/data/reward/9101005.yaml @@ -0,0 +1,4 @@ +# Mob 9101005 (9101005) + +rewards: +- [1003068, 1, 1, 0.0015] diff --git a/data/reward/9240549.yaml b/data/reward/9240549.yaml new file mode 100644 index 00000000..392aa4c4 --- /dev/null +++ b/data/reward/9240549.yaml @@ -0,0 +1,5 @@ +# Mob 9240549 (9240549) + +rewards: +- [2290052, 1, 1, 0.1] +- [2430144, 1, 2, 0.1] diff --git a/data/reward/9300004.yaml b/data/reward/9300004.yaml new file mode 100644 index 00000000..ed3ce5c3 --- /dev/null +++ b/data/reward/9300004.yaml @@ -0,0 +1,4 @@ +# Mimic (9300004) + +rewards: +- [4001008, 1, 1, 0.999999] diff --git a/data/reward/9300095.yaml b/data/reward/9300095.yaml new file mode 100644 index 00000000..5586c694 --- /dev/null +++ b/data/reward/9300095.yaml @@ -0,0 +1,6 @@ +# Lycanthrope the Kidnapper (9300095) + +rewards: +- [2280004, 1, 1, 0.007] +- [2280005, 1, 1, 0.007] +- [2280006, 1, 1, 0.007] diff --git a/data/reward/9300147.yaml b/data/reward/9300147.yaml new file mode 100644 index 00000000..7b8727e7 --- /dev/null +++ b/data/reward/9300147.yaml @@ -0,0 +1,4 @@ +# Homunculus (9300147) + +rewards: +- [4001132, 1, 1, 0.1] diff --git a/data/reward/9300148.yaml b/data/reward/9300148.yaml new file mode 100644 index 00000000..c642ad9a --- /dev/null +++ b/data/reward/9300148.yaml @@ -0,0 +1,4 @@ +# Neo Huroid (9300148) + +rewards: +- [4001133, 1, 1, 0.1] diff --git a/data/reward/9300169.yaml b/data/reward/9300169.yaml new file mode 100644 index 00000000..4734f6dd --- /dev/null +++ b/data/reward/9300169.yaml @@ -0,0 +1,4 @@ +# Ratz from Another Dimension (9300169) + +rewards: +- [4001156, 1, 1, 0.999999] diff --git a/data/reward/9300170.yaml b/data/reward/9300170.yaml new file mode 100644 index 00000000..1cfb62d0 --- /dev/null +++ b/data/reward/9300170.yaml @@ -0,0 +1,4 @@ +# Black Ratz from Another Dimension (9300170) + +rewards: +- [4001156, 1, 1, 0.999999] diff --git a/data/reward/9300171.yaml b/data/reward/9300171.yaml new file mode 100644 index 00000000..ac7ad30d --- /dev/null +++ b/data/reward/9300171.yaml @@ -0,0 +1,4 @@ +# Bloctopus from Another Dimension (9300171) + +rewards: +- [4001156, 1, 1, 0.999999] diff --git a/data/reward/9300173.yaml b/data/reward/9300173.yaml new file mode 100644 index 00000000..18219dee --- /dev/null +++ b/data/reward/9300173.yaml @@ -0,0 +1,4 @@ +# Poisoned Stone Bug (9300173) + +rewards: +- [4001161, 1, 1, 0.999999] diff --git a/data/reward/9300217.yaml b/data/reward/9300217.yaml new file mode 100644 index 00000000..0696883d --- /dev/null +++ b/data/reward/9300217.yaml @@ -0,0 +1,7 @@ +# Blue Snail (9300217) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300218.yaml b/data/reward/9300218.yaml new file mode 100644 index 00000000..ce02f1b4 --- /dev/null +++ b/data/reward/9300218.yaml @@ -0,0 +1,7 @@ +# Red Snail (9300218) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300219.yaml b/data/reward/9300219.yaml new file mode 100644 index 00000000..6342b4bb --- /dev/null +++ b/data/reward/9300219.yaml @@ -0,0 +1,7 @@ +# Stump (9300219) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300220.yaml b/data/reward/9300220.yaml new file mode 100644 index 00000000..70ac453c --- /dev/null +++ b/data/reward/9300220.yaml @@ -0,0 +1,7 @@ +# Axe Stump (9300220) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300221.yaml b/data/reward/9300221.yaml new file mode 100644 index 00000000..8f693021 --- /dev/null +++ b/data/reward/9300221.yaml @@ -0,0 +1,7 @@ +# Cactus (9300221) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300222.yaml b/data/reward/9300222.yaml new file mode 100644 index 00000000..593a6f18 --- /dev/null +++ b/data/reward/9300222.yaml @@ -0,0 +1,7 @@ +# Royal Cactus (9300222) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300223.yaml b/data/reward/9300223.yaml new file mode 100644 index 00000000..68889167 --- /dev/null +++ b/data/reward/9300223.yaml @@ -0,0 +1,7 @@ +# Slime (9300223) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300224.yaml b/data/reward/9300224.yaml new file mode 100644 index 00000000..7611830b --- /dev/null +++ b/data/reward/9300224.yaml @@ -0,0 +1,7 @@ +# Black Sheep (9300224) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300225.yaml b/data/reward/9300225.yaml new file mode 100644 index 00000000..7bfe3fd8 --- /dev/null +++ b/data/reward/9300225.yaml @@ -0,0 +1,7 @@ +# Lupin (9300225) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300226.yaml b/data/reward/9300226.yaml new file mode 100644 index 00000000..c07b5b6e --- /dev/null +++ b/data/reward/9300226.yaml @@ -0,0 +1,7 @@ +# Zombie Lupin (9300226) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300227.yaml b/data/reward/9300227.yaml new file mode 100644 index 00000000..1dd02261 --- /dev/null +++ b/data/reward/9300227.yaml @@ -0,0 +1,7 @@ +# Lorang (9300227) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300228.yaml b/data/reward/9300228.yaml new file mode 100644 index 00000000..b8e7e120 --- /dev/null +++ b/data/reward/9300228.yaml @@ -0,0 +1,7 @@ +# Clang (9300228) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300229.yaml b/data/reward/9300229.yaml new file mode 100644 index 00000000..d1bc8b19 --- /dev/null +++ b/data/reward/9300229.yaml @@ -0,0 +1,8 @@ +# Orange Mushroom (9300229) + +rewards: + - [2022430, 1, 1, 0.7] + - [2022431, 1, 1, 0.7] + - [2022432, 1, 1, 0.7] + - [2022433, 1, 1, 0.7] + - [ 4032314, 1, 1, 0.050000, 21709 ] # Orange Mushroom Horn \ No newline at end of file diff --git a/data/reward/9300230.yaml b/data/reward/9300230.yaml new file mode 100644 index 00000000..78d5ca41 --- /dev/null +++ b/data/reward/9300230.yaml @@ -0,0 +1,7 @@ +# Platoon Chronos (9300230) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300231.yaml b/data/reward/9300231.yaml new file mode 100644 index 00000000..5174aaf1 --- /dev/null +++ b/data/reward/9300231.yaml @@ -0,0 +1,7 @@ +# Master Chronos (9300231) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300232.yaml b/data/reward/9300232.yaml new file mode 100644 index 00000000..85ea6b48 --- /dev/null +++ b/data/reward/9300232.yaml @@ -0,0 +1,7 @@ +# Tick (9300232) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300233.yaml b/data/reward/9300233.yaml new file mode 100644 index 00000000..ad694b65 --- /dev/null +++ b/data/reward/9300233.yaml @@ -0,0 +1,7 @@ +# Tick-Tock (9300233) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300234.yaml b/data/reward/9300234.yaml new file mode 100644 index 00000000..a9187eaa --- /dev/null +++ b/data/reward/9300234.yaml @@ -0,0 +1,7 @@ +# Ligator (9300234) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300235.yaml b/data/reward/9300235.yaml new file mode 100644 index 00000000..6b06b001 --- /dev/null +++ b/data/reward/9300235.yaml @@ -0,0 +1,7 @@ +# Croko (9300235) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300236.yaml b/data/reward/9300236.yaml new file mode 100644 index 00000000..bc8e57fa --- /dev/null +++ b/data/reward/9300236.yaml @@ -0,0 +1,7 @@ +# Luster Pixie (9300236) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300237.yaml b/data/reward/9300237.yaml new file mode 100644 index 00000000..ab923d21 --- /dev/null +++ b/data/reward/9300237.yaml @@ -0,0 +1,7 @@ +# Ghost Pixie (9300237) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300238.yaml b/data/reward/9300238.yaml new file mode 100644 index 00000000..20707c01 --- /dev/null +++ b/data/reward/9300238.yaml @@ -0,0 +1,7 @@ +# Zombie Mushroom (9300238) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300239.yaml b/data/reward/9300239.yaml new file mode 100644 index 00000000..8994ac16 --- /dev/null +++ b/data/reward/9300239.yaml @@ -0,0 +1,7 @@ +# Zeta (9300239) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300240.yaml b/data/reward/9300240.yaml new file mode 100644 index 00000000..618f7d59 --- /dev/null +++ b/data/reward/9300240.yaml @@ -0,0 +1,7 @@ +# Ultra Gray (9300240) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300241.yaml b/data/reward/9300241.yaml new file mode 100644 index 00000000..175e954b --- /dev/null +++ b/data/reward/9300241.yaml @@ -0,0 +1,7 @@ +# Kru (9300241) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300242.yaml b/data/reward/9300242.yaml new file mode 100644 index 00000000..ae5e01d3 --- /dev/null +++ b/data/reward/9300242.yaml @@ -0,0 +1,7 @@ +# Captain (9300242) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300243.yaml b/data/reward/9300243.yaml new file mode 100644 index 00000000..6ff457ba --- /dev/null +++ b/data/reward/9300243.yaml @@ -0,0 +1,7 @@ +# Samiho (9300243) - Mu Lung Dojo + +rewards: + - [ 2022430, 1, 1, 0.700000 ] + - [ 2022431, 1, 1, 0.700000 ] + - [ 2022432, 1, 1, 0.700000 ] + - [ 2022433, 1, 1, 0.700000 ] diff --git a/data/reward/9300244.yaml b/data/reward/9300244.yaml new file mode 100644 index 00000000..9296af6e --- /dev/null +++ b/data/reward/9300244.yaml @@ -0,0 +1,7 @@ +# Grizzly (9300244) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300245.yaml b/data/reward/9300245.yaml new file mode 100644 index 00000000..06e3586b --- /dev/null +++ b/data/reward/9300245.yaml @@ -0,0 +1,7 @@ +# Panda (9300245) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300246.yaml b/data/reward/9300246.yaml new file mode 100644 index 00000000..8c1f523a --- /dev/null +++ b/data/reward/9300246.yaml @@ -0,0 +1,7 @@ +# Tree Road (9300246) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300247.yaml b/data/reward/9300247.yaml new file mode 100644 index 00000000..72e1cfed --- /dev/null +++ b/data/reward/9300247.yaml @@ -0,0 +1,7 @@ +# Stone Bug (9300247) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300248.yaml b/data/reward/9300248.yaml new file mode 100644 index 00000000..6fa9d9e6 --- /dev/null +++ b/data/reward/9300248.yaml @@ -0,0 +1,7 @@ +# Sage Cat (9300248) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300249.yaml b/data/reward/9300249.yaml new file mode 100644 index 00000000..9ffa5273 --- /dev/null +++ b/data/reward/9300249.yaml @@ -0,0 +1,7 @@ +# Tauromacis (9300249) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300250.yaml b/data/reward/9300250.yaml new file mode 100644 index 00000000..90a7fe79 --- /dev/null +++ b/data/reward/9300250.yaml @@ -0,0 +1,7 @@ +# Taurospear (9300250) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300251.yaml b/data/reward/9300251.yaml new file mode 100644 index 00000000..6469121e --- /dev/null +++ b/data/reward/9300251.yaml @@ -0,0 +1,7 @@ +# Lucida (9300251) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300252.yaml b/data/reward/9300252.yaml new file mode 100644 index 00000000..d3a88dc4 --- /dev/null +++ b/data/reward/9300252.yaml @@ -0,0 +1,7 @@ +# Reinforced Iron Mutae (9300252) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300253.yaml b/data/reward/9300253.yaml new file mode 100644 index 00000000..c1389375 --- /dev/null +++ b/data/reward/9300253.yaml @@ -0,0 +1,7 @@ +# Reinforced Mithril Mutae (9300253) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300254.yaml b/data/reward/9300254.yaml new file mode 100644 index 00000000..b440e40f --- /dev/null +++ b/data/reward/9300254.yaml @@ -0,0 +1,7 @@ +# Reinforced Iron Mutae (9300254) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300255.yaml b/data/reward/9300255.yaml new file mode 100644 index 00000000..2cd59388 --- /dev/null +++ b/data/reward/9300255.yaml @@ -0,0 +1,7 @@ +# Mithril Mutae (9300255) + +rewards: + - [2022430, 1, 1, 0.7] + - [2022431, 1, 1, 0.7] + - [2022432, 1, 1, 0.7] + - [2022433, 1, 1, 0.7] diff --git a/data/reward/9300256.yaml b/data/reward/9300256.yaml new file mode 100644 index 00000000..e80d2050 --- /dev/null +++ b/data/reward/9300256.yaml @@ -0,0 +1,7 @@ +# Transforming Doll Machine (Before) (9300256) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300257.yaml b/data/reward/9300257.yaml new file mode 100644 index 00000000..9aefbe3a --- /dev/null +++ b/data/reward/9300257.yaml @@ -0,0 +1,7 @@ +# Transforming Doll Machine (After) (9300257) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300258.yaml b/data/reward/9300258.yaml new file mode 100644 index 00000000..3663c282 --- /dev/null +++ b/data/reward/9300258.yaml @@ -0,0 +1,7 @@ +# Yeti (9300258) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300259.yaml b/data/reward/9300259.yaml new file mode 100644 index 00000000..2784cd20 --- /dev/null +++ b/data/reward/9300259.yaml @@ -0,0 +1,7 @@ +# Blue Mushroom (9300259) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300260.yaml b/data/reward/9300260.yaml new file mode 100644 index 00000000..4f5e49ab --- /dev/null +++ b/data/reward/9300260.yaml @@ -0,0 +1,7 @@ +# Jr. Balrog (9300260) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300261.yaml b/data/reward/9300261.yaml new file mode 100644 index 00000000..6741fb32 --- /dev/null +++ b/data/reward/9300261.yaml @@ -0,0 +1,7 @@ +# Black Kentaurus (9300261) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300262.yaml b/data/reward/9300262.yaml new file mode 100644 index 00000000..19876087 --- /dev/null +++ b/data/reward/9300262.yaml @@ -0,0 +1,7 @@ +# Red Kentaurus (9300262) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300263.yaml b/data/reward/9300263.yaml new file mode 100644 index 00000000..6efd7205 --- /dev/null +++ b/data/reward/9300263.yaml @@ -0,0 +1,7 @@ +# Blue Kentaurus (9300263) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300264.yaml b/data/reward/9300264.yaml new file mode 100644 index 00000000..3c42a58c --- /dev/null +++ b/data/reward/9300264.yaml @@ -0,0 +1,7 @@ +# Dark Wyvern (9300264) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300265.yaml b/data/reward/9300265.yaml new file mode 100644 index 00000000..3ae0121b --- /dev/null +++ b/data/reward/9300265.yaml @@ -0,0 +1,7 @@ +# Blue Wyvern (9300265) + +rewards: + - [ 2022430, 1, 1, 0.7 ] + - [ 2022431, 1, 1, 0.7 ] + - [ 2022432, 1, 1, 0.7 ] + - [ 2022433, 1, 1, 0.7 ] diff --git a/data/reward/9300266.yaml b/data/reward/9300266.yaml new file mode 100644 index 00000000..76eb956d --- /dev/null +++ b/data/reward/9300266.yaml @@ -0,0 +1,7 @@ +# High Darkstar (9300266) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300267.yaml b/data/reward/9300267.yaml new file mode 100644 index 00000000..d5724ee9 --- /dev/null +++ b/data/reward/9300267.yaml @@ -0,0 +1,7 @@ +# Low Darkstar (9300267) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300270.yaml b/data/reward/9300270.yaml new file mode 100644 index 00000000..bf5f28bd --- /dev/null +++ b/data/reward/9300270.yaml @@ -0,0 +1,7 @@ +# Mingu (9300270) + +rewards: +- [2022430, 1, 1, 0.7] +- [2022431, 1, 1, 0.7] +- [2022432, 1, 1, 0.7] +- [2022433, 1, 1, 0.7] diff --git a/data/reward/9300274.yaml b/data/reward/9300274.yaml index 599e1dda..fa8c079c 100644 --- a/data/reward/9300274.yaml +++ b/data/reward/9300274.yaml @@ -19,3 +19,5 @@ rewards: - [ 4010000, 1, 1, 0.002000 ] # Bronze Ore - [ 4020007, 1, 1, 0.002000 ] # Diamond Ore - [ 4030001, 1, 1, 0.050000 ] # Omok Piece : Mushroom + - [ 4032190, 1, 1, 1.000000, 20705 ] # Cynical Orange Mushroom Puppet (Quest 20705 - Cygnus only) + - [ 4032315, 1, 1, 1.000000, 21711 ] # Cynical Orange Mushroom Puppet (Quest 21711 - Aran only) diff --git a/data/reward/9300287.yaml b/data/reward/9300287.yaml new file mode 100644 index 00000000..fffe85e5 --- /dev/null +++ b/data/reward/9300287.yaml @@ -0,0 +1,4 @@ +# Level 100/110 Skill Quest Boss (9300287) - Dawn Warrior + +rewards: + - [ 4032120, 1, 1, 1.000000 ] # Proof of Qualification (Dawn Warrior) diff --git a/data/reward/9300288.yaml b/data/reward/9300288.yaml new file mode 100644 index 00000000..00af2b55 --- /dev/null +++ b/data/reward/9300288.yaml @@ -0,0 +1,6 @@ +# Level 100/110 Skill Quest Boss (9300288) - Blaze Wizard / Thunder Breaker +# Drops quest-specific items based on which quest you are on + +rewards: + - [ 4032121, 1, 1, 1.000000, 20602 ] # Proof of Qualification (Blaze Wizard) - Quest 20602 + - [ 4032124, 1, 1, 1.000000, 20605 ] # Proof of Qualification (Thunder Breaker) - Quest 20605 diff --git a/data/reward/9300289.yaml b/data/reward/9300289.yaml new file mode 100644 index 00000000..43cced15 --- /dev/null +++ b/data/reward/9300289.yaml @@ -0,0 +1,4 @@ +# Level 100/110 Skill Quest Boss (9300289) - Wind Archer + +rewards: + - [ 4032122, 1, 1, 1.000000 ] # Proof of Qualification (Wind Archer) diff --git a/data/reward/9300290.yaml b/data/reward/9300290.yaml new file mode 100644 index 00000000..a33017af --- /dev/null +++ b/data/reward/9300290.yaml @@ -0,0 +1,4 @@ +# Level 100/110 Skill Quest Boss (9300290) - Night Walker + +rewards: + - [ 4032123, 1, 1, 1.000000 ] # Proof of Qualification (Night Walker) diff --git a/data/reward/9300291.yaml b/data/reward/9300291.yaml new file mode 100644 index 00000000..fe49360d --- /dev/null +++ b/data/reward/9300291.yaml @@ -0,0 +1,4 @@ +# Level 120 Skill Quest Boss (9300291) - Dawn Warrior + +rewards: + - [ 4032125, 1, 1, 1.000000 ] # Proof of Ability (Dawn Warrior) diff --git a/data/reward/9300292.yaml b/data/reward/9300292.yaml new file mode 100644 index 00000000..2c98c212 --- /dev/null +++ b/data/reward/9300292.yaml @@ -0,0 +1,5 @@ +# Level 120 Skill Quest Boss (9300292) - Blaze Wizard / Thunder Breaker + +rewards: + - [ 4032126, 1, 1, 1.000000 ] # Proof of Ability (Blaze Wizard) + - [ 4032129, 1, 1, 1.000000 ] # Proof of Ability (Thunder Breaker) diff --git a/data/reward/9300293.yaml b/data/reward/9300293.yaml new file mode 100644 index 00000000..d6ac823b --- /dev/null +++ b/data/reward/9300293.yaml @@ -0,0 +1,4 @@ +# Level 120 Skill Quest Boss (9300293) - Wind Archer + +rewards: + - [ 4032127, 1, 1, 1.000000 ] # Proof of Ability (Wind Archer) diff --git a/data/reward/9300294.yaml b/data/reward/9300294.yaml new file mode 100644 index 00000000..2a77696b --- /dev/null +++ b/data/reward/9300294.yaml @@ -0,0 +1,4 @@ +# Level 120 Skill Quest Boss (9300294) - Night Walker + +rewards: + - [ 4032128, 1, 1, 1.000000 ] # Proof of Ability (Night Walker) diff --git a/data/reward/9300315.yaml b/data/reward/9300315.yaml new file mode 100644 index 00000000..6a8d8644 --- /dev/null +++ b/data/reward/9300315.yaml @@ -0,0 +1,19 @@ +# Buffy (9300315) + +rewards: +- [2022162, 1, 1, 0.01] +- [2022177, 1, 1, 0.01] +- [2022161, 1, 1, 0.01] +- [2022176, 1, 1, 0.01] +- [2022160, 1, 1, 0.01] +- [2022175, 1, 1, 0.01] +- [2022159, 1, 1, 0.01] +- [2022174, 1, 1, 0.01] +- [2022158, 1, 1, 0.01] +- [2022166, 1, 1, 0.01] +- [2022157, 1, 1, 0.01] +- [2022165, 1, 1, 0.01] +- [2022164, 1, 1, 0.01] +- [2022163, 1, 1, 0.01] +- [2022178, 1, 1, 0.01] +- [4001254, 1, 1, 0.002] diff --git a/data/reward/9300316.yaml b/data/reward/9300316.yaml new file mode 100644 index 00000000..08004fb8 --- /dev/null +++ b/data/reward/9300316.yaml @@ -0,0 +1,19 @@ +# Soul Teddy (9300316) + +rewards: +- [2022163, 1, 1, 0.01] +- [2022177, 1, 1, 0.01] +- [2022162, 1, 1, 0.01] +- [2022176, 1, 1, 0.01] +- [2022161, 1, 1, 0.01] +- [2022175, 1, 1, 0.01] +- [2022160, 1, 1, 0.01] +- [2022174, 1, 1, 0.01] +- [2022159, 1, 1, 0.01] +- [2022166, 1, 1, 0.01] +- [2022158, 1, 1, 0.01] +- [2022165, 1, 1, 0.01] +- [2022157, 1, 1, 0.01] +- [2022164, 1, 1, 0.01] +- [2022178, 1, 1, 0.01] +- [4001254, 1, 1, 0.002] diff --git a/data/reward/9300317.yaml b/data/reward/9300317.yaml new file mode 100644 index 00000000..beb6a22b --- /dev/null +++ b/data/reward/9300317.yaml @@ -0,0 +1,19 @@ +# Lazy Buffy (9300317) + +rewards: +- [2022176, 1, 1, 0.01] +- [2022162, 1, 1, 0.01] +- [2022175, 1, 1, 0.01] +- [2022161, 1, 1, 0.01] +- [2022174, 1, 1, 0.01] +- [2022160, 1, 1, 0.01] +- [2022166, 1, 1, 0.01] +- [2022159, 1, 1, 0.01] +- [2022165, 1, 1, 0.01] +- [2022158, 1, 1, 0.01] +- [2022178, 1, 1, 0.01] +- [2022164, 1, 1, 0.01] +- [2022157, 1, 1, 0.01] +- [2022177, 1, 1, 0.01] +- [2022163, 1, 1, 0.01] +- [4001254, 1, 1, 0.002] diff --git a/data/reward/9300318.yaml b/data/reward/9300318.yaml new file mode 100644 index 00000000..b25cecd8 --- /dev/null +++ b/data/reward/9300318.yaml @@ -0,0 +1,19 @@ +# Master Soul Teddy (9300318) + +rewards: +- [2022162, 1, 1, 0.01] +- [2022176, 1, 1, 0.01] +- [2022161, 1, 1, 0.01] +- [2022175, 1, 1, 0.01] +- [2022160, 1, 1, 0.01] +- [2022174, 1, 1, 0.01] +- [2022159, 1, 1, 0.01] +- [2022166, 1, 1, 0.01] +- [2022158, 1, 1, 0.01] +- [2022165, 1, 1, 0.01] +- [2022157, 1, 1, 0.01] +- [2022178, 1, 1, 0.01] +- [2022164, 1, 1, 0.01] +- [2022177, 1, 1, 0.01] +- [2022163, 1, 1, 0.01] +- [4001254, 1, 1, 0.002] diff --git a/data/reward/9300319.yaml b/data/reward/9300319.yaml new file mode 100644 index 00000000..63c1a12b --- /dev/null +++ b/data/reward/9300319.yaml @@ -0,0 +1,19 @@ +# Klock (9300319) + +rewards: +- [2022161, 1, 1, 0.01] +- [2022175, 1, 1, 0.01] +- [2022160, 1, 1, 0.01] +- [2022174, 1, 1, 0.01] +- [2022159, 1, 1, 0.01] +- [2022166, 1, 1, 0.01] +- [2022158, 1, 1, 0.01] +- [2022165, 1, 1, 0.01] +- [2022157, 1, 1, 0.01] +- [2022178, 1, 1, 0.01] +- [2022164, 1, 1, 0.01] +- [2022177, 1, 1, 0.01] +- [2022163, 1, 1, 0.01] +- [2022176, 1, 1, 0.01] +- [2022162, 1, 1, 0.01] +- [4001254, 1, 1, 0.002] diff --git a/data/reward/9300320.yaml b/data/reward/9300320.yaml new file mode 100644 index 00000000..186f1ad9 --- /dev/null +++ b/data/reward/9300320.yaml @@ -0,0 +1,19 @@ +# Buffoon (9300320) + +rewards: +- [2022175, 1, 1, 0.01] +- [2022160, 1, 1, 0.01] +- [2022174, 1, 1, 0.01] +- [2022159, 1, 1, 0.01] +- [2022166, 1, 1, 0.01] +- [2022158, 1, 1, 0.01] +- [2022165, 1, 1, 0.01] +- [2022157, 1, 1, 0.01] +- [2022178, 1, 1, 0.01] +- [2022164, 1, 1, 0.01] +- [2022177, 1, 1, 0.01] +- [2022163, 1, 1, 0.01] +- [2022176, 1, 1, 0.01] +- [2022162, 1, 1, 0.01] +- [2022161, 1, 1, 0.01] +- [4001254, 1, 1, 0.002] diff --git a/data/reward/9300321.yaml b/data/reward/9300321.yaml new file mode 100644 index 00000000..48701ecb --- /dev/null +++ b/data/reward/9300321.yaml @@ -0,0 +1,19 @@ +# Deep Buffoon (9300321) + +rewards: +- [2022161, 1, 1, 0.01] +- [2022160, 1, 1, 0.01] +- [2022175, 1, 1, 0.01] +- [2022159, 1, 1, 0.01] +- [2022174, 1, 1, 0.01] +- [2022158, 1, 1, 0.01] +- [2022166, 1, 1, 0.01] +- [2022157, 1, 1, 0.01] +- [2022165, 1, 1, 0.01] +- [2022178, 1, 1, 0.01] +- [2022164, 1, 1, 0.01] +- [2022177, 1, 1, 0.01] +- [2022163, 1, 1, 0.01] +- [2022176, 1, 1, 0.01] +- [2022162, 1, 1, 0.01] +- [4001254, 1, 1, 0.002] diff --git a/data/reward/9300322.yaml b/data/reward/9300322.yaml new file mode 100644 index 00000000..8af94ba0 --- /dev/null +++ b/data/reward/9300322.yaml @@ -0,0 +1,19 @@ +# Ghost Pirate (9300322) + +rewards: +- [2022160, 1, 1, 0.01] +- [2022159, 1, 1, 0.01] +- [2022158, 1, 1, 0.01] +- [2022157, 1, 1, 0.01] +- [4001254, 1, 1, 0.002] +- [2022161, 1, 1, 0.01] +- [2022162, 1, 1, 0.01] +- [2022163, 1, 1, 0.01] +- [2022164, 1, 1, 0.01] +- [2022165, 1, 1, 0.01] +- [2022166, 1, 1, 0.01] +- [2022174, 1, 1, 0.01] +- [2022175, 1, 1, 0.01] +- [2022176, 1, 1, 0.01] +- [2022177, 1, 1, 0.01] +- [2022178, 1, 1, 0.01] diff --git a/data/reward/9300323.yaml b/data/reward/9300323.yaml new file mode 100644 index 00000000..7f20adb3 --- /dev/null +++ b/data/reward/9300323.yaml @@ -0,0 +1,19 @@ +# Death Teddy (9300323) + +rewards: +- [4001254, 1, 1, 0.002] +- [2022157, 1, 1, 0.01] +- [2022158, 1, 1, 0.01] +- [2022159, 1, 1, 0.01] +- [2022160, 1, 1, 0.01] +- [2022161, 1, 1, 0.01] +- [2022162, 1, 1, 0.01] +- [2022163, 1, 1, 0.01] +- [2022164, 1, 1, 0.01] +- [2022165, 1, 1, 0.01] +- [2022166, 1, 1, 0.01] +- [2022174, 1, 1, 0.01] +- [2022175, 1, 1, 0.01] +- [2022176, 1, 1, 0.01] +- [2022177, 1, 1, 0.01] +- [2022178, 1, 1, 0.01] diff --git a/data/reward/9300324.yaml b/data/reward/9300324.yaml new file mode 100644 index 00000000..ebd1e8e0 --- /dev/null +++ b/data/reward/9300324.yaml @@ -0,0 +1,19 @@ +# Viking (9300324) + +rewards: +- [4001254, 1, 1, 0.002] +- [2022157, 1, 1, 0.01] +- [2022158, 1, 1, 0.01] +- [2022159, 1, 1, 0.01] +- [2022160, 1, 1, 0.01] +- [2022161, 1, 1, 0.01] +- [2022162, 1, 1, 0.01] +- [2022163, 1, 1, 0.01] +- [2022164, 1, 1, 0.01] +- [2022165, 1, 1, 0.01] +- [2022166, 1, 1, 0.01] +- [2022174, 1, 1, 0.01] +- [2022175, 1, 1, 0.01] +- [2022176, 1, 1, 0.01] +- [2022177, 1, 1, 0.01] +- [2022178, 1, 1, 0.01] diff --git a/data/reward/9300344.yaml b/data/reward/9300344.yaml new file mode 100644 index 00000000..f483e232 --- /dev/null +++ b/data/reward/9300344.yaml @@ -0,0 +1,4 @@ +# Puppeteer (9300344) - Quest Boss for Aran + +rewards: + - [ 4032322, 1, 1, 1.000000, 21731 ] # The Black Wings' Clue (Quest 21731 - Aran only) diff --git a/data/reward/9300347.yaml b/data/reward/9300347.yaml new file mode 100644 index 00000000..af2b224f --- /dev/null +++ b/data/reward/9300347.yaml @@ -0,0 +1,5 @@ +# Giant Nependeath (9300347) - Quest Monster for Aran + +rewards: + - [ 0, 12, 19, 0.600000 ] + - [ 4032324, 1, 1, 1.000000, 21737 ] # Giant Nependeath Food (Quest 21737 - Aran only) diff --git a/data/reward/9300367.yaml b/data/reward/9300367.yaml new file mode 100644 index 00000000..b09055e9 --- /dev/null +++ b/data/reward/9300367.yaml @@ -0,0 +1,7 @@ +# Witch Bear (9300367) + +rewards: +- [2022179, 1, 1, 0.3] +- [1002971, 1, 1, 0.04] +- [1442046, 1, 1, 0.03] +- [1442057, 1, 1, 0.04] diff --git a/data/reward/9300368.yaml b/data/reward/9300368.yaml new file mode 100644 index 00000000..932cf47f --- /dev/null +++ b/data/reward/9300368.yaml @@ -0,0 +1,7 @@ +# Witch Bear (9300368) + +rewards: +- [2022179, 1, 1, 0.3] +- [1002971, 1, 1, 0.04] +- [1442046, 1, 1, 0.03] +- [1442057, 1, 1, 0.04] diff --git a/data/reward/9300369.yaml b/data/reward/9300369.yaml new file mode 100644 index 00000000..214949ad --- /dev/null +++ b/data/reward/9300369.yaml @@ -0,0 +1,7 @@ +# Witch Bear (9300369) + +rewards: +- [2022179, 1, 1, 0.3] +- [1002971, 1, 1, 0.04] +- [1442046, 1, 1, 0.03] +- [1442057, 1, 1, 0.04] diff --git a/data/reward/9300370.yaml b/data/reward/9300370.yaml new file mode 100644 index 00000000..fb3d368e --- /dev/null +++ b/data/reward/9300370.yaml @@ -0,0 +1,7 @@ +# Witch Bear (9300370) + +rewards: +- [2022179, 1, 1, 0.3] +- [1002971, 1, 1, 0.04] +- [1442046, 1, 1, 0.03] +- [1442057, 1, 1, 0.04] diff --git a/data/reward/9300371.yaml b/data/reward/9300371.yaml new file mode 100644 index 00000000..60e2dbcf --- /dev/null +++ b/data/reward/9300371.yaml @@ -0,0 +1,7 @@ +# Witch Bear (9300371) + +rewards: +- [2022179, 1, 1, 0.3] +- [1002971, 1, 1, 0.04] +- [1442046, 1, 1, 0.03] +- [1442057, 1, 1, 0.04] diff --git a/data/reward/9300372.yaml b/data/reward/9300372.yaml new file mode 100644 index 00000000..56728990 --- /dev/null +++ b/data/reward/9300372.yaml @@ -0,0 +1,7 @@ +# Witch Bear (9300372) + +rewards: +- [2022179, 1, 1, 0.3] +- [1002971, 1, 1, 0.04] +- [1442046, 1, 1, 0.03] +- [1442057, 1, 1, 0.04] diff --git a/data/reward/9300373.yaml b/data/reward/9300373.yaml new file mode 100644 index 00000000..5af8e29d --- /dev/null +++ b/data/reward/9300373.yaml @@ -0,0 +1,7 @@ +# Witch Bear (9300373) + +rewards: +- [2022179, 1, 1, 0.3] +- [1002971, 1, 1, 0.04] +- [1442046, 1, 1, 0.03] +- [1442057, 1, 1, 0.04] diff --git a/data/reward/9300374.yaml b/data/reward/9300374.yaml new file mode 100644 index 00000000..7b492eb2 --- /dev/null +++ b/data/reward/9300374.yaml @@ -0,0 +1,7 @@ +# Witch Bear (9300374) + +rewards: +- [2022179, 1, 1, 0.3] +- [1002971, 1, 1, 0.04] +- [1442046, 1, 1, 0.03] +- [1442057, 1, 1, 0.04] diff --git a/data/reward/9300375.yaml b/data/reward/9300375.yaml new file mode 100644 index 00000000..0cdb3d0c --- /dev/null +++ b/data/reward/9300375.yaml @@ -0,0 +1,7 @@ +# Witch Bear (9300375) + +rewards: +- [2022179, 1, 1, 0.3] +- [1002971, 1, 1, 0.04] +- [1442046, 1, 1, 0.03] +- [1442057, 1, 1, 0.04] diff --git a/data/reward/9300376.yaml b/data/reward/9300376.yaml new file mode 100644 index 00000000..89ab5526 --- /dev/null +++ b/data/reward/9300376.yaml @@ -0,0 +1,7 @@ +# Witch Bear (9300376) + +rewards: +- [2022179, 1, 1, 0.3] +- [1002971, 1, 1, 0.04] +- [1442046, 1, 1, 0.03] +- [1442057, 1, 1, 0.04] diff --git a/data/reward/9300377.yaml b/data/reward/9300377.yaml new file mode 100644 index 00000000..78e76f23 --- /dev/null +++ b/data/reward/9300377.yaml @@ -0,0 +1,7 @@ +# Witch Bear (9300377) + +rewards: + - [2022179, 1, 1, 0.005] + - [1002971, 1, 1, 0.001] + - [1442046, 1, 1, 0.0025] + - [1442057, 1, 1, 0.0025] diff --git a/data/reward/9300378.yaml b/data/reward/9300378.yaml new file mode 100644 index 00000000..241a19a2 --- /dev/null +++ b/data/reward/9300378.yaml @@ -0,0 +1,4 @@ +# Giant Nependeath (9300347) + +rewards: + - [ 4032324, 1, 1, 0.4000000, 21737 ] \ No newline at end of file diff --git a/data/reward/9300384.yaml b/data/reward/9300384.yaml new file mode 100644 index 00000000..b77058bd --- /dev/null +++ b/data/reward/9300384.yaml @@ -0,0 +1,6 @@ +# Red Slime (9300384) + +rewards: +- [2290005, 1, 1, 0.0005] +- [2290097, 1, 1, 0.0005] +- [2290098, 1, 1, 0.0005] diff --git a/data/reward/9300414.yaml b/data/reward/9300414.yaml new file mode 100644 index 00000000..36fd346c --- /dev/null +++ b/data/reward/9300414.yaml @@ -0,0 +1,5 @@ +# Mob 9300414 (9300414) + +rewards: +- [4032745, 1, 1, 0.999999, 23120] +- [4032745, 1, 1, 0.999999, 23121] diff --git a/data/reward/9300446.yaml b/data/reward/9300446.yaml new file mode 100644 index 00000000..bf6d70bb --- /dev/null +++ b/data/reward/9300446.yaml @@ -0,0 +1,4 @@ +# Mob 9300446 (9300446) + +rewards: +- [2430364, 1, 1, 0.999999] diff --git a/data/reward/9300447.yaml b/data/reward/9300447.yaml new file mode 100644 index 00000000..58238d87 --- /dev/null +++ b/data/reward/9300447.yaml @@ -0,0 +1,4 @@ +# Mob 9300447 (9300447) + +rewards: +- [2430364, 1, 1, 0.999999] diff --git a/data/reward/9302010.yaml b/data/reward/9302010.yaml new file mode 100644 index 00000000..690b4eb0 --- /dev/null +++ b/data/reward/9302010.yaml @@ -0,0 +1,4 @@ +# Golden Pig (9302010) + +rewards: +- [2022524, 1, 1, 0.1] diff --git a/data/reward/9400010.yaml b/data/reward/9400010.yaml new file mode 100644 index 00000000..30332068 --- /dev/null +++ b/data/reward/9400010.yaml @@ -0,0 +1,4 @@ +# Flaming Raccoon (9400010) + +rewards: +- [0, 52, 78, 0.2] diff --git a/data/reward/9400201.yaml b/data/reward/9400201.yaml new file mode 100644 index 00000000..129c544f --- /dev/null +++ b/data/reward/9400201.yaml @@ -0,0 +1,4 @@ +# Wild Cargo (9400201) + +rewards: +- [0, 320, 463, 0.2] diff --git a/data/reward/9400202.yaml b/data/reward/9400202.yaml new file mode 100644 index 00000000..c9123b21 --- /dev/null +++ b/data/reward/9400202.yaml @@ -0,0 +1,6 @@ +# Golden Slime (9400202) + +rewards: + - [ 0, 1500, 2000, 1.0 ] + - [ 2049100, 1, 1, 0.001 ] + - [ 4032513, 1, 1, 0.050000, 3722 ] # Wrinkled Sketchbook Loose-Leaf : The Crying Girf's Sketchbook \ No newline at end of file diff --git a/data/reward/9400253.yaml b/data/reward/9400253.yaml new file mode 100644 index 00000000..5fe8a05a --- /dev/null +++ b/data/reward/9400253.yaml @@ -0,0 +1,26 @@ +# Mob 9400253 (9400253) + +rewards: +- [2290055, 1, 1, 0.0005] +- [4032155, 1, 1, 0.6] +- [4032151, 1, 1, 0.002] +- [4032154, 1, 1, 0.004] +- [2000006, 1, 1, 0.1] +- [2000004, 1, 1, 0.02] +- [2000005, 1, 1, 0.02] +- [2050004, 1, 1, 0.05] +- [2022345, 1, 1, 0.003] +- [4130018, 1, 1, 0.006] +- [4004004, 1, 1, 0.01] +- [4006001, 1, 1, 0.01] +- [4006000, 1, 1, 0.01] +- [4032181, 1, 1, 0.3] +- [4032166, 1, 1, 0.01] +- [4020010, 1, 1, 0.009] +- [1092049, 1, 1, 0.0001] +- [1452021, 1, 1, 0.0005] +- [1462017, 1, 1, 0.0005] +- [2040106, 1, 1, 0.0003] +- [2040625, 1, 1, 0.0003] +- [2290009, 1, 1, 0.0005] +- [4002151, 1, 1, 0.01] diff --git a/data/reward/9400254.yaml b/data/reward/9400254.yaml new file mode 100644 index 00000000..c5ebbdbc --- /dev/null +++ b/data/reward/9400254.yaml @@ -0,0 +1,25 @@ +# Mob 9400254 (9400254) + +rewards: +- [2290053, 1, 1, 0.0005] +- [4032156, 1, 1, 0.6] +- [4032152, 1, 1, 0.004] +- [4032153, 1, 1, 0.004] +- [2000006, 1, 1, 0.1] +- [2000004, 1, 1, 0.02] +- [2000005, 1, 1, 0.02] +- [2050004, 1, 1, 0.05] +- [2022345, 1, 1, 0.003] +- [4130022, 1, 1, 0.006] +- [4020005, 1, 1, 0.009] +- [4004001, 1, 1, 0.01] +- [4006001, 1, 1, 0.01] +- [4006000, 1, 1, 0.01] +- [4032181, 1, 1, 0.3] +- [1422027, 1, 1, 0.0007] +- [1002381, 1, 1, 0.0015] +- [1032030, 1, 1, 0.001] +- [2040201, 1, 1, 0.0003] +- [2040613, 1, 1, 0.0003] +- [2290127, 1, 1, 0.0005] +- [2290144, 1, 1, 0.0005] diff --git a/data/reward/9400255.yaml b/data/reward/9400255.yaml new file mode 100644 index 00000000..5fc44b59 --- /dev/null +++ b/data/reward/9400255.yaml @@ -0,0 +1,24 @@ +# Mob 9400255 (9400255) + +rewards: +- [4032159, 1, 1, 0.6] +- [4032158, 1, 1, 0.002] +- [2000006, 1, 1, 0.1] +- [2000004, 1, 1, 0.02] +- [2000005, 1, 1, 0.02] +- [2050004, 1, 1, 0.05] +- [2022345, 1, 1, 0.003] +- [4130019, 1, 1, 0.006] +- [4020007, 1, 1, 0.009] +- [4004003, 1, 1, 0.01] +- [4006001, 1, 1, 0.01] +- [4006000, 1, 1, 0.01] +- [4032181, 1, 1, 0.3] +- [4032166, 1, 1, 0.01] +- [4020010, 1, 1, 0.009] +- [1082213, 1, 1, 0.001] +- [1040122, 1, 1, 0.0008] +- [1050096, 1, 1, 0.0007] +- [2040516, 1, 1, 0.0003] +- [2041013, 1, 1, 0.0003] +- [2290098, 1, 1, 0.0005] diff --git a/data/reward/9400256.yaml b/data/reward/9400256.yaml new file mode 100644 index 00000000..333d0e72 --- /dev/null +++ b/data/reward/9400256.yaml @@ -0,0 +1,25 @@ +# Mob 9400256 (9400256) + +rewards: +- [4032192, 1, 1, 0.05] +- [4032160, 1, 1, 0.0003] +- [2000006, 1, 1, 0.1] +- [2000004, 1, 1, 0.02] +- [2000005, 1, 1, 0.02] +- [2050004, 1, 1, 0.05] +- [2022345, 1, 1, 0.003] +- [4130020, 1, 1, 0.006] +- [4004002, 1, 1, 0.01] +- [4006001, 1, 1, 0.01] +- [4006000, 1, 1, 0.01] +- [4032181, 1, 1, 0.3] +- [1032062, 1, 1, 0.0001] +- [4032167, 1, 1, 0.01] +- [4020012, 1, 1, 0.009] +- [1332051, 1, 1, 0.0005] +- [1332052, 1, 1, 0.0005] +- [1072228, 1, 1, 0.0008] +- [2040704, 1, 1, 0.0003] +- [2041019, 1, 1, 0.0003] +- [2290130, 1, 1, 0.0005] +- [2290150, 1, 1, 0.0005] diff --git a/data/reward/9400257.yaml b/data/reward/9400257.yaml new file mode 100644 index 00000000..d0a2d499 --- /dev/null +++ b/data/reward/9400257.yaml @@ -0,0 +1,25 @@ +# Mob 9400257 (9400257) + +rewards: +- [4032192, 1, 1, 0.05] +- [4032163, 1, 1, 0.6] +- [4032164, 1, 1, 0.002] +- [2000006, 1, 1, 0.1] +- [2000004, 1, 1, 0.02] +- [2000005, 1, 1, 0.02] +- [2050004, 1, 1, 0.05] +- [2022345, 1, 1, 0.003] +- [4130021, 1, 1, 0.006] +- [4020003, 1, 1, 0.009] +- [4004002, 1, 1, 0.01] +- [4006001, 1, 1, 0.01] +- [4006000, 1, 1, 0.01] +- [4032181, 1, 1, 0.3] +- [1032062, 1, 1, 0.0001] +- [4032167, 1, 1, 0.01] +- [4020012, 1, 1, 0.009] +- [1302056, 1, 1, 0.0007] +- [1402035, 1, 1, 0.0007] +- [1060110, 1, 1, 0.0008] +- [2040801, 1, 1, 0.0003] +- [2040621, 1, 1, 0.0003] diff --git a/data/reward/9400258.yaml b/data/reward/9400258.yaml new file mode 100644 index 00000000..e70371e4 --- /dev/null +++ b/data/reward/9400258.yaml @@ -0,0 +1,24 @@ +# Mob 9400258 (9400258) + +rewards: +- [4032163, 1, 1, 0.6] +- [4032192, 1, 1, 0.0003] +- [2000006, 1, 1, 0.1] +- [2000004, 1, 1, 0.02] +- [2000005, 1, 1, 0.02] +- [2050004, 1, 1, 0.05] +- [2022345, 1, 1, 0.003] +- [4130016, 1, 1, 0.006] +- [4020003, 1, 1, 0.009] +- [4004002, 1, 1, 0.01] +- [4006001, 1, 1, 0.01] +- [4006000, 1, 1, 0.01] +- [4032181, 1, 1, 0.3] +- [1032062, 1, 1, 0.0001] +- [4032167, 1, 1, 0.01] +- [4020012, 1, 1, 0.009] +- [1032026, 1, 1, 0.001] +- [1432030, 1, 1, 0.0005] +- [1472053, 1, 1, 0.0005] +- [2040321, 1, 1, 0.0003] +- [2040301, 1, 1, 0.0003] diff --git a/data/reward/9400259.yaml b/data/reward/9400259.yaml new file mode 100644 index 00000000..74b54b3b --- /dev/null +++ b/data/reward/9400259.yaml @@ -0,0 +1,22 @@ +# Mob 9400259 (9400259) + +rewards: +- [4032163, 1, 1, 0.6] +- [2000006, 1, 1, 0.1] +- [2000004, 1, 1, 0.02] +- [2000005, 1, 1, 0.02] +- [2050004, 1, 1, 0.05] +- [2022345, 1, 1, 0.003] +- [4130017, 1, 1, 0.006] +- [4004002, 1, 1, 0.01] +- [4006001, 1, 1, 0.01] +- [4006000, 1, 1, 0.01] +- [4032181, 1, 1, 0.3] +- [1032062, 1, 1, 0.0001] +- [4032167, 1, 1, 0.01] +- [4020012, 1, 1, 0.009] +- [1492012, 1, 1, 0.0005] +- [1482012, 1, 1, 0.0005] +- [1041124, 1, 1, 0.0008] +- [2044501, 1, 1, 0.0003] +- [2043801, 1, 1, 0.0003] diff --git a/data/reward/9400260.yaml b/data/reward/9400260.yaml new file mode 100644 index 00000000..16ff74ff --- /dev/null +++ b/data/reward/9400260.yaml @@ -0,0 +1,23 @@ +# Mob 9400260 (9400260) + +rewards: +- [4032161, 1, 1, 0.6] +- [4032180, 1, 1, 0.002] +- [2000006, 1, 1, 0.1] +- [2000004, 1, 1, 0.02] +- [2000005, 1, 1, 0.02] +- [2050004, 1, 1, 0.05] +- [2022345, 1, 1, 0.003] +- [4130018, 1, 1, 0.006] +- [4004004, 1, 1, 0.01] +- [4006001, 1, 1, 0.01] +- [4006000, 1, 1, 0.01] +- [4032181, 1, 1, 0.3] +- [4032168, 1, 1, 0.01] +- [4020011, 1, 1, 0.009] +- [1061123, 1, 1, 0.0008] +- [1442046, 1, 1, 0.0007] +- [1312030, 1, 1, 0.0007] +- [2040914, 1, 1, 0.0003] +- [2040919, 1, 1, 0.0003] +- [2290118, 1, 1, 0.0005] diff --git a/data/reward/9400262.yaml b/data/reward/9400262.yaml new file mode 100644 index 00000000..833ca7f8 --- /dev/null +++ b/data/reward/9400262.yaml @@ -0,0 +1,6 @@ +# Mob 9400262 (9400262) + +rewards: +- [2290053, 1, 1, 0.0005] +- [2290127, 1, 1, 0.0005] +- [2290144, 1, 1, 0.0005] diff --git a/data/reward/9400265.yaml b/data/reward/9400265.yaml new file mode 100644 index 00000000..598a7801 --- /dev/null +++ b/data/reward/9400265.yaml @@ -0,0 +1,72 @@ +# Mob 9400265 (9400265) + +rewards: +- [4032157, 1, 1, 0.0072] +- [2000006, 1, 1, 1.0] +- [2000004, 1, 1, 0.999999] +- [2000005, 1, 1, 0.999999] +- [2050004, 1, 1, 1.0] +- [2022345, 1, 1, 0.999999] +- [4130018, 1, 1, 0.144] +- [4130022, 1, 1, 0.144] +- [4130019, 1, 1, 0.144] +- [4130020, 1, 1, 0.144] +- [4130021, 1, 1, 0.144] +- [4130016, 1, 1, 0.144] +- [4130017, 1, 1, 0.144] +- [4005000, 1, 1, 0.024] +- [4005001, 1, 1, 0.024] +- [4005002, 1, 1, 0.024] +- [4005003, 1, 1, 0.024] +- [4004004, 1, 1, 0.24] +- [4006001, 1, 1, 0.24] +- [4006000, 1, 1, 0.24] +- [4032181, 1, 1, 0.999999] +- [4032166, 1, 1, 0.24] +- [4032167, 1, 1, 0.24] +- [4032168, 1, 1, 0.24] +- [4020010, 1, 1, 0.216] +- [4020011, 1, 1, 0.216] +- [4020012, 1, 1, 0.216] +- [1092049, 1, 1, 0.0024] +- [1302056, 1, 1, 0.0168] +- [1312030, 1, 1, 0.0168] +- [1322045, 1, 1, 0.0168] +- [1332051, 1, 1, 0.012] +- [1332052, 1, 1, 0.012] +- [1372010, 1, 1, 0.0168] +- [1382035, 1, 1, 0.0168] +- [1402035, 1, 1, 0.0168] +- [1412021, 1, 1, 0.0168] +- [1422027, 1, 1, 0.0168] +- [1432030, 1, 1, 0.012] +- [1442044, 1, 1, 0.0168] +- [1452021, 1, 1, 0.012] +- [1462017, 1, 1, 0.012] +- [1472053, 1, 1, 0.012] +- [1482012, 1, 1, 0.012] +- [1492012, 1, 1, 0.012] +- [2043001, 1, 1, 0.0072] +- [2043101, 1, 1, 0.0072] +- [2043201, 1, 1, 0.0072] +- [2043301, 1, 1, 0.0072] +- [2043701, 1, 1, 0.0072] +- [2043801, 1, 1, 0.0072] +- [2044001, 1, 1, 0.0072] +- [2044101, 1, 1, 0.0072] +- [2044301, 1, 1, 0.0072] +- [2044201, 1, 1, 0.0072] +- [2044401, 1, 1, 0.0072] +- [2044501, 1, 1, 0.0072] +- [2044601, 1, 1, 0.0072] +- [2044701, 1, 1, 0.0072] +- [2040805, 1, 1, 0.0072] +- [2044901, 1, 1, 0.0072] +- [2044801, 1, 1, 0.0072] +- [2040106, 1, 1, 0.0072] +- [2040625, 1, 1, 0.0072] +- [2040914, 1, 1, 0.0072] +- [2040919, 1, 1, 0.0072] +- [2040321, 1, 1, 0.0072] +- [2040301, 1, 1, 0.0072] +- [ 2044601, 1, 1, 0.0072 ] diff --git a/data/reward/9400266.yaml b/data/reward/9400266.yaml new file mode 100644 index 00000000..cab40ba2 --- /dev/null +++ b/data/reward/9400266.yaml @@ -0,0 +1,72 @@ +# Mob 9400266 (9400266) + +rewards: +- [4032150, 1, 1, 0.003] +- [2000006, 1, 1, 1.0] +- [2000004, 1, 1, 0.999999] +- [2000005, 1, 1, 0.999999] +- [2050004, 1, 1, 0.5] +- [2022345, 1, 1, 0.999999] +- [4130018, 1, 1, 0.06] +- [4130022, 1, 1, 0.06] +- [4130019, 1, 1, 0.06] +- [4130020, 1, 1, 0.06] +- [4130021, 1, 1, 0.06] +- [4130016, 1, 1, 0.06] +- [4130017, 1, 1, 0.06] +- [4005000, 1, 1, 0.01] +- [4005001, 1, 1, 0.01] +- [4005002, 1, 1, 0.01] +- [4005003, 1, 1, 0.01] +- [4004004, 1, 1, 0.1] +- [4006001, 1, 1, 0.1] +- [4006000, 1, 1, 0.1] +- [4032181, 1, 1, 0.999999] +- [4032166, 1, 1, 0.1] +- [4032167, 1, 1, 0.1] +- [4032168, 1, 1, 0.1] +- [4020010, 1, 1, 0.09] +- [4020011, 1, 1, 0.09] +- [4020012, 1, 1, 0.09] +- [1092049, 1, 1, 0.001] +- [1302056, 1, 1, 0.007] +- [1312030, 1, 1, 0.007] +- [1322045, 1, 1, 0.007] +- [1332051, 1, 1, 0.005] +- [1332052, 1, 1, 0.005] +- [1372010, 1, 1, 0.007] +- [1382035, 1, 1, 0.007] +- [1402035, 1, 1, 0.007] +- [1412021, 1, 1, 0.007] +- [1422027, 1, 1, 0.007] +- [1432030, 1, 1, 0.005] +- [1442044, 1, 1, 0.007] +- [1452021, 1, 1, 0.005] +- [1462017, 1, 1, 0.005] +- [1472053, 1, 1, 0.005] +- [1482012, 1, 1, 0.005] +- [1492012, 1, 1, 0.005] +- [2043001, 1, 1, 0.003] +- [2043101, 1, 1, 0.003] +- [2043201, 1, 1, 0.003] +- [2043301, 1, 1, 0.003] +- [2043701, 1, 1, 0.003] +- [2043801, 1, 1, 0.003] +- [2044001, 1, 1, 0.003] +- [2044101, 1, 1, 0.003] +- [2044301, 1, 1, 0.003] +- [2044201, 1, 1, 0.003] +- [2044401, 1, 1, 0.003] +- [2044501, 1, 1, 0.003] +- [2044601, 1, 1, 0.003] +- [2044701, 1, 1, 0.003] +- [2040805, 1, 1, 0.003] +- [2044901, 1, 1, 0.003] +- [2044801, 1, 1, 0.003] +- [2040106, 1, 1, 0.003] +- [2040625, 1, 1, 0.003] +- [2040914, 1, 1, 0.003] +- [2040919, 1, 1, 0.003] +- [2040321, 1, 1, 0.003] +- [2040301, 1, 1, 0.003] +- [ 2044601, 1, 1, 0.003 ] diff --git a/data/reward/9400270.yaml b/data/reward/9400270.yaml new file mode 100644 index 00000000..47e8d7bf --- /dev/null +++ b/data/reward/9400270.yaml @@ -0,0 +1,74 @@ +# Mob 9400270 (9400270) + +rewards: +- [4032162, 1, 1, 0.0072] +- [2000006, 1, 1, 1.0] +- [2000004, 1, 1, 0.999999] +- [2000005, 1, 1, 0.999999] +- [2050004, 1, 1, 1.0] +- [2022345, 1, 1, 0.999999] +- [4130018, 1, 1, 0.144] +- [4130022, 1, 1, 0.144] +- [4130019, 1, 1, 0.144] +- [4130020, 1, 1, 0.144] +- [4130021, 1, 1, 0.144] +- [4130016, 1, 1, 0.144] +- [4130017, 1, 1, 0.144] +- [4005000, 1, 1, 0.024] +- [4005001, 1, 1, 0.024] +- [4005002, 1, 1, 0.024] +- [4005003, 1, 1, 0.024] +- [4004004, 1, 1, 0.24] +- [4006001, 1, 1, 0.24] +- [4006000, 1, 1, 0.24] +- [4032181, 1, 1, 0.999999] +- [4032166, 1, 1, 0.24] +- [4032167, 1, 1, 0.24] +- [4032168, 1, 1, 0.24] +- [4020010, 1, 1, 0.216] +- [4020011, 1, 1, 0.216] +- [4020012, 1, 1, 0.216] +- [1092049, 1, 1, 0.0024] +- [1302056, 1, 1, 0.0168] +- [1312030, 1, 1, 0.0168] +- [1322045, 1, 1, 0.0168] +- [1332051, 1, 1, 0.012] +- [1332052, 1, 1, 0.012] +- [1372010, 1, 1, 0.0168] +- [1382035, 1, 1, 0.0168] +- [1402035, 1, 1, 0.0168] +- [1412021, 1, 1, 0.0168] +- [1422027, 1, 1, 0.0168] +- [1432030, 1, 1, 0.012] +- [1442044, 1, 1, 0.0168] +- [1452021, 1, 1, 0.012] +- [1462017, 1, 1, 0.012] +- [1472053, 1, 1, 0.012] +- [1482012, 1, 1, 0.012] +- [1492012, 1, 1, 0.012] +- [2043001, 1, 1, 0.0072] +- [2043101, 1, 1, 0.0072] +- [2043201, 1, 1, 0.0072] +- [2043301, 1, 1, 0.0072] +- [2043701, 1, 1, 0.0072] +- [2043801, 1, 1, 0.0072] +- [2044001, 1, 1, 0.0072] +- [2044101, 1, 1, 0.0072] +- [2044301, 1, 1, 0.0072] +- [2044201, 1, 1, 0.0072] +- [2044401, 1, 1, 0.0072] +- [2044501, 1, 1, 0.0072] +- [2044601, 1, 1, 0.0072] +- [2044701, 1, 1, 0.0072] +- [2040805, 1, 1, 0.0072] +- [2044901, 1, 1, 0.0072] +- [2044801, 1, 1, 0.0072] +- [2040106, 1, 1, 0.0072] +- [2040625, 1, 1, 0.0072] +- [2040914, 1, 1, 0.0072] +- [2040919, 1, 1, 0.0072] +- [2040321, 1, 1, 0.0072] +- [2040301, 1, 1, 0.0072] +- [2430144, 1, 2, 0.025] +- [2290101, 1, 1, 0.025] +- [ 2044601, 1, 1, 0.0072 ] diff --git a/data/reward/9400273.yaml b/data/reward/9400273.yaml new file mode 100644 index 00000000..bcdeab12 --- /dev/null +++ b/data/reward/9400273.yaml @@ -0,0 +1,79 @@ +# Mob 9400273 (9400273) + +rewards: +- [2290088, 1, 1, 0.005] +- [4032165, 1, 1, 0.0072] +- [2000006, 1, 1, 1.0] +- [2000004, 1, 1, 0.999999] +- [2000005, 1, 1, 0.999999] +- [2050004, 1, 1, 1.0] +- [2022345, 1, 1, 0.999999] +- [4130018, 1, 1, 0.144] +- [4130022, 1, 1, 0.144] +- [4130019, 1, 1, 0.144] +- [4130020, 1, 1, 0.144] +- [4130021, 1, 1, 0.144] +- [4130016, 1, 1, 0.144] +- [4130017, 1, 1, 0.144] +- [4005000, 1, 1, 0.024] +- [4005001, 1, 1, 0.024] +- [4005002, 1, 1, 0.024] +- [4005003, 1, 1, 0.024] +- [4004004, 1, 1, 0.24] +- [4006001, 1, 1, 0.24] +- [4006000, 1, 1, 0.24] +- [4032181, 1, 1, 0.999999] +- [1032062, 1, 1, 0.0024] +- [4032166, 1, 1, 0.24] +- [4032167, 1, 1, 0.24] +- [4032168, 1, 1, 0.24] +- [4020010, 1, 1, 0.216] +- [4020011, 1, 1, 0.216] +- [4020012, 1, 1, 0.216] +- [1092049, 1, 1, 0.0024] +- [1302056, 1, 1, 0.0168] +- [1312030, 1, 1, 0.0168] +- [1322045, 1, 1, 0.0168] +- [1332051, 1, 1, 0.012] +- [1332052, 1, 1, 0.012] +- [1372010, 1, 1, 0.0168] +- [1382035, 1, 1, 0.0168] +- [1402035, 1, 1, 0.0168] +- [1412021, 1, 1, 0.0168] +- [1422027, 1, 1, 0.0168] +- [1432030, 1, 1, 0.012] +- [1442044, 1, 1, 0.0168] +- [1452021, 1, 1, 0.012] +- [1462017, 1, 1, 0.012] +- [1472053, 1, 1, 0.012] +- [1482012, 1, 1, 0.012] +- [1492012, 1, 1, 0.012] +- [2043001, 1, 1, 0.0072] +- [2043101, 1, 1, 0.0072] +- [2043201, 1, 1, 0.0072] +- [2043301, 1, 1, 0.0072] +- [2043701, 1, 1, 0.0072] +- [2043801, 1, 1, 0.0072] +- [2044001, 1, 1, 0.0072] +- [2044101, 1, 1, 0.0072] +- [2044301, 1, 1, 0.0072] +- [2044201, 1, 1, 0.0072] +- [2044401, 1, 1, 0.0072] +- [2044501, 1, 1, 0.0072] +- [2044601, 1, 1, 0.0072] +- [2044701, 1, 1, 0.0072] +- [2040805, 1, 1, 0.0072] +- [2044901, 1, 1, 0.0072] +- [2044801, 1, 1, 0.0072] +- [2040106, 1, 1, 0.0072] +- [2040625, 1, 1, 0.0072] +- [2040914, 1, 1, 0.0072] +- [2040919, 1, 1, 0.0072] +- [2040321, 1, 1, 0.0072] +- [2040301, 1, 1, 0.0072] +- [2430144, 1, 2, 0.025] +- [2430144, 1, 2, 0.025] +- [2290007, 1, 1, 0.005] +- [2290004, 1, 1, 0.005] +- [2290113, 1, 1, 0.005] +- [ 2044601, 1, 1, 0.0072 ] diff --git a/data/reward/9400274.yaml b/data/reward/9400274.yaml new file mode 100644 index 00000000..46e88c6b --- /dev/null +++ b/data/reward/9400274.yaml @@ -0,0 +1,4 @@ +# Mob 9400274 (9400274) + +rewards: +- [2290146, 1, 1, 0.0005] diff --git a/data/reward/9400276.yaml b/data/reward/9400276.yaml new file mode 100644 index 00000000..0e215294 --- /dev/null +++ b/data/reward/9400276.yaml @@ -0,0 +1,6 @@ +# Mob 9400276 (9400276) + +rewards: +- [2290053, 1, 1, 0.0005] +- [2290127, 1, 1, 0.0005] +- [2290144, 1, 1, 0.0005] diff --git a/data/reward/9400287.yaml b/data/reward/9400287.yaml new file mode 100644 index 00000000..987403f1 --- /dev/null +++ b/data/reward/9400287.yaml @@ -0,0 +1,12 @@ +# Mob 9400287 (9400287) + +rewards: +- [2000006, 1, 1, 0.1] +- [2000004, 1, 1, 0.02] +- [2000005, 1, 1, 0.02] +- [2050004, 1, 1, 0.05] +- [2022345, 1, 1, 0.003] +- [4006001, 1, 1, 0.01] +- [4006000, 1, 1, 0.01] +- [4032181, 1, 1, 0.3] +- [4032355, 1, 1, 0.0003] diff --git a/data/reward/9400288.yaml b/data/reward/9400288.yaml new file mode 100644 index 00000000..46d47ab8 --- /dev/null +++ b/data/reward/9400288.yaml @@ -0,0 +1,12 @@ +# Mob 9400288 (9400288) + +rewards: +- [2000006, 1, 1, 1.0] +- [2000004, 1, 1, 0.999999] +- [2000005, 1, 1, 0.999999] +- [2050004, 1, 1, 0.5] +- [2022345, 1, 1, 0.999999] +- [4006001, 1, 1, 0.1] +- [4006000, 1, 1, 0.1] +- [4032181, 1, 1, 0.999999] +- [4032356, 1, 1, 0.003] diff --git a/data/reward/9400289.yaml b/data/reward/9400289.yaml new file mode 100644 index 00000000..0d47c88f --- /dev/null +++ b/data/reward/9400289.yaml @@ -0,0 +1,17 @@ +# Mob 9400289 (9400289) + +rewards: +- [2000004, 1, 1, 0.999999] +- [2000005, 1, 1, 0.999999] +- [2050004, 1, 1, 0.5] +- [2022345, 1, 1, 0.999999] +- [4006001, 1, 1, 0.1] +- [4006000, 1, 1, 0.1] +- [4032181, 1, 1, 0.999999] +- [4032357, 1, 1, 0.003] +- [2040036, 1, 1, 0.003] +- [2040035, 1, 1, 0.003] +- [2040034, 1, 1, 0.003] +- [2040033, 1, 1, 0.003] +- [2040037, 1, 1, 0.003] +- [1002972, 1, 1, 0.2] diff --git a/data/reward/9400294.yaml b/data/reward/9400294.yaml new file mode 100644 index 00000000..0d645a4c --- /dev/null +++ b/data/reward/9400294.yaml @@ -0,0 +1,17 @@ +# Mob 9400294 (9400294) + +rewards: +- [2000006, 1, 1, 1.0] +- [2000004, 1, 1, 0.999999] +- [2000005, 1, 1, 0.999999] +- [2050004, 1, 1, 1.0] +- [2022345, 1, 1, 0.999999] +- [4006001, 1, 1, 0.24] +- [4006000, 1, 1, 0.24] +- [4032181, 1, 1, 0.999999] +- [2040036, 1, 1, 0.0072] +- [2040035, 1, 1, 0.0072] +- [2040034, 1, 1, 0.0072] +- [2040033, 1, 1, 0.0072] +- [2430144, 1, 2, 0.025] +- [2290101, 1, 1, 0.025] diff --git a/data/reward/9400296.yaml b/data/reward/9400296.yaml new file mode 100644 index 00000000..f942d786 --- /dev/null +++ b/data/reward/9400296.yaml @@ -0,0 +1,4 @@ +# Mob 9400296 (9400296) + +rewards: +- [802000801, 1, 1, 0.999999] diff --git a/data/reward/9400300.yaml b/data/reward/9400300.yaml new file mode 100644 index 00000000..d3e7b055 --- /dev/null +++ b/data/reward/9400300.yaml @@ -0,0 +1,14 @@ +# The Boss (9400300) + +rewards: +- [0, 20000, 30000, 0.2] +- [0, 20000, 30000, 0.2] +- [0, 20000, 30000, 0.2] +- [4000141, 1, 1, 0.6] +- [2000004, 1, 1, 0.999999] +- [2040813, 1, 1, 0.003] +- [2041030, 1, 1, 0.003] +- [2041040, 1, 1, 0.003] +- [1072238, 1, 1, 0.008] +- [1032026, 1, 1, 0.01] +- [1372011, 1, 1, 0.007] diff --git a/data/reward/9400382.yaml b/data/reward/9400382.yaml new file mode 100644 index 00000000..4a88efa8 --- /dev/null +++ b/data/reward/9400382.yaml @@ -0,0 +1,6 @@ +# Mob 9400382 (9400382) + +rewards: +- [2290053, 1, 1, 0.0005] +- [2290127, 1, 1, 0.0005] +- [2290144, 1, 1, 0.0005] diff --git a/data/reward/9400407.yaml b/data/reward/9400407.yaml new file mode 100644 index 00000000..06c67406 --- /dev/null +++ b/data/reward/9400407.yaml @@ -0,0 +1,4 @@ +# Mob 9400407 (9400407) + +rewards: +- [4000343, 1, 1, 0.1] diff --git a/data/reward/9400409.yaml b/data/reward/9400409.yaml new file mode 100644 index 00000000..60f8a928 --- /dev/null +++ b/data/reward/9400409.yaml @@ -0,0 +1,157 @@ +# Mob 9400409 (9400409) + +rewards: +- [0, 10000, 20000, 0.2] +- [0, 10000, 20000, 0.2] +- [0, 10000, 20000, 0.2] +- [0, 10000, 20000, 0.2] +- [0, 10000, 20000, 0.2] +- [4000345, 1, 1, 0.6] +- [2000005, 1, 1, 0.999999] +- [4000346, 1, 1, 0.6] +- [2040100, 1, 1, 0.003] +- [2040101, 1, 1, 0.003] +- [2040102, 1, 1, 0.003] +- [2040104, 1, 1, 0.003] +- [2040105, 1, 1, 0.003] +- [2040103, 1, 1, 0.003] +- [2040106, 1, 1, 0.003] +- [2040107, 1, 1, 0.003] +- [2040109, 1, 1, 0.003] +- [2040108, 1, 1, 0.003] +- [2040200, 1, 1, 0.003] +- [2040201, 1, 1, 0.003] +- [2040202, 1, 1, 0.003] +- [2040203, 1, 1, 0.003] +- [2040204, 1, 1, 0.003] +- [2040205, 1, 1, 0.003] +- [2040206, 1, 1, 0.003] +- [2040207, 1, 1, 0.003] +- [2040208, 1, 1, 0.003] +- [2040209, 1, 1, 0.003] +- [1382046, 1, 1, 0.007] +- [1382047, 1, 1, 0.007] +- [1382048, 1, 1, 0.007] +- [1382045, 1, 1, 0.007] +- [4000345, 1, 1, 0.0] +- [4000345, 1, 1, 0.0] +- [4000345, 1, 1, 0.6] +- [4000346, 1, 1, 0.6] +- [2040100, 1, 1, 0.003] +- [2040102, 1, 1, 0.003] +- [2040104, 1, 1, 0.003] +- [2040107, 1, 1, 0.003] +- [2040108, 1, 1, 0.003] +- [2040202, 1, 1, 0.003] +- [2040205, 1, 1, 0.003] +- [2040207, 1, 1, 0.003] +- [2040209, 1, 1, 0.003] +- [1382046, 1, 1, 0.005] +- [1382047, 1, 1, 0.005] +- [1382048, 1, 1, 0.005] +- [1382045, 1, 1, 0.005] +- [4000345, 1, 1, 0.6] +- [4000346, 1, 1, 0.6] +- [2040100, 1, 1, 0.003] +- [2040102, 1, 1, 0.003] +- [2040104, 1, 1, 0.003] +- [2040107, 1, 1, 0.003] +- [2040108, 1, 1, 0.003] +- [2040202, 1, 1, 0.003] +- [2040205, 1, 1, 0.003] +- [2040207, 1, 1, 0.003] +- [2040209, 1, 1, 0.003] +- [1382046, 1, 1, 0.005] +- [1382047, 1, 1, 0.005] +- [1382048, 1, 1, 0.005] +- [1382045, 1, 1, 0.005] +- [4000345, 1, 1, 0.6] +- [4000346, 1, 1, 0.6] +- [2040100, 1, 1, 0.003] +- [2040102, 1, 1, 0.003] +- [2040104, 1, 1, 0.003] +- [2040107, 1, 1, 0.003] +- [2040108, 1, 1, 0.003] +- [2040202, 1, 1, 0.003] +- [2040205, 1, 1, 0.003] +- [2040207, 1, 1, 0.003] +- [2040209, 1, 1, 0.003] +- [1382046, 1, 1, 0.005] +- [1382047, 1, 1, 0.005] +- [1382048, 1, 1, 0.005] +- [1382045, 1, 1, 0.005] +- [4000345, 1, 1, 0.6] +- [4000346, 1, 1, 0.6] +- [2040100, 1, 1, 0.003] +- [2040102, 1, 1, 0.003] +- [2040104, 1, 1, 0.003] +- [2040107, 1, 1, 0.003] +- [2040108, 1, 1, 0.003] +- [2040202, 1, 1, 0.003] +- [2040205, 1, 1, 0.003] +- [2040207, 1, 1, 0.003] +- [2040209, 1, 1, 0.003] +- [1382046, 1, 1, 0.005] +- [1382047, 1, 1, 0.005] +- [1382048, 1, 1, 0.005] +- [1382045, 1, 1, 0.005] +- [4000345, 1, 1, 0.6] +- [4000346, 1, 1, 0.6] +- [2040100, 1, 1, 0.003] +- [2040102, 1, 1, 0.003] +- [2040104, 1, 1, 0.003] +- [2040107, 1, 1, 0.003] +- [2040108, 1, 1, 0.003] +- [2040202, 1, 1, 0.003] +- [2040205, 1, 1, 0.003] +- [2040207, 1, 1, 0.003] +- [2040209, 1, 1, 0.003] +- [1382046, 1, 1, 0.005] +- [1382047, 1, 1, 0.005] +- [1382048, 1, 1, 0.005] +- [1382045, 1, 1, 0.005] +- [4000345, 1, 1, 0.6] +- [4000346, 1, 1, 0.6] +- [2040100, 1, 1, 0.003] +- [2040102, 1, 1, 0.003] +- [2040104, 1, 1, 0.003] +- [2040107, 1, 1, 0.003] +- [2040108, 1, 1, 0.003] +- [2040202, 1, 1, 0.003] +- [2040205, 1, 1, 0.003] +- [2040207, 1, 1, 0.003] +- [2040209, 1, 1, 0.003] +- [1382046, 1, 1, 0.005] +- [1382047, 1, 1, 0.005] +- [1382048, 1, 1, 0.005] +- [1382045, 1, 1, 0.005] +- [4000345, 1, 1, 0.6] +- [4000346, 1, 1, 0.6] +- [2040100, 1, 1, 0.003] +- [2040102, 1, 1, 0.003] +- [2040104, 1, 1, 0.003] +- [2040107, 1, 1, 0.003] +- [2040108, 1, 1, 0.003] +- [2040202, 1, 1, 0.003] +- [2040205, 1, 1, 0.003] +- [2040207, 1, 1, 0.003] +- [2040209, 1, 1, 0.003] +- [1382046, 1, 1, 0.005] +- [1382047, 1, 1, 0.005] +- [1382048, 1, 1, 0.005] +- [1382045, 1, 1, 0.005] +- [4000345, 1, 1, 0.6] +- [4000346, 1, 1, 0.6] +- [2040100, 1, 1, 0.003] +- [2040102, 1, 1, 0.003] +- [2040104, 1, 1, 0.003] +- [2040107, 1, 1, 0.003] +- [2040108, 1, 1, 0.003] +- [2040202, 1, 1, 0.003] +- [2040205, 1, 1, 0.003] +- [2040207, 1, 1, 0.003] +- [2040209, 1, 1, 0.003] +- [1382046, 1, 1, 0.005] +- [1382047, 1, 1, 0.005] +- [1382048, 1, 1, 0.005] +- [1382045, 1, 1, 0.005] diff --git a/data/reward/9400533.yaml b/data/reward/9400533.yaml new file mode 100644 index 00000000..dc412293 --- /dev/null +++ b/data/reward/9400533.yaml @@ -0,0 +1,5 @@ +# Indigo Eye PQ (9400533) + +rewards: +- [4031597, 1, 1, 0.999999] +- [4031597, 1, 1, 0.999999] diff --git a/data/reward/9400534.yaml b/data/reward/9400534.yaml new file mode 100644 index 00000000..b0ddde24 --- /dev/null +++ b/data/reward/9400534.yaml @@ -0,0 +1,5 @@ +# Crystal Boar PQ (9400534) + +rewards: +- [4031597, 1, 1, 0.999999] +- [4031597, 1, 1, 0.999999] diff --git a/data/reward/9400574.yaml b/data/reward/9400574.yaml new file mode 100644 index 00000000..87779340 --- /dev/null +++ b/data/reward/9400574.yaml @@ -0,0 +1,6 @@ +# Typhon (9400574) + +rewards: +- [2041028, 1, 1, 0.0003] +- [4032005, 1, 1, 0.05] +- [2290156, 1, 1, 0.0005] diff --git a/data/reward/9400575.yaml b/data/reward/9400575.yaml new file mode 100644 index 00000000..1379abe0 --- /dev/null +++ b/data/reward/9400575.yaml @@ -0,0 +1,5 @@ +# Bigfoot (9400575) + +rewards: +- [0, 1000, 5000, 0.2] +- [4032013, 1, 1, 0.04] diff --git a/data/reward/9400576.yaml b/data/reward/9400576.yaml new file mode 100644 index 00000000..05a33668 --- /dev/null +++ b/data/reward/9400576.yaml @@ -0,0 +1,6 @@ +# Windraider (9400576) + +rewards: +- [2001515, 1, 1, 0.02] +- [2040821, 1, 1, 0.0003] +- [4032007, 1, 1, 0.05] diff --git a/data/reward/9400578.yaml b/data/reward/9400578.yaml new file mode 100644 index 00000000..ba587400 --- /dev/null +++ b/data/reward/9400578.yaml @@ -0,0 +1,7 @@ +# Firebrand (9400578) + +rewards: +- [2022020, 1, 1, 0.02] +- [2001000, 1, 1, 0.02] +- [4032008, 1, 1, 0.05] +- [1082246, 1, 1, 0.0001] diff --git a/data/reward/9400579.yaml b/data/reward/9400579.yaml new file mode 100644 index 00000000..2508d0b7 --- /dev/null +++ b/data/reward/9400579.yaml @@ -0,0 +1,4 @@ +# Nightshadow (9400579) + +rewards: +- [4032009, 1, 1, 0.05] diff --git a/data/reward/9400580.yaml b/data/reward/9400580.yaml new file mode 100644 index 00000000..df6a513c --- /dev/null +++ b/data/reward/9400580.yaml @@ -0,0 +1,8 @@ +# Elderwraith (9400580) + +rewards: + - [ 0, 573, 854, 0.600000 ] + - [ 4032011, 1, 1, 0.400000 ] # Soiled Rags + - [ 4032010, 1, 1, 0.200000 ] # Elder Ashes + - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore + - [ 2290024, 1, 1, 0.002500 ] # [Mastery Book] Mana Reflection 20 \ No newline at end of file diff --git a/data/reward/9400581.yaml b/data/reward/9400581.yaml new file mode 100644 index 00000000..5caa80d4 --- /dev/null +++ b/data/reward/9400581.yaml @@ -0,0 +1,4 @@ +# Stormbreaker (9400581) + +rewards: +- [ 4032006, 1, 1, 0.050000 ] diff --git a/data/reward/9400582.yaml b/data/reward/9400582.yaml new file mode 100644 index 00000000..e43f1376 --- /dev/null +++ b/data/reward/9400582.yaml @@ -0,0 +1,7 @@ +# Crimson Guardian (9400582) + +rewards: +- [4032012, 1, 1, 0.01] +- [4032011, 1, 1, 0.05] +- [4032010, 1, 1, 0.05] +- [2290008, 1, 1, 0.0005] diff --git a/data/reward/9400583.yaml b/data/reward/9400583.yaml new file mode 100644 index 00000000..a854c371 --- /dev/null +++ b/data/reward/9400583.yaml @@ -0,0 +1,13 @@ +# Leprechaun [2] (9400584) + +rewards: + - [ 4032031, 1, 1, 0.600000 ] # Lucky Charm + - [ 4006000, 1, 1, 0.000700 ] # The Magic Rock + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2050004, 1, 1, 0.010000 ] # All Cure Potion + - [ 2041030, 1, 1, 0.010000 ] # Dark scroll for Cape for HP 70% + - [ 2070005, 1, 1, 0.000400 ] # Steely Throwing-Knives + - [ 1002391, 1, 1, 0.000100] # Green Bandana + - [ 1402013, 1, 1, 0.000100] # Japanese Map \ No newline at end of file diff --git a/data/reward/9400584.yaml b/data/reward/9400584.yaml new file mode 100644 index 00000000..fab6d2d0 --- /dev/null +++ b/data/reward/9400584.yaml @@ -0,0 +1,4 @@ +# Leprechaun [1] (9400584) + +rewards: +- [1082246, 1, 1, 2e-05] diff --git a/data/reward/9400585.yaml b/data/reward/9400585.yaml new file mode 100644 index 00000000..54fce87c --- /dev/null +++ b/data/reward/9400585.yaml @@ -0,0 +1,4 @@ +# Crimson Tree (9400585) + +rewards: +- [4032004, 1, 1, 0.05] diff --git a/data/reward/9400586.yaml b/data/reward/9400586.yaml new file mode 100644 index 00000000..9a3c8fca --- /dev/null +++ b/data/reward/9400586.yaml @@ -0,0 +1,4 @@ +# Crimson Tree (9400586) + +rewards: +- [4032004, 1, 1, 0.05] diff --git a/data/reward/9400587.yaml b/data/reward/9400587.yaml new file mode 100644 index 00000000..3f7c1570 --- /dev/null +++ b/data/reward/9400587.yaml @@ -0,0 +1,4 @@ +# Phantom Tree (9400587) + +rewards: +- [4032003, 1, 1, 0.05] diff --git a/data/reward/9400588.yaml b/data/reward/9400588.yaml new file mode 100644 index 00000000..148788d9 --- /dev/null +++ b/data/reward/9400588.yaml @@ -0,0 +1,4 @@ +# Phantom Tree (9400588) + +rewards: +- [4032003, 1, 1, 0.05] diff --git a/data/reward/9400589.yaml b/data/reward/9400589.yaml new file mode 100644 index 00000000..066c559b --- /dev/null +++ b/data/reward/9400589.yaml @@ -0,0 +1,4 @@ +# MV (9400589) + +rewards: +- [1122059, 1, 1, 0.999999] diff --git a/data/reward/9400590.yaml b/data/reward/9400590.yaml new file mode 100644 index 00000000..01fc1452 --- /dev/null +++ b/data/reward/9400590.yaml @@ -0,0 +1,5 @@ +# Margana (9400590) + +rewards: +- [1122059, 1, 1, 0.999999] +- [1082230, 1, 1, 0.01] diff --git a/data/reward/9400591.yaml b/data/reward/9400591.yaml new file mode 100644 index 00000000..b79e41b6 --- /dev/null +++ b/data/reward/9400591.yaml @@ -0,0 +1,5 @@ +# Red Nirg (9400591) + +rewards: +- [1122059, 1, 1, 0.999999] +- [1412040, 1, 1, 0.3] diff --git a/data/reward/9400592.yaml b/data/reward/9400592.yaml new file mode 100644 index 00000000..610c5e4d --- /dev/null +++ b/data/reward/9400592.yaml @@ -0,0 +1,5 @@ +# Rellik (9400592) + +rewards: +- [1122059, 1, 1, 0.999999] +- [1022082, 1, 1, 3.3e-05] diff --git a/data/reward/9400593.yaml b/data/reward/9400593.yaml new file mode 100644 index 00000000..93b99387 --- /dev/null +++ b/data/reward/9400593.yaml @@ -0,0 +1,4 @@ +# Hsalf (9400593) + +rewards: +- [1122059, 1, 1, 0.999999] diff --git a/data/reward/9400601.yaml b/data/reward/9400601.yaml new file mode 100644 index 00000000..ae7c0d53 --- /dev/null +++ b/data/reward/9400601.yaml @@ -0,0 +1,4 @@ +# Birthday Candle (9400601) + +rewards: +- [4032658, 1, 1, 0.21] diff --git a/data/reward/9400602.yaml b/data/reward/9400602.yaml new file mode 100644 index 00000000..6fcbd55b --- /dev/null +++ b/data/reward/9400602.yaml @@ -0,0 +1,4 @@ +# Strawberry Cake (9400602) + +rewards: +- [4032658, 1, 1, 0.21] diff --git a/data/reward/9400605.yaml b/data/reward/9400605.yaml new file mode 100644 index 00000000..1ebac7fc --- /dev/null +++ b/data/reward/9400605.yaml @@ -0,0 +1,4 @@ +# Chocolate Cake (9400605) + +rewards: +- [4032658, 1, 1, 0.21] diff --git a/data/reward/9400606.yaml b/data/reward/9400606.yaml new file mode 100644 index 00000000..f18669f7 --- /dev/null +++ b/data/reward/9400606.yaml @@ -0,0 +1,4 @@ +# Giant Cake (9400606) + +rewards: +- [4032658, 1, 1, 0.21] diff --git a/data/reward/9400609.yaml b/data/reward/9400609.yaml new file mode 100644 index 00000000..0dc2184e --- /dev/null +++ b/data/reward/9400609.yaml @@ -0,0 +1,5 @@ +# Andras (9400609) + +rewards: +- [1072419, 1, 1, 0.01] +- [1082256, 1, 1, 0.01] diff --git a/data/reward/9400610.yaml b/data/reward/9400610.yaml new file mode 100644 index 00000000..bcb6829f --- /dev/null +++ b/data/reward/9400610.yaml @@ -0,0 +1,5 @@ +# Amdusias (9400610) + +rewards: +- [1072422, 1, 1, 0.01] +- [1082259, 1, 1, 0.01] diff --git a/data/reward/9400611.yaml b/data/reward/9400611.yaml new file mode 100644 index 00000000..d4ad3a3a --- /dev/null +++ b/data/reward/9400611.yaml @@ -0,0 +1,5 @@ +# Crocell (9400611) + +rewards: +- [1072423, 1, 1, 0.01] +- [1082260, 1, 1, 0.01] diff --git a/data/reward/9400612.yaml b/data/reward/9400612.yaml new file mode 100644 index 00000000..a98caf91 --- /dev/null +++ b/data/reward/9400612.yaml @@ -0,0 +1,5 @@ +# Marbas (9400612) + +rewards: +- [1072420, 1, 1, 0.01] +- [1082257, 1, 1, 0.01] diff --git a/data/reward/9400613.yaml b/data/reward/9400613.yaml new file mode 100644 index 00000000..bbe16156 --- /dev/null +++ b/data/reward/9400613.yaml @@ -0,0 +1,5 @@ +# Valefor (9400613) + +rewards: +- [1072421, 1, 1, 0.01] +- [1082258, 1, 1, 0.01] diff --git a/data/reward/9400614.yaml b/data/reward/9400614.yaml new file mode 100644 index 00000000..43573cea --- /dev/null +++ b/data/reward/9400614.yaml @@ -0,0 +1,5 @@ +# Strange Orange Mushroom (9400614) + +rewards: +- [4001352, 1, 1, 0.999999, 28205] +- [4001369, 1, 1, 0.999999, 28259] diff --git a/data/reward/9400615.yaml b/data/reward/9400615.yaml new file mode 100644 index 00000000..ce211244 --- /dev/null +++ b/data/reward/9400615.yaml @@ -0,0 +1,5 @@ +# Strange Ribbon Pig (9400615) + +rewards: +- [4001352, 1, 1, 0.999999, 28205] +- [4001369, 1, 1, 0.999999, 28259] diff --git a/data/reward/9400616.yaml b/data/reward/9400616.yaml new file mode 100644 index 00000000..93e708cb --- /dev/null +++ b/data/reward/9400616.yaml @@ -0,0 +1,5 @@ +# Strange Green Mushroom (9400616) + +rewards: +- [4001352, 1, 1, 0.999999, 28205] +- [4001369, 1, 1, 0.999999, 28259] diff --git a/data/reward/9400617.yaml b/data/reward/9400617.yaml new file mode 100644 index 00000000..09520ca7 --- /dev/null +++ b/data/reward/9400617.yaml @@ -0,0 +1,6 @@ +# Strange Pig (9400617) + +rewards: +- [4001352, 1, 1, 0.999999, 28205] +- [4001369, 1, 1, 0.999999, 28259] +- [4001370, 1, 1, 0.999999, 28260] diff --git a/data/reward/9400618.yaml b/data/reward/9400618.yaml new file mode 100644 index 00000000..3bbf07c4 --- /dev/null +++ b/data/reward/9400618.yaml @@ -0,0 +1,4 @@ +# Strange Dark Axe Stump (9400618) + +rewards: +- [4001367, 1, 1, 0.999999, 28257] diff --git a/data/reward/9400619.yaml b/data/reward/9400619.yaml new file mode 100644 index 00000000..1541ab0f --- /dev/null +++ b/data/reward/9400619.yaml @@ -0,0 +1,4 @@ +# Strange Zombie Mushroom (9400619) + +rewards: +- [4001367, 1, 1, 0.999999, 28257] diff --git a/data/reward/9400620.yaml b/data/reward/9400620.yaml new file mode 100644 index 00000000..b16a8597 --- /dev/null +++ b/data/reward/9400620.yaml @@ -0,0 +1,4 @@ +# Strange Dark Stump (9400620) + +rewards: +- [4001371, 1, 1, 0.999999, 28261] diff --git a/data/reward/9400621.yaml b/data/reward/9400621.yaml new file mode 100644 index 00000000..60a19d67 --- /dev/null +++ b/data/reward/9400621.yaml @@ -0,0 +1,4 @@ +# Strange Horny Mushroom (9400621) + +rewards: +- [4001368, 1, 1, 0.999999, 28258] diff --git a/data/reward/9400622.yaml b/data/reward/9400622.yaml new file mode 100644 index 00000000..a9cd5fe5 --- /dev/null +++ b/data/reward/9400622.yaml @@ -0,0 +1,4 @@ +# Strange Blue Mushroom (9400622) + +rewards: +- [4001368, 1, 1, 0.999999, 28258] diff --git a/data/reward/9400623.yaml b/data/reward/9400623.yaml new file mode 100644 index 00000000..53593958 --- /dev/null +++ b/data/reward/9400623.yaml @@ -0,0 +1,5 @@ +# Amdusias (9400623) + +rewards: +- [1072422, 1, 1, 0.01] +- [1082259, 1, 1, 0.01] diff --git a/data/reward/9400633.yaml b/data/reward/9400633.yaml new file mode 100644 index 00000000..81203746 --- /dev/null +++ b/data/reward/9400633.yaml @@ -0,0 +1,16 @@ +# Astaroth (9400633) + +rewards: +- [1332099, 1, 1, 0.05] +- [1302133, 1, 1, 0.05] +- [1372058, 1, 1, 0.05] +- [1382080, 1, 1, 0.05] +- [1402072, 1, 1, 0.05] +- [1432061, 1, 1, 0.05] +- [1412046, 1, 1, 0.05] +- [1442103, 1, 1, 0.05] +- [1452085, 1, 1, 0.05] +- [1462075, 1, 1, 0.05] +- [1472100, 1, 1, 0.05] +- [1482046, 1, 1, 0.05] +- [1492048, 1, 1, 0.05] diff --git a/data/reward/9400640.yaml b/data/reward/9400640.yaml new file mode 100644 index 00000000..b7f93c96 --- /dev/null +++ b/data/reward/9400640.yaml @@ -0,0 +1,22 @@ +# Twisted Jester (9400640) + +rewards: + - [ 0, 177, 264, 0.600000 ] + - [ 4006001, 1, 1, 0.007000 ] # The Summoning Rock + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2043701, 1, 1, 0.000100 ] # Scroll for Wand for Magic Attack 60% + - [ 1041087, 1, 1, 0.002500 ] # Red Shouldermail + - [ 1061086, 1, 1, 0.002500 ] # Red Shouldermail Pants + - [ 1072148, 1, 1, 0.002500 ] # Orihalcon Camel Boots + - [ 1442010, 1, 1, 0.002500 ] # Skylar + - [ 1072154, 1, 1, 0.002500 ] # Blue Carzen Boots + - [ 1082061, 1, 1, 0.002500 ] # Dark Clench + - [ 1050047, 1, 1, 0.002500 ] # Orange Calas + - [ 1051054, 1, 1, 0.002500 ] # Brown Requierre + - [ 1082091, 1, 1, 0.002500 ] # Dark Garner + - [ 1002286, 1, 1, 0.002500 ] # Blue Patriot + - [ 1002210, 1, 1, 0.002500 ] # Brown Sonata + - [ 1040096, 1, 1, 0.002500 ] # Brown China + - [ 1060085, 1, 1, 0.002500 ] # Brown China Pants + - [ 1082093, 1, 1, 0.002500 ] # Steal Pow + - [ 1002281, 1, 1, 0.002500 ] # Brown Nightfox \ No newline at end of file diff --git a/data/reward/9400648.yaml b/data/reward/9400648.yaml new file mode 100644 index 00000000..212b198f --- /dev/null +++ b/data/reward/9400648.yaml @@ -0,0 +1,4 @@ +# Possessed Bear Doll (9400648) + +rewards: +- [4001106, 1, 1, 0.999999] diff --git a/data/reward/9400649.yaml b/data/reward/9400649.yaml new file mode 100644 index 00000000..118be71e --- /dev/null +++ b/data/reward/9400649.yaml @@ -0,0 +1,4 @@ +# Possessed Rabbit Doll (9400649) + +rewards: +- [4001106, 1, 1, 0.999999] diff --git a/data/reward/9400651.yaml b/data/reward/9400651.yaml new file mode 100644 index 00000000..fe8bf412 --- /dev/null +++ b/data/reward/9400651.yaml @@ -0,0 +1,4 @@ +# Possessed Rabbit Doll (9400651) + +rewards: +- [4001106, 25, 25, 0.999999] diff --git a/data/reward/9400652.yaml b/data/reward/9400652.yaml new file mode 100644 index 00000000..2f6f097c --- /dev/null +++ b/data/reward/9400652.yaml @@ -0,0 +1,5 @@ +# Possessed Bear Doll (9400652) + +rewards: +- [4001106, 25, 25, 0.999999] +- [4001106, 50, 50, 0.999999] diff --git a/data/reward/9400653.yaml b/data/reward/9400653.yaml new file mode 100644 index 00000000..78b685cb --- /dev/null +++ b/data/reward/9400653.yaml @@ -0,0 +1,4 @@ +# Possessed Rabbit Doll (9400653) + +rewards: +- [4001106, 50, 50, 0.999999] diff --git a/data/reward/9400655.yaml b/data/reward/9400655.yaml new file mode 100644 index 00000000..0a134224 --- /dev/null +++ b/data/reward/9400655.yaml @@ -0,0 +1,4 @@ +# Strange Orange Mushroom (9400655) + +rewards: +- [4001370, 1, 1, 0.999999, 28260] diff --git a/data/reward/9400656.yaml b/data/reward/9400656.yaml new file mode 100644 index 00000000..5d84e15b --- /dev/null +++ b/data/reward/9400656.yaml @@ -0,0 +1,4 @@ +# Strange Ribbon Pig (9400656) + +rewards: +- [4001370, 1, 1, 0.999999, 28260] diff --git a/data/reward/9400657.yaml b/data/reward/9400657.yaml new file mode 100644 index 00000000..48eea0f7 --- /dev/null +++ b/data/reward/9400657.yaml @@ -0,0 +1,4 @@ +# Strange Green Mushroom (9400657) + +rewards: +- [4001371, 1, 1, 0.999999, 28261] diff --git a/data/reward/9400661.yaml b/data/reward/9400661.yaml new file mode 100644 index 00000000..fff1954b --- /dev/null +++ b/data/reward/9400661.yaml @@ -0,0 +1,4 @@ +# Mob 9400661 (9400661) + +rewards: +- [4002151, 1, 1, 0.01] diff --git a/data/reward/9400700.yaml b/data/reward/9400700.yaml new file mode 100644 index 00000000..332273de --- /dev/null +++ b/data/reward/9400700.yaml @@ -0,0 +1,14 @@ +# Mob 9400700 (9400700) + +rewards: +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4031730, 1, 1, 0.999999] diff --git a/data/reward/9400701.yaml b/data/reward/9400701.yaml new file mode 100644 index 00000000..772208c3 --- /dev/null +++ b/data/reward/9400701.yaml @@ -0,0 +1,5 @@ +# Mob 9400701 (9400701) + +rewards: +- [2022241, 1, 1, 0.002] +- [2022254, 1, 1, 0.1] diff --git a/data/reward/9400702.yaml b/data/reward/9400702.yaml new file mode 100644 index 00000000..76438501 --- /dev/null +++ b/data/reward/9400702.yaml @@ -0,0 +1,5 @@ +# Mob 9400702 (9400702) + +rewards: +- [2022241, 1, 1, 0.002] +- [2022254, 1, 1, 0.1] diff --git a/data/reward/9400703.yaml b/data/reward/9400703.yaml new file mode 100644 index 00000000..b3a6c2af --- /dev/null +++ b/data/reward/9400703.yaml @@ -0,0 +1,5 @@ +# Mob 9400703 (9400703) + +rewards: +- [2022241, 1, 1, 0.002] +- [2022254, 1, 1, 0.1] diff --git a/data/reward/9400734.yaml b/data/reward/9400734.yaml new file mode 100644 index 00000000..7830c6ca --- /dev/null +++ b/data/reward/9400734.yaml @@ -0,0 +1,14 @@ +# Mob 9400734 (9400734) + +rewards: +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4001194, 1, 5, 0.999999] +- [4031730, 1, 1, 0.999999] diff --git a/data/reward/9400737.yaml b/data/reward/9400737.yaml new file mode 100644 index 00000000..62d1c8df --- /dev/null +++ b/data/reward/9400737.yaml @@ -0,0 +1,5 @@ +# Mob 9400737 (9400737) + +rewards: +- [2022241, 1, 1, 0.002] +- [2022254, 1, 1, 0.1] diff --git a/data/reward/9400738.yaml b/data/reward/9400738.yaml new file mode 100644 index 00000000..51590733 --- /dev/null +++ b/data/reward/9400738.yaml @@ -0,0 +1,5 @@ +# Mob 9400738 (9400738) + +rewards: +- [2022241, 1, 1, 0.002] +- [2022254, 1, 1, 0.1] diff --git a/data/reward/9400739.yaml b/data/reward/9400739.yaml new file mode 100644 index 00000000..2acd76d9 --- /dev/null +++ b/data/reward/9400739.yaml @@ -0,0 +1,4 @@ +# MV Minion (9400739) + +rewards: +- [4032248, 1, 1, 0.999999] diff --git a/data/reward/9400740.yaml b/data/reward/9400740.yaml new file mode 100644 index 00000000..242b495e --- /dev/null +++ b/data/reward/9400740.yaml @@ -0,0 +1,4 @@ +# MV Minion (9400740) + +rewards: +- [4032248, 1, 1, 0.999999] diff --git a/data/reward/9400748.yaml b/data/reward/9400748.yaml new file mode 100644 index 00000000..0dcc090a --- /dev/null +++ b/data/reward/9400748.yaml @@ -0,0 +1,16 @@ +# MV (9400748) + +rewards: +- [1002858, 1, 1, 0.05] +- [1002859, 1, 1, 0.05] +- [1002860, 1, 1, 0.05] +- [1002861, 1, 1, 0.05] +- [4020000, 1, 1, 0.1] +- [4020003, 1, 1, 0.1] +- [4020006, 1, 1, 0.1] +- [4020007, 1, 1, 0.1] +- [2022453, 1, 1, 0.12] +- [1032001, 1, 1, 0.05] +- [1032002, 1, 1, 0.05] +- [1032003, 1, 1, 0.05] +- [1032004, 1, 1, 0.05] diff --git a/data/reward/9400805.yaml b/data/reward/9400805.yaml new file mode 100644 index 00000000..f8689e06 --- /dev/null +++ b/data/reward/9400805.yaml @@ -0,0 +1,5 @@ +# Mob 9400805 (9400805) + +rewards: +- [4033189, 1, 1, 0.07, 28747] +- [2290285, 1, 1, 0.0013] diff --git a/data/reward/9400807.yaml b/data/reward/9400807.yaml new file mode 100644 index 00000000..2f6a9415 --- /dev/null +++ b/data/reward/9400807.yaml @@ -0,0 +1,4 @@ +# Mob 9400807 (9400807) + +rewards: +- [2290285, 1, 1, 0.0013] diff --git a/data/reward/9400902.yaml b/data/reward/9400902.yaml new file mode 100644 index 00000000..a9a41511 --- /dev/null +++ b/data/reward/9400902.yaml @@ -0,0 +1,4 @@ +# Mob 9400902 (9400902) + +rewards: +- [2028091, 1, 2, 1e-06] diff --git a/data/reward/9409015.yaml b/data/reward/9409015.yaml new file mode 100644 index 00000000..5ea3e06e --- /dev/null +++ b/data/reward/9409015.yaml @@ -0,0 +1,5 @@ +# Blue Goblin (9409015) + +rewards: + - [ 4000577, 1, 1, 0.400000 ] # Blue Goblin Crown + - [ 4001433, 1, 1, 0.010000 ] \ No newline at end of file diff --git a/data/reward/9409016.yaml b/data/reward/9409016.yaml new file mode 100644 index 00000000..6b83bf76 --- /dev/null +++ b/data/reward/9409016.yaml @@ -0,0 +1,5 @@ +# Red Goblin (9409016) + +rewards: + - [ 4000576, 1, 1, 0.400000 ] # Red Goblin Axe + - [4001433, 1, 1, 0.01] \ No newline at end of file diff --git a/data/reward/9409017.yaml b/data/reward/9409017.yaml new file mode 100644 index 00000000..7c306bc2 --- /dev/null +++ b/data/reward/9409017.yaml @@ -0,0 +1,4 @@ +# Mob 9409017 (9409017) + +rewards: +- [4001433, 1, 1, 0.01] diff --git a/data/reward/9409018.yaml b/data/reward/9409018.yaml new file mode 100644 index 00000000..6dd5a81d --- /dev/null +++ b/data/reward/9409018.yaml @@ -0,0 +1,4 @@ +# Mob 9409018 (9409018) + +rewards: +- [1003068, 1, 1, 0.0015] diff --git a/data/reward/9410009.yaml b/data/reward/9410009.yaml new file mode 100644 index 00000000..fd5a32ca --- /dev/null +++ b/data/reward/9410009.yaml @@ -0,0 +1,4 @@ +# Yeti Doll (9410009) + +rewards: +- [0, 50, 90, 0.2] diff --git a/data/reward/9410011.yaml b/data/reward/9410011.yaml new file mode 100644 index 00000000..94f062b7 --- /dev/null +++ b/data/reward/9410011.yaml @@ -0,0 +1,4 @@ +# Jr. Pepe Doll (9410011) + +rewards: +- [0, 50, 90, 0.2] diff --git a/data/reward/9410066.yaml b/data/reward/9410066.yaml new file mode 100644 index 00000000..b1ae3d67 --- /dev/null +++ b/data/reward/9410066.yaml @@ -0,0 +1,26 @@ +# Mob 9410066 (9410066) + +rewards: +- [5490001, 1, 1, 0.07] +- [5490001, 1, 1, 0.07] +- [5490000, 1, 1, 0.03] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] +- [4000306, 1, 1, 0.7] diff --git a/data/reward/9410067.yaml b/data/reward/9410067.yaml new file mode 100644 index 00000000..b4ac3fb4 --- /dev/null +++ b/data/reward/9410067.yaml @@ -0,0 +1,5 @@ +# Mob 9410067 (9410067) + +rewards: +- [5220010, 1, 1, 1.0] +- [5220020, 1, 1, 0.001] diff --git a/data/reward/9420001.yaml b/data/reward/9420001.yaml new file mode 100644 index 00000000..e487f539 --- /dev/null +++ b/data/reward/9420001.yaml @@ -0,0 +1,4 @@ +# Frog (9420001) + +rewards: +- [4031401, 1, 1, 1.0, 8761] diff --git a/data/reward/9420002.yaml b/data/reward/9420002.yaml new file mode 100644 index 00000000..028a9075 --- /dev/null +++ b/data/reward/9420002.yaml @@ -0,0 +1,4 @@ +# Python (9420002) + +rewards: +- [4000248, 1, 1, 1.0] diff --git a/data/reward/9420003.yaml b/data/reward/9420003.yaml new file mode 100644 index 00000000..9fc9028a --- /dev/null +++ b/data/reward/9420003.yaml @@ -0,0 +1,6 @@ +# Red Lizard (9420003) + +rewards: +- [4031400, 1, 1, 1.0, 8761] +- [4000251, 1, 1, 1.0] +- [4007004, 1, 1, 0.01] diff --git a/data/reward/9420005.yaml b/data/reward/9420005.yaml new file mode 100644 index 00000000..ccbcef0a --- /dev/null +++ b/data/reward/9420005.yaml @@ -0,0 +1,5 @@ +# White Rooster (9420005) + +rewards: +- [4000252, 1, 1, 1.0] +- [4000253, 1, 1, 1.0] diff --git a/data/reward/9420015.yaml b/data/reward/9420015.yaml new file mode 100644 index 00000000..85a165f9 --- /dev/null +++ b/data/reward/9420015.yaml @@ -0,0 +1,110 @@ +# NooNoo (9420015) + +rewards: +- [2022042, 1, 1, 0.7] +- [2022042, 1, 1, 0.7] +- [2022042, 1, 1, 0.7] +- [2022042, 1, 1, 0.7] +- [2022042, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4032176, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000420, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000421, 1, 1, 0.7] +- [4000420, 1, 1, 0.6] +- [4000421, 1, 1, 0.6] diff --git a/data/reward/9420065.yaml b/data/reward/9420065.yaml new file mode 100644 index 00000000..888cf9c4 --- /dev/null +++ b/data/reward/9420065.yaml @@ -0,0 +1,29 @@ +# Mob 9420065 (9420065) + +rewards: +- [1032080, 1, 1, 0.005] +- [1122081, 1, 1, 0.005] +- [1132036, 1, 1, 0.005] +- [1112435, 1, 1, 0.005] +- [1092070, 1, 1, 0.005] +- [1092075, 1, 1, 0.005] +- [1092080, 1, 1, 0.005] +- [1302143, 1, 1, 0.005] +- [1312058, 1, 1, 0.005] +- [1322086, 1, 1, 0.005] +- [1332116, 1, 1, 0.005] +- [1332121, 1, 1, 0.005] +- [1342029, 1, 1, 0.005] +- [1372074, 1, 1, 0.005] +- [1382095, 1, 1, 0.005] +- [1402086, 1, 1, 0.005] +- [1412058, 1, 1, 0.005] +- [1422059, 1, 1, 0.005] +- [1432077, 1, 1, 0.005] +- [1442107, 1, 1, 0.005] +- [1452102, 1, 1, 0.005] +- [1462087, 1, 1, 0.005] +- [1472113, 1, 1, 0.005] +- [1482075, 1, 1, 0.005] +- [1492075, 1, 1, 0.005] +- [3010156, 1, 1, 0.0005] diff --git a/data/reward/9420066.yaml b/data/reward/9420066.yaml new file mode 100644 index 00000000..b55e167d --- /dev/null +++ b/data/reward/9420066.yaml @@ -0,0 +1,104 @@ +# Mob 9420066 (9420066) + +rewards: +- [1032080, 1, 1, 0.005] +- [1122081, 1, 1, 0.005] +- [1132036, 1, 1, 0.005] +- [1112435, 1, 1, 0.005] +- [1092070, 1, 1, 0.005] +- [1092075, 1, 1, 0.005] +- [1092080, 1, 1, 0.005] +- [1302143, 1, 1, 0.005] +- [1312058, 1, 1, 0.005] +- [1322086, 1, 1, 0.005] +- [1332116, 1, 1, 0.005] +- [1332121, 1, 1, 0.005] +- [1342029, 1, 1, 0.005] +- [1372074, 1, 1, 0.005] +- [1382095, 1, 1, 0.005] +- [1402086, 1, 1, 0.005] +- [1412058, 1, 1, 0.005] +- [1422059, 1, 1, 0.005] +- [1432077, 1, 1, 0.005] +- [1442107, 1, 1, 0.005] +- [1452102, 1, 1, 0.005] +- [1462087, 1, 1, 0.005] +- [1472113, 1, 1, 0.005] +- [1482075, 1, 1, 0.005] +- [1492075, 1, 1, 0.005] +- [1032081, 1, 1, 0.005] +- [1122082, 1, 1, 0.005] +- [1132037, 1, 1, 0.005] +- [1112436, 1, 1, 0.005] +- [1092071, 1, 1, 0.005] +- [1092076, 1, 1, 0.005] +- [1092081, 1, 1, 0.005] +- [1302144, 1, 1, 0.005] +- [1312059, 1, 1, 0.005] +- [1322088, 1, 1, 0.005] +- [1332117, 1, 1, 0.005] +- [1332122, 1, 1, 0.005] +- [1342030, 1, 1, 0.005] +- [1372075, 1, 1, 0.005] +- [1382096, 1, 1, 0.005] +- [1402087, 1, 1, 0.005] +- [1412059, 1, 1, 0.005] +- [1422060, 1, 1, 0.005] +- [1432078, 1, 1, 0.005] +- [1442108, 1, 1, 0.005] +- [1452103, 1, 1, 0.005] +- [1462088, 1, 1, 0.005] +- [1472114, 1, 1, 0.005] +- [1482076, 1, 1, 0.005] +- [1492076, 1, 1, 0.005] +- [1032082, 1, 1, 0.005] +- [1122083, 1, 1, 0.005] +- [1132038, 1, 1, 0.005] +- [1112437, 1, 1, 0.005] +- [1092072, 1, 1, 0.005] +- [1092077, 1, 1, 0.005] +- [1092082, 1, 1, 0.005] +- [1302145, 1, 1, 0.005] +- [1312060, 1, 1, 0.005] +- [1322088, 1, 1, 0.005] +- [1332118, 1, 1, 0.005] +- [1332123, 1, 1, 0.005] +- [1342031, 1, 1, 0.005] +- [1372076, 1, 1, 0.005] +- [1382097, 1, 1, 0.005] +- [1402088, 1, 1, 0.005] +- [1412060, 1, 1, 0.005] +- [1422061, 1, 1, 0.005] +- [1432079, 1, 1, 0.005] +- [1442109, 1, 1, 0.005] +- [1452104, 1, 1, 0.005] +- [1462089, 1, 1, 0.005] +- [1472115, 1, 1, 0.005] +- [1482077, 1, 1, 0.005] +- [1492077, 1, 1, 0.005] +- [1032083, 1, 1, 0.005] +- [1122084, 1, 1, 0.005] +- [1132039, 1, 1, 0.005] +- [1112438, 1, 1, 0.005] +- [1092073, 1, 1, 0.005] +- [1092078, 1, 1, 0.005] +- [1092083, 1, 1, 0.005] +- [1302146, 1, 1, 0.005] +- [1312061, 1, 1, 0.005] +- [1322089, 1, 1, 0.005] +- [1332119, 1, 1, 0.005] +- [1332124, 1, 1, 0.005] +- [1342032, 1, 1, 0.005] +- [1372077, 1, 1, 0.005] +- [1382098, 1, 1, 0.005] +- [1402089, 1, 1, 0.005] +- [1412061, 1, 1, 0.005] +- [1422062, 1, 1, 0.005] +- [1432080, 1, 1, 0.005] +- [1442110, 1, 1, 0.005] +- [1452105, 1, 1, 0.005] +- [1462090, 1, 1, 0.005] +- [1472116, 1, 1, 0.005] +- [1482078, 1, 1, 0.005] +- [1492078, 1, 1, 0.005] +- [3010156, 1, 1, 0.005] diff --git a/data/reward/9420067.yaml b/data/reward/9420067.yaml new file mode 100644 index 00000000..b741a562 --- /dev/null +++ b/data/reward/9420067.yaml @@ -0,0 +1,29 @@ +# Mob 9420067 (9420067) + +rewards: +- [1032084, 1, 1, 0.005] +- [1122085, 1, 1, 0.005] +- [1132040, 1, 1, 0.005] +- [1112439, 1, 1, 0.005] +- [1092074, 1, 1, 0.005] +- [1092079, 1, 1, 0.005] +- [1092084, 1, 1, 0.005] +- [1302147, 1, 1, 0.005] +- [1312062, 1, 1, 0.005] +- [1322090, 1, 1, 0.005] +- [1332120, 1, 1, 0.005] +- [1332125, 1, 1, 0.005] +- [1342034, 1, 1, 0.005] +- [1372078, 1, 1, 0.005] +- [1382099, 1, 1, 0.005] +- [1402090, 1, 1, 0.005] +- [1412062, 1, 1, 0.005] +- [1422063, 1, 1, 0.005] +- [1432081, 1, 1, 0.005] +- [1442111, 1, 1, 0.005] +- [1452106, 1, 1, 0.005] +- [1462091, 1, 1, 0.005] +- [1472117, 1, 1, 0.005] +- [1482079, 1, 1, 0.005] +- [1492079, 1, 1, 0.005] +- [3010156, 1, 1, 0.05] diff --git a/data/reward/9420500.yaml b/data/reward/9420500.yaml new file mode 100644 index 00000000..8e03c2e3 --- /dev/null +++ b/data/reward/9420500.yaml @@ -0,0 +1,19 @@ +# Stopnow (9420500) + +rewards: + - [ 0, 64, 98, 0.600000 ] + - [ 4000369, 1, 1, 0.400000 ] # Stop Sign + - [ 4000370, 1, 1, 0.200000 ] # Metal pole + - [ 4010006, 1, 1, 0.002000 ] # Gold Ore + - [ 4020005, 1, 1, 0.002000 ] # Sapphire Ore + - [ 2000001, 1, 1, 0.010000 ] # Orange Potion + - [ 2002002, 1, 1, 0.010000 ] # Magic Potion + - [ 2002003, 1, 1, 0.010000 ] # Wizard Potion + - [ 2044002, 1, 1, 0.000100 ] # Scroll for Two-handed Sword for ATT 10% + - [ 1002141, 1, 1, 0.000100 ] # Red Matty + - [ 1002178, 1, 1, 0.000100 ] # Green Burgler + - [ 1051007, 1, 1, 0.000200 ] # Red Avenger + - [ 1051009, 1, 1, 0.000100 ] # Purple Avenger + - [ 1060052, 1, 1, 0.000100 ] # Black Knucklevest Pants + - [ 1092007, 1, 1, 0.000100 ] # Battle Shield + - [ 1332001, 1, 1, 0.000100 ] # Halfmoon Zamadar \ No newline at end of file diff --git a/data/reward/9420501.yaml b/data/reward/9420501.yaml new file mode 100644 index 00000000..c8a16fa7 --- /dev/null +++ b/data/reward/9420501.yaml @@ -0,0 +1,28 @@ +# Freezer [2] (9420501) + +rewards: + - [ 0, 102, 155, 0.600000 ] + - [ 4000372, 1, 1, 0.400000 ] # Fire Extinguisher + - [ 4006001, 1, 1, 0.000700 ] # The Summoning Rock + - [ 4010005, 1, 1, 0.002000 ] # Orihalcon Ore + - [ 4020000, 1, 1, 0.002000 ] # Garnet Ore + - [ 4030012, 1, 1, 0.020000 ] # Monster Card + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 2040800, 1, 1, 0.000100 ] # Scroll For Gloves For DEX 100% + - [ 2043200, 1, 1, 0.000100 ] # Scroll for One-Handed BW for ATT 10% + - [ 2043301, 1, 1, 0.000100 ] # Scroll for Dagger for ATT 60% + - [ 2044002, 1, 1, 0.000100 ] # Scroll for Two-handed Sword for ATT 10% + - [ 1032008, 1, 1, 0.000100 ] # Cat's Eye + - [ 1040099, 1, 1, 0.000100 ] # Ocher Scorpio + - [ 1050011, 1, 1, 0.000100 ] # Black Dragon Robe + - [ 1050058, 1, 1, 0.000100 ] # Orange Tai + - [ 1060088, 1, 1, 0.000100 ] # Ocher Scorpio Pants + - [ 1072103, 1, 1, 0.000100 ] # Red Silky Boots + - [ 1302017, 1, 1, 0.000100 ] # Sky Blue Umbrella + - [ 1332010, 1, 1, 0.000100 ] # Iron Dagger + - [ 1332020, 1, 1, 0.000100 ] # Korean Fan + - [ 1382002, 1, 1, 0.000200 ] # Wizard Staff + - [ 1432002, 1, 1, 0.000100 ] # Forked Spear + - [ 1452005, 1, 1, 0.002000 ] # Ryden + - [ 1472011, 1, 1, 0.000100 ] # Bronze Guardian \ No newline at end of file diff --git a/data/reward/9420502.yaml b/data/reward/9420502.yaml new file mode 100644 index 00000000..5c689c89 --- /dev/null +++ b/data/reward/9420502.yaml @@ -0,0 +1,12 @@ +# Biner (9420502) + +rewards: + - [ 0, 38, 59, 0.600000 ] + - [ 4000366, 1, 1, 0.400000 ] # Waster Paper + - [ 4000367, 1, 1, 0.200000 ] # Recycle Water Bottle + - [ 4010005, 1, 1, 0.002000 ] # Orihalcon Ore + - [ 4020004, 1, 1, 0.002000 ] # Opal Ore + - [ 2000000, 1, 1, 0.010000 ] # Red Potion + - [ 1002177, 1, 1, 0.000100 ] # Blue Burgler + - [ 1072048, 1, 1, 0.000100 ] # Brown Aroa Boots + - [ 1382002, 1, 1, 0.000100 ] # Wizard Staff \ No newline at end of file diff --git a/data/reward/9420503.yaml b/data/reward/9420503.yaml new file mode 100644 index 00000000..12e14a07 --- /dev/null +++ b/data/reward/9420503.yaml @@ -0,0 +1,9 @@ +# Nospeed (9420503) + +rewards: + - [ 0, 76, 114, 0.600000 ] + - [ 4000370, 1, 1, 0.400000 ] # Metal pole + - [ 4000371, 1, 1, 0.200000 ] # Speed Limit Sign + - [ 4001000, 1, 1, 0.085000 ] # Arwen's Glass Shoes + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion \ No newline at end of file diff --git a/data/reward/9420504.yaml b/data/reward/9420504.yaml new file mode 100644 index 00000000..b986fccd --- /dev/null +++ b/data/reward/9420504.yaml @@ -0,0 +1,8 @@ +# Tippo Red (9420504) + +rewards: + - [ 0, 100, 170, 0.600000 ] + - [ 4000377, 1, 1, 0.400000 ] # Small Poop + - [ 4006001, 1, 1, 0.000700 ] # The Summoning Rock + - [ 1302013, 1, 1, 0.000100 ] # Red Whip + - [ 4004004, 1, 1, 0.002000 ] # Dark Crystal Ore \ No newline at end of file diff --git a/data/reward/9420505.yaml b/data/reward/9420505.yaml new file mode 100644 index 00000000..6fb732bc --- /dev/null +++ b/data/reward/9420505.yaml @@ -0,0 +1,9 @@ +# Tippo Blue (9420505) + +rewards: + - [ 0, 110, 200, 0.600000 ] + - [ 4000378, 1, 1, 0.400000 ] # Big Poop + - [ 1082002, 1, 1, 0.000100 ] # Dark Pilfer + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2002004, 1, 1, 0.010000 ] # Warrior Potion + - [ 2020028, 1, 1, 0.010000 ] # Chocolate \ No newline at end of file diff --git a/data/reward/9420506.yaml b/data/reward/9420506.yaml new file mode 100644 index 00000000..3374bd74 --- /dev/null +++ b/data/reward/9420506.yaml @@ -0,0 +1,5 @@ +# Batoo (9420506) + +rewards: + - [ 0, 76, 114, 0.600000 ] + - [ 4000368, 1, 1, 0.400000 ] # Broken Wing \ No newline at end of file diff --git a/data/reward/9420507.yaml b/data/reward/9420507.yaml new file mode 100644 index 00000000..5b613788 --- /dev/null +++ b/data/reward/9420507.yaml @@ -0,0 +1,18 @@ +# Trucker (9420507) + +rewards: + - [ 0, 130, 200, 0.600000 ] + - [ 4000374, 1, 1, 0.400000 ] # Headlight + - [ 4000376, 1, 1, 0.200000 ] # Batteries + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 2043201, 1, 1, 0.010000 ] # Scroll for One-Handed BW for ATT 60% + - [ 1302016, 1, 1, 0.000100 ] # Yellow Umbrella + - [ 1040089, 1, 1, 0.000100 ] # Umber Shouldermail + - [ 1060078, 1, 1, 0.000100 ] # Umber Shouldermail Pants + - [ 1051032, 1, 1, 0.000200 ] # Blue Calaf + - [ 1002242, 1, 1, 0.000100 ] # Red Seraphis + - [ 1002243, 1, 1, 0.000100 ] # Blue Seraphis + - [ 1002253, 1, 1, 0.000100 ] # Blue Infinium Circlet + - [ 1050054, 1, 1, 0.000100 ] # Red Anakamoon + - [ 1050053, 1, 1, 0.000100 ] # Blue Anakamoon + - [ 1002214, 1, 1, 0.000100 ] # Black Maro \ No newline at end of file diff --git a/data/reward/9420508.yaml b/data/reward/9420508.yaml new file mode 100644 index 00000000..63fd4abc --- /dev/null +++ b/data/reward/9420508.yaml @@ -0,0 +1,9 @@ +# Octobunny (9420508) + +rewards: + - [ 0, 110, 200, 0.600000 ] + - [ 4000373, 1, 1, 0.400000 ] # Ink + - [ 1002128, 1, 1, 0.000100 ] # Blue Loosecap + - [ 1040086, 1, 1, 0.000100 ] # Blue Jangoon Armor + - [ 1041087, 1, 1, 0.000100 ] # Red Shouldermail + - [ 1051010, 1, 1, 0.000100 ] # Dark Engrit \ No newline at end of file diff --git a/data/reward/9420509.yaml b/data/reward/9420509.yaml new file mode 100644 index 00000000..aa9edec5 --- /dev/null +++ b/data/reward/9420509.yaml @@ -0,0 +1,18 @@ +# Pac Pinky (9420509) + +rewards: + - [ 0, 300, 450, 0.600000 ] + - [ 4000380, 1, 1, 0.400000 ] # Pink Essence + - [ 4001005, 1, 1, 0.200000 ] # Ancient Scroll + - [ 1051001, 1, 1, 0.000100 ] # Emerald Fitted Mail + - [ 1402007, 1, 1, 0.000100 ] # Zard + - [ 1050048, 1, 1, 0.000100 ] # White Calas + - [ 1050037, 1, 1, 0.000100 ] # Green Starlight + - [ 1050059, 1, 1, 0.000100 ] # Blue Tai + - [ 1060098, 1, 1, 0.000100 ] # Red Pirate Pants + - [ 1061094, 1, 1, 0.000100 ] # Bloody Mantis Pants + - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore + - [ 2000004, 1, 1, 0.010000 ] # Elixir + - [ 2020028, 1, 1, 0.010000 ] # Chocolate + - [ 2070004, 1, 1, 0.000400 ] # Tobi Throwing-Stars + - [ 2044201, 1, 1, 0.000100 ] # Scroll for Two-Handed BW for ATT 60% \ No newline at end of file diff --git a/data/reward/9420510.yaml b/data/reward/9420510.yaml new file mode 100644 index 00000000..d3f00e8a --- /dev/null +++ b/data/reward/9420510.yaml @@ -0,0 +1,13 @@ +# Slimy (9420510) + +rewards: + - [ 0, 329, 476, 0.600000 ] + - [ 1032011, 1, 1, 0.000100 ] # Blue Moon + - [ 1050055, 1, 1, 0.000100 ] # White Anakamoon + - [ 1050056, 1, 1, 0.000100 ] # Dark Anakamoon + - [ 1061053, 1, 1, 0.000100 ] # Dark Legolia + - [ 1061089, 1, 1, 0.000100 ] # Dark Shadow Pants + - [ 1040109, 1, 1, 0.000100 ] # Red Pirate Top + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2070005, 1, 1, 0.000400 ] # Steely Throwing-Knives + - [ 4000379, 1, 1, 0.400000 ] # Green Essence \ No newline at end of file diff --git a/data/reward/9420511.yaml b/data/reward/9420511.yaml new file mode 100644 index 00000000..c67bfeea --- /dev/null +++ b/data/reward/9420511.yaml @@ -0,0 +1,11 @@ +# Selkie Jr. (9420511) + +rewards: + - [ 0, 304, 456, 0.600000 ] + - [ 4000382, 1, 1, 0.400000 ] # Blue Essence + - [ 4001006, 1, 1, 0.010000 ] # Flaming Feather + - [ 1050056, 1, 1, 0.000100 ] # Dark Anakamoon + - [ 1061078, 1, 1, 0.000100 ] # Brown Moon Pants + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2022025, 1, 1, 0.010000 ] # Chocolate + - [ 2000004, 1, 1, 0.001000 ] # Elixir \ No newline at end of file diff --git a/data/reward/9420512.yaml b/data/reward/9420512.yaml new file mode 100644 index 00000000..1054b98a --- /dev/null +++ b/data/reward/9420512.yaml @@ -0,0 +1,22 @@ +# Mr. Anchor (9420512) + +rewards: + - [ 0, 332, 498, 0.600000 ] + - [ 4000383, 1, 1, 0.400000 ] # Red Essence + # - [ 4000381, 1, 1, 0.200000 ] # White Essence (CHECK) + - [ 4006000, 1, 1, 0.000700 ] # The Magic Rock + - [ 1032015, 1, 1, 0.000100 ] # Metal Silver Earrings + - [ 1040093, 1, 1, 0.000100 ] # Dark Orientican + - [ 1040089, 1, 1, 0.000100 ] # Umber Shouldermail + - [ 1060078, 1, 1, 0.000100 ] # Umber Shouldermail Pants + - [ 1312009, 1, 1, 0.000100 ] # Hawkhead + - [ 1302011, 1, 1, 0.000100 ] # Neocora + - [ 1082082, 1, 1, 0.000100 ] # Blue Pennance + - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore + - [ 4010006, 1, 1, 0.002000 ] # Gold Ore + - [ 4020006, 1, 1, 0.002000 ] # Topaz Ore + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2000004, 1, 1, 0.001000 ] # Elixir + - [ 2022025, 1, 1, 0.010000 ] # Chocolate + - [ 2041022, 1, 1, 0.000100 ] # Scroll for Cape for LUK 60% + - [ 2043301, 1, 1, 0.000100 ] # Scroll for Dagger for ATT 60% \ No newline at end of file diff --git a/data/reward/9420513.yaml b/data/reward/9420513.yaml new file mode 100644 index 00000000..3b00ece9 --- /dev/null +++ b/data/reward/9420513.yaml @@ -0,0 +1,22 @@ +# Capt. Latanica (9420513) + +rewards: + - [0, 4055, 4339, 0.600000] + - [4000384, 1, 1, 0.600000] + - [4000385, 1, 1, 0.600000] + - [2020013, 1, 1, 0.999999] + - [2020015, 1, 1, 0.999999] + - [2000006, 1, 1, 1.000000] + - [1072178, 1, 1, 0.008000] + - [1040112, 1, 1, 0.008000] + - [1041120, 1, 1, 0.008000] + - [1061119, 1, 1, 0.008000] + - [1051097, 1, 1, 0.007000] + - [1060106, 1, 1, 0.008000] + - [1041118, 1, 1, 0.008000] + - [1072198, 1, 1, 0.008000] + - [1312015, 1, 1, 0.007000] + - [1332026, 1, 1, 0.005000] + - [1462018, 1, 1, 0.005000] + - [1372009, 1, 1, 0.007000] + - [2041022, 1, 1, 0.003000] \ No newline at end of file diff --git a/data/reward/9420514.yaml b/data/reward/9420514.yaml new file mode 100644 index 00000000..842cfd33 --- /dev/null +++ b/data/reward/9420514.yaml @@ -0,0 +1,30 @@ +# Berserkie (9420514) + +# Dark Battle Road - didn't add check + +rewards: + - [ 0, 477, 700, 0.600000 ] + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2000005, 1, 1, 0.001000 ] # Power Elixir + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2043802, 1, 1, 0.010000 ] # Scroll for Staff for Magic Attack 10% + - [ 2044702, 1, 1, 0.000100 ] # Scroll for Claw for ATT 10% + - [ 2290099, 1, 1, 0.002500 ] # [Mastery Book] Energy Orb + - [ 2330004, 1, 1, 0.000400 ] # Shiny Bullet + - [ 4000429, 1, 1, 0.400000 ] # Sweat Bead + - [ 4006000, 1, 1, 0.000700 ] # The Magic Rock + - [ 4010006, 1, 1, 0.002000 ] # Gold Ore + - [ 4020008, 1, 1, 0.002000 ] # Black Crystal Ore + - [ 1002330, 1, 1, 0.000100 ] # Dark Pireta Hat + - [ 1002640, 1, 1, 0.000100 ] # Blue Sun Boat Hat + - [ 1052125, 1, 1, 0.000100 ] # White Pioneer + - [ 1072185, 1, 1, 0.000100 ] # Dark Wing Boots + - [ 1082119, 1, 1, 0.000100 ] # Purple Larceny + - [ 1082207, 1, 1, 0.000100 ] # Blue Halfglove + - [ 1092027, 1, 1, 0.000100 ] # Silver Kalkan + - [ 1402011, 1, 1, 0.000200 ] # Sparta + - [ 1332052, 1, 1, 0.000100 ] # Blood Dagger + - [ 1372010, 1, 1, 0.000100 ] # Dimon Wand + - [ 1432010, 1, 1, 0.000100 ] # Omega Spear + - [ 1452015, 1, 1, 0.000200 ] # Dark Arund + - [ 1462013, 1, 1, 0.000100 ] # Dark Raven \ No newline at end of file diff --git a/data/reward/9420515.yaml b/data/reward/9420515.yaml new file mode 100644 index 00000000..2f007d3d --- /dev/null +++ b/data/reward/9420515.yaml @@ -0,0 +1,25 @@ +# Veetron (9420515) + +rewards: + - [ 0, 522, 750, 0.600000 ] + - [ 4000430, 1, 1, 0.400000 ] # Veetron Horn + - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore + - [ 4006001, 1, 1, 0.000700 ] # The Summoning Rock + - [ 4010003, 1, 1, 0.002000 ] # Adamantium Ore + - [ 4020001, 1, 1, 0.002000 ] # Amethyst Ore + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2000005, 1, 1, 0.001000 ] # Power Elixir + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2040901, 1, 1, 0.010000 ] # Scroll for Shield for DEF 60% + - [ 2048004, 1, 1, 0.000100 ] # Scroll for Pet Equip. for Jump 60% + - [ 1002328, 1, 1, 0.000100 ] # Green Pireta Hat + - [ 1032023, 1, 1, 0.000100 ] # Strawberry Earrings + - [ 1040109, 1, 1, 0.000100 ] # Red Pirate Top + - [ 1061105, 1, 1, 0.000100 ] # Red Pirate Skirt + - [ 1072179, 1, 1, 0.000100 ] # Dark Enigma Shoes + - [ 1082117, 1, 1, 0.000100 ] # Dark Emperor + - [ 1312030, 1, 1, 0.000200 ] # Tomahawk + - [ 1382008, 1, 1, 0.000100 ] # Kage + - [ 1402035, 1, 1, 0.000200 ] # The Beheader + - [ 1452014, 1, 1, 0.000100 ] # Golden Arund + - [ 1472053, 1, 1, 0.000100 ] # Red Craven \ No newline at end of file diff --git a/data/reward/9420516.yaml b/data/reward/9420516.yaml new file mode 100644 index 00000000..e1ff9e62 --- /dev/null +++ b/data/reward/9420516.yaml @@ -0,0 +1,19 @@ +# Mob 9420516 (9420516) + +rewards: +- [4000431, 1, 1, 0.6] +- [2000006, 1, 1, 0.1] +- [2000005, 1, 1, 0.02] +- [4004002, 1, 1, 0.01] +- [4020000, 1, 1, 0.009] +- [4006001, 1, 1, 0.01] +- [1002365, 1, 1, 0.0015] +- [1050089, 1, 1, 0.0007] +- [1041118, 1, 1, 0.0008] +- [1061116, 1, 1, 0.0008] +- [1082129, 1, 1, 0.001] +- [1072223, 1, 1, 0.0008] +- [1452017, 1, 1, 0.0005] +- [2040701, 1, 1, 0.0003] +- [2040516, 1, 1, 0.0003] +- [2043002, 1, 1, 0.0003] diff --git a/data/reward/9420517.yaml b/data/reward/9420517.yaml new file mode 100644 index 00000000..87ecb01c --- /dev/null +++ b/data/reward/9420517.yaml @@ -0,0 +1,28 @@ +# Mob 9420517 (9420517) + +rewards: +- [2290060, 1, 1, 0.0005] +- [4000432, 1, 1, 0.6] +- [2022003, 1, 1, 0.02] +- [2000006, 1, 1, 0.1] +- [2000005, 1, 1, 0.02] +- [4004003, 1, 1, 0.01] +- [4020007, 1, 1, 0.009] +- [4006001, 1, 1, 0.01] +- [4006000, 1, 1, 0.01] +- [1002406, 1, 1, 0.0015] +- [1051101, 1, 1, 0.0007] +- [1050097, 1, 1, 0.0007] +- [1082139, 1, 1, 0.001] +- [1072225, 1, 1, 0.0008] +- [1032023, 1, 1, 0.001] +- [1412021, 1, 1, 0.0007] +- [1452019, 1, 1, 0.0005] +- [2043801, 1, 1, 0.0003] +- [2041023, 1, 1, 0.0003] +- [2040707, 1, 1, 0.0003] +- [1082152, 1, 1, 0.001] +- [2290038, 1, 1, 0.0005] +- [2290008, 1, 1, 0.0005] +- [2290000, 1, 1, 0.0005] +- [2290103, 1, 1, 0.0005] diff --git a/data/reward/9420518.yaml b/data/reward/9420518.yaml new file mode 100644 index 00000000..d0f774df --- /dev/null +++ b/data/reward/9420518.yaml @@ -0,0 +1,24 @@ +# Montrecer (9420518) + +rewards: +- [2000006, 1, 1, 0.1] +- [2000005, 1, 1, 0.02] +- [2022003, 1, 1, 0.02] +- [2020013, 1, 1, 0.02] +- [4020002, 1, 1, 0.009] +- [4004004, 1, 1, 0.01] +- [1002530, 1, 1, 0.0015] +- [1050098, 1, 1, 0.0007] +- [1041122, 1, 1, 0.0008] +- [1061121, 1, 1, 0.0008] +- [1051101, 1, 1, 0.0007] +- [1082158, 1, 1, 0.001] +- [1072208, 1, 1, 0.0008] +- [1092027, 1, 1, 0.0007] +- [1402016, 1, 1, 0.0007] +- [1382035, 1, 1, 0.0007] +- [2040302, 1, 1, 0.0003] +- [2040501, 1, 1, 0.0003] +- [4000433, 1, 1, 0.6] +- [1082136, 1, 1, 0.001] +- [2044902, 1, 1, 0.0003] diff --git a/data/reward/9420519.yaml b/data/reward/9420519.yaml new file mode 100644 index 00000000..219042fb --- /dev/null +++ b/data/reward/9420519.yaml @@ -0,0 +1,24 @@ +# Duku (9420519) + +rewards: +- [4000434, 1, 1, 0.6] +- [2022003, 1, 1, 0.02] +- [2000006, 1, 1, 0.1] +- [2000005, 1, 1, 0.02] +- [4004004, 1, 1, 0.01] +- [4020006, 1, 1, 0.009] +- [4020002, 1, 1, 0.009] +- [4006001, 1, 1, 0.01] +- [4006000, 1, 1, 0.01] +- [1002380, 1, 1, 0.0015] +- [1051102, 1, 1, 0.0007] +- [1040121, 1, 1, 0.0008] +- [1060109, 1, 1, 0.0008] +- [1082151, 1, 1, 0.001] +- [1072215, 1, 1, 0.0008] +- [1322045, 1, 1, 0.0007] +- [1302056, 1, 1, 0.0007] +- [2041004, 1, 1, 0.0003] +- [2041013, 1, 1, 0.0003] +- [2044201, 1, 1, 0.0003] +- [2330005, 1, 1, 0.004] diff --git a/data/reward/9420522.yaml b/data/reward/9420522.yaml new file mode 100644 index 00000000..a8f11dc5 --- /dev/null +++ b/data/reward/9420522.yaml @@ -0,0 +1,27 @@ +# Krexel [3] (9420522) + +rewards: + - [2020013, 1, 1, 0.999999] + - [2020015, 1, 1, 0.999999] + - [2000005, 1, 1, 0.999999] + - [1302056, 1, 1, 0.0203] + - [1312030, 1, 1, 0.0203] + - [1322045, 1, 1, 0.0203] + - [1332051, 1, 1, 0.0145] + - [1332052, 1, 1, 0.0145] + - [1372010, 1, 1, 0.0203] + - [1382035, 1, 1, 0.0203] + - [1402035, 1, 1, 0.0203] + - [1412021, 1, 1, 0.0203] + - [1422027, 1, 1, 0.0203] + - [1432030, 1, 1, 0.0145] + - [1442044, 1, 1, 0.0203] + - [1452019, 1, 1, 0.0145] + - [1452020, 1, 1, 0.0145] + - [1462015, 1, 1, 0.0145] + - [1462016, 1, 1, 0.0145] + - [1472053, 1, 1, 0.0145] + - [2000004, 1, 1, 0.999999] + - [4000435, 1, 1, 0.6] + - [2430144, 1, 2, 0.075] + - [2290000, 1, 1, 0.025] \ No newline at end of file diff --git a/data/reward/9420527.yaml b/data/reward/9420527.yaml new file mode 100644 index 00000000..63dbca1a --- /dev/null +++ b/data/reward/9420527.yaml @@ -0,0 +1,15 @@ +# Chlorotrap (9420527) + +rewards: + - [ 0, 201, 300, 0.600000 ] + - [ 4000465, 1, 1, 0.400000 ] # Coconut Husk + - [ 1050046, 1, 1, 0.000100 ] # Red Calas + - [ 1082064, 1, 1, 0.000100 ] # Dark Arten + - [ 1072124, 1, 1, 0.000100 ] # Blue Steel-Tip Boots + - [ 1051039, 1, 1, 0.000100 ] # Red Lumati + - [ 1052110, 1, 1, 0.000100 ] # Blue Brace Look + - [ 2022025, 1, 1, 0.010000 ] # Chocolate + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2002010, 1, 1, 0.010000 ] # Speed Pill + - [ 2060001, 10, 20, 0.008000 ] # Bronze Arrow for Bow + - [ 2061001, 10, 20, 0.008000 ] # Bronze Arrow for Crossbow \ No newline at end of file diff --git a/data/reward/9420528.yaml b/data/reward/9420528.yaml new file mode 100644 index 00000000..1f532a50 --- /dev/null +++ b/data/reward/9420528.yaml @@ -0,0 +1,14 @@ +# Emo Slime (9420528) + +rewards: + - [ 0, 211, 311, 0.600000 ] + - [ 4000466, 1, 1, 0.400000 ] # Rebab + - [ 1102003, 1, 1, 0.000100 ] # White Napoleon + - [ 1032025, 1, 1, 0.000100 ] # Pansy Earrings + - [ 1702173, 1, 1, 0.000100 ] # Yellow Umbrella + - [ 1322017, 1, 1, 0.000200 ] # Knuckle Mace + - [ 1072134, 1, 1, 0.000100 ] # Orihalcon Hildon Boots + - [ 1002207, 1, 1, 0.000100 ] # Emerald Dome + - [ 2000003, 1, 1, 0.010000 ] # Blue Pill + - [ 2022103, 1, 1, 0.010000 ] # Hot Dog Supreme + - [ 2050004, 1, 1, 0.010000 ] # All Cure Potion \ No newline at end of file diff --git a/data/reward/9420529.yaml b/data/reward/9420529.yaml new file mode 100644 index 00000000..a0bd79c1 --- /dev/null +++ b/data/reward/9420529.yaml @@ -0,0 +1,24 @@ +# Dark Fission (9420529) + +rewards: + - [ 0, 273, 445, 0.600000 ] + - [ 4000467, 1, 1, 0.400000 ] # Yellow Wig + - [ 4006001, 1, 1, 0.000700 ] # The Summoning Rock + - [ 1402010, 1, 1, 0.000100 ] # Aluminum Baseball Bat + - [ 1002099, 1, 1, 0.000100 ] # Mithril Nordic Helm + - [ 1040089, 1, 1, 0.000100 ] # Umber Shouldermail + - [ 1092009, 1, 1, 0.000100 ] # Wooden Legend Shield + - [ 1051030, 1, 1, 0.000200 ] # Dark Calaf + - [ 1002216, 1, 1, 0.000100 ] # Aqua Golden Circlet + - [ 1082072, 1, 1, 0.000100 ] # Gold Brace + - [ 1002166, 1, 1, 0.000100 ] # Red Distinction + - [ 1051038, 1, 1, 0.000100 ] # Green Lumati + - [ 1452004, 1, 1, 0.000100 ] # Olympus + - [ 1082083, 1, 1, 0.000100 ] # Dark Willow + - [ 1002184, 1, 1, 0.000100 ] # Brown Pilfer + - [ 1040096, 1, 1, 0.000100 ] # Brown China + - [ 1060085, 1, 1, 0.000100 ] # Brown China Pants + - [ 1041080, 1, 1, 0.000100 ] # Red Moon + - [ 1472028, 1, 1, 0.000100 ] # Dark Slain + - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir \ No newline at end of file diff --git a/data/reward/9420530.yaml b/data/reward/9420530.yaml new file mode 100644 index 00000000..1e1ce885 --- /dev/null +++ b/data/reward/9420530.yaml @@ -0,0 +1,17 @@ +# Oly Oly (9420530) + +rewards: + - [ 0, 280, 476, 0.600000 ] + - [ 4000468, 1, 1, 0.400000 ] # Somebody's Tire + - [ 1092008, 1, 1, 0.000100 ] # Pan Lid + - [ 1032012, 1, 1, 0.000100 ] # Skull Earrings + - [ 1040091, 1, 1, 0.000100 ] # Red Orientican + - [ 1051010, 1, 1, 0.000100 ] # Dark Engrit + - [ 1082028, 1, 1, 0.000200 ] # Dark Mesana + - [ 1002166, 1, 1, 0.000200 ] # Red Distinction + - [ 1051006, 1, 1, 0.000100 ] # Dark Avenger + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2002009, 1, 1, 0.010000 ] # Dexterity Pill + - [ 2020014, 1, 1, 0.010000 ] # Sunrise Dew + - [ 2040901, 1, 1, 0.000100 ] # Scroll for Shield for DEF 60% \ No newline at end of file diff --git a/data/reward/9420531.yaml b/data/reward/9420531.yaml new file mode 100644 index 00000000..387cf70e --- /dev/null +++ b/data/reward/9420531.yaml @@ -0,0 +1,28 @@ +# Scaredy Scarlion (9420531) + +rewards: +- [4000469, 1, 1, 0.6] +- [2060001, 1, 1, 0.03] +- [2061001, 1, 1, 0.03] +- [4003005, 1, 1, 0.2] +- [4004003, 1, 1, 0.01] +- [4020006, 1, 1, 0.009] +- [2000002, 1, 1, 0.1] +- [2000006, 1, 1, 0.1] +- [2000005, 1, 1, 0.02] +- [1332017, 1, 1, 0.0005] +- [1302010, 1, 1, 0.0007] +- [1372007, 1, 1, 0.0007] +- [1402003, 1, 1, 0.0007] +- [1412007, 1, 1, 0.0007] +- [1452004, 1, 1, 0.0005] +- [1092012, 1, 1, 0.0007] +- [1082067, 1, 1, 0.001] +- [1082091, 1, 1, 0.001] +- [1041081, 1, 1, 0.0008] +- [1061080, 1, 1, 0.0008] +- [1041093, 1, 1, 0.0008] +- [1061092, 1, 1, 0.0008] +- [2040804, 1, 1, 0.0003] +- [2041005, 1, 1, 0.0003] +- [2330002, 1, 1, 0.004] diff --git a/data/reward/9420532.yaml b/data/reward/9420532.yaml new file mode 100644 index 00000000..86984284 --- /dev/null +++ b/data/reward/9420532.yaml @@ -0,0 +1,31 @@ +# Ratatula (9420532) + +rewards: +- [4000470, 1, 1, 0.6] +- [2000002, 1, 1, 0.1] +- [2000006, 1, 1, 0.1] +- [2043009, 1, 1, 0.0003] +- [2044102, 1, 1, 0.0003] +- [1032008, 1, 1, 0.001] +- [1002243, 1, 1, 0.0015] +- [1002244, 1, 1, 0.0015] +- [1002270, 1, 1, 0.0015] +- [1002155, 1, 1, 0.0015] +- [1051043, 1, 1, 0.0007] +- [1041087, 1, 1, 0.0008] +- [1061086, 1, 1, 0.0008] +- [1041095, 1, 1, 0.0008] +- [1061094, 1, 1, 0.0008] +- [1072149, 1, 1, 0.0008] +- [1072110, 1, 1, 0.0008] +- [1051046, 1, 1, 0.0007] +- [1051047, 1, 1, 0.0007] +- [1102017, 1, 1, 0.001] +- [1472020, 1, 1, 0.0005] +- [1332021, 1, 1, 0.0005] +- [1332029, 1, 1, 0.0005] +- [1372017, 1, 1, 0.0007] +- [1002622, 1, 1, 0.0015] +- [1052107, 1, 1, 0.0007] +- [1082189, 1, 1, 0.001] +- [1492003, 1, 1, 0.0005] diff --git a/data/reward/9420533.yaml b/data/reward/9420533.yaml new file mode 100644 index 00000000..af2decb9 --- /dev/null +++ b/data/reward/9420533.yaml @@ -0,0 +1,28 @@ +# Rodeo (9420533) + +rewards: + - [ 0, 390, 552, 0.600000 ] + - [ 4000471, 1, 1, 0.400000 ] # Rodeo's Master + - [ 1322012, 1, 1, 0.000100 ] # Red Brick + - [ 1332020, 1, 1, 0.000100 ] # Korean Fan + - [ 1432007, 1, 1, 0.000100 ] # Redemption + - [ 1312009, 1, 1, 0.000100 ] # Hawkhead + - [ 1402017, 1, 1, 0.000100 ] # Daiwa Sword + - [ 1051023, 1, 1, 0.000100 ] # Purple Moonlight + - [ 1050039, 1, 1, 0.000200 ] # Dark Starlight + - [ 1372008, 1, 1, 0.000100 ] # Hinomaru Fan + - [ 1002254, 1, 1, 0.000100 ] # Dark Infinium Circlet + - [ 1051038, 1, 1, 0.000100 ] # Green Lumati + - [ 1050052, 1, 1, 0.000100 ] # Blue-Lined Kismet + - [ 1041096, 1, 1, 0.000100 ] # Umber Mantis + - [ 1061095, 1, 1, 0.000100 ] # Umber Mantis Pants + - [ 1002248, 1, 1, 0.000100 ] # Silver Identity + - [ 1002249, 1, 1, 0.000100 ] # Dark Identity + - [ 1082180, 1, 1, 0.000100 ] # Green Lagger Halfglove + - [ 2000003, 1, 1, 0.010000 ] # White Pill + - [ 2000003, 1, 1, 0.010000 ] # Blue Potion + - [ 2022003, 1, 1, 0.010000 ] # Warrior Potion + - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir + - [ 2022000, 1, 1, 0.010000 ] # Pain Reliever + - [ 2048003, 1, 1, 0.000100 ] # Scroll for Pet Equip. for Speed 60% + - [ 2040901, 1, 1, 0.000100 ] # Scroll for Shield for DEF 60% \ No newline at end of file diff --git a/data/reward/9420534.yaml b/data/reward/9420534.yaml new file mode 100644 index 00000000..c1a3072c --- /dev/null +++ b/data/reward/9420534.yaml @@ -0,0 +1,27 @@ +# Charmer (9420534) + +rewards: + - [ 0, 391, 599, 0.600000 ] + - [ 4000472, 1, 1, 0.400000 ] # Charmer's Flute + - [ 4130013, 1, 1, 0.000300 ] # Crossbow Production Stimulator + - [ 4006000, 1, 1, 0.000700 ] # The Magic Rock + - [ 1032011, 1, 1, 0.000100 ] # Blue Moon + - [ 1082104, 1, 1, 0.000100 ] # Mithril Husk + - [ 1072041, 1, 1, 0.000100 ] # Blood Battle Grieves + - [ 1442008, 1, 1, 0.000100 ] # The Gold Dragon + - [ 1050067, 1, 1, 0.000100 ] # Blue Requiem + - [ 1050068, 1, 1, 0.000100 ] # Red Requiem + - [ 1382015, 1, 1, 0.000100 ] # Poison Mushroom + - [ 1002254, 1, 1, 0.000200 ] # Dark Infinium Circlet + - [ 1452010, 1, 1, 0.000100 ] # Blue Hinkel + - [ 1041103, 1, 1, 0.000100 ] # Red Mystique + - [ 1061102, 1, 1, 0.000100 ] # Red Mystique Pants + - [ 1061077, 1, 1, 0.000100 ] # Blue Moon Pants + - [ 1472017, 1, 1, 0.000100 ] # Dark Avarice + - [ 4021008, 1, 1, 0.002000 ] # Dark Crystal Ore + - [ 4020008, 1, 1, 0.002000 ] # Black Crystal Ore + - [ 2000002, 1, 1, 0.010000 ] # White Potion + - [ 2000004, 1, 1, 0.001000 ] # Elixir + - [ 2070003, 1, 1, 0.000400 ] # Vital Bullet + - [ 2044701, 1, 1, 0.000100 ] # Scroll for Claw for ATT 60% + - [ 2044801, 1, 1, 0.001000 ] # Scroll for Gun for Attack 60% diff --git a/data/reward/9420535.yaml b/data/reward/9420535.yaml new file mode 100644 index 00000000..dac6a010 --- /dev/null +++ b/data/reward/9420535.yaml @@ -0,0 +1,31 @@ +# Jester Scarlion (9420535) + +rewards: + - [ 0, 480, 550, 0.600000 ] + - [4000473, 1, 1, 0.6] + - [4030009, 1, 1, 0.0003] + - [2000002, 1, 1, 0.1] + - [2000004, 1, 1, 0.02] + - [2020028, 1, 1, 0.02] + - [4030012, 1, 1, 0.0003] + - [2022003, 1, 1, 0.02] + - [2044402, 1, 1, 0.0003] + - [2043701, 1, 1, 0.0003] + - [4020000, 1, 1, 0.009] + - [1050060, 1, 1, 0.0007] + - [1041076, 1, 1, 0.0008] + - [1061071, 1, 1, 0.0008] + - [1322007, 1, 1, 0.0007] + - [1442009, 1, 1, 0.0007] + - [1102012, 1, 1, 0.001] + - [1092029, 1, 1, 0.0007] + - [1072018, 1, 1, 0.0008] + - [1072159, 1, 1, 0.0008] + - [1072161, 1, 1, 0.0008] + - [1051016, 1, 1, 0.0007] + - [1050070, 1, 1, 0.0007] + - [1051055, 1, 1, 0.0007] + - [1032020, 1, 1, 0.001] + - [1082106, 1, 1, 0.001] + - [1082093, 1, 1, 0.001] + - [1492009, 1, 1, 0.0005] diff --git a/data/reward/9420536.yaml b/data/reward/9420536.yaml new file mode 100644 index 00000000..82d7c765 --- /dev/null +++ b/data/reward/9420536.yaml @@ -0,0 +1,34 @@ +# Froscola (9420536) + +rewards: + - [ 0, 500, 580, 0.600000 ] + - [4000474, 1, 1, 0.6] + - [2070008, 1, 1, 0.001] + - [2022003, 1, 1, 0.02] + - [2000004, 1, 1, 0.02] + - [2040805, 1, 1, 0.0003] + - [1422005, 1, 1, 0.0007] + - [1452011, 1, 1, 0.0005] + - [1462011, 1, 1, 0.0005] + - [1332019, 1, 1, 0.0005] + - [1382007, 1, 1, 0.0007] + - [1082097, 1, 1, 0.001] + - [1082105, 1, 1, 0.001] + - [1072167, 1, 1, 0.0008] + - [1072154, 1, 1, 0.0008] + - [1092016, 1, 1, 0.0007] + - [1050074, 1, 1, 0.0007] + - [1051058, 1, 1, 0.0007] + - [1051065, 1, 1, 0.0007] + - [1050064, 1, 1, 0.0007] + - [1050082, 1, 1, 0.0007] + - [1051079, 1, 1, 0.0007] + - [1032021, 1, 1, 0.001] + - [1002278, 1, 1, 0.0015] + - [1002273, 1, 1, 0.0015] + - [1002095, 1, 1, 0.0015] + - [1072309, 1, 1, 0.0008] + - [1082204, 1, 1, 0.001] + - [1482009, 1, 1, 0.0005] + - [2330004, 1, 1, 0.004] + - [2332000, 1, 1, 0.004] diff --git a/data/reward/9420537.yaml b/data/reward/9420537.yaml new file mode 100644 index 00000000..ee52b9e0 --- /dev/null +++ b/data/reward/9420537.yaml @@ -0,0 +1,32 @@ +# Yabber Doo (9420537) + +rewards: + - [ 0, 400, 623, 0.600000 ] + - [4000475, 1, 1, 0.6] + - [2022003, 1, 1, 0.02] + - [2000006, 1, 1, 0.1] + - [1322009, 1, 1, 0.0007] + - [4020007, 1, 1, 0.009] + - [4006000, 1, 1, 0.01] + - [4004002, 1, 1, 0.01] + - [1372015, 1, 1, 0.0007] + - [1102030, 1, 1, 0.001] + - [1041102, 1, 1, 0.0008] + - [1061101, 1, 1, 0.0008] + - [1050083, 1, 1, 0.0007] + - [1051080, 1, 1, 0.0007] + - [1072155, 1, 1, 0.0008] + - [1072165, 1, 1, 0.0008] + - [1032022, 1, 1, 0.001] + - [1002252, 1, 1, 0.0015] + - [1002284, 1, 1, 0.0015] + - [1002289, 1, 1, 0.0015] + - [1452011, 1, 1, 0.0005] + - [1462013, 1, 1, 0.0005] + - [1472027, 1, 1, 0.0005] + - [2043301, 1, 1, 0.0003] + - [2040513, 1, 1, 0.0003] + - [1002634, 1, 1, 0.0015] + - [1052119, 1, 1, 0.0007] + - [1072306, 1, 1, 0.0008] + - [1082201, 1, 1, 0.001] diff --git a/data/reward/9420538.yaml b/data/reward/9420538.yaml new file mode 100644 index 00000000..5c947ef4 --- /dev/null +++ b/data/reward/9420538.yaml @@ -0,0 +1,31 @@ +# Booper Scarlion (9420538) + +rewards: + - [ 0, 450, 750, 0.600000 ] + - [4000476, 1, 1, 0.6] + - [4004002, 1, 1, 0.01] + - [2000006, 1, 1, 0.1] + - [2000004, 1, 1, 0.02] + - [2070004, 1, 1, 0.001] + - [4006000, 1, 1, 0.01] + - [2040705, 1, 1, 0.0003] + - [2041017, 1, 1, 0.0003] + - [1432010, 1, 1, 0.0005] + - [1422012, 1, 1, 0.0007] + - [1102029, 1, 1, 0.001] + - [1092015, 1, 1, 0.0007] + - [1082123, 1, 1, 0.001] + - [1082112, 1, 1, 0.001] + - [1060091, 1, 1, 0.0008] + - [1060094, 1, 1, 0.0008] + - [1002274, 1, 1, 0.0015] + - [1002278, 1, 1, 0.0015] + - [1072179, 1, 1, 0.0008] + - [1072163, 1, 1, 0.0008] + - [1072155, 1, 1, 0.0008] + - [1040109, 1, 1, 0.0008] + - [1041106, 1, 1, 0.0008] + - [1060098, 1, 1, 0.0008] + - [1072312, 1, 1, 0.0008] + - [1482010, 1, 1, 0.0005] + - [1492010, 1, 1, 0.0005] diff --git a/data/reward/9420539.yaml b/data/reward/9420539.yaml new file mode 100644 index 00000000..bb6f893e --- /dev/null +++ b/data/reward/9420539.yaml @@ -0,0 +1,29 @@ +# Vikerola (9420539) + +rewards: + - [ 0, 600, 810, 0.600000 ] + - [4000477, 1, 1, 0.6] + - [4020005, 1, 1, 0.009] + - [4004004, 1, 1, 0.01] + - [2000006, 1, 1, 0.1] + - [2000009, 1, 1, 0.02] + - [2002008, 1, 1, 0.02] + - [2070005, 1, 1, 0.0008] + - [4006001, 1, 1, 0.01] + - [4006000, 1, 1, 0.01] + - [2040514, 1, 1, 0.0003] + - [2040802, 1, 1, 0.0003] + - [2044601, 1, 1, 0.0003] + - [1412009, 1, 1, 0.0007] + - [1402033, 1, 1, 0.0007] + - [1382028, 1, 1, 0.0007] + - [1102035, 1, 1, 0.001] + - [1102031, 1, 1, 0.001] + - [1082116, 1, 1, 0.001] + - [1082108, 1, 1, 0.001] + - [1082099, 1, 1, 0.001] + - [1002284, 1, 1, 0.0015] + - [1050074, 1, 1, 0.0007] + - [1050083, 1, 1, 0.0007] + - [1050078, 1, 1, 0.0007] + - [ 2044601, 1, 1, 0.0003 ] diff --git a/data/reward/9420540.yaml b/data/reward/9420540.yaml new file mode 100644 index 00000000..795fac0c --- /dev/null +++ b/data/reward/9420540.yaml @@ -0,0 +1,27 @@ +# Gallopera (9420540) + +rewards: + - [ 0, 647, 944, 0.600000 ] + - [4000478, 1, 1, 0.6] + - [4020000, 1, 1, 0.009] + - [4004000, 1, 1, 0.01] + - [2000005, 1, 1, 0.02] + - [2000011, 1, 1, 0.02] + - [2002006, 1, 1, 0.02] + - [2070012, 1, 1, 0.003] + - [4006001, 1, 1, 0.01] + - [2022003, 1, 1, 0.02] + - [2041014, 1, 1, 0.0003] + - [2040804, 1, 1, 0.0003] + - [2040613, 1, 1, 0.0003] + - [1452017, 1, 1, 0.0005] + - [1472031, 1, 1, 0.0005] + - [1002329, 1, 1, 0.0015] + - [1002366, 1, 1, 0.0015] + - [1002405, 1, 1, 0.0015] + - [1050095, 1, 1, 0.0007] + - [1051097, 1, 1, 0.0007] + - [1072198, 1, 1, 0.0008] + - [1072209, 1, 1, 0.0008] + - [1082134, 1, 1, 0.001] + - [2330005, 1, 1, 0.004] diff --git a/data/reward/9420544.yaml b/data/reward/9420544.yaml new file mode 100644 index 00000000..95a5ed8e --- /dev/null +++ b/data/reward/9420544.yaml @@ -0,0 +1,68 @@ +# Furious Targa (9420544) + +rewards: + - [2280010, 1, 1, 0.07] + - [2280009, 1, 1, 0.09] + - [2280008, 1, 1, 0.07] + - [2280007, 1, 1, 0.07] + - [1032031, 1, 1, 0.01] + - [4001241, 1, 1, 0.6] + - [2020013, 1, 1, 0.999999] + - [2020014, 1, 1, 0.2] + - [1302056, 1, 1, 0.007] + - [1312030, 1, 1, 0.007] + - [1322045, 1, 1, 0.007] + - [1332051, 1, 1, 0.005] + - [1332052, 1, 1, 0.005] + - [1372010, 1, 1, 0.007] + - [1382035, 1, 1, 0.007] + - [1402035, 1, 1, 0.007] + - [1422027, 1, 1, 0.007] + - [1412021, 1, 1, 0.007] + - [1432030, 1, 1, 0.005] + - [1452020, 1, 1, 0.005] + - [1462015, 1, 1, 0.005] + - [1462016, 1, 1, 0.005] + - [1472053, 1, 1, 0.005] + - [1452019, 1, 1, 0.005] + - [1442044, 1, 1, 0.007] + - [1492012, 1, 1, 0.005] + - [1482012, 1, 1, 0.005] + - [2043001, 1, 1, 0.003] + - [2043101, 1, 1, 0.003] + - [2043201, 1, 1, 0.003] + - [2043301, 1, 1, 0.003] + - [2043701, 1, 1, 0.003] + - [2043801, 1, 1, 0.003] + - [2044001, 1, 1, 0.003] + - [2044101, 1, 1, 0.003] + - [2044301, 1, 1, 0.003] + - [2044201, 1, 1, 0.003] + - [2044401, 1, 1, 0.003] + - [2044501, 1, 1, 0.003] + - [2044601, 1, 1, 0.003] + - [2044701, 1, 1, 0.003] + - [2040804, 1, 1, 0.003] + - [2040001, 1, 1, 0.003] + - [2040004, 1, 1, 0.003] + - [2040301, 1, 1, 0.003] + - [2040401, 1, 1, 0.003] + - [2040501, 1, 1, 0.003] + - [2040504, 1, 1, 0.003] + - [2040516, 1, 1, 0.003] + - [2040513, 1, 1, 0.003] + - [2040601, 1, 1, 0.003] + - [2040701, 1, 1, 0.003] + - [2040704, 1, 1, 0.003] + - [2040707, 1, 1, 0.003] + - [2040801, 1, 1, 0.003] + - [2040901, 1, 1, 0.003] + - [2290002, 1, 1, 0.005] + - [1002926, 1, 1, 0.999999] + - [1002926, 1, 1, 0.999999] + - [1002926, 1, 1, 0.3] + - [1002926, 1, 1, 0.3] + - [1002926, 1, 1, 0.3] + - [2290027, 1, 1, 0.1] + - [ 1003023, 1, 3, 0.500000 ] # Targa Hat(INT) + - [ 1003024, 1, 3, 0.500000 ] # Targa Hat(LUK) - [ 2044601, 1, 1, 0.003 ] diff --git a/data/reward/9420549.yaml b/data/reward/9420549.yaml new file mode 100644 index 00000000..37a36c4c --- /dev/null +++ b/data/reward/9420549.yaml @@ -0,0 +1,67 @@ +# Furious Scarlion Boss (9420549) + +rewards: + - [2280010, 1, 1, 0.05] + - [2280009, 1, 1, 0.07] + - [2280008, 1, 1, 0.05] + - [2280007, 1, 1, 0.05] + - [1032031, 1, 1, 0.01] + - [2043001, 1, 1, 0.003] + - [2020013, 1, 1, 0.999999] + - [2020014, 1, 1, 0.2] + - [1302056, 1, 1, 0.007] + - [1312030, 1, 1, 0.007] + - [1322045, 1, 1, 0.007] + - [1332051, 1, 1, 0.005] + - [1332052, 1, 1, 0.005] + - [1372010, 1, 1, 0.007] + - [1382035, 1, 1, 0.007] + - [1402035, 1, 1, 0.007] + - [1422027, 1, 1, 0.007] + - [1412021, 1, 1, 0.007] + - [1432030, 1, 1, 0.005] + - [1452020, 1, 1, 0.005] + - [1462015, 1, 1, 0.005] + - [1462016, 1, 1, 0.005] + - [1472053, 1, 1, 0.005] + - [1452019, 1, 1, 0.005] + - [1442044, 1, 1, 0.007] + - [1492012, 1, 1, 0.005] + - [1482012, 1, 1, 0.005] + - [2043101, 1, 1, 0.003] + - [2043201, 1, 1, 0.003] + - [2043301, 1, 1, 0.003] + - [2043701, 1, 1, 0.003] + - [2043801, 1, 1, 0.003] + - [2044001, 1, 1, 0.003] + - [2044101, 1, 1, 0.003] + - [2044301, 1, 1, 0.003] + - [2044201, 1, 1, 0.003] + - [2044401, 1, 1, 0.003] + - [2044501, 1, 1, 0.003] + - [2044601, 1, 1, 0.003] + - [2044701, 1, 1, 0.003] + - [2040804, 1, 1, 0.003] + - [2040001, 1, 1, 0.003] + - [2040004, 1, 1, 0.003] + - [2040301, 1, 1, 0.003] + - [2040401, 1, 1, 0.003] + - [2040501, 1, 1, 0.003] + - [2040504, 1, 1, 0.003] + - [2040516, 1, 1, 0.003] + - [2040513, 1, 1, 0.003] + - [2040601, 1, 1, 0.003] + - [2040701, 1, 1, 0.003] + - [2040704, 1, 1, 0.003] + - [2040707, 1, 1, 0.003] + - [2040801, 1, 1, 0.003] + - [2040901, 1, 1, 0.003] + - [2290002, 1, 1, 0.005] + - [4001242, 1, 1, 0.6] + - [1002927, 1, 1, 0.999999] + - [1002927, 1, 1, 0.999999] + - [1002927, 1, 1, 0.3] + - [1002927, 1, 1, 0.3] + - [1002927, 1, 1, 0.3] + - [ 1003025, 1, 3, 0.500000 ] # Scarlion Hat(DEX) + - [ 1003026, 1, 3, 0.500000 ] # Scarlion Hat(STR) - [ 2044601, 1, 1, 0.003 ] diff --git a/data/reward/9500109.yaml b/data/reward/9500109.yaml index 29eac986..c5b69901 100644 --- a/data/reward/9500109.yaml +++ b/data/reward/9500109.yaml @@ -18,7 +18,7 @@ rewards: - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow - [ 4000021, 1, 1, 0.040000 ] # Leather - - [ 4000095, 1, 1, 0.400000 ] # Rat Trap + - [ 4000095, 1, 1, 0.400000, 3209 ] # Rat Trap (Quest 3209) - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore - [ 4010001, 1, 1, 0.002000 ] # Steel Ore - [ 4010005, 1, 1, 0.002000 ] # Orihalcon Ore diff --git a/data/reward/9500117.yaml b/data/reward/9500117.yaml index dfaa3d18..7b4148c3 100644 --- a/data/reward/9500117.yaml +++ b/data/reward/9500117.yaml @@ -24,7 +24,7 @@ rewards: - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow - [ 2070003, 1, 1, 0.000400 ] # Kumbi Throwing-Stars - - [ 4000103, 1, 1, 0.400000 ] # Propeller + - [ 4000103, 1, 1, 0.400000, 3203 ] # Propeller (Quest 3203) - [ 4004000, 1, 1, 0.001000 ] # Power Crystal Ore - [ 4010003, 1, 1, 0.002000 ] # Adamantium Ore - [ 4020005, 1, 1, 0.002000 ] # Sapphire Ore diff --git a/data/reward/9500118.yaml b/data/reward/9500118.yaml index afe50e5c..d75aa194 100644 --- a/data/reward/9500118.yaml +++ b/data/reward/9500118.yaml @@ -24,7 +24,7 @@ rewards: - [ 2060000, 10, 20, 0.008000 ] # Arrow for Bow - [ 2061000, 10, 20, 0.008000 ] # Arrow for Crossbow - [ 4000003, 1, 1, 0.400000 ] # Tree Branch - - [ 4000116, 1, 1, 0.400000 ] # Small Egg + - [ 4000116, 1, 1, 0.400000, 3208 ] # Small Egg (Quest 3208) - [ 4003005, 1, 1, 0.040000 ] # Soft Feather - [ 4004002, 1, 1, 0.001000 ] # DEX Crystal Ore - [ 4020001, 1, 1, 0.002000 ] # Amethyst Ore diff --git a/data/reward/9500127.yaml b/data/reward/9500127.yaml index 886db16d..9ecf8134 100644 --- a/data/reward/9500127.yaml +++ b/data/reward/9500127.yaml @@ -16,7 +16,7 @@ rewards: - [ 2041022, 1, 1, 0.000100 ] # Scroll for Cape for LUK 60% - [ 2043301, 1, 1, 0.000100 ] # Scroll for Dagger for ATT 60% - [ 4000021, 1, 1, 0.040000 ] # Leather - - [ 4000144, 1, 1, 0.400000 ] # Free Spirit + - [ 4000144, 1, 1, 0.400000, 3445 ] # Free Spirit (Quest 3445) - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore - [ 4006000, 1, 1, 0.000700 ] # The Magic Rock - [ 4010006, 1, 1, 0.002000 ] # Gold Ore diff --git a/data/reward/9500165.yaml b/data/reward/9500165.yaml index 66766963..0e9fc66d 100644 --- a/data/reward/9500165.yaml +++ b/data/reward/9500165.yaml @@ -1,26 +1,5 @@ -# Red Kentaurus (9500165) +# Mob 9500165 - Mobs in Minar Forest +# Drops Pocket Watch for quest 3718 (Andy the Time Traveler) rewards: - - [ 0, 492, 739, 0.600000 ] - - [ 1002274, 1, 1, 0.000100 ] # Dark Galaxy - - [ 1032022, 1, 1, 0.000100 ] # Half Earrings - - [ 1051068, 1, 1, 0.000100 ] # Green Pria - - [ 1072211, 1, 1, 0.000100 ] # Blue Rivers Boots - - [ 1082118, 1, 1, 0.000100 ] # Green Larceny - - [ 1092023, 1, 1, 0.000100 ] # Steel Aquila Shield - - [ 1332023, 1, 1, 0.000100 ] # Dragon's Tail - - [ 1382035, 1, 1, 0.000100 ] # Blue Marine - - [ 1402004, 1, 1, 0.000100 ] # Blue Screamer - - [ 1422027, 1, 1, 0.000100 ] # Golden Smith Hammer - - [ 1432004, 1, 1, 0.000100 ] # Serpent's Tongue - - [ 1452019, 1, 1, 0.000100 ] # White Nisrock - - [ 2000002, 1, 1, 0.010000 ] # White Potion - - [ 2000005, 1, 1, 0.001000 ] # Power Elixir - - [ 2000006, 1, 1, 0.010000 ] # Mana Elixir - - [ 2040703, 1, 1, 0.000100 ] # Scroll for Shoes for Jump 100% - - [ 2040805, 1, 1, 0.000100 ] # Scroll for Gloves for ATT 10% - - [ 4000232, 1, 1, 0.400000 ] # Kentaurus's Flame - - [ 4004004, 1, 1, 0.001000 ] # Dark Crystal Ore - - [ 4006000, 1, 1, 0.000700 ] # The Magic Rock - - [ 4010001, 1, 1, 0.002000 ] # Steel Ore - - [ 4020000, 1, 1, 0.002000 ] # Garnet Ore + - [ 4032511, 1, 1, 0.4, 3718 ] # Pocket Watch - Quest 3718 (40% drop when quest active) diff --git a/data/reward/9500197.yaml b/data/reward/9500197.yaml new file mode 100644 index 00000000..aa9db240 --- /dev/null +++ b/data/reward/9500197.yaml @@ -0,0 +1,10 @@ +# Mob 9500197 + +rewards: + - [ 0, 180, 270, 0.600000 ] + - [ 2000002, 1, 1, 0.015000 ] # White Potion + - [ 2000003, 1, 1, 0.015000 ] # Blue Potion + - [ 4000021, 1, 1, 0.040000 ] # Leather + - [ 4004003, 1, 1, 0.001000 ] # LUK Crystal Ore + - [ 4010001, 1, 1, 0.002000 ] # Steel Ore + - [ 4020004, 1, 1, 0.002000 ] # Opal Ore diff --git a/data/reward/9500379.yaml b/data/reward/9500379.yaml new file mode 100644 index 00000000..caffa5d3 --- /dev/null +++ b/data/reward/9500379.yaml @@ -0,0 +1,4 @@ +# Mob 9500379 (9500379) + +rewards: +- [2280031, 1, 1, 0.0005] diff --git a/data/reward/9500387.yaml b/data/reward/9500387.yaml new file mode 100644 index 00000000..38b429f8 --- /dev/null +++ b/data/reward/9500387.yaml @@ -0,0 +1,5 @@ +# Blue Goblin (9500387) + +rewards: + - [ 4000564, 1, 1, 0.400000 ] # Blue Goblin Crown + - [ 4001433, 1, 1, 0.010000 ] diff --git a/data/reward/9500388.yaml b/data/reward/9500388.yaml new file mode 100644 index 00000000..4bef8fc5 --- /dev/null +++ b/data/reward/9500388.yaml @@ -0,0 +1,5 @@ +# Red Goblin (9500388) + +rewards: + - [ 4000563, 1, 1, 0.400000 ] # Red Goblin Axe + - [ 4001433, 1, 1, 0.010000 ] \ No newline at end of file diff --git a/data/reward/9500389.yaml b/data/reward/9500389.yaml new file mode 100644 index 00000000..bbbca1ef --- /dev/null +++ b/data/reward/9500389.yaml @@ -0,0 +1,4 @@ +# Mob 9500389 (9500389) + +rewards: +- [4001433, 1, 1, 0.01] diff --git a/data/reward/9500391.yaml b/data/reward/9500391.yaml new file mode 100644 index 00000000..42b67127 --- /dev/null +++ b/data/reward/9500391.yaml @@ -0,0 +1,89 @@ +# Mob 9500391 (9500391) + +rewards: +- [1492012, 1, 1, 0.0075] +- [1482012, 1, 1, 0.0075] +- [1472053, 1, 1, 0.0075] +- [1462016, 1, 1, 0.0075] +- [1462015, 1, 1, 0.0075] +- [1452020, 1, 1, 0.0075] +- [1452019, 1, 1, 0.0075] +- [1442044, 1, 1, 0.0075] +- [1432030, 1, 1, 0.0075] +- [1422027, 1, 1, 0.0075] +- [1412021, 1, 1, 0.0075] +- [1402035, 1, 1, 0.0075] +- [1382035, 1, 1, 0.0075] +- [1372010, 1, 1, 0.0105] +- [1332052, 1, 1, 0.0075] +- [1332051, 1, 1, 0.0075] +- [1322045, 1, 1, 0.0105] +- [1312030, 1, 1, 0.0105] +- [1302056, 1, 1, 0.0105] +- [1372015, 1, 1, 0.0105] +- [1302012, 1, 1, 0.0105] +- [1312010, 1, 1, 0.0105] +- [1322019, 1, 1, 0.0105] +- [1332018, 1, 1, 0.0075] +- [1382007, 1, 1, 0.0075] +- [1402011, 1, 1, 0.0075] +- [1412008, 1, 1, 0.0075] +- [1432007, 1, 1, 0.0075] +- [1442008, 1, 1, 0.0075] +- [1452009, 1, 1, 0.0075] +- [1462009, 1, 1, 0.0075] +- [1472026, 1, 1, 0.0075] +- [1482009, 1, 1, 0.0075] +- [1492009, 1, 1, 0.0075] +- [4020009, 1, 1, 0.075] +- [1003068, 1, 1, 0.0225] +- [2049000, 1, 1, 0.00225] +- [2049100, 1, 1, 0.0045] +- [2044601, 1, 1, 0.0045] +- [2044901, 1, 1, 0.0045] +- [2044501, 1, 1, 0.0045] +- [2044801, 1, 1, 0.0045] +- [2044701, 1, 1, 0.0045] +- [2044401, 1, 1, 0.0045] +- [2044301, 1, 1, 0.0045] +- [2044201, 1, 1, 0.0045] +- [2044101, 1, 1, 0.0045] +- [2044001, 1, 1, 0.0045] +- [2043801, 1, 1, 0.0045] +- [2043701, 1, 1, 0.0045] +- [2043301, 1, 1, 0.0045] +- [2043201, 1, 1, 0.0045] +- [2043101, 1, 1, 0.0045] +- [2043001, 1, 1, 0.0045] +- [2040321, 1, 1, 0.0045] +- [2040317, 1, 1, 0.0045] +- [2040326, 1, 1, 0.0045] +- [2040301, 1, 1, 0.0045] +- [2041019, 1, 1, 0.0045] +- [2041016, 1, 1, 0.0045] +- [2041022, 1, 1, 0.0045] +- [2041013, 1, 1, 0.0045] +- [4004004, 1, 1, 0.15] +- [4004003, 1, 1, 0.15] +- [4004002, 1, 1, 0.15] +- [4004001, 1, 1, 0.15] +- [4004000, 1, 1, 0.15] +- [4020008, 1, 1, 0.135] +- [4020007, 1, 1, 0.135] +- [4020006, 1, 1, 0.135] +- [4020005, 1, 1, 0.135] +- [4020004, 1, 1, 0.135] +- [4020003, 1, 1, 0.135] +- [4020002, 1, 1, 0.135] +- [4020001, 1, 1, 0.135] +- [4020000, 1, 1, 0.135] +- [2050000, 1, 1, 0.75] +- [2020015, 1, 1, 0.5] +- [2020013, 1, 1, 0.5] +- [2000005, 1, 1, 0.5] +- [2001002, 1, 1, 0.3] +- [2001001, 1, 1, 0.3] +- [1003068, 1, 1, 0.05] +- [1003068, 1, 1, 0.05] +- [2049115, 1, 1, 0.05] +- [ 2044601, 1, 1, 0.0045 ] diff --git a/data/reward/9500392.yaml b/data/reward/9500392.yaml new file mode 100644 index 00000000..32df49dd --- /dev/null +++ b/data/reward/9500392.yaml @@ -0,0 +1,92 @@ +# Mob 9500392 (9500392) + +rewards: +- [2001001, 1, 1, 0.6] +- [1492012, 1, 1, 0.015] +- [1482012, 1, 1, 0.015] +- [1472053, 1, 1, 0.015] +- [1462016, 1, 1, 0.015] +- [1462015, 1, 1, 0.015] +- [1452020, 1, 1, 0.015] +- [1452019, 1, 1, 0.015] +- [1442044, 1, 1, 0.015] +- [1432030, 1, 1, 0.015] +- [1422027, 1, 1, 0.015] +- [1412021, 1, 1, 0.015] +- [1402035, 1, 1, 0.015] +- [1382035, 1, 1, 0.015] +- [1372010, 1, 1, 0.021] +- [1332052, 1, 1, 0.015] +- [1332051, 1, 1, 0.015] +- [1322045, 1, 1, 0.021] +- [1312030, 1, 1, 0.021] +- [1302056, 1, 1, 0.021] +- [1372015, 1, 1, 0.021] +- [1302012, 1, 1, 0.021] +- [1312010, 1, 1, 0.021] +- [1322019, 1, 1, 0.021] +- [1332018, 1, 1, 0.015] +- [1382007, 1, 1, 0.015] +- [1402011, 1, 1, 0.015] +- [1412008, 1, 1, 0.015] +- [1432007, 1, 1, 0.015] +- [1442008, 1, 1, 0.015] +- [1452009, 1, 1, 0.015] +- [1003068, 1, 1, 0.1] +- [1003068, 1, 1, 0.1] +- [1003068, 1, 1, 0.1] +- [2049115, 1, 1, 0.1] +- [2049115, 1, 1, 0.1] +- [2049115, 1, 1, 0.1] +- [1462009, 1, 1, 0.015] +- [1472026, 1, 1, 0.015] +- [1482009, 1, 1, 0.015] +- [1492009, 1, 1, 0.015] +- [4020009, 1, 1, 0.15] +- [1003068, 1, 1, 0.045] +- [2049000, 1, 1, 0.0045] +- [2049100, 1, 1, 0.009] +- [2044601, 1, 1, 0.009] +- [2044901, 1, 1, 0.009] +- [2044501, 1, 1, 0.009] +- [2044801, 1, 1, 0.009] +- [2044701, 1, 1, 0.009] +- [2044401, 1, 1, 0.009] +- [2044301, 1, 1, 0.009] +- [2044201, 1, 1, 0.009] +- [2044101, 1, 1, 0.009] +- [2044001, 1, 1, 0.009] +- [2043801, 1, 1, 0.009] +- [2043701, 1, 1, 0.009] +- [2043301, 1, 1, 0.009] +- [2043201, 1, 1, 0.009] +- [2043101, 1, 1, 0.009] +- [2043001, 1, 1, 0.009] +- [2040321, 1, 1, 0.009] +- [2040317, 1, 1, 0.009] +- [2040326, 1, 1, 0.009] +- [2040301, 1, 1, 0.009] +- [2041019, 1, 1, 0.009] +- [2041016, 1, 1, 0.009] +- [2041022, 1, 1, 0.009] +- [2041013, 1, 1, 0.009] +- [4004004, 1, 1, 0.3] +- [4004003, 1, 1, 0.3] +- [4004002, 1, 1, 0.3] +- [4004001, 1, 1, 0.3] +- [4004000, 1, 1, 0.3] +- [4020008, 1, 1, 0.27] +- [4020007, 1, 1, 0.27] +- [4020006, 1, 1, 0.27] +- [4020005, 1, 1, 0.27] +- [4020004, 1, 1, 0.27] +- [4020003, 1, 1, 0.27] +- [4020002, 1, 1, 0.27] +- [4020001, 1, 1, 0.27] +- [4020000, 1, 1, 0.27] +- [2050000, 1, 1, 1.0] +- [2020015, 1, 1, 0.999999] +- [2020013, 1, 1, 0.999999] +- [2000005, 1, 1, 0.999999] +- [2001002, 1, 1, 0.6] +- [ 2044601, 1, 1, 0.009 ] diff --git a/data/reward/9500393.yaml b/data/reward/9500393.yaml new file mode 100644 index 00000000..da8bf9f6 --- /dev/null +++ b/data/reward/9500393.yaml @@ -0,0 +1,4 @@ +# Blue Goblin (9500393) + +rewards: + - [ 4001433, 1, 1, 0.01 ] diff --git a/data/reward/9500394.yaml b/data/reward/9500394.yaml new file mode 100644 index 00000000..943a83c1 --- /dev/null +++ b/data/reward/9500394.yaml @@ -0,0 +1,4 @@ +# Mob 9500394 (9500394) + +rewards: +- [4001433, 1, 1, 0.01] diff --git a/data/reward/9500395.yaml b/data/reward/9500395.yaml new file mode 100644 index 00000000..cedc6d5b --- /dev/null +++ b/data/reward/9500395.yaml @@ -0,0 +1,4 @@ +# Mob 9500395 (9500395) + +rewards: +- [4001433, 1, 1, 0.01] diff --git a/data/reward/9600001.yaml b/data/reward/9600001.yaml new file mode 100644 index 00000000..37c01258 --- /dev/null +++ b/data/reward/9600001.yaml @@ -0,0 +1,4 @@ +# Rooster (9600001) + +rewards: +- [4000187, 1, 1, 1.0] diff --git a/data/reward/9600002.yaml b/data/reward/9600002.yaml new file mode 100644 index 00000000..752d13b1 --- /dev/null +++ b/data/reward/9600002.yaml @@ -0,0 +1,5 @@ +# Duck (9600002) + +rewards: +- [0, 43, 65, 0.2] +- [4000188, 1, 1, 1.0] diff --git a/data/reward/9600003.yaml b/data/reward/9600003.yaml new file mode 100644 index 00000000..0dbbbd94 --- /dev/null +++ b/data/reward/9600003.yaml @@ -0,0 +1,5 @@ +# Sheep (9600003) + +rewards: +- [0, 58, 81, 0.2] +- [4000189, 1, 1, 1.0] diff --git a/data/reward/9600004.yaml b/data/reward/9600004.yaml new file mode 100644 index 00000000..b7ee7d19 --- /dev/null +++ b/data/reward/9600004.yaml @@ -0,0 +1,5 @@ +# Goat (9600004) + +rewards: +- [0, 64, 99, 0.2] +- [4000190, 1, 1, 1.0] diff --git a/data/reward/9600005.yaml b/data/reward/9600005.yaml new file mode 100644 index 00000000..9b988c7e --- /dev/null +++ b/data/reward/9600005.yaml @@ -0,0 +1,5 @@ +# Black Goat (9600005) + +rewards: +- [0, 93, 138, 0.2] +- [4000191, 1, 1, 1.0] diff --git a/data/reward/9600006.yaml b/data/reward/9600006.yaml new file mode 100644 index 00000000..2bf04971 --- /dev/null +++ b/data/reward/9600006.yaml @@ -0,0 +1,5 @@ +# Cow (9600006) + +rewards: +- [0, 83, 116, 0.2] +- [4000192, 1, 1, 1.0] diff --git a/data/reward/9600007.yaml b/data/reward/9600007.yaml new file mode 100644 index 00000000..ab4bcdc9 --- /dev/null +++ b/data/reward/9600007.yaml @@ -0,0 +1,5 @@ +# Plow Ox (9600007) + +rewards: +- [0, 136, 162, 0.2] +- [4000193, 1, 1, 1.0] diff --git a/data/reward/9600008.yaml b/data/reward/9600008.yaml new file mode 100644 index 00000000..2aaf89d4 --- /dev/null +++ b/data/reward/9600008.yaml @@ -0,0 +1,4 @@ +# Black Sheep (9600008) + +rewards: +- [0, 43, 60, 0.2] diff --git a/data/reward/9600009.yaml b/data/reward/9600009.yaml new file mode 100644 index 00000000..5e299c0a --- /dev/null +++ b/data/reward/9600009.yaml @@ -0,0 +1,15 @@ +# Giant Centipede (9600009) + +rewards: +- [2290077, 1, 1, 0.005] +- [2290075, 1, 1, 0.005] +- [2290058, 1, 1, 0.005] +- [2290049, 1, 1, 0.005] +- [2290035, 1, 1, 0.005] +- [2430144, 1, 2, 0.025] +- [2430144, 1, 2, 0.025] +- [2290005, 1, 1, 0.005] +- [2290119, 1, 1, 0.005] +- [2290123, 1, 1, 0.005] +- [2290146, 1, 1, 0.005] +- [2290324, 1, 1, 0.005] diff --git a/data/reward/9600010.yaml b/data/reward/9600010.yaml new file mode 100644 index 00000000..a5542076 --- /dev/null +++ b/data/reward/9600010.yaml @@ -0,0 +1,15 @@ +# Giant Centipede (9600010) + +rewards: +- [2290077, 1, 1, 0.005] +- [2290075, 1, 1, 0.005] +- [2290058, 1, 1, 0.005] +- [2290049, 1, 1, 0.005] +- [2290035, 1, 1, 0.005] +- [2430144, 1, 2, 0.025] +- [2430144, 1, 2, 0.025] +- [2290005, 1, 1, 0.005] +- [2290119, 1, 1, 0.005] +- [2290123, 1, 1, 0.005] +- [2290146, 1, 1, 0.005] +- [2290324, 1, 1, 0.005] diff --git a/data/reward/9600037.yaml b/data/reward/9600037.yaml new file mode 100644 index 00000000..31d69fd8 --- /dev/null +++ b/data/reward/9600037.yaml @@ -0,0 +1,4 @@ +# Mob 9600037 (9600037) + +rewards: +- [3994059, 1, 1, 0.999999] diff --git a/data/reward/9600038.yaml b/data/reward/9600038.yaml new file mode 100644 index 00000000..39dfdadb --- /dev/null +++ b/data/reward/9600038.yaml @@ -0,0 +1,4 @@ +# Mob 9600038 (9600038) + +rewards: +- [3994060, 1, 1, 0.999999] diff --git a/data/reward/9600039.yaml b/data/reward/9600039.yaml new file mode 100644 index 00000000..ad3ce2d4 --- /dev/null +++ b/data/reward/9600039.yaml @@ -0,0 +1,4 @@ +# Mob 9600039 (9600039) + +rewards: +- [3994061, 1, 1, 0.999999] diff --git a/data/reward/9600040.yaml b/data/reward/9600040.yaml new file mode 100644 index 00000000..ec3283cd --- /dev/null +++ b/data/reward/9600040.yaml @@ -0,0 +1,4 @@ +# Mob 9600040 (9600040) + +rewards: +- [3994062, 1, 1, 0.999999] diff --git a/data/reward/9600041.yaml b/data/reward/9600041.yaml new file mode 100644 index 00000000..a8dbf2e0 --- /dev/null +++ b/data/reward/9600041.yaml @@ -0,0 +1,4 @@ +# Mob 9600041 (9600041) + +rewards: +- [3994063, 1, 1, 0.999999] diff --git a/data/reward/9600042.yaml b/data/reward/9600042.yaml new file mode 100644 index 00000000..0d0b5a39 --- /dev/null +++ b/data/reward/9600042.yaml @@ -0,0 +1,4 @@ +# Mob 9600042 (9600042) + +rewards: +- [3994064, 1, 1, 0.999999] diff --git a/data/reward/9600043.yaml b/data/reward/9600043.yaml new file mode 100644 index 00000000..5538fb09 --- /dev/null +++ b/data/reward/9600043.yaml @@ -0,0 +1,4 @@ +# Mob 9600043 (9600043) + +rewards: +- [3994065, 1, 1, 0.999999] diff --git a/data/reward/9600044.yaml b/data/reward/9600044.yaml new file mode 100644 index 00000000..0b5d11f5 --- /dev/null +++ b/data/reward/9600044.yaml @@ -0,0 +1,4 @@ +# Mob 9600044 (9600044) + +rewards: +- [3994066, 1, 1, 0.999999] diff --git a/data/reward/9600045.yaml b/data/reward/9600045.yaml new file mode 100644 index 00000000..9f2ed379 --- /dev/null +++ b/data/reward/9600045.yaml @@ -0,0 +1,4 @@ +# Mob 9600045 (9600045) + +rewards: +- [3994067, 1, 1, 0.999999] diff --git a/data/reward/9600046.yaml b/data/reward/9600046.yaml new file mode 100644 index 00000000..d6589b0f --- /dev/null +++ b/data/reward/9600046.yaml @@ -0,0 +1,4 @@ +# Mob 9600046 (9600046) + +rewards: +- [3994068, 1, 1, 0.999999] diff --git a/data/reward/9600047.yaml b/data/reward/9600047.yaml new file mode 100644 index 00000000..b549a8d0 --- /dev/null +++ b/data/reward/9600047.yaml @@ -0,0 +1,4 @@ +# Mob 9600047 (9600047) + +rewards: +- [3994069, 1, 1, 0.999999] diff --git a/data/reward/9600048.yaml b/data/reward/9600048.yaml new file mode 100644 index 00000000..fabd2856 --- /dev/null +++ b/data/reward/9600048.yaml @@ -0,0 +1,4 @@ +# Mob 9600048 (9600048) + +rewards: +- [3994070, 1, 1, 0.999999] diff --git a/data/reward/9600049.yaml b/data/reward/9600049.yaml new file mode 100644 index 00000000..c4fe40db --- /dev/null +++ b/data/reward/9600049.yaml @@ -0,0 +1,4 @@ +# Mob 9600049 (9600049) + +rewards: +- [3994071, 1, 1, 0.999999] diff --git a/data/reward/9600050.yaml b/data/reward/9600050.yaml new file mode 100644 index 00000000..9e2b9587 --- /dev/null +++ b/data/reward/9600050.yaml @@ -0,0 +1,4 @@ +# Mob 9600050 (9600050) + +rewards: +- [3994072, 1, 1, 0.999999] diff --git a/data/reward/9600051.yaml b/data/reward/9600051.yaml new file mode 100644 index 00000000..188a6064 --- /dev/null +++ b/data/reward/9600051.yaml @@ -0,0 +1,4 @@ +# Mob 9600051 (9600051) + +rewards: +- [3994073, 1, 1, 0.999999] diff --git a/data/reward/9600052.yaml b/data/reward/9600052.yaml new file mode 100644 index 00000000..33f38031 --- /dev/null +++ b/data/reward/9600052.yaml @@ -0,0 +1,4 @@ +# Mob 9600052 (9600052) + +rewards: +- [3994074, 1, 1, 0.999999] diff --git a/data/reward/9600053.yaml b/data/reward/9600053.yaml new file mode 100644 index 00000000..11bb2ad2 --- /dev/null +++ b/data/reward/9600053.yaml @@ -0,0 +1,4 @@ +# Mob 9600053 (9600053) + +rewards: +- [3994075, 1, 1, 0.999999] diff --git a/data/reward/9600054.yaml b/data/reward/9600054.yaml new file mode 100644 index 00000000..4862e5b1 --- /dev/null +++ b/data/reward/9600054.yaml @@ -0,0 +1,4 @@ +# Mob 9600054 (9600054) + +rewards: +- [3994076, 1, 1, 0.999999] diff --git a/data/reward/9600055.yaml b/data/reward/9600055.yaml new file mode 100644 index 00000000..7b660488 --- /dev/null +++ b/data/reward/9600055.yaml @@ -0,0 +1,4 @@ +# Mob 9600055 (9600055) + +rewards: +- [3994077, 1, 1, 0.999999] diff --git a/data/reward/9600056.yaml b/data/reward/9600056.yaml new file mode 100644 index 00000000..fd9b53c0 --- /dev/null +++ b/data/reward/9600056.yaml @@ -0,0 +1,4 @@ +# Mob 9600056 (9600056) + +rewards: +- [3994078, 1, 1, 0.999999] diff --git a/data/reward/9600057.yaml b/data/reward/9600057.yaml new file mode 100644 index 00000000..61e3f287 --- /dev/null +++ b/data/reward/9600057.yaml @@ -0,0 +1,4 @@ +# Mob 9600057 (9600057) + +rewards: +- [3994079, 1, 1, 0.999999] diff --git a/data/reward/9600058.yaml b/data/reward/9600058.yaml new file mode 100644 index 00000000..46263be5 --- /dev/null +++ b/data/reward/9600058.yaml @@ -0,0 +1,4 @@ +# Mob 9600058 (9600058) + +rewards: +- [3994080, 1, 1, 0.999999] diff --git a/data/reward/9600059.yaml b/data/reward/9600059.yaml new file mode 100644 index 00000000..b21ad733 --- /dev/null +++ b/data/reward/9600059.yaml @@ -0,0 +1,4 @@ +# Mob 9600059 (9600059) + +rewards: +- [3994081, 1, 1, 0.999999] diff --git a/data/reward/9600060.yaml b/data/reward/9600060.yaml new file mode 100644 index 00000000..dd0bf3c8 --- /dev/null +++ b/data/reward/9600060.yaml @@ -0,0 +1,4 @@ +# Mob 9600060 (9600060) + +rewards: +- [3994082, 1, 1, 0.999999] diff --git a/data/reward/9600061.yaml b/data/reward/9600061.yaml new file mode 100644 index 00000000..b5a68aff --- /dev/null +++ b/data/reward/9600061.yaml @@ -0,0 +1,4 @@ +# Mob 9600061 (9600061) + +rewards: +- [3994083, 1, 1, 0.999999] diff --git a/data/reward/9600062.yaml b/data/reward/9600062.yaml new file mode 100644 index 00000000..b94781ec --- /dev/null +++ b/data/reward/9600062.yaml @@ -0,0 +1,4 @@ +# Mob 9600062 (9600062) + +rewards: +- [3994084, 1, 1, 0.999999] diff --git a/data/reward/9700019.yaml b/data/reward/9700019.yaml new file mode 100644 index 00000000..5c804914 --- /dev/null +++ b/data/reward/9700019.yaml @@ -0,0 +1,4 @@ +# Pharaoh Jr. Yeti (9700019) + +rewards: +- [2022613, 1, 1, 0.999999] diff --git a/data/reward/9700020.yaml b/data/reward/9700020.yaml new file mode 100644 index 00000000..a963edee --- /dev/null +++ b/data/reward/9700020.yaml @@ -0,0 +1,4 @@ +# Metro Bubbling (9700020) + +rewards: +- [2022615, 1, 1, 0.999999] diff --git a/data/reward/9700029.yaml b/data/reward/9700029.yaml new file mode 100644 index 00000000..9df2e516 --- /dev/null +++ b/data/reward/9700029.yaml @@ -0,0 +1,4 @@ +# Pharaoh Jr. Yeti (9700029) + +rewards: +- [2022618, 1, 1, 0.999999] diff --git a/data/shop/1011000.yaml b/data/shop/1011000.yaml index e3006fd8..c5b873a9 100644 --- a/data/shop/1011000.yaml +++ b/data/shop/1011000.yaml @@ -2,16 +2,16 @@ recharge: true items: - - [ 1302007, 3000 ] # Long Sword - - [ 1322007, 6000 ] # Leather Purse - - [ 1322008, 12000 ] # Hard Briefcase - - [ 1422004, 20000 ] # Monkey Wrench - - [ 1442004, 24000 ] # Janitor's Mop - - [ 1452000, 20000 ] # Battle Bow - - [ 1452001, 10000 ] # Hunter's Bow - - [ 1452002, 3000 ] # War Bow - - [ 1452003, 6000 ] # Composite Bow - - [ 1462000, 30000 ] # Mountain Crossbow - - [ 1462001, 4000 ] # Crossbow - - [ 1462002, 8000 ] # Battle Crossbow - - [ 1462003, 12000 ] # Balanche + - [ 1302007, 3000 ] + - [ 1322007, 6000 ] + - [ 1322008, 12000 ] + - [ 1422004, 20000 ] + - [ 1442004, 24000 ] + - [ 1452000, 20000 ] + - [ 1452001, 10000 ] + - [ 1452002, 3000 ] + - [ 1452003, 6000 ] + - [ 1462000, 30000 ] + - [ 1462001, 4000 ] + - [ 1462002, 8000 ] + - [ 1462003, 12000 ] diff --git a/data/shop/1052116.yaml b/data/shop/1052116.yaml new file mode 100644 index 00000000..7345d898 --- /dev/null +++ b/data/shop/1052116.yaml @@ -0,0 +1,22 @@ +# Thompson : General Merchant (1052116) - Kerning Square : Kerning Square Lobby (103040000) + +recharge: true +items: + - [ 2000000, 50, 1, 100 ] # Red Potion + - [ 2000001, 160, 1, 100 ] # Orange Potion + - [ 2000002, 320, 1, 100 ] # White Potion + - [ 2000003, 200, 1, 100 ] # Blue Potion + - [ 2000006, 620, 1, 100 ] # Mana Elixir + - [ 2010000, 30, 1, 100 ] # Apple + - [ 2010001, 106, 1, 100 ] # Meat + - [ 2010002, 50, 1, 100 ] # Egg + - [ 2010003, 100, 1, 100 ] # Orange + - [ 2010004, 310, 1, 100 ] # Lemon + - [ 2022000, 1650, 1, 100 ] # Pure Water + - [ 2022003, 1100, 1, 100 ] # Unagi + - [ 2030000, 400, 1, 100 ] # Return Scroll - Nearest Town + - [ 2030006, 600, 1, 100 ] # Return Scroll to Sleepywood + - [ 2060000, 1, 1, 2000 ] # Arrow for Bow + - [ 2061000, 1, 1, 2000 ] # Arrow for Crossbow + - [ 2070000, 500, 500, 500 ] # Subi Throwing-Stars + - [ 2330000, 500, 500, 500 ] # Bullet \ No newline at end of file diff --git a/data/shop/1055000.yaml b/data/shop/1055000.yaml new file mode 100644 index 00000000..dde73f58 --- /dev/null +++ b/data/shop/1055000.yaml @@ -0,0 +1,63 @@ +# Lumi : Weapon/Armour Seller (1055000) - Victoria Road : The Secret Garden Basement (103050300) + +items: + - [ 1302007, 3000 ] # Long Sword + - [ 1332000, 4000 ] # Triangular Zamadar + - [ 1332006, 7000 ] # Field Dagger + - [ 1332002, 8000 ] # Triple-Tipped Zamadar + - [ 1332008, 10000 ] # Coconut Knife + - [ 1332013, 15000 ] # Stinger + - [ 1332010, 22000 ] # Iron Dagger + - [ 1332004, 38000 ] # Forked Dagger + - [ 1342001, 42000 ] # Guardian Katara + - [ 1332012, 40000 ] # Reef Claw + - [ 1332009, 44500 ] # Cass + - [ 1322009, 20000 ] # Plunger + - [ 1402001, 3000 ] # Wooden Sword + - [ 1412001, 3000 ] # Metal Axe + - [ 1422000, 3000 ] # Wooden Mallet + - [ 1432000, 3000 ] # Spear + - [ 1432001, 7000 ] # Fork on a Stick + - [ 1442000, 3000 ] # Pole Arm + - [ 1472000, 3000 ] # Garnier + - [ 1342000, 10000 ] # Champion Katara + - [ 1342001, 42000 ] # Guardian Katara + - [ 1002125, 900 ] # Black Ghetto Beanie + - [ 1002001, 3000 ] # Metal Gear + - [ 1002110, 4000 ] # Black Thief Hood + - [ 1002130, 12000 ] # Black Loosecap + - [ 1002097, 7400 ] # Yellow Starry Bandana + - [ 1002150, 20000 ] # Dark Tiberian + - [ 1002175, 30000 ] # Dark Guise + - [ 1040033, 3000 ] # Black Cloth Vest + - [ 1040034, 5000 ] # Dark Nightshift + - [ 1040044, 9000 ] # Black Pao + - [ 1040050, 16000 ] # Black Sneak + - [ 1040057, 45000 ] # Dark Brown Stealer + - [ 1040058, 45000 ] # Dark Silver Stealer + - [ 1040033, 3000 ] # Black Cloth Vest + - [ 1041044, 5000 ] # Red Nightshift + - [ 1041045, 5000 ] # Brown Nightshift + - [ 1041040, 9000 ] # Blue Qi Pao + - [ 1041057, 18000 ] # Dark Sneak + - [ 1041060, 18000 ] # Gold Sneak + - [ 1041050, 45000 ] # Purple Steal + - [ 1060023, 2800 ] # Black Cloth Pants + - [ 1060024, 4800 ] # Dark Nightshift Pants + - [ 1060025, 4800 ] # Blue Nightshift Pants + - [ 1060033, 8000 ] # Black Pao Bottom + - [ 1060039, 19000 ] # Black Sneak Pants + - [ 1060043, 40000 ] # Dark Brown Stealer Pants + - [ 1060044, 40000 ] # Dark Silver Stealer Pants + - [ 1060023, 2800 ] # Black Cloth Pants + - [ 1061037, 4800 ] # Red Nightshift Pants + - [ 1061038, 4800 ] # Brown Nightshift Pants + - [ 1061033, 8000 ] # Blue Qi Pao Pants + - [ 1061042, 12000 ] # Blue Qi Pao Skirt + - [ 1061053, 18000 ] # Dark Sneak Pants + - [ 1061056, 18000 ] # Gold Sneak Pants + - [ 1061046, 40000 ] # Purple Steal Pants + - [ 1072043, 2000 ] # Smelly Gomushin + - [ 1072028, 4500 ] # White Ninja Sandals + - [ 1072031, 4500 ] # Red Ninja Sandals + - [ 1072066, 9000 ] # Blue Enamel Boots \ No newline at end of file diff --git a/data/shop/1055002.yaml b/data/shop/1055002.yaml new file mode 100644 index 00000000..96bfb36a --- /dev/null +++ b/data/shop/1055002.yaml @@ -0,0 +1,43 @@ +# Taeha : Merchant (1055002) - Victoria Road : The Secret Garden Basement (103050300) + +recharge: true +items: + - [ 2000000, 50, 1, 100 ] # Red Potion + - [ 2000001, 160, 1, 100 ] # Orange Potion + - [ 2000002, 320, 1, 100 ] # White Potion + - [ 2000003, 200, 1, 100 ] # Blue Potion + - [ 2000006, 620, 1, 100 ] # Mana Elixir + - [ 2001000, 3200, 1, 100 ] # Watermelon + - [ 2001001, 2300, 1, 100 ] # Ice Cream Pop + - [ 2001002, 4000, 1, 100 ] # Red Bean Sundae + - [ 2002016, 5000, 1, 100 ] # Thief Elixir + - [ 2002017, 5000, 1, 100 ] # Warrior Elixir + - [ 2002018, 5000, 1, 100 ] # Wizard Elixir + - [ 2002019, 5000, 1, 100 ] # Archer Elixir + - [ 2002020, 2800, 1, 150 ] # Mana Bull + - [ 2002021, 2800, 1, 150 ] # Honster + - [ 2002022, 2100, 1, 100 ] # Ginseng Root + - [ 2002023, 3800, 1, 100 ] # Ginger Ale + - [ 2002024, 1500, 1, 150 ] # Sorcerer Elixir + - [ 2002025, 1500, 1, 150 ] # Barbarian Elixir + - [ 2010000, 30, 1, 100 ] # Apple + - [ 2010001, 106, 1, 100 ] # Meat + - [ 2010002, 50, 1, 100 ] # Egg + - [ 2010003, 100, 1, 100 ] # Orange + - [ 2010004, 310, 1, 100 ] # Lemon + - [ 2020012, 4500, 1, 100 ] # Melting Cheese + - [ 2020013, 5600, 1, 100 ] # Reindeer Milk + - [ 2020014, 8100, 1, 100 ] # Sunrise Dew + - [ 2020015, 10200, 1, 100 ] # Sunset Dew + - [ 2022000, 1650, 1, 100 ] # Pure Water + - [ 2022003, 1100, 1, 100 ] # Unagi + - [ 2022189, 1000, 1, 100 ] # Grilled Cheese + - [ 2022190, 3000, 1, 100 ] # Cherry Pie + - [ 2022191, 1000, 1, 100 ] # Supreme Pizza + - [ 2022192, 600, 1, 100 ] # Waffle + - [ 2022195, 15000, 1, 150 ] # Mapleade + - [ 2030000, 400, 1, 100 ] # Return Scroll - Nearest Town + - [ 2060000, 1, 1, 2000 ] # Arrow for Bow + - [ 2061000, 1, 1, 2000 ] # Arrow for Crossbow + - [ 2070000, 500, 500, 500 ] # Subi Throwing-Stars + - [ 2330000, 500, 500, 500 ] # Bullet \ No newline at end of file diff --git a/data/shop/1091000.yaml b/data/shop/1091000.yaml new file mode 100644 index 00000000..0ec757d1 --- /dev/null +++ b/data/shop/1091000.yaml @@ -0,0 +1,16 @@ +# Morgan : Weapon Seller (1091000) | Nautilus : Mid Floor - Hallway (120000200) + +items: + - [ 1492000, 6000, 1, 1 ] # Pistol + - [ 1492001, 12000, 1, 1 ] # Dellinger Special + - [ 1492002, 20000, 1, 1 ] # The Negotiator + - [ 1492003, 44000, 1, 1 ] # Golden Hook + - [ 1492004, 75000, 1, 1 ] # Cold Mind + - [ 1482000, 6000, 1, 1 ] # Steel Knuckler + - [ 1482001, 12000, 1, 1 ] # Leather Arms + - [ 1482002, 20000, 1, 1 ] # Double Tail Knuckler + - [ 1482003, 40000, 1, 1 ] # Norman Grip + - [ 1482004, 75000, 1, 1 ] # Prime Hands + - [ 1442004, 33000, 1, 1 ] # Janitor's Mop + - [ 1302007, 6000, 1, 1 ] # Long Sword + - [ 1322007, 12000, 1, 1 ] # Leather Purse \ No newline at end of file diff --git a/data/shop/11000.yaml b/data/shop/11000.yaml index 0e90d7e9..15be86e0 100644 --- a/data/shop/11000.yaml +++ b/data/shop/11000.yaml @@ -1,7 +1,7 @@ # Sid : Weapon Seller (11000) - Rainbow Street : Amherst Weapon Store (1000001) items: - - [ 1302000, 50 ] # Sword - - [ 1312004, 50 ] # Hand Axe - - [ 1322005, 50 ] # Wooden Club - - [ 1332005, 500 ] # Razor + - [ 1302000, 50 ] + - [ 1312004, 50 ] + - [ 1322005, 50 ] + - [ 1332005, 500 ] diff --git a/data/shop/1100001.yaml b/data/shop/1100001.yaml new file mode 100644 index 00000000..e47d882b --- /dev/null +++ b/data/shop/1100001.yaml @@ -0,0 +1,20 @@ +# Kiriyu (1100001) - Weapon & Armor Shop + +items: + - [ 1302000, 50 ] # Sword + - [ 1312004, 50 ] # Hand Axe + - [ 1322005, 50 ] # Wooden Club + - [ 1332005, 500 ] # Razor + - [ 1040002, 50 ] # White Undershirt + - [ 1040006, 50 ] # Undershirt + - [ 1040010, 50 ] # Grey T-Shirt + - [ 1041002, 50 ] # White Tubetop + - [ 1041006, 50 ] # Yellow T-Shirt + - [ 1041010, 50 ] # Green T-Shirt + - [ 1041011, 50 ] # Red-Striped Top + - [ 1060002, 50 ] # Blue Jean Shorts + - [ 1060006, 50 ] # Brown Cotton Shorts + - [ 1061002, 50 ] # Red Miniskirt + - [ 1061008, 50 ] # Indigo Miniskirt + - [ 1072001, 50 ] # Red Rubber Boots + - [ 1072005, 50 ] # Leather Sandals diff --git a/data/shop/1100002.yaml b/data/shop/1100002.yaml new file mode 100644 index 00000000..777b9f0b --- /dev/null +++ b/data/shop/1100002.yaml @@ -0,0 +1,25 @@ +# Kiriwing (1100002) - Consumables Shop + +recharge: true +items: + - [ 2000000, 5 ] # Red Potion + - [ 2000001, 48 ] # Orange Potion + - [ 2000002, 96 ] # White Potion + - [ 2000003, 20 ] # Blue Potion + - [ 2000006, 186 ] # Mana Elixir + - [ 2002000, 500 ] # Dexterity Potion + - [ 2002001, 400 ] # Speed Potion + - [ 2002002, 500 ] # Magic Potion + - [ 2002004, 500 ] # Warrior Potion + - [ 2002005, 500 ] # Sniper Potion + - [ 2010000, 3 ] # Apple + - [ 2010002, 5 ] # Egg + - [ 2010001, 10 ] # Meat + - [ 2010003, 10 ] # Orange + - [ 2010004, 93 ] # Lemon + - [ 2030000, 400 ] # Return Scroll - Nearest Town + - [ 2060000, 1 ] # Arrow for Bow (single) + - [ 2061000, 1 ] # Arrow for Crossbow (single) + - [ 2050000, 200 ] # Antidote + - [ 2050001, 200 ] # Eyedrop + - [ 2050002, 300 ] # Tonic diff --git a/data/shop/1301000.yaml b/data/shop/1301000.yaml new file mode 100644 index 00000000..3245d375 --- /dev/null +++ b/data/shop/1301000.yaml @@ -0,0 +1,25 @@ +# Thorr : Merchant (1301000) - Mushroom Castle : Mushroom Forest Field (106020000) + +recharge: true +items: + - [ 2000000, 50, 1, 100 ] # Red Potion + - [ 2000001, 160, 1, 100 ] # Orange Potion + - [ 2000002, 320, 1, 100 ] # White Potion + - [ 2000003, 200, 1, 100 ] # Blue Potion + - [ 2000006, 620, 1, 100 ] # Mana Elixir + - [ 2002000, 500, 1, 100 ] # Dexterity Potion + - [ 2002001, 400, 1, 100 ] # Speed Potion + - [ 2002002, 500, 1, 100 ] # Magic Potion + - [ 2002004, 500, 1, 100 ] # Warrior Potion + - [ 2002005, 500, 1, 100 ] # Sniper Potion + - [ 2010000, 30, 1, 100 ] # Apple + - [ 2010001, 106, 1, 100 ] # Meat + - [ 2010002, 50, 1, 100 ] # Egg + - [ 2010003, 100, 1, 100 ] # Orange + - [ 2010004, 310, 1, 100 ] # Lemon + - [ 2020028, 3000, 1, 100 ] # Chocolate + - [ 2030000, 400, 1, 100 ] # Return Scroll - Nearest Town + - [ 2060000, 1, 1, 2000 ] # Arrow for Bow + - [ 2061000, 1, 1, 2000 ] # Arrow for Crossbow + - [ 2070000, 500, 500, 500 ] # Subi Throwing-Stars + - [ 2330000, 500, 500, 500 ] # Bullet diff --git a/data/shop/2152016.yaml b/data/shop/2152016.yaml new file mode 100644 index 00000000..ca3af7bf --- /dev/null +++ b/data/shop/2152016.yaml @@ -0,0 +1,66 @@ +# Bonjasky : Weapon / Armor Seller (2152016) - Edelstein : Edelstein (310000000) + +recharge: true +items: + # Weapons for Battle Mage (Staves) + - [ 1382000, 1000 ] # Wooden Staff + - [ 1382003, 3000 ] # Blue Metal Staff + - [ 1382005, 5000 ] # Iron Staff + - [ 1382007, 8000 ] # Emerald Staff + - [ 1382017, 12000 ] # Fairy Staff + + # Weapons for Wild Hunter (Crossbows) + - [ 1462000, 30000 ] # Mountain Crossbow + - [ 1462001, 4000 ] # Crossbow + - [ 1462002, 8000 ] # Battle Crossbow + - [ 1462003, 12000 ] # Balanche + + # Weapons for Mechanic (Guns - if available, otherwise general weapons) + - [ 1492000, 10000 ] # Pistol + - [ 1492001, 15000 ] # Mauser + + # General Melee Weapons + - [ 1302007, 3000 ] # Long Sword + - [ 1312004, 50 ] # Hand Axe + - [ 1322005, 50 ] # Wooden Club + + # Armor - Hats + - [ 1002008, 500 ] # Brown Skullcap + - [ 1002010, 800 ] # Brown Winter Hat + - [ 1002012, 3000 ] # Red Baseball Cap + - [ 1002060, 3000 ] # Black Baseball Cap + - [ 1002061, 3000 ] # Yellow Baseball Cap + - [ 1002062, 3000 ] # Brown Baseball Cap + - [ 1002063, 3000 ] # Blue Baseball Cap + + # Armor - Tops (for various armor types) + - [ 1040003, 9000 ] # Brown Hard Leather Top + - [ 1040007, 5500 ] # Green Leather Hoodwear + - [ 1040008, 3200 ] # Brown Archer Top + - [ 1040011, 5500 ] # Silver Leather Hoodwear + - [ 1040022, 15000 ] # Green Bennis Chainmail + - [ 1040023, 15000 ] # Black Bennis Chainmail + - [ 1040071, 3200 ] # Green Archer Top + + # Armor - Bottoms + - [ 1060005, 14000 ] # Warfare Pants + - [ 1062002, 9000 ] # Brown Hard Leather Pants + - [ 1062004, 3000 ] # Archer Pants + - [ 1062006, 13000 ] # Bennis Chainpants + + # Armor - Shoes + - [ 1072015, 1800 ] # Brown Hard Leather Boots + - [ 1072016, 4500 ] # Green Woodsman Boots + - [ 1072059, 1800 ] # Green Hard Leather Boots + - [ 1072060, 4500 ] # Brown Woodsman Boots + - [ 1072061, 4500 ] # Blue Woodsman Boots + + # Armor - Gloves + - [ 1082002, 400 ] # Blue Work Gloves + - [ 1082003, 500 ] # Brown Work Gloves + - [ 1082019, 2500 ] # Green Mittens + - [ 1082023, 4000 ] # Blue Wrist Band + + # Accessories + - [ 1032001, 7000 ] # Single Earring + - [ 1032003, 7000 ] # Amethyst Earrings diff --git a/data/shop/9000081.yaml b/data/shop/9000081.yaml new file mode 100644 index 00000000..791d5b66 --- /dev/null +++ b/data/shop/9000081.yaml @@ -0,0 +1,31 @@ +# Ms. Tang : Merchant (9000081) - Golden Temple : Golden Temple (950100000) + +recharge: true +items: + - [ 2000000, 50, 1, 100 ] # Red Potion + - [ 2000001, 160, 1, 100 ] # Orange Potion + - [ 2000002, 320, 1, 100 ] # White Potion + - [ 2000003, 200, 1, 100 ] # Blue Potion + - [ 2000006, 620, 1, 100 ] # Mana Elixir + - [ 2001000, 3200, 1, 100 ] # Watermelon + - [ 2001001, 2300, 1, 100 ] # Ice Cream Pop + - [ 2001002, 4000, 1, 100 ] # Red Bean Sundae + - [ 2002000, 500, 1, 100 ] # Dexterity Potion + - [ 2002001, 400, 1, 100 ] # Speed Potion + - [ 2002002, 500, 1, 100 ] # Magic Potion + - [ 2002004, 500, 1, 100 ] # Warrior Potion + - [ 2002005, 500, 1, 100 ] # Sniper Potion + - [ 2010000, 30, 1, 100 ] # Apple + - [ 2010001, 106, 1, 100 ] # Meat + - [ 2010002, 50, 1, 100 ] # Egg + - [ 2010003, 100, 1, 100 ] # Orange + - [ 2010004, 310, 1, 100 ] # Lemon + - [ 2020012, 4500, 1, 100 ] # Melting Cheese + - [ 2020013, 5500, 1, 100 ] # Reindeer Milk + - [ 2020014, 8100, 1, 100 ] # Sunrise Dew + - [ 2022000, 1650, 1, 100 ] # Pure Water + - [ 2030000, 400, 1, 100 ] # Return Scroll - Nearest Town + - [ 2060000, 1, 1, 2000 ] # Arrow for Bow + - [ 2061000, 1, 1, 2000 ] # Arrow for Crossbow + - [ 2070000, 500, 500, 500 ] # Subi Throwing-Stars + - [ 2330000, 500, 500, 500 ] # Bullet diff --git a/data/shop/9090000.yaml b/data/shop/9090000.yaml new file mode 100644 index 00000000..82507a41 --- /dev/null +++ b/data/shop/9090000.yaml @@ -0,0 +1,43 @@ +# MiuMiu Travel Shop + +recharge: true +items: + - [ 2000000, 50, 1, 100 ] # Red Potion + - [ 2000001, 160, 1, 100 ] # Orange Potion + - [ 2000002, 320, 1, 100 ] # White Potion + - [ 2000003, 200, 1, 100 ] # Blue Potion + - [ 2000006, 620, 1, 100 ] # Mana Elixir + - [ 2001000, 3200, 1, 100 ] # Watermelon + - [ 2001001, 2300, 1, 100 ] # Ice Cream Pop + - [ 2001002, 4000, 1, 100 ] # Red Bean Sundae + - [ 2002016, 5000, 1, 100 ] # Thief Elixir + - [ 2002017, 5000, 1, 100 ] # Warrior Elixir + - [ 2002018, 5000, 1, 100 ] # Wizard Elixir + - [ 2002019, 5000, 1, 100 ] # Archer Elixir + - [ 2002020, 2800, 1, 150 ] # Mana Bull + - [ 2002021, 2800, 1, 150 ] # Honster + - [ 2002022, 2100, 1, 100 ] # Ginseng Root + - [ 2002023, 3800, 1, 100 ] # Ginger Ale + - [ 2002024, 1500, 1, 150 ] # Sorcerer Elixir + - [ 2002025, 1500, 1, 150 ] # Barbarian Elixir + - [ 2010000, 30, 1, 100 ] # Apple + - [ 2010001, 106, 1, 100 ] # Meat + - [ 2010002, 50, 1, 100 ] # Egg + - [ 2010003, 100, 1, 100 ] # Orange + - [ 2010004, 310, 1, 100 ] # Lemon + - [ 2020012, 4500, 1, 100 ] # Melting Cheese + - [ 2020013, 5600, 1, 100 ] # Reindeer Milk + - [ 2020014, 8100, 1, 100 ] # Sunrise Dew + - [ 2020015, 10200, 1, 100 ] # Sunset Dew + - [ 2022000, 1650, 1, 100 ] # Pure Water + - [ 2022003, 1100, 1, 100 ] # Unagi + - [ 2022189, 1000, 1, 100 ] # Grilled Cheese + - [ 2022190, 3000, 1, 100 ] # Cherry Pie + - [ 2022191, 1000, 1, 100 ] # Supreme Pizza + - [ 2022192, 600, 1, 100 ] # Waffle + - [ 2022195, 15000, 1, 150 ] # Mapleade + - [ 2030000, 400, 1, 100 ] # Return Scroll - Nearest Town + - [ 2060000, 1, 1, 2000 ] # Arrow for Bow + - [ 2061000, 1, 1, 2000 ] # Arrow for Crossbow + - [ 2070000, 500, 500, 500 ] # Subi Throwing-Stars + - [ 2330000, 500, 500, 500 ] # Bullet \ No newline at end of file diff --git a/data/shop/9110100.yaml b/data/shop/9110100.yaml new file mode 100644 index 00000000..412593bc --- /dev/null +++ b/data/shop/9110100.yaml @@ -0,0 +1,17 @@ +# Yokoda : Weapon Seller (9110100) - Zipangu : Outside Ninja Castle (800040000) + +recharge: true +items: + - [ 1302021, 1250000 ] # Pico-Pico Hammer + - [ 1302022, 80000 ] # Bamboo Sword + - [ 1312013, 100000 ] # Green Paint Brush + - [ 1322012, 15000 ] # Red Brick + - [ 1332024, 2000000 ] # Bushido + - [ 1382011, 2000000 ] # Mystic Cane + - [ 1402009, 30000 ] # Wooden Baseball Bat + - [ 1402010, 150000 ] # Aluminum Baseball Bat + - [ 1432008, 150000 ] # Fish Spear + - [ 1462006, 500000 ] # Silver Crow + - [ 1472008, 250000 ] # Steel Guards + - [ 2070000, 500, 500, 500 ] # Subi Throwing-Stars + - [ 2330000, 500, 500, 500 ] # Bullet \ No newline at end of file diff --git a/data/shop/9110101.yaml b/data/shop/9110101.yaml new file mode 100644 index 00000000..9cbd6522 --- /dev/null +++ b/data/shop/9110101.yaml @@ -0,0 +1,15 @@ +# Teyandei : Armour Seller (9110101) - Zipangu : Outside Ninja Castle (800040000) + +recharge: true +items: + - [ 1002136, 100000 ] # Dark Pole-Feather Hat + - [ 1032002, 110000 ] # Sapphire Earrings + - [ 1040029, 110000 ] # Blue Dragon + - [ 1051006, 115000 ] # Dark Avenger + - [ 1060020, 110000 ] # White Martial Arts Shorts + - [ 1072051, 25000 ] # Silver War Boots + - [ 1072034, 25000 ] # Green Jack Boots + - [ 1072086, 25000 ] # Green Lappy Boots + - [ 1072020, 22000 ] # Purple Jewelry Boots + - [ 2070012, 100000 ] # Paper Fighter Plane + - [ 2070013, 100000 ] # Orange \ No newline at end of file diff --git a/data/shop/9110102.yaml b/data/shop/9110102.yaml new file mode 100644 index 00000000..fc80207b --- /dev/null +++ b/data/shop/9110102.yaml @@ -0,0 +1,14 @@ +# Ishirasu : Merchant (9110102) - Zipangu : Castle Corridor (800040209) + +recharge: true +items: + - [ 2060000, 1, 1, 2000 ] # Arrow for Bow + - [ 2000001, 160, 1, 100 ] # Orange Potion + - [ 2000002, 320, 1, 100 ] # White Potion + - [ 2001001, 2300, 1, 100 ] # Ice Cream Pop + - [ 2020012, 4500, 1, 100 ] # Melting Cheese + - [ 2000003, 200, 1, 100 ] # Blue Potion + - [ 2001002, 4000, 1, 100 ] # Red Bean Sundae + - [ 2020014, 7695, 1, 100 ] # Sunrise Dew + - [ 2022002, 1000, 1, 100 ] # Cider + - [ 2070000, 500, 500, 500 ] # Subi Throwing-Stars \ No newline at end of file diff --git a/data/shop/9201099.yaml b/data/shop/9201099.yaml new file mode 100644 index 00000000..2ea84483 --- /dev/null +++ b/data/shop/9201099.yaml @@ -0,0 +1,22 @@ +# Mo : Merchant (9201099) - Phantom Forest : Dead Man's Gorge + +recharge: true +items: + - [ 2050004, 400, 1, 100 ] # All Cure Potion + - [ 2050000, 200, 1, 100 ] # Antidote + - [ 2020012, 4500, 1, 100 ] # Melting Cheese + - [ 2020013, 5000, 1, 100 ] # Reindeer Milk + - [ 2020014, 8100, 1, 100 ] # Sunrise Dew + - [ 2020015, 9690, 1, 100 ] # Sunset Dew + - [ 2050001, 200, 1, 100 ] # Eyedrop + - [ 2050002, 300, 1, 100 ] # Tonic + - [ 2050003, 500, 1, 100 ] # Holy Water + - [ 2022000, 1650, 1, 100 ] # Pure Water + - [ 2002017, 5000, 1, 100 ] # Warrior Elixir + - [ 2060004, 40000, 2000, 2000 ] # Diamond Arrow for Bow + - [ 2061004, 40000, 2000, 2000 ] # Diamond Arrow for Crossbow + - [ 2070010, 2000, 100, 100 ] # Icicle + - [ 2022003, 1100, 1, 100 ] # Unagi + - [ 2000006, 620, 1, 100 ] # Mana Elixir + - [ 2022002, 1000, 1, 100 ] # Cider + - [ 2030020, 400, 1, 100 ] # Return to New Leaf City Scroll \ No newline at end of file diff --git a/data/shop/9270019.yaml b/data/shop/9270019.yaml new file mode 100644 index 00000000..82488998 --- /dev/null +++ b/data/shop/9270019.yaml @@ -0,0 +1,19 @@ +# Chan : Weapon Seller (9270019) - Singapore : CBD (540000000) + +items: + - [ 1302008, 42500 ] # Gladius + - [ 1302068, 352500 ] # Onyx Blade + - [ 1312005, 42500 ] # Fireman's Axe + - [ 1322014, 42500 ] # War Hammer + - [ 1332009, 44500 ] # Cass + - [ 1332012, 42500 ] # Reef Claw + - [ 1372003, 40500 ] # Mithril Wand + - [ 1382002, 22500 ] # Wizard Staff + - [ 1402002, 152500 ] # Scimitar + - [ 1412006, 47500 ] # Blue Axe + - [ 1422001, 47500 ] # Mithril Maul + - [ 1432002, 62500 ] # Forked Spear + - [ 1442001, 62500 ] # Mithril Pole Arm + - [ 1452005, 152500 ] # Ryden + - [ 1462000, 32500 ] # Mountain Crossbow + - [ 1472001, 22500 ] # Steel Titans \ No newline at end of file diff --git a/data/shop/9270020.yaml b/data/shop/9270020.yaml new file mode 100644 index 00000000..63927fdf --- /dev/null +++ b/data/shop/9270020.yaml @@ -0,0 +1,90 @@ +# Hui Ting : Weapon Store (9270020) - Singapore : CBD (540000000) + +recharge: true +items: + - [ 1002004, 160000 ] # Great Brown Helmet + - [ 1002135, 100000 ] # Brown Pole-Feather Hat + - [ 1002137, 100000 ] # Green Pole-Feather Hat + - [ 1002138, 100000 ] # Blue Pole-Feather Hat + - [ 1002139, 100000 ] # Red Pole-Feather Hat + - [ 1002141, 96000 ] # Red Matty + - [ 1002142, 96000 ] # Blue Matty + - [ 1002143, 96000 ] # Green Matty + - [ 1002144, 96000 ] # Brown Matty + - [ 1002176, 100000 ] # Red Burgler + - [ 1002177, 100000 ] # Blue Burgler + - [ 1002178, 100000 ] # Green Burgler + - [ 1002179, 100000 ] # Brown Burgler + - [ 1002180, 100000 ] # Dark Burgler + - [ 1002625, 75000 ] # Blue Den Marine + - [ 1040000, 200000 ] # Yellow Jangoon Armor + - [ 1040061, 114000 ] # Green Knucklevest + - [ 1040062, 114000 ] # Red Knucklevest + - [ 1040063, 114000 ] # Black Knucklevest + - [ 1040072, 114000 ] # Red Legolier + - [ 1040073, 114000 ] # Blue Legolier + - [ 1040074, 114000 ] # Green Legolier + - [ 1040076, 114000 ] # Brown Legolier + - [ 1040079, 180000 ] # Brown Piette + - [ 1040081, 180000 ] # White Piette + - [ 1040082, 180000 ] # Khaki Shadow + - [ 1040083, 180000 ] # Marine Shadow + - [ 1040085, 200000 ] # Maroon Jangoon Armor + - [ 1041051, 120000 ] # Red Amoria Top + - [ 1041052, 120000 ] # Blue Amoria Top + - [ 1041065, 114000 ] # Red Legolia + - [ 1041066, 114000 ] # Blue Legolia + - [ 1041067, 114000 ] # Green Legolia + - [ 1041069, 114000 ] # Brown Legolia + - [ 1041074, 180000 ] # Purple Shadow + - [ 1041075, 180000 ] # Red Shadow + - [ 1041081, 180000 ] # White Piettra + - [ 1041082, 180000 ] # Brown Piettra + - [ 1041084, 200000 ] # Red Jangoon Armor + - [ 1041085, 200000 ] # Brown Jangoon Armor + - [ 1050000, 112500 ] # White Crusader Chainmail + - [ 1050002, 300000 ] # Blood Chaos Robe + - [ 1050021, 112500 ] # Blue Crusader Chainmail + - [ 1050031, 300000 ] # White Chaos Robe + - [ 1050035, 450000 ] # Brown Starlight + - [ 1050036, 450000 ] # Red Starlight + - [ 1050037, 450000 ] # Green Starlight + - [ 1050038, 450000 ] # Blue Starlight + - [ 1051001, 112500 ] # Emerald Fitted Mail + - [ 1051007, 111000 ] # Red Avenger + - [ 1051008, 111000 ] # Blue Avenger + - [ 1051009, 111000 ] # Purple Avenger + - [ 1051014, 112500 ] # Sapphire Fitted Mail + - [ 1051023, 450000 ] # Purple Moonlight + - [ 1051024, 450000 ] # Red Moonlight + - [ 1051025, 450000 ] # Blue Moonlight + - [ 1051027, 450000 ] # Brown Moonlight + - [ 1052110, 100000 ] # Blue Brace Look + - [ 1052113, 120000 ] # Red Barbay + - [ 1060050, 108000 ] # Blue Knucklevest Pants + - [ 1060051, 108000 ] # Red Knucklevest Pants + - [ 1060052, 108000 ] # Black Knucklevest Pants + - [ 1060061, 108000 ] # Red Legolier Pants + - [ 1060062, 108000 ] # Blue Legolier Pants + - [ 1060063, 108000 ] # Green Legolier Pants + - [ 1060065, 108000 ] # Brown Legolier Pants + - [ 1060069, 160000 ] # Brown Piette Pants + - [ 1060070, 160000 ] # Blue Piette Pants + - [ 1060071, 160000 ] # Khaki Shadow Pants + - [ 1060072, 160000 ] # Marine Shadow Pants + - [ 1060074, 180000 ] # White Jangoon Pants + - [ 1060075, 180000 ] # Brown Jangoon Pants + - [ 1061047, 120000 ] # Red Amoria Skirt + - [ 1061048, 120000 ] # Blue Amoria Skirt + - [ 1061060, 108000 ] # Red Legolia Pants + - [ 1061061, 108000 ] # Blue Legolia Pants + - [ 1061062, 108000 ] # Green Legolia Pants + - [ 1061064, 108000 ] # Brown Legolia Pants + - [ 1061069, 160000 ] # Purple Shadow Pants + - [ 1061070, 160000 ] # Red Shadow Pants + - [ 1061080, 160000 ] # White Piettra Skirt + - [ 1061081, 160000 ] # Brown Piettra Skirt + - [ 1061083, 180000 ] # Red Jangoon Skirt + - [ 1061084, 180000 ] # Brown Jangoon Skirt + - [ 1092001, 100000 ] # Red Triangular Shield + - [ 1092002, 200000 ] # Red Cross Shield diff --git a/data/shop/9270021.yaml b/data/shop/9270021.yaml new file mode 100644 index 00000000..f77614e8 --- /dev/null +++ b/data/shop/9270021.yaml @@ -0,0 +1,34 @@ +# Wendy : Potion Seller (9270021) - Singapore : CBD (540000000) + +recharge: true +items: + - [ 2000000, 50, 1, 100 ] # Red Potion + - [ 2000001, 160, 1, 100 ] # Orange Potion + - [ 2000002, 320, 1, 100 ] # White Potion + - [ 2000003, 200, 1, 100 ] # Blue Potion + - [ 2000006, 620, 1, 100 ] # Mana Elixir + - [ 2001000, 3200, 1, 100 ] # Watermelon + - [ 2001001, 2300, 1, 100 ] # Ice Cream Pop + - [ 2001002, 4000, 1, 100 ] # Red Bean Sundae + - [ 2002000, 500, 1, 100 ] # Dexterity Potion + - [ 2002001, 400, 1, 100 ] # Speed Potion + - [ 2002002, 500, 1, 100 ] # Magic Potion + - [ 2002004, 500, 1, 100 ] # Warrior Potion + - [ 2002005, 500, 1, 100 ] # Sniper Potion + - [ 2010000, 30, 1, 100 ] # Apple + - [ 2010001, 106, 1, 100 ] # Meat + - [ 2010002, 50, 1, 100 ] # Egg + - [ 2010003, 100, 1, 100 ] # Orange + - [ 2010004, 310, 1, 100 ] # Lemon + - [ 2020028, 3000, 1, 100 ] # Chocolate + - [ 2022000, 1650, 1, 100 ] # Pure Water + - [ 2022003, 1100, 1, 100 ] # Unagi + - [ 2030000, 400, 1, 100 ] # Return Scroll - Nearest Town + - [ 2050000, 200, 1, 100 ] # Antidote + - [ 2050001, 200, 1, 100 ] # Eyedrop + - [ 2050002, 300, 1, 100 ] # Tonic + - [ 2050003, 500, 1, 100 ] # Holy Water + - [ 2060000, 1, 1, 2000 ] # Arrow for Bow + - [ 2061000, 1, 1, 2000 ] # Arrow for Crossbow + - [ 2070000, 500, 500, 500 ] # Subi Throwing-Stars + - [ 2330000, 500, 500, 500 ] # Bullet diff --git a/data/shop/9270022.yaml b/data/shop/9270022.yaml new file mode 100644 index 00000000..7141b51b --- /dev/null +++ b/data/shop/9270022.yaml @@ -0,0 +1,30 @@ +# Candy : Merchant (9270022) - Singapore : Boat Quay Town (541000000) + +recharge: true +items: + - [ 2000000, 50, 1, 100 ] # Red Potion + - [ 2000001, 160, 1, 100 ] # Orange Potion + - [ 2000002, 320, 1, 100 ] # White Potion + - [ 2000003, 200, 1, 100 ] # Blue Potion + - [ 2000006, 620, 1, 100 ] # Mana Elixir + - [ 2002000, 500, 1, 100 ] # Dexterity Potion + - [ 2002001, 400, 1, 100 ] # Speed Potion + - [ 2002002, 500, 1, 100 ] # Magic Potion + - [ 2002004, 500, 1, 100 ] # Warrior Potion + - [ 2002005, 500, 1, 100 ] # Sniper Potion + - [ 2010000, 30, 1, 100 ] # Apple + - [ 2010001, 106, 1, 100 ] # Meat + - [ 2010002, 50, 1, 100 ] # Egg + - [ 2010003, 100, 1, 100 ] # Orange + - [ 2010004, 310, 1, 100 ] # Lemon + - [ 2020028, 3000, 1, 100 ] # Chocolate + - [ 2030000, 400, 1, 100 ] # Return Scroll - Nearest Town + - [ 2030004, 500, 1, 100 ] # Return Scroll to Henesys + - [ 2050000, 200, 1, 100 ] # Antidote + - [ 2050001, 200, 1, 100 ] # Eyedrop + - [ 2050002, 300, 1, 100 ] # Tonic + - [ 2060000, 1, 1, 2000 ] # Arrow for Bow + - [ 2061000, 1, 1, 2000 ] # Arrow for Crossbow + - [ 2070000, 500, 500, 500 ] # Subi Throwing-Stars + - [ 2120008, 200, 1, 200 ] # Dry Treat + - [ 2330000, 500, 500, 500 ] # Bullet \ No newline at end of file diff --git a/data/shop/9270027.yaml b/data/shop/9270027.yaml new file mode 100644 index 00000000..8ae1ca34 --- /dev/null +++ b/data/shop/9270027.yaml @@ -0,0 +1,14 @@ +# Alwyn : Local Specialities (9270027) - Singapore : CBD (540000000) + +items: + - [ 2022203, 800, 1, 100 ] # Laksa + - [ 2022204, 1200, 1, 100 ] # Hokkien Mee + - [ 2022205, 1800, 1, 100 ] # Carrot Cake + - [ 2022206, 2200, 1, 100 ] # Chicken Rice + - [ 2022207, 2600, 1, 100 ] # Satay + - [ 2022208, 1000, 1, 100 ] # Guava + - [ 2022209, 1600, 1, 100 ] # Rambutan + - [ 2022210, 3200, 1, 100 ] # Dragon Fruit + - [ 2022211, 6400, 1, 100 ] # Durian + - [ 2022214, 3200, 1, 100 ] # Pepper Crab + - [ 2022215, 6800, 1, 100 ] # Chili Crab \ No newline at end of file diff --git a/data/shop/9270055.yaml b/data/shop/9270055.yaml new file mode 100644 index 00000000..42df463e --- /dev/null +++ b/data/shop/9270055.yaml @@ -0,0 +1,53 @@ +# Chiang : Weapon Seller (9270055) - Malaysia : Trend Zone Metropolis (550000000) + +items: + - [ 1302008, 40000 ] # Gladius + - [ 1302004, 100000 ] # Cutlus + - [ 1302009, 225000 ] # Traus + - [ 1312005, 40000 ] # Fireman's Axe + - [ 1312006, 100000 ] # Dankke + - [ 1312007, 175000 ] # Blue Counter + - [ 1322014, 40000 ] # War Hammer + - [ 1322015, 100000 ] # Heavy Hammer + - [ 1322016, 175000 ] # Jacker + - [ 1332009, 42000 ] # Cass + - [ 1332012, 40000 ] # Reef Claw + - [ 1332001, 200000 ] # Halfmoon Zamadar + - [ 1332014, 375000 ] # Gephart + - [ 1332011, 425000 ] # Bazlud + - [ 1372003, 38000 ] # Mithril Wand + - [ 1372001, 175000 ] # Wizard Wand + - [ 1372000, 400000 ] # Fairy Wand + - [ 1382002, 20000 ] # Wizard Staff + - [ 1402002, 150000 ] # Scimitar + - [ 1402006, 350000 ] # Lionheart + - [ 1402007, 450000 ] # Zard + - [ 1412006, 45000 ] # Blue Axe + - [ 1412004, 200000 ] # Niam + - [ 1412005, 250000 ] # Sabretooth + - [ 1422001, 45000 ] # Mithril Maul + - [ 1422008, 200000 ] # Sledgehammer + - [ 1422007, 250000 ] # Titan + - [ 1432002, 60000 ] # Forked Spear + - [ 1432003, 175000 ] # Nakamaki + - [ 1432005, 225000 ] # Zeco + - [ 1442001, 60000 ] # Mithril Pole Arm + - [ 1442003, 175000 ] # Axe Pole Arm + - [ 1442009, 300000 ] # Crescent Polearm + - [ 1452005, 150000 ] # Ryden + - [ 1452006, 250000 ] # Red Viper + - [ 1452007, 375000 ] # Vaulter 2000 + - [ 1462000, 30000 ] # Mountain Crossbow + - [ 1462004, 200000 ] # Eagle Crow + - [ 1462005, 250000 ] # Heckler + - [ 1472001, 20000 ] # Steel Titans + - [ 1472004, 30000 ] # Bronze Igor + - [ 1472007, 60000 ] # Meba + - [ 1482003, 40000 ] # Norman Grip + - [ 1482004, 75000 ] # Prime Hands + - [ 1482005, 100000 ] # Silver Maiden + - [ 1482006, 150000 ] # Neozard + - [ 1492003, 22000 ] # Golden Hook + - [ 1492004, 75000 ] # Cold Mind + - [ 1492005, 100000 ] # Shooting Star + - [ 1492006, 160000 ] # Lunar Shooter \ No newline at end of file diff --git a/data/shop/9270056.yaml b/data/shop/9270056.yaml new file mode 100644 index 00000000..005b1f61 --- /dev/null +++ b/data/shop/9270056.yaml @@ -0,0 +1,86 @@ +# Riduan : Armour Seller (9270056) - Malaysia : Trend Zone Metropolis (550000000) + +items: + - [ 1002004, 160000 ] # Great Brown Helmet + - [ 1041084, 200000 ] # Yellow Jangoon Armor + - [ 1041085, 200000 ] # Maroon Jangoon Armor + - [ 1041084, 200000 ] # Red Jangoon Armor + - [ 1041085, 200000 ] # Brown Jangoon Armor + - [ 1051001, 112500 ] # White Crusader Chainmail + - [ 1051006, 112500 ] # Blue Crusader Chainmail + - [ 1051001, 112500 ] # Emerald Fitted Mail + - [ 1051006, 112500 ] # Sapphire Fitted Mail + - [ 1061083, 180000 ] # Brown Jangoon Pants + - [ 1060074, 180000 ] # White Jangoon Pants + - [ 1061083, 180000 ] # Red Jangoon Skirt + - [ 1061083, 180000 ] # Brown Jangoon Skirt + - [ 1092001, 100000 ] # Red Triangular Shield + - [ 1092002, 200000 ] # Red Cross Shield + - [ 1002141, 96000 ] # Red Matty + - [ 1002142, 96000 ] # Blue Matty + - [ 1002143, 96000 ] # Green Matty + - [ 1002144, 96000 ] # Brown Matty + - [ 1041052, 120000 ] # Blue Amoria Top + - [ 1041051, 120000 ] # Red Amoria Top + - [ 1050031, 300000 ] # White Chaos Robe + - [ 1050002, 300000 ] # Blood Chaos Robe + - [ 1050039, 450000 ] # Brown Starlight + - [ 1050039, 450000 ] # Blue Starlight + - [ 1050039, 450000 ] # Red Starlight + - [ 1050039, 450000 ] # Green Starlight + - [ 1051026, 450000 ] # Purple Moonlight + - [ 1051025, 450000 ] # Blue Moonlight + - [ 1051024, 450000 ] # Red Moonlight + - [ 1051027, 450000 ] # Brown Moonlight + - [ 1061048, 120000 ] # Blue Amoria Skirt + - [ 1061047, 120000 ] # Red Amoria Skirt + - [ 1002138, 100000 ] # Blue Pole-Feather Hat + - [ 1002139, 100000 ] # Red Pole-Feather Hat + - [ 1002135, 100000 ] # Brown Pole-Feather Hat + - [ 1002137, 100000 ] # Green Pole-Feather Hat + - [ 1040072, 114000 ] # Red Legolier + - [ 1060062, 114000 ] # Blue Legolier + - [ 1060063, 114000 ] # Green Legolier + - [ 1040074, 114000 ] # Brown Legolier + - [ 1040081, 180000 ] # White Piette + - [ 1040079, 180000 ] # Brown Piette + - [ 1041067, 114000 ] # Blue Legolia + - [ 1041069, 114000 ] # Brown Legolia + - [ 1041065, 114000 ] # Red Legolia + - [ 1041067, 114000 ] # Green Legolia + - [ 1041082, 180000 ] # Brown Piettra + - [ 1041081, 180000 ] # White Piettra + - [ 1061062, 108000 ] # Blue Legolier Pants + - [ 1060065, 108000 ] # Brown Legolier Pants + - [ 1061062, 108000 ] # Red Legolier Pants + - [ 1061062, 108000 ] # Green Legolier Pants + - [ 1060070, 160000 ] # Blue Piette Pants + - [ 1060069, 160000 ] # Brown Piette Pants + - [ 1061061, 108000 ] # Blue Legolia Pants + - [ 1061061, 108000 ] # Brown Legolia Pants + - [ 1061061, 108000 ] # Red Legolia Pants + - [ 1061061, 108000 ] # Green Legolia Pants + - [ 1061081, 160000 ] # Brown Piettra Skirt + - [ 1061080, 160000 ] # White Piettra Skirt + - [ 1002176, 100000 ] # Red Burgler + - [ 1002179, 100000 ] # Blue Burgler + - [ 1002178, 100000 ] # Green Burgler + - [ 1002179, 100000 ] # Brown Burgler + - [ 1002180, 100000 ] # Dark Burgler + - [ 1040061, 114000 ] # Black Knucklevest + - [ 1040062, 114000 ] # Red Knucklevest + - [ 1040061, 114000 ] # Green Knucklevest + - [ 1040082, 180000 ] # Khaki Shadow + - [ 1040083, 180000 ] # Marine Shadow + - [ 1041075, 180000 ] # Red Shadow + - [ 1041074, 180000 ] # Purple Shadow + - [ 1051007, 111000 ] # Red Avenger + - [ 1051008, 111000 ] # Blue Avenger + - [ 1051009, 111000 ] # Purple Avenger + - [ 1060050, 108000 ] # Red Knucklevest Pants + - [ 1060050, 108000 ] # Blue Knucklevest Pants + - [ 1060050, 108000 ] # Black Knucklevest Pants + - [ 1060071, 160000 ] # Khaki Shadow Pants + - [ 1060072, 160000 ] # Marine Shadow Pants + - [ 1061070, 160000 ] # Red Shadow Pants + - [ 1061069, 160000 ] # Purple Shadow Pants \ No newline at end of file diff --git a/data/shop/9270057.yaml b/data/shop/9270057.yaml new file mode 100644 index 00000000..51857732 --- /dev/null +++ b/data/shop/9270057.yaml @@ -0,0 +1,38 @@ +# Kok Hua : Potion Seller (9270057) - Malaysia : Trend Zone Metropolis (550000000) + +# Very Special Sundae - didn't add check + +items: + - [ 2000000, 50, 1, 100 ] # Red Potion + - [ 2000001, 160, 1, 100 ] # Orange Potion + - [ 2000002, 320, 1, 100 ] # White Potion + - [ 2000003, 200, 1, 100 ] # Blue Potion + - [ 2000006, 620, 1, 100 ] # Mana Elixir + - [ 2000007, 50, 1, 150 ] # Red Pill + - [ 2000008, 160, 1, 150 ] # Orange Pill + - [ 2000009, 320, 1, 150 ] # White Pill + - [ 2000010, 200, 1, 150 ] # Blue Pill + - [ 2000011, 620, 1, 150 ] # Mana Elixir Pill + - [ 2001001, 2300, 1, 100 ] # Ice Cream Pop + - [ 2001002, 4000, 1, 100 ] # Red Bean Sundae + - [ 2002006, 600, 1, 100 ] # Warrior Pill + - [ 2002007, 600, 1, 100 ] # Magic Pill + - [ 2002008, 600, 1, 100 ] # Sniper Pill + - [ 2002009, 600, 1, 100 ] # Dexterity Pill + - [ 2002010, 500, 1, 100 ] # Speed Pill + - [ 2020012, 4680, 1, 100 ] # Melting Cheese + - [ 2020013, 5824, 1, 100 ] # Reindeer Milk + - [ 2020014, 8100, 1, 100 ] # Sunrise Dew + - [ 2020015, 10200, 1, 100 ] # Sunset Dew + - [ 2022000, 1650, 1, 100 ] # Pure Water + - [ 2022003, 1100, 1, 100 ] # Unagi + - [ 2030000, 400, 1, 100 ] # Return Scroll - Nearest Town + - [ 2050000, 200, 1, 100 ] # Antidote + - [ 2050001, 200, 1, 100 ] # Eyedrop + - [ 2050002, 300, 1, 100 ] # Tonic + - [ 2050003, 500, 1, 100 ] # Holy Water + - [ 2060000, 1, 1, 2000 ] # Arrow for Bow + - [ 2060001, 10, 1, 1800 ] # Bronze Arrow for Bow + - [ 2061000, 1, 1, 2000 ] # Arrow for Crossbow + - [ 2061001, 10, 1, 1800 ] # Bronze Arrow for Crossbow + - [ 2022015, 12000 ] # Mushroom Miso Ramen \ No newline at end of file diff --git a/data/shop/9270065.yaml b/data/shop/9270065.yaml new file mode 100644 index 00000000..790585e0 --- /dev/null +++ b/data/shop/9270065.yaml @@ -0,0 +1,26 @@ +# Ali : Local Specialities (9270065) - Malaysia : Kampung Village (551000000) + +items: + - [ 2022476, 4200, 1, 100 ] # Chicken Kapitan + - [ 2022477, 9200, 1, 100 ] # Mee Siam + - [ 2022478, 3200, 1, 100 ] # Rojak + - [ 2022479, 3800, 1, 100 ] # Kangkung belacan + - [ 2022480, 12000, 1, 100 ] # Kuih + - [ 2022203, 800, 1, 100 ] # Laksa + - [ 2022204, 1200, 1, 100 ] # Hokkien Mee + - [ 2022205, 1800, 1, 100 ] # Carrot Cake + - [ 2022206, 2200, 1, 100 ] # Chicken Rice + - [ 2022207, 2600, 1, 100 ] # Satay + - [ 2022208, 1000, 1, 100 ] # Guava + - [ 2022209, 1600, 1, 100 ] # Rambutan + - [ 2022210, 3200, 1, 100 ] # Dragon Fruit + - [ 2022211, 6400, 1, 100 ] # Durian + - [ 2022214, 3200, 1, 100 ] # Pepper Crab + - [ 2022215, 6800, 1, 100 ] # Chili Crab + - [ 2020000, 200, 1, 100 ] # Antidote + - [ 2020001, 200, 1, 100 ] # Eyedrop + - [ 2020002, 300, 1, 100 ] # Tonic + - [ 2020003, 500, 1, 100 ] # Holy Water + - [ 2030000, 400, 1, 100 ] # Return Scroll - Nearest Town + - [ 2060000, 1, 1, 2000 ] # Arrow for Bow + - [ 2061000, 1, 1, 2000 ] # Arrow for Crossbow \ No newline at end of file diff --git a/data/shop/9991000.yaml b/data/shop/9991000.yaml new file mode 100644 index 00000000..383e712b --- /dev/null +++ b/data/shop/9991000.yaml @@ -0,0 +1,28 @@ +# Alpha VIP Seller (9991000) + +items: + - [ 4322000, 100000, 1, 1 ] # Fast Travel Ticket + - [ 2340000, 9999999, 1, 1 ] # White Scroll + - [ 5072000, 2000000, 1, 1 ] # Super Megaphone + - [ 5073000, 1000000, 1, 1 ] # Heart Megaphone + - [ 5074000, 1000000, 1, 1 ] # Skull Megaphone + - [ 5076000, 2200000, 1, 1 ] # Item Megaphone + - [ 5077000, 2200000, 1, 1 ] # Triple Megaphone + - [ 1302147, 600000, 1, 1 ] # VIP One-Handed Sword + - [ 1312062, 600000, 1, 1 ] # VIP One-Handed Axe + - [ 1322090, 600000, 1, 1 ] # VIP One-Handed BW + - [ 1332120, 600000, 1, 1 ] # VIP Dagger (LUK) + - [ 1332125, 600000, 1, 1 ] # VIP Dagger (STR) + - [ 1342033, 600000, 1, 1 ] # VIP Katara + - [ 1372078, 600000, 1, 1 ] # VIP Wand + - [ 1382099, 600000, 1, 1 ] # VIP Staff + - [ 1402090, 600000, 1, 1 ] # VIP Two-handed Sword + - [ 1412062, 600000, 1, 1 ] # VIP Two-handed Axe + - [ 1422063, 600000, 1, 1 ] # VIP Two-handed BW + - [ 1432081, 600000, 1, 1 ] # VIP Spear + - [ 1442111, 600000, 1, 1 ] # VIP Polearm + - [ 1452106, 600000, 1, 1 ] # VIP Bow + - [ 1462091, 600000, 1, 1 ] # VIP Crossbow + - [ 1472117, 600000, 1, 1 ] # VIP Claw + - [ 1482079, 600000, 1, 1 ] # VIP Knuckle + - [ 1492079, 600000, 1, 1 ] # VIP Gun \ No newline at end of file diff --git a/data/shop/9991001.yaml b/data/shop/9991001.yaml new file mode 100644 index 00000000..33c0aed5 --- /dev/null +++ b/data/shop/9991001.yaml @@ -0,0 +1,18 @@ +# Alpha Timeless Seller (9991001) + +items: + - [ 1332073, 600000, 1, 1 ] # Timeless Pescas + - [ 1332074, 600000, 1, 1 ] # Timeless Killic + - [ 1342011, 600000, 1, 1 ] # Timeless Katara + - [ 1372044, 600000, 1, 1 ] # Timeless Enreal Tear + - [ 1382057, 600000, 1, 1 ] # Timeless Aeas Hand + - [ 1402046, 600000, 1, 1 ] # Timeless Nibleheim + - [ 1412033, 600000, 1, 1 ] # Timeless Tabarzin + - [ 1422037, 600000, 1, 1 ] # Timeless Bellocce + - [ 1432047, 600000, 1, 1 ] # Timeless Alchupiz + - [ 1442063, 600000, 1, 1 ] # Timeless Diesra + - [ 1452057, 600000, 1, 1 ] # Timeless Engaw + - [ 1462050, 600000, 1, 1 ] # Timeless Black Beauty + - [ 1472068, 600000, 1, 1 ] # Timeless Rampion + - [ 1482023, 600000, 1, 1 ] # Timeless Equinox + - [ 1492023, 600000, 1, 1 ] # Timeless Blindness \ No newline at end of file From 94377a4c4428b59d12d3c13278a21d21943a5a36 Mon Sep 17 00:00:00 2001 From: Khuwanko Date: Mon, 17 Nov 2025 13:49:15 +0400 Subject: [PATCH 70/83] Fix 5 critical security bugs - Fix itemSN duplication for PostgreSQL - Fix money duplication race condition - Fix trading room concurrency issues - Add database sequence error checking - Implement shop transaction rollback --- .../database/postgresql/type/ItemDao.java | 7 +++- .../server/dialog/miniroom/PersonalShop.java | 33 ++++++++++++++++--- .../server/dialog/miniroom/TradingRoom.java | 27 ++++++++------- .../kinoko/world/item/InventoryManager.java | 4 +-- .../java/kinoko/world/user/CharacterData.java | 9 +++-- 5 files changed, 58 insertions(+), 22 deletions(-) diff --git a/src/main/java/kinoko/database/postgresql/type/ItemDao.java b/src/main/java/kinoko/database/postgresql/type/ItemDao.java index f596c452..765455f8 100644 --- a/src/main/java/kinoko/database/postgresql/type/ItemDao.java +++ b/src/main/java/kinoko/database/postgresql/type/ItemDao.java @@ -90,8 +90,13 @@ public static void saveItemsBatch(Connection conn, Collection items) throw try (PreparedStatement seqStmt = conn.prepareStatement( "SELECT nextval(pg_get_serial_sequence('item.items', 'item_sn'))"); ResultSet rs = seqStmt.executeQuery()) { - rs.next(); + if (!rs.next()) { + throw new SQLException("Failed to generate item_sn: sequence query returned no results"); + } itemSn = rs.getLong(1); + if (itemSn <= 0) { + throw new SQLException("Generated invalid item_sn: " + itemSn); + } item.setItemSn(itemSn); } diff --git a/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java b/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java index ef416a88..0a219126 100644 --- a/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java +++ b/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java @@ -155,21 +155,44 @@ public void handlePacket(User user, MiniRoomProtocol mrp, InPacket inPacket) { user.dispose(); return; } - // Do transaction - item.getItem().setQuantity((short) (item.getItem().getQuantity() - totalCount)); + // Do transaction (ACID-compliant order: validate, execute reversible ops, commit) + // Save original quantity for rollback + final int originalQuantity = item.getItem().getQuantity(); + + // Create buy item (no state modification yet) final Item buyItem = new Item(item.getItem()); buyItem.setItemSn(owner.getNextItemSn()); buyItem.setQuantity((short) totalCount); + + // Step 1: Deduct buyer's money (reversible) if (!im.addMoney((int) -totalPrice)) { - throw new IllegalStateException("Could not deduct total price from user"); + user.write(MiniRoomPacket.PlayerShop.buyResult(PlayerShopBuyResult.NoMoney)); + user.dispose(); + return; } + + // Step 2: Add item to buyer (reversible) final Optional> addItemResult = im.addItem(buyItem); if (addItemResult.isEmpty()) { - throw new IllegalStateException("Could not add bought item to inventory"); + // Rollback: Return money to buyer + im.addMoney((int) totalPrice); + user.write(MiniRoomPacket.PlayerShop.buyResult(PlayerShopBuyResult.NoSlot)); + user.dispose(); + return; } + + // Step 3: Add money to seller (reversible) if (!owner.getInventoryManager().addMoney(moneyForOwner)) { - throw new IllegalStateException("Could not add money to personal shop owner"); + // Rollback: Remove item from buyer, return money + im.removeItem(buyItem.getItemId(), totalCount); + im.addMoney((int) totalPrice); + user.write(MiniRoomPacket.PlayerShop.buyResult(PlayerShopBuyResult.OverPrice)); + user.dispose(); + return; } + + // Step 4: FINALLY modify shop quantity (commit point - no rollback after this) + item.getItem().setQuantity((short) (originalQuantity - totalCount)); // Update clients user.write(WvsContext.statChanged(Stat.MONEY, im.getMoney(), false)); user.write(WvsContext.inventoryOperation(addItemResult.get(), true)); diff --git a/src/main/java/kinoko/server/dialog/miniroom/TradingRoom.java b/src/main/java/kinoko/server/dialog/miniroom/TradingRoom.java index 0bfe8e81..7dff5715 100644 --- a/src/main/java/kinoko/server/dialog/miniroom/TradingRoom.java +++ b/src/main/java/kinoko/server/dialog/miniroom/TradingRoom.java @@ -13,12 +13,13 @@ import kinoko.world.user.stat.Stat; import java.util.*; +import java.util.concurrent.ConcurrentHashMap; import java.util.stream.Collectors; public final class TradingRoom extends MiniRoom { - private final Map> items = new HashMap<>(); // user -> slot, items - private final Map money = new HashMap<>(); // user -> offered money - private final Map confirm = new HashMap<>(); // user -> trade confirmation + private final Map> items = new ConcurrentHashMap<>(); // user -> slot, items + private final Map money = new ConcurrentHashMap<>(); // user -> offered money + private final Map confirm = new ConcurrentHashMap<>(); // user -> trade confirmation public TradingRoom() { @@ -73,15 +74,17 @@ public void handlePacket(User user, MiniRoomProtocol mrp, InPacket inPacket) { } case TRP_Trade -> { // CTradingRoomDlg::Trade - confirm.put(user, true); - // Update other - if (!confirm.getOrDefault(other, false)) { - other.write(MiniRoomPacket.TradingRoom.trade()); - return; - } - // Complete trade - if (!completeTrade(user)) { - cancelTrade(user, MiniRoomLeaveType.TradeFail); // Trade unsuccessful. + synchronized (confirm) { + confirm.put(user, true); + // Update other + if (!confirm.getOrDefault(other, false)) { + other.write(MiniRoomPacket.TradingRoom.trade()); + return; + } + // Complete trade + if (!completeTrade(user)) { + cancelTrade(user, MiniRoomLeaveType.TradeFail); // Trade unsuccessful. + } } } case TRP_ItemCRC -> { diff --git a/src/main/java/kinoko/world/item/InventoryManager.java b/src/main/java/kinoko/world/item/InventoryManager.java index 0dbf8a9f..31128752 100644 --- a/src/main/java/kinoko/world/item/InventoryManager.java +++ b/src/main/java/kinoko/world/item/InventoryManager.java @@ -125,12 +125,12 @@ public Inventory getInventoryByType(InventoryType inventoryType) { // HELPER METHODS ------------------------------------------------------------------------------------------------- - public boolean canAddMoney(int money) { + public synchronized boolean canAddMoney(int money) { final long newMoney = ((long) getMoney()) + money; return newMoney <= Integer.MAX_VALUE && newMoney >= 0; } - public boolean addMoney(int money) { + public synchronized boolean addMoney(int money) { final long newMoney = ((long) getMoney()) + money; if (newMoney > Integer.MAX_VALUE || newMoney < 0) { return false; diff --git a/src/main/java/kinoko/world/user/CharacterData.java b/src/main/java/kinoko/world/user/CharacterData.java index 2b20e5c3..10bf7b57 100644 --- a/src/main/java/kinoko/world/user/CharacterData.java +++ b/src/main/java/kinoko/world/user/CharacterData.java @@ -181,8 +181,13 @@ public void setMaxLevelTime(Instant maxLevelTime) { public long getNextItemSn() { if (DatabaseManager.isRelational()) { - // Let the relational database handle SN generation; return placeholder - return -1; + // Generate temporary unique SN for PostgreSQL using timestamp + random bits + // This prevents in-memory collisions before database save + // Database will replace with actual sequence value during save + long timestamp = System.currentTimeMillis(); + long random = (long) (Math.random() * 1048576); // 20 bits of randomness + long counter = itemSnCounter.getAndIncrement() & 0x3; // 2 bits of counter + return -(timestamp << 22 | random << 2 | counter); // Negative to distinguish from real SNs } return ((long) itemSnCounter.getAndIncrement()) | (((long) getCharacterId()) << 32); From a667a88f97a0b02ce3f687e018349f4bd23171a6 Mon Sep 17 00:00:00 2001 From: Khuwanko Date: Tue, 18 Nov 2025 00:23:50 +0400 Subject: [PATCH 71/83] Revert back --- src/main/java/kinoko/world/user/CharacterData.java | 11 +++-------- 1 file changed, 3 insertions(+), 8 deletions(-) diff --git a/src/main/java/kinoko/world/user/CharacterData.java b/src/main/java/kinoko/world/user/CharacterData.java index 10bf7b57..ef1dfc56 100644 --- a/src/main/java/kinoko/world/user/CharacterData.java +++ b/src/main/java/kinoko/world/user/CharacterData.java @@ -181,13 +181,8 @@ public void setMaxLevelTime(Instant maxLevelTime) { public long getNextItemSn() { if (DatabaseManager.isRelational()) { - // Generate temporary unique SN for PostgreSQL using timestamp + random bits - // This prevents in-memory collisions before database save - // Database will replace with actual sequence value during save - long timestamp = System.currentTimeMillis(); - long random = (long) (Math.random() * 1048576); // 20 bits of randomness - long counter = itemSnCounter.getAndIncrement() & 0x3; // 2 bits of counter - return -(timestamp << 22 | random << 2 | counter); // Negative to distinguish from real SNs + // Let the relational database handle SN generation; return placeholder + return -1; } return ((long) itemSnCounter.getAndIncrement()) | (((long) getCharacterId()) << 32); @@ -395,4 +390,4 @@ public void encodeCharacterData(DBChar flag, OutPacket outPacket) { public void encode(OutPacket outPacket) { encodeCharacterData(DBChar.ALL, outPacket); } -} +} \ No newline at end of file From 23dd0fbd03f8e1fadafa67e7fbb572f5c6d358ad Mon Sep 17 00:00:00 2001 From: MujyKun Date: Wed, 19 Nov 2025 17:24:32 -0500 Subject: [PATCH 72/83] Snapshot before removing familytree lock --- .../kinoko/database/DatabaseConnector.java | 2 + .../java/kinoko/database/DatabaseManager.java | 4 + .../java/kinoko/database/FamilyAccessor.java | 19 + .../cassandra/CassandraConnector.java | 8 + .../cassandra/CassandraFamilyAccessor.java | 21 + .../postgresql/PostgresConnector.java | 3 + .../postgresql/PostgresFamilyAccessor.java | 29 + .../database/postgresql/setup/updates/2.sql | 56 ++ .../postgresql/type/FamilyMemberDao.java | 105 ++++ .../postgresql/type/FamilyTreeDao.java | 76 +++ .../kinoko/handler/stage/LoginHandler.java | 2 +- .../handler/stage/MigrationHandler.java | 3 + .../kinoko/handler/user/FamilyHandler.java | 383 ++++++++++++ .../java/kinoko/handler/user/UserHandler.java | 2 +- .../kinoko/packet/world/FamilyPacket.java | 152 +++++ src/main/java/kinoko/server/Server.java | 6 + .../java/kinoko/server/family/Family.java | 175 ++++++ .../kinoko/server/family/FamilyRequest.java | 133 +++++ .../server/family/FamilyRequestType.java | 31 + .../server/family/FamilyResultType.java | 52 ++ .../kinoko/server/family/FamilyStorage.java | 108 ++++ .../java/kinoko/server/family/FamilyTree.java | 556 ++++++++++++++++++ .../server/netty/ChannelPacketHandler.java | 1 + .../kinoko/server/node/CentralServerNode.java | 78 +++ .../java/kinoko/server/packet/OutPacket.java | 4 + .../java/kinoko/world/user/FamilyMember.java | 177 ++++++ src/main/java/kinoko/world/user/User.java | 12 + 27 files changed, 2196 insertions(+), 2 deletions(-) create mode 100644 src/main/java/kinoko/database/FamilyAccessor.java create mode 100644 src/main/java/kinoko/database/cassandra/CassandraFamilyAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/PostgresFamilyAccessor.java create mode 100644 src/main/java/kinoko/database/postgresql/setup/updates/2.sql create mode 100644 src/main/java/kinoko/database/postgresql/type/FamilyMemberDao.java create mode 100644 src/main/java/kinoko/database/postgresql/type/FamilyTreeDao.java create mode 100644 src/main/java/kinoko/handler/user/FamilyHandler.java create mode 100644 src/main/java/kinoko/packet/world/FamilyPacket.java create mode 100644 src/main/java/kinoko/server/family/Family.java create mode 100644 src/main/java/kinoko/server/family/FamilyRequest.java create mode 100644 src/main/java/kinoko/server/family/FamilyRequestType.java create mode 100644 src/main/java/kinoko/server/family/FamilyResultType.java create mode 100644 src/main/java/kinoko/server/family/FamilyStorage.java create mode 100644 src/main/java/kinoko/server/family/FamilyTree.java create mode 100644 src/main/java/kinoko/world/user/FamilyMember.java diff --git a/src/main/java/kinoko/database/DatabaseConnector.java b/src/main/java/kinoko/database/DatabaseConnector.java index 35555959..9eaee193 100644 --- a/src/main/java/kinoko/database/DatabaseConnector.java +++ b/src/main/java/kinoko/database/DatabaseConnector.java @@ -17,6 +17,8 @@ public interface DatabaseConnector { ItemAccessor getItemAccessor(); + FamilyAccessor getFamilyAccessor(); + void initialize(); void shutdown(); diff --git a/src/main/java/kinoko/database/DatabaseManager.java b/src/main/java/kinoko/database/DatabaseManager.java index 9d8ff108..86e08f64 100644 --- a/src/main/java/kinoko/database/DatabaseManager.java +++ b/src/main/java/kinoko/database/DatabaseManager.java @@ -40,6 +40,10 @@ public static MemoAccessor memoAccessor() { return connector.getMemoAccessor(); } + public static FamilyAccessor familyAccessor() { + return connector.getFamilyAccessor(); + } + public static boolean isRelational() { // Get whether the database connection is a relational database. diff --git a/src/main/java/kinoko/database/FamilyAccessor.java b/src/main/java/kinoko/database/FamilyAccessor.java new file mode 100644 index 00000000..e14a9bb6 --- /dev/null +++ b/src/main/java/kinoko/database/FamilyAccessor.java @@ -0,0 +1,19 @@ +package kinoko.database; + +import kinoko.server.family.FamilyTree; +import kinoko.server.guild.Guild; +import kinoko.server.guild.GuildRanking; + +import java.util.Collection; +import java.util.List; +import java.util.Optional; + +public interface FamilyAccessor { + default Collection getAllFamilies(){ + throw new UnsupportedOperationException("This database must implement getting families"); + }; + + default void saveAll(Collection families){ + + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/database/cassandra/CassandraConnector.java b/src/main/java/kinoko/database/cassandra/CassandraConnector.java index 8aec501a..0d59329d 100644 --- a/src/main/java/kinoko/database/cassandra/CassandraConnector.java +++ b/src/main/java/kinoko/database/cassandra/CassandraConnector.java @@ -50,6 +50,7 @@ public final class CassandraConnector implements DatabaseConnector { private GiftAccessor giftAccessor; private MemoAccessor memoAccessor; private ItemAccessor itemAccessor; + private FamilyAccessor familyAccessor; public boolean createKeyspace(CqlSession session, String keyspace) { @@ -119,6 +120,12 @@ public ItemAccessor getItemAccessor() { return itemAccessor; } + @Override + public FamilyAccessor getFamilyAccessor() { + return familyAccessor; + } + + @Override public void initialize() { // Create Config @@ -197,6 +204,7 @@ public void initialize() { guildAccessor = new CassandraGuildAccessor(cqlSession, DATABASE_KEYSPACE); giftAccessor = new CassandraGiftAccessor(cqlSession, DATABASE_KEYSPACE); memoAccessor = new CassandraMemoAccessor(cqlSession, DATABASE_KEYSPACE); + familyAccessor = new CassandraFamilyAccessor(cqlSession, DATABASE_KEYSPACE); itemAccessor = new ItemAccessor() {}; // Not needed for Cassandra. } diff --git a/src/main/java/kinoko/database/cassandra/CassandraFamilyAccessor.java b/src/main/java/kinoko/database/cassandra/CassandraFamilyAccessor.java new file mode 100644 index 00000000..5c52a514 --- /dev/null +++ b/src/main/java/kinoko/database/cassandra/CassandraFamilyAccessor.java @@ -0,0 +1,21 @@ +package kinoko.database.cassandra; + +import com.datastax.oss.driver.api.core.CqlSession; +import com.datastax.oss.driver.api.core.cql.ResultSet; +import com.datastax.oss.driver.api.core.cql.Row; +import kinoko.database.FamilyAccessor; +import kinoko.database.MemoAccessor; +import kinoko.database.cassandra.table.MemoTable; +import kinoko.server.memo.Memo; +import kinoko.server.memo.MemoType; + +import java.util.ArrayList; +import java.util.List; + +import static com.datastax.oss.driver.api.querybuilder.QueryBuilder.*; + +public final class CassandraFamilyAccessor extends CassandraAccessor implements FamilyAccessor { + public CassandraFamilyAccessor(CqlSession session, String keyspace) { + super(session, keyspace); + } +} diff --git a/src/main/java/kinoko/database/postgresql/PostgresConnector.java b/src/main/java/kinoko/database/postgresql/PostgresConnector.java index a735860c..14b955ec 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresConnector.java +++ b/src/main/java/kinoko/database/postgresql/PostgresConnector.java @@ -19,6 +19,7 @@ public final class PostgresConnector implements DatabaseConnector { private GiftAccessor giftAccessor; private MemoAccessor memoAccessor; private ItemAccessor itemAccessor; + private FamilyAccessor familyAccessor; @Override public void initialize() { @@ -64,6 +65,7 @@ public void initialize() { giftAccessor = new PostgresGiftAccessor(dataSource); memoAccessor = new PostgresMemoAccessor(dataSource); itemAccessor = new PostgresItemAccessor(dataSource); + familyAccessor = new PostgresFamilyAccessor(dataSource); @@ -89,4 +91,5 @@ public void shutdown() { @Override public GiftAccessor getGiftAccessor() { return giftAccessor; } @Override public MemoAccessor getMemoAccessor() { return memoAccessor; } @Override public ItemAccessor getItemAccessor() {return itemAccessor; } + @Override public FamilyAccessor getFamilyAccessor() {return familyAccessor; } } diff --git a/src/main/java/kinoko/database/postgresql/PostgresFamilyAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresFamilyAccessor.java new file mode 100644 index 00000000..d7ebca55 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/PostgresFamilyAccessor.java @@ -0,0 +1,29 @@ +package kinoko.database.postgresql; + +import com.zaxxer.hikari.HikariDataSource; +import kinoko.database.FamilyAccessor; +import kinoko.database.postgresql.type.FamilyTreeDao; +import kinoko.server.family.FamilyTree; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.Collection; +import java.util.Collections; + +public final class PostgresFamilyAccessor extends PostgresAccessor implements FamilyAccessor { + + public PostgresFamilyAccessor(HikariDataSource dataSource) { + super(dataSource); + } + + + @Override + public Collection getAllFamilies(){ + try (Connection conn = getConnection()) { + return FamilyTreeDao.getAllFamilies(conn); + } catch (SQLException e) { + e.printStackTrace(); + return Collections.emptySet(); + } + }; +} diff --git a/src/main/java/kinoko/database/postgresql/setup/updates/2.sql b/src/main/java/kinoko/database/postgresql/setup/updates/2.sql new file mode 100644 index 00000000..db3aff6d --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/setup/updates/2.sql @@ -0,0 +1,56 @@ +-- 2.sql +-- Contains the schema and initial data setup for the Family system. +-- This file defines all tables, indexes, defaults, and relationships +-- required for Family functionality within the server. + + +BEGIN; + +CREATE TABLE player.family ( + character_id INT PRIMARY KEY + REFERENCES player.characters(id) + ON DELETE CASCADE + ON UPDATE CASCADE, + + -- The 'parent' or 'senior' of this character. NULL if this character is a root/leader. + parent_id INT REFERENCES player.characters(id) + ON DELETE SET NULL -- If a parent is deleted, the child becomes a root of their own branch. + ON UPDATE CASCADE, + + reputation INT NOT NULL DEFAULT 0, + total_reputation INT NOT NULL DEFAULT 0, + reps_to_senior INT NOT NULL DEFAULT 0, + + CONSTRAINT self_parent_check CHECK (character_id <> parent_id) -- A character cannot be their own parent. + +); + +CREATE INDEX idx_family_parent_id ON player.family(parent_id); + + +CREATE OR REPLACE VIEW player.family_hierarchy AS +WITH RECURSIVE family_tree AS ( + SELECT + character_id, + parent_id, + reputation, + character_id AS root_id, + 0 AS level + FROM player.family + WHERE parent_id IS NULL + + UNION ALL + + SELECT + f.character_id, + f.parent_id, + f.reputation, + ft.root_id, + ft.level + 1 + FROM player.family f + JOIN family_tree ft ON f.parent_id = ft.character_id +) +SELECT * FROM family_tree; + + +COMMIT; diff --git a/src/main/java/kinoko/database/postgresql/type/FamilyMemberDao.java b/src/main/java/kinoko/database/postgresql/type/FamilyMemberDao.java new file mode 100644 index 00000000..6a5c1a7b --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/FamilyMemberDao.java @@ -0,0 +1,105 @@ +package kinoko.database.postgresql.type; + +import kinoko.world.user.FamilyMember; + + +import java.sql.Connection; +import java.sql.PreparedStatement; +import java.sql.ResultSet; +import java.sql.SQLException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; + + +public final class FamilyMemberDao { + /** + * Retrieves all family members from the database, including their associated character + * information (name, level, job) and family relationships (parent ID, reputation). + * + * This method joins the `player.family` table with `player.characters` and `player.stats` + * to build a complete list of `FamilyMember` objects representing every character that is + * part of a family, regardless of which family they belong to. + * + * @param conn the active SQL connection to use for the query + * @return a list of all `FamilyMember` objects in the database + * @throws SQLException if a database access error occurs while executing the query + */ + public static List getAllFamilyMembers(Connection conn) throws SQLException { + String sql = """ + SELECT f.character_id, f.parent_id, f.reputation, f.total_reputation, f.reps_to_senior, + c.name, s.level, s.job + FROM player.family f + JOIN player.characters c ON f.character_id = c.id + JOIN player.stats s ON s.character_id = c.id + """; + + List members = new ArrayList<>(); + + try (PreparedStatement stmt = conn.prepareStatement(sql); + ResultSet rs = stmt.executeQuery()) { + + while (rs.next()) { + int charId = rs.getInt("character_id"); + Integer parentId = rs.getObject("parent_id") == null ? null : rs.getInt("parent_id"); + int reputation = rs.getInt("reputation"); + int totalReputation = rs.getInt("total_reputation"); + int repsToSenior = rs.getInt("reps_to_senior"); + String name = rs.getString("name"); + int level = rs.getInt("level"); + int job = rs.getInt("job"); + + FamilyMember member = new FamilyMember( + charId, + name, + level, + job, + reputation, + totalReputation, + 0, + repsToSenior, + parentId, + Collections.emptyMap() + ); + + members.add(member); + } + } + + return members; + } + + /** + * Saves a FamilyMember to the database. If the member already exists (based on `character_id`), + * their record is updated with the latest information. Otherwise, a new record is inserted. + * + * @param conn The active SQL Connection to use for executing the statement. + * @param member The FamilyMember object containing the data to save. + * @throws SQLException If a database access error occurs while executing the insert or update. + */ + public static void saveFamilyMember(Connection conn, FamilyMember member) throws SQLException { + String sql = """ + INSERT INTO player.family (character_id, parent_id, reputation, total_reputation, reps_to_senior) + VALUES (?, ?, ?, ?, ?) + ON CONFLICT (character_id) DO UPDATE SET + parent_id = EXCLUDED.parent_id, + reputation = EXCLUDED.reputation, + total_reputation = EXCLUDED.total_reputation, + reps_to_senior = EXCLUDED.reps_to_senior + """; + + try (PreparedStatement stmt = conn.prepareStatement(sql)) { + stmt.setInt(1, member.getCharacterId()); + if (member.getParentId() == null) { + stmt.setNull(2, java.sql.Types.INTEGER); + } else { + stmt.setInt(2, member.getParentId()); + } + stmt.setInt(3, member.getReputation()); + stmt.setInt(4, member.getTotalReputation()); + stmt.setInt(5, member.getReputationToSenior()); + + stmt.executeUpdate(); + } + } +} diff --git a/src/main/java/kinoko/database/postgresql/type/FamilyTreeDao.java b/src/main/java/kinoko/database/postgresql/type/FamilyTreeDao.java new file mode 100644 index 00000000..2e358002 --- /dev/null +++ b/src/main/java/kinoko/database/postgresql/type/FamilyTreeDao.java @@ -0,0 +1,76 @@ +package kinoko.database.postgresql.type; + +import kinoko.server.family.FamilyTree; +import kinoko.world.user.FamilyMember; + +import java.sql.Connection; +import java.sql.SQLException; +import java.util.*; +import java.util.function.Consumer; + + +public final class FamilyTreeDao { + /** + * Retrieves all FamilyTree objects from the database by loading all family members + * and assembling them into in-memory tree structures. + * + * Each FamilyTree represents one family, with the root member being the leader + * (i.e., the member with no parent). Children are properly attached under their + * respective parents, regardless of the order in which they are loaded from the database. + * + * @param conn Active SQL connection + * @return Collection of fully built FamilyTree objects + * @throws SQLException if a database access error occurs during retrieval + */ + public static Collection getAllFamilies(Connection conn) throws SQLException { + // Load all members using FamilyMemberDao + List members = FamilyMemberDao.getAllFamilyMembers(conn); + + // Map characterId -> FamilyMember + Map allMembersMap = new HashMap<>(); + // Map parentId -> list of children + Map> childrenMap = new HashMap<>(); + // Map of root members (parentId == null) + Map roots = new HashMap<>(); + + for (FamilyMember member : members) { + allMembersMap.put(member.getCharacterId(), member); + + Integer parentId = member.getParentId(); + if (parentId == null) { + // Root member (leader of their own family) + roots.put(member.getCharacterId(), member); + } else { + // Add to parent -> children map + childrenMap.computeIfAbsent(parentId, k -> new ArrayList<>()).add(member); + } + } + + // Helper: recursively attach children to parents + Consumer addChildren = tree -> { + Queue queue = new ArrayDeque<>(); + queue.add(tree.getMember(tree.getLeaderId())); + + while (!queue.isEmpty()) { + FamilyMember parent = queue.poll(); + List children = childrenMap.get(parent.getCharacterId()); + if (children != null) { + for (FamilyMember child : children) { + tree.addMember(child, parent.getCharacterId()); + queue.add(child); + } + } + } + }; + + // Build FamilyTree objects + List familyTrees = new ArrayList<>(); + for (FamilyMember root : roots.values()) { + FamilyTree tree = new FamilyTree(root); + addChildren.accept(tree); + familyTrees.add(tree); + } + + return familyTrees; + } +} diff --git a/src/main/java/kinoko/handler/stage/LoginHandler.java b/src/main/java/kinoko/handler/stage/LoginHandler.java index 9a3c3470..47d5264d 100644 --- a/src/main/java/kinoko/handler/stage/LoginHandler.java +++ b/src/main/java/kinoko/handler/stage/LoginHandler.java @@ -85,7 +85,7 @@ public static void handleCheckPassword(Client c, InPacket inPacket) { } // Check password - if (!DatabaseManager.accountAccessor().checkPassword(account, password, false)) { + if (!ServerConfig.TESPIA && !DatabaseManager.accountAccessor().checkPassword(account, password, false)) { c.write(LoginPacket.checkPasswordResultFail(LoginResultType.IncorrectPassword)); return; } diff --git a/src/main/java/kinoko/handler/stage/MigrationHandler.java b/src/main/java/kinoko/handler/stage/MigrationHandler.java index 17f7ec2f..ef9fc6b8 100644 --- a/src/main/java/kinoko/handler/stage/MigrationHandler.java +++ b/src/main/java/kinoko/handler/stage/MigrationHandler.java @@ -15,6 +15,7 @@ import kinoko.packet.world.WvsContext; import kinoko.provider.MapProvider; import kinoko.provider.map.PortalInfo; +import kinoko.server.Server; import kinoko.server.cashshop.Gift; import kinoko.server.field.InstanceFieldStorage; import kinoko.server.guild.GuildRequest; @@ -112,6 +113,8 @@ public static void handleMigrateIn(Client c, InPacket inPacket) { // Initialize User final User user = new User(c, characterData); + user.setFamilyInfo(Server.getCentralServerNode().getFamilyInfo(user.getId())); + user.setMessengerId(migrationInfo.getMessengerId()); // this is required before user connect if (channelServerNode.isConnected(user)) { log.error("Tried to connect to channel server while already connected"); diff --git a/src/main/java/kinoko/handler/user/FamilyHandler.java b/src/main/java/kinoko/handler/user/FamilyHandler.java new file mode 100644 index 00000000..83edd6e8 --- /dev/null +++ b/src/main/java/kinoko/handler/user/FamilyHandler.java @@ -0,0 +1,383 @@ +package kinoko.handler.user; + +import kinoko.handler.Handler; +import kinoko.packet.world.FamilyPacket; +import kinoko.server.Server; +import java.util.concurrent.locks.ReentrantLock; +import kinoko.server.family.FamilyResultType; +import kinoko.server.family.FamilyTree; +import kinoko.server.header.InHeader; +import kinoko.server.node.CentralServerNode; +import kinoko.server.packet.InPacket; +import kinoko.server.packet.OutPacket; +import kinoko.world.user.FamilyMember; +import kinoko.world.user.User; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.util.Collections; +import java.util.Optional; + +public final class FamilyHandler { + private static final Logger log = LogManager.getLogger(FamilyHandler.class); + + // FAMILY HANDLERS ------------------------------------------------------------------------------------------------- + @Handler(InHeader.FamilyInfoRequest) + public static void handleFamilyInfoRequest(User user, InPacket inPacket) { + user.write(FamilyPacket.userFamilyInfo(user)); + } + + @Handler(InHeader.FamilyChartRequest) + public static void handleFamilyChartRequest(User user, InPacket inPacket) { + OutPacket outPacket = FamilyPacket.userFamilyChart(user); + if (outPacket == null){ + return; + } + user.write(outPacket); + System.out.println("Handled Family Chart Request"); + } + + /** + * Handles the result of a family join request when a target user responds + * to an invitation from a senior (inviter) to become their junior. + * + * This method performs the following steps: + * 1. Decodes the inviter ID, inviter name, and whether the invite was accepted from the packet. + * 2. Validates that the inviter exists and is online. + * 3. Initializes or retrieves FamilyMember objects for both the user and the senior. + * 4. Checks if the user is already a junior of another member; if so, the request is rejected. + * 5. Checks if the senior already has the maximum allowed number of juniors (2); if so, the request is rejected. + * 6. Sets the parent ID of the user to the inviter ID to link the hierarchy. + * 7. Ensures the senior has a FamilyTree in the central server node; if not, creates one. + * 8. Moves the user (or their existing subtree) under the senior in the FamilyTree. + * 9. Updates the central server's lookup for faster FamilyTree access. + * 10. Sends the appropriate response packets to both the senior and the user. + * + * @param user the user responding to the family join request + * @param inPacket the packet containing the join result data from the client + */ + @Handler(InHeader.FamilyJoinResult) + public static void handleFamilyJoinResult(User user, InPacket inPacket) { + int inviterID = inPacket.decodeInt(); + String inviterName = inPacket.decodeString(); + boolean accepted = inPacket.decodeByte() != 0; + + CentralServerNode centralServerNode = Server.getCentralServerNode(); + Optional inviterUserOpt = centralServerNode.getUserByCharacterName(inviterName); + + if (inviterUserOpt.isEmpty()){ + user.write(FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0)); + return; + } + + User seniorUser = inviterUserOpt.get(); + + FamilyMember userMember; + if (user.getFamilyInfo().isDefault()){ + userMember = new FamilyMember( + user.getCharacterId(), + user.getCharacterName(), + user.getLevel(), + user.getJob(), + 0, + 0, + 0, + 0, + inviterID, + Collections.emptyMap() + ); + } + else { + userMember = user.getFamilyInfo(); + if (userMember.getParentId() != null){ + user.write(FamilyPacket.of(FamilyResultType.AlreadyJuniorOfAnother, 0)); + return; + } + } + + FamilyMember seniorMember; + if (seniorUser.getFamilyInfo().isDefault()){ + seniorMember = new FamilyMember( + seniorUser.getCharacterId(), + seniorUser.getCharacterName(), + seniorUser.getLevel(), + seniorUser.getJob(), + 0, + 0, + 0, + 0, + null, + Collections.emptyMap() + ); + } + else { + seniorMember = seniorUser.getFamilyInfo(); + } + + if (seniorMember.getChildrenCount() >= 2){ + user.write(FamilyPacket.of(FamilyResultType.CannotAddJunior, 0)); + return; + } + + userMember.setParentId(inviterID); + + seniorUser.setFamilyInfo(seniorMember); + user.setFamilyInfo(userMember); + + // Trees + Optional userTreeOpt = centralServerNode.getFamilyTree(user.getCharacterId()); + Optional seniorTreeOpt = centralServerNode.getFamilyTree(seniorUser.getCharacterId()); + + + FamilyTree seniorTree = seniorTreeOpt.orElseGet(() -> { + FamilyTree newTree = new FamilyTree(seniorMember); + centralServerNode.addFamilyTree(newTree); + return newTree; + }); + + // Move the user (or their subtree) under the senior + if (!seniorTree.hasMember(userMember.getCharacterId())) { + if (userTreeOpt.isPresent()) { + // User has their own subtree + seniorTree.addSubTree(userTreeOpt.get(), inviterID); + } else { + // User is a single member + seniorTree.addMember(userMember, inviterID); + } + } + + centralServerNode.updateFamilyTree(seniorTree); // update lookups + + seniorUser.write(FamilyPacket.createFamilyJoinRequestResult(user.getCharacterName(), accepted)); + user.write(FamilyPacket.createFamilyJoinAccepted(inviterName)); + } + + /** + * Handles a request from a user to register another user as their junior in the family system. + * + * This method performs several validations before sending an invite: + * 1. Checks that the target user exists and is online. + * 2. Ensures both users are at least level 10. + * 3. Validates that the level difference between the inviter and target is no more than 50. + * 4. Confirms that the target user is not already a junior of another user. + * 5. Ensures that the inviter and target are not already in the same family. + * + * If all checks pass, the target user receives a family invite packet from the inviter. + * Otherwise, an appropriate FamilyResultType error is sent to the inviter. + * + * @param user the user sending the junior registration request + * @param inPacket the packet containing the target username + */ + @Handler(InHeader.FamilyRegisterJunior) + public static void handleFamilyRegisterJunior(User user, InPacket inPacket) { + String targetUsername = inPacket.decodeString(); + + CentralServerNode centralServerNode = Server.getCentralServerNode(); + Optional targetUserOpt = centralServerNode.getUserByCharacterName(targetUsername); + + if (targetUserOpt.isEmpty()){ + user.write(FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0)); + return; + } + + User targetUser = targetUserOpt.get(); + + // Both users must be at least level 10 + if (user.getLevel() < 10 || targetUser.getLevel() < 10) { + user.write(FamilyPacket.of(FamilyResultType.JuniorMustBeOverLevel10, 0)); + return; + } + + // Level gap check (must be within 50 levels) + int levelGap = Math.abs(user.getLevel() - targetUser.getLevel()); + if (levelGap > 50) { + user.write(FamilyPacket.of(FamilyResultType.LevelGapTooHigh, 0)); + return; + } + + if (targetUser.getFamilyInfo().getParentId() != null){ + user.write(FamilyPacket.of(FamilyResultType.AlreadyJuniorOfAnother, 0)); + return; + } + + // make sure both are not in the same family + Optional userTreeOpt = centralServerNode.getFamilyTree(user.getCharacterId()); + Optional targetTreeOpt = centralServerNode.getFamilyTree(targetUser.getCharacterId()); + if (userTreeOpt.isPresent() && targetTreeOpt.isPresent() && userTreeOpt.get() == targetTreeOpt.get()) { + user.write(FamilyPacket.of(FamilyResultType.SameFamily, 0)); + return; + } + + targetUser.write(FamilyPacket.createFamilyInvite(user, targetUser)); + } + + + @Handler(InHeader.FamilySetPrecept) + public static void handleFamilySetPrecept(User user, InPacket inPacket) { + System.out.println("Handled FamilySetPrecept"); + } + + + @Handler(InHeader.FamilySummonResult) + public static void handleFamilySummonResult(User user, InPacket inPacket) { + System.out.println("Handled FamilySummonResult"); + } + + @Handler(InHeader.FamilyUnregisterJunior) + public static void handleFamilyUnregisterJunior(User user, InPacket inPacket) { + int juniorID = inPacket.decodeInt(); + + CentralServerNode centralServerNode = Server.getCentralServerNode(); + + ReentrantLock lock = centralServerNode.getGlobalFamilyLock(); + lock.lock(); + + try { + + Optional userTreeOpt = centralServerNode.getFamilyTree(user.getCharacterId()); + Optional juniorTreeOpt = centralServerNode.getFamilyTree(juniorID); + + if (userTreeOpt.isPresent() && juniorTreeOpt.isPresent()) { + FamilyTree userTree = userTreeOpt.get(); + FamilyTree juniorTree = juniorTreeOpt.get(); + FamilyMember juniorMember = juniorTree.getMember(juniorID); + if (juniorMember.getParentId() != user.getCharacterId()) { + user.write(FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0)); + return; + } + + // unregister junior and extract subtree + FamilyTree juniorSubTree = userTree.extractAndRemoveSubTree(juniorID); + centralServerNode.addFamilyTree(juniorSubTree); + + // todo: test with bigger trees + // todo: write to the target user if they're online. + user.write(FamilyPacket.unregisterJunior(juniorID)); + + } else { // something went wrong + user.write(FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0)); + return; + } + } + finally { + lock.unlock(); + } + + } + + @Handler({InHeader.FamilyUnregisterParent}) + public static void handleFamilyUnregisterParent(User user, InPacket inPacket) { + System.out.println("Handled FamilyUnregisterParent"); + } + + @Handler({InHeader.FamilyUsePrivilege}) + public static void handleFamilyUsePrivilege(User user, InPacket inPacket) { + System.out.println("Handled FamilyUsePrivilege"); + } + +// +// @Handler(InHeader.PartyRequest) +// public static void handlePartyRequest(User user, InPacket inPacket) { +// final int type = inPacket.decodeByte(); +// final PartyRequestType requestType = PartyRequestType.getByValue(type); +// switch (requestType) { +// case CreateNewParty -> { +// // CField::SendCreateNewPartyMsg +// if (user.hasParty()) { +// user.write(PartyPacket.of(PartyResultType.CreateNewParty_AlreadyJoined)); +// return; +// } +// user.getConnectedServer().submitPartyRequest(user, PartyRequest.createNewParty()); +// } +// case WithdrawParty -> { +// // CField::SendWithdrawPartyMsg +// if (!user.hasParty()) { +// user.write(PartyPacket.of(PartyResultType.WithdrawParty_NotJoined)); +// return; +// } +// inPacket.decodeByte(); // hardcoded 0 +// user.getConnectedServer().submitPartyRequest(user, PartyRequest.withdrawParty()); +// } +// case JoinParty -> { +// // CWvsContext::OnPartyResult +// if (user.hasParty()) { +// user.write(PartyPacket.of(PartyResultType.JoinParty_AlreadyJoined)); +// return; +// } +// final int inviterId = inPacket.decodeInt(); +// inPacket.decodeByte(); // unknown byte from InviteParty +// user.getConnectedServer().submitPartyRequest(user, PartyRequest.joinParty(inviterId)); +// } +// case InviteParty -> { +// // CField::SendJoinPartyMsg +// if (user.hasParty() && !user.isPartyBoss()) { +// user.write(PartyPacket.serverMsg("You are not the leader of the party.")); +// return; +// } +// final String targetName = inPacket.decodeString(); +// user.getConnectedServer().submitPartyRequest(user, PartyRequest.invite(targetName)); +// } +// case KickParty -> { +// // CField::SendKickPartyMsg +// if (!user.isPartyBoss()) { +// user.write(PartyPacket.serverMsg("You are not the leader of the party.")); +// return; +// } +// final int targetId = inPacket.decodeInt(); +// user.getConnectedServer().submitPartyRequest(user, PartyRequest.kickParty(targetId)); +// } +// case ChangePartyBoss -> { +// // CField::SendChangePartyBossMsg +// if (!user.isPartyBoss()) { +// user.write(PartyPacket.serverMsg("You are not the leader of the party.")); +// return; +// } +// final int targetId = inPacket.decodeInt(); +// user.getConnectedServer().submitPartyRequest(user, PartyRequest.changePartyBoss(targetId, false)); +// } +// case null -> { +// log.error("Unknown party request type : {}", type); +// } +// default -> { +// log.error("Unhandled party request type : {}", requestType); +// } +// } +// } +// +// @Handler(InHeader.PartyResult) +// public static void handlePartyResult(User user, InPacket inPacket) { +// final int type = inPacket.decodeByte(); +// final PartyResultType resultType = PartyResultType.getByValue(type); +// switch (resultType) { +// case InviteParty_Sent, InviteParty_BlockedUser, InviteParty_AlreadyInvited, +// InviteParty_AlreadyInvitedByInviter, InviteParty_Rejected -> { +// final int inviterId = inPacket.decodeInt(); +// final String message = switch (resultType) { +// // These messages are from the client string pool, but are not used (except for InviteParty_Sent) +// case InviteParty_Sent, InviteParty_BlockedUser -> +// String.format("You have invited '%s' to your party.", user.getCharacterName()); +// case InviteParty_AlreadyInvited -> +// String.format("'%s' is taking care of another invitation.", user.getCharacterName()); +// case InviteParty_AlreadyInvitedByInviter -> +// String.format("You have already invited '%s' to your party.", user.getCharacterName()); +// case InviteParty_Rejected -> +// String.format("'%s' has declined the party request.", user.getCharacterName()); +// default -> { +// throw new IllegalStateException("Unexpected party result type"); +// } +// }; +// user.getConnectedServer().submitUserPacketReceive(inviterId, PartyPacket.serverMsg(message)); +// } +// case InviteParty_Accepted -> { +// final int inviterId = inPacket.decodeInt(); +// user.getConnectedServer().submitPartyRequest(user, PartyRequest.joinParty(inviterId)); +// } +// case null -> { +// log.error("Unknown party result type : {}", type); +// } +// default -> { +// log.error("Unhandled party result type : {}", resultType); +// } +// } +// } +} diff --git a/src/main/java/kinoko/handler/user/UserHandler.java b/src/main/java/kinoko/handler/user/UserHandler.java index 82313b45..8d6f9ef1 100644 --- a/src/main/java/kinoko/handler/user/UserHandler.java +++ b/src/main/java/kinoko/handler/user/UserHandler.java @@ -33,6 +33,7 @@ import kinoko.server.messenger.MessengerProtocol; import kinoko.server.messenger.MessengerRequest; import kinoko.server.packet.InPacket; +import kinoko.server.packet.OutPacket; import kinoko.server.rank.RankManager; import kinoko.server.user.RemoteUser; import kinoko.util.Triple; @@ -287,7 +288,6 @@ public static void handleUserTrunkRequest(User user, InPacket inPacket) { trunkDialog.handlePacket(user, inPacket); } - // INVENTORY HANDLERS ---------------------------------------------------------------------------------------------- @Handler(InHeader.UserGatherItemRequest) diff --git a/src/main/java/kinoko/packet/world/FamilyPacket.java b/src/main/java/kinoko/packet/world/FamilyPacket.java new file mode 100644 index 00000000..eb269154 --- /dev/null +++ b/src/main/java/kinoko/packet/world/FamilyPacket.java @@ -0,0 +1,152 @@ +package kinoko.packet.world; + +import kinoko.server.Server; +import kinoko.server.family.FamilyResultType; +import kinoko.server.family.FamilyTree; +import kinoko.server.header.OutHeader; +import kinoko.server.packet.OutPacket; +import kinoko.world.user.FamilyMember; +import kinoko.world.user.User; + +import java.util.Optional; + +public final class FamilyPacket { + // CWvsContext::OnFamilyResult ------------------------------------------------------------------------------------- + public static OutPacket registerJuniorSuccess(User leader, User junior) { + final OutPacket outPacket = FamilyPacket.of(FamilyResultType.RegisterJunior_Success, 0); + outPacket.encodeInt(junior.getCharacterId()); + outPacket.encodeString(junior.getCharacterName()); + outPacket.encodeInt(junior.getLevel()); + outPacket.encodeInt(junior.getJob()); + return outPacket; + } + + /** + * CWvsContext::OnFamilyJoinRequest + * Builds a Family Join Request / Invite packet. + * + * This packet prompts the client with a yes/no dialog asking + * the target player to accept a family invitation. + * + * Packet Structure (v95): + * byte OutHeader.FamilyResult (we reuse FamilyResult for simplicity) + * int FamilyResultType (use a dedicated type like RegisterJunior_Invite) + * int leaderCharacterId + * int leaderJob + * String leaderName + * String requesterName + * + * @param senior The user sending the invite (senior) + * @param targetUser The user being invited (junior) + * @return The encoded packet to send to the client + */ + public static OutPacket createFamilyInvite(User senior, User targetUser) { + final OutPacket outPacket = OutPacket.of(OutHeader.FamilyJoinRequest); + + outPacket.encodeInt(senior.getCharacterId()); + outPacket.encodeInt(senior.getLevel()); + outPacket.encodeInt(senior.getJob()); + outPacket.encodeString(senior.getCharacterName()); + + return outPacket; + } + + /** + * Creates a packet to inform the original inviter of the family join result. + * This packet is sent to the senior character after the junior has responded. + * + * @param responderName The name of the character who responded (the junior). + * @param accepted True if the invitation was accepted, false if it was declined. + * @return The encoded packet to send to the inviter's client. + */ + public static OutPacket createFamilyJoinRequestResult(String responderName, boolean accepted) { + final OutPacket outPacket = OutPacket.of(OutHeader.FamilyJoinRequestResult); + outPacket.encodeByte(accepted ? 1 : 0); + outPacket.encodeString(responderName); + return outPacket; + } + + /** + * Creates a packet indicating that a family join request has been accepted. + * This packet is sent only to the senior member who approved the request. + * It contains the senior member's name for client-side display or logging. + * + * @param seniorName the name of the senior family member who accepted the request + * @return an OutPacket representing the FamilyJoinAccepted response + */ + public static OutPacket createFamilyJoinAccepted(String seniorName) { + final OutPacket outPacket = OutPacket.of(OutHeader.FamilyJoinAccepted); + outPacket.encodeString(seniorName); + return outPacket; + } + + public static OutPacket unregisterJunior(int juniorId) { + final OutPacket outPacket = FamilyPacket.of(FamilyResultType.UnregisterJunior, 0); + outPacket.encodeInt(juniorId); + return outPacket; + } + + public static OutPacket summonJunior(User leader, User junior) { + final OutPacket outPacket = FamilyPacket.of(FamilyResultType.SummonJunior, 0); + outPacket.encodeInt(junior.getCharacterId()); + outPacket.encodeString(junior.getCharacterName()); + return outPacket; + } + + public static OutPacket entitlementError(String message) { + final OutPacket outPacket = FamilyPacket.of(FamilyResultType.EntitlementError, 0); + outPacket.encodeString(message != null ? message : ""); + return outPacket; + } + + public static OutPacket userFamilyInfo(User user) { + FamilyMember familyInfo = user.getFamilyInfo(); + if (familyInfo == null){ + familyInfo = FamilyMember.EMPTY; + } + final OutPacket outPacket = OutPacket.of(OutHeader.FamilyInfoResult); + familyInfo.encode(outPacket); + return outPacket; + } + + public static OutPacket userFamilyChart(User user) { + Optional optionalTree = Server.getCentralServerNode().getFamilyTree(user.getCharacterId()); + final OutPacket outPacket = OutPacket.of(OutHeader.FamilyChartResult); + + if (optionalTree.isPresent()) { + FamilyTree familyTree = optionalTree.get(); + // encode the pedigree for this user + familyTree.encodeChart(outPacket, user.getCharacterId()); + } + else { + return null; + } + return outPacket; + } + + /** + * Builds a Family Result packet. + * + * This packet is used for family-related error and informational messages + * that may require an additional Mesos value, such as separation fees or + * cost-related failures. + * + * The resultType determines the message shown to the client. These map to + * the FamilyResultType enum values. + * + * Packet Structure (v95): + * byte OutHeader.FamilyResult + * int resultType (from FamilyResultType) + * int mesos (used for fee-related messages; 0 if not applicable) + * + * @param resultType The family result type to display + * @param mesos Mesos amount required or related to the message (0 if not applicable) + * @return The encoded FamilyResult packet + */ + public static OutPacket of(FamilyResultType resultType, int mesos) { + final OutPacket outPacket = OutPacket.of(OutHeader.FamilyResult); + outPacket.encodeInt(resultType.getValue()); + outPacket.encodeInt(mesos); + return outPacket; + } +} diff --git a/src/main/java/kinoko/server/Server.java b/src/main/java/kinoko/server/Server.java index 3cbab12e..b30aa517 100644 --- a/src/main/java/kinoko/server/Server.java +++ b/src/main/java/kinoko/server/Server.java @@ -66,6 +66,12 @@ private static void initialize() throws Exception { // Initialize nodes centralServerNode = new CentralServerNode(ServerConstants.CENTRAL_PORT); + + start = Instant.now(); + centralServerNode.createAllFamilies(); + log.info("Loaded families in {} milliseconds", Duration.between(start, Instant.now()).toMillis()); + + ServerExecutor.submitService(() -> { try { centralServerNode.initialize(); diff --git a/src/main/java/kinoko/server/family/Family.java b/src/main/java/kinoko/server/family/Family.java new file mode 100644 index 00000000..5296147d --- /dev/null +++ b/src/main/java/kinoko/server/family/Family.java @@ -0,0 +1,175 @@ +package kinoko.server.family; + +import kinoko.server.packet.OutPacket; +import kinoko.server.user.RemoteTownPortal; +import kinoko.server.user.RemoteUser; +import kinoko.util.Encodable; +import kinoko.util.Lockable; +import kinoko.world.GameConstants; +import kinoko.world.user.PartyInfo; + +import java.util.*; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; + +/** + * Party instance managed by CentralServerNode. RemoteUser instances managed by this object should always be pointing to + * the instance stored in UserStorage. + */ +public final class Family implements Encodable, Lockable { + private static final RemoteUser EMPTY_MEMBER = new RemoteUser(0, 0, "", 0, 0, GameConstants.CHANNEL_OFFLINE, GameConstants.UNDEFINED_FIELD_ID, 0, 0, 0, RemoteTownPortal.EMPTY); + private final Lock lock = new ReentrantLock(); + private final int partyId; + private final List partyMembers; + private final Map partyInvites; // invitee ID -> inviter ID + private int partyBossId; + + public Family(int partyId, RemoteUser remoteUser) { + this.partyId = partyId; + this.partyMembers = new ArrayList<>(GameConstants.PARTY_MAX); + this.partyMembers.add(remoteUser); + this.partyInvites = new HashMap<>(); + this.partyBossId = remoteUser.getCharacterId(); + } + + public int getPartyId() { + return partyId; + } + + public boolean canAddMember(RemoteUser remoteUser) { + if (partyMembers.size() >= GameConstants.PARTY_MAX) { + return false; + } + for (RemoteUser member : partyMembers) { + if (member.getCharacterId() == remoteUser.getCharacterId()) { + return false; + } + } + return true; + } + + public boolean addMember(RemoteUser remoteUser) { + if (!canAddMember(remoteUser)) { + return false; + } + partyMembers.add(remoteUser); + return true; + } + + public boolean removeMember(RemoteUser remoteUser) { + return partyMembers.removeIf((member) -> member.getCharacterId() == remoteUser.getCharacterId()); + } + + public void registerInvite(int inviterId, int targetId) { + partyInvites.put(targetId, inviterId); + } + + public boolean unregisterInvite(int inviterId, int targetId) { + return partyInvites.remove(targetId) == inviterId; + } + + public int getPartyBossId() { + return partyBossId; + } + + public boolean setPartyBossId(int currentBossId, int newBossId) { + if (partyBossId != 0 && partyBossId != currentBossId) { + return false; + } + if (!hasMember(newBossId)) { + return false; + } + this.partyBossId = newBossId; + return true; + } + + public Optional getMember(int characterId) { + return partyMembers.stream() + .filter((member) -> member.getCharacterId() == characterId) + .findFirst(); + } + + public int getMemberIndex(RemoteUser remoteUser) { + for (int i = 0; i < GameConstants.PARTY_MAX; i++) { + if (i >= partyMembers.size()) { + break; + } + if (partyMembers.get(i).getCharacterId() == remoteUser.getCharacterId()) { + return i + 1; // used for affectedMemberBitMap + } + } + return 0; + } + + public boolean hasMember(int characterId) { + return getMember(characterId).isPresent(); + } + + public void updateMember(RemoteUser remoteUser) { + for (int i = 0; i < GameConstants.PARTY_MAX; i++) { + if (i >= partyMembers.size()) { + break; + } + if (partyMembers.get(i).getCharacterId() == remoteUser.getCharacterId()) { + partyMembers.set(i, remoteUser); + } + } + } + + public PartyInfo createInfo(RemoteUser remoteUser) { + return new PartyInfo(partyId, getMemberIndex(remoteUser), partyBossId == remoteUser.getCharacterId()); + } + + public void forEachMember(Consumer consumer) { + for (RemoteUser member : partyMembers) { + consumer.accept(member); + } + } + + private void forEachMemberForPartyData(Consumer consumer) { + for (int i = 0; i < GameConstants.PARTY_MAX; i++) { + if (i < partyMembers.size()) { + consumer.accept(partyMembers.get(i)); + } else { + consumer.accept(EMPTY_MEMBER); + } + } + } + + @Override + public String toString() { + return "Party{" + + "partyId=" + partyId + + ", partyMembers=" + partyMembers + + ", partyBossId=" + partyBossId + + '}'; + } + + @Override + public void encode(OutPacket outPacket) { + // PARTYDATA::Decode (378) + forEachMemberForPartyData((member) -> outPacket.encodeInt(member.getCharacterId())); // adwCharacterID + forEachMemberForPartyData((member) -> outPacket.encodeString(member.getCharacterName(), 13)); // asCharacterName + forEachMemberForPartyData((member) -> outPacket.encodeInt(member.getJob())); // anJob + forEachMemberForPartyData((member) -> outPacket.encodeInt(member.getLevel())); // anLevel + forEachMemberForPartyData((member) -> outPacket.encodeInt(member.getChannelId())); // anChannelID + outPacket.encodeInt(partyBossId); // dwPartyBossCharacterID + forEachMemberForPartyData((member) -> outPacket.encodeInt(member.getFieldId())); // adwFieldID + forEachMemberForPartyData((member) -> member.getTownPortal().encode(outPacket)); // aTownPortal + forEachMemberForPartyData((member) -> outPacket.encodeInt(0)); // aPQReward + forEachMemberForPartyData((member) -> outPacket.encodeInt(0)); // aPQRewardType + outPacket.encodeInt(0); // dwPQRewardMobTemplateID + outPacket.encodeInt(0); // bPQReward + } + + @Override + public void lock() { + lock.lock(); + } + + @Override + public void unlock() { + lock.unlock(); + } +} diff --git a/src/main/java/kinoko/server/family/FamilyRequest.java b/src/main/java/kinoko/server/family/FamilyRequest.java new file mode 100644 index 00000000..ab749f92 --- /dev/null +++ b/src/main/java/kinoko/server/family/FamilyRequest.java @@ -0,0 +1,133 @@ +package kinoko.server.family; + +import kinoko.server.header.CentralHeader; +import kinoko.server.packet.InPacket; +import kinoko.server.packet.OutPacket; +import kinoko.util.Encodable; + +/** + * Utility class for {@link CentralHeader#PartyRequest} + */ +public final class FamilyRequest implements Encodable { + private final FamilyRequestType requestType; + private int partyId; + private int characterId; + private String characterName; + private boolean isDisconnect; + + FamilyRequest(FamilyRequestType requestType) { + this.requestType = requestType; + } + + public FamilyRequestType getRequestType() { + return requestType; + } + + public int getPartyId() { + return partyId; + } + + public int getCharacterId() { + return characterId; + } + + public String getCharacterName() { + return characterName; + } + + public boolean isDisconnect() { + return isDisconnect; + } + + @Override + public void encode(OutPacket outPacket) { + outPacket.encodeByte(requestType.getValue()); + switch (requestType) { + case LoadParty -> { + outPacket.encodeInt(partyId); + } + case CreateNewParty, WithdrawParty -> { + // no encodes + } + case JoinParty, KickParty -> { + outPacket.encodeInt(characterId); + } + case InviteParty -> { + outPacket.encodeString(characterName); + } + case ChangePartyBoss -> { + outPacket.encodeInt(characterId); + outPacket.encodeByte(isDisconnect); + } + } + } + + public static FamilyRequest decode(InPacket inPacket) { + final int type = inPacket.decodeByte(); + final FamilyRequest request = new FamilyRequest(FamilyRequestType.getByValue(type)); + switch (request.requestType) { + case LoadParty -> { + request.partyId = inPacket.decodeInt(); + } + case CreateNewParty, WithdrawParty -> { + // no decodes + } + case JoinParty, KickParty -> { + request.characterId = inPacket.decodeInt(); + } + case InviteParty -> { + request.characterName = inPacket.decodeString(); + } + case ChangePartyBoss -> { + request.characterId = inPacket.decodeInt(); + request.isDisconnect = inPacket.decodeBoolean(); + } + case null -> { + throw new IllegalStateException(String.format("Unknown party request type %d", type)); + } + default -> { + throw new IllegalStateException(String.format("Unhandled party request type %d", type)); + } + } + return request; + } + + public static FamilyRequest loadParty(int partyId) { + final FamilyRequest request = new FamilyRequest(FamilyRequestType.LoadParty); + request.partyId = partyId; + return request; + } + + public static FamilyRequest createNewParty() { + return new FamilyRequest(FamilyRequestType.CreateNewParty); + } + + public static FamilyRequest withdrawParty() { + return new FamilyRequest(FamilyRequestType.WithdrawParty); + } + + public static FamilyRequest joinParty(int inviterId) { + final FamilyRequest request = new FamilyRequest(FamilyRequestType.JoinParty); + request.characterId = inviterId; + return request; + } + + public static FamilyRequest invite(String characterName) { + final FamilyRequest request = new FamilyRequest(FamilyRequestType.InviteParty); + request.characterName = characterName; + return request; + } + + public static FamilyRequest kickParty(int targetId) { + final FamilyRequest request = new FamilyRequest(FamilyRequestType.KickParty); + request.characterId = targetId; + return request; + } + + public static FamilyRequest changePartyBoss(int targetId, boolean isDisconnect) { + final FamilyRequest request = new FamilyRequest(FamilyRequestType.ChangePartyBoss); + request.characterId = targetId; + request.isDisconnect = isDisconnect; + return request; + } +} diff --git a/src/main/java/kinoko/server/family/FamilyRequestType.java b/src/main/java/kinoko/server/family/FamilyRequestType.java new file mode 100644 index 00000000..4fa8bed6 --- /dev/null +++ b/src/main/java/kinoko/server/family/FamilyRequestType.java @@ -0,0 +1,31 @@ +package kinoko.server.family; + +public enum FamilyRequestType { + // PartyReq + LoadParty(0), + CreateNewParty(1), + WithdrawParty(2), + JoinParty(3), + InviteParty(4), + KickParty(5), + ChangePartyBoss(6); + + private final int value; + + FamilyRequestType(int value) { + this.value = value; + } + + public final int getValue() { + return value; + } + + public static FamilyRequestType getByValue(int value) { + for (FamilyRequestType type : values()) { + if (type.getValue() == value) { + return type; + } + } + return null; + } +} diff --git a/src/main/java/kinoko/server/family/FamilyResultType.java b/src/main/java/kinoko/server/family/FamilyResultType.java new file mode 100644 index 00000000..ff1d50bc --- /dev/null +++ b/src/main/java/kinoko/server/family/FamilyResultType.java @@ -0,0 +1,52 @@ +package kinoko.server.family; + +public enum FamilyResultType { + // Generic messages + EmptyFamily(0), + EntitlementError(2), // Level too low, not eligible, etc. + + // Success operations + + UnregisterJunior(1), // Junior removed / family ties severed + RegisterJunior_Success(10), // Successfully registered a junior + SummonJunior(12), // Summoning a junior + + // Error / warning messages + CannotAddJunior(64), // 0x40 + IncorrectOrOffline(65), // 0x41 + SameFamily(66), // 0x42 + DifferentFamily(67), // 0x43 + JuniorMustBeInSameMap(69), // 0x45 + AlreadyJuniorOfAnother(70), // 0x46 + JuniorMustBeLowerRank(71), // 0x47 + LevelGapTooHigh(72), // 0x48 + AnotherRequestPending(73), // 0x49 + AnotherSummonPending(74), // 0x4A + SummonFailed(75), // 0x4B + MaxGenerationsReached(76), // 0x4C + JuniorMustBeOverLevel10(77), // 0x4D + CannotAddAfterWorldChange(79), // 0x4F + SeparationNotEnoughMesos1(80), // 0x50 + SeparationNotEnoughMesos2(81), // 0x51 + EntitlementLevelMismatch(82); // 0x52 + + + private final int value; + + FamilyResultType(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + public static FamilyResultType getByValue(int value) { + for (FamilyResultType type : values()) { + if (type.getValue() == value) { + return type; + } + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/server/family/FamilyStorage.java b/src/main/java/kinoko/server/family/FamilyStorage.java new file mode 100644 index 00000000..f56f4db3 --- /dev/null +++ b/src/main/java/kinoko/server/family/FamilyStorage.java @@ -0,0 +1,108 @@ +package kinoko.server.family; + +import kinoko.world.user.FamilyMember; + +import java.util.Optional; +import java.util.concurrent.ConcurrentHashMap; +import java.util.concurrent.locks.ReentrantLock; + +/** + * Stores all FamilyTree instances in memory. + * Keyed by leaderId (root character of the family). + */ +public final class FamilyStorage { + + private final ConcurrentHashMap families = new ConcurrentHashMap<>(); + private final ConcurrentHashMap memberLookup = new ConcurrentHashMap<>(); + + private final ReentrantLock globalFamilyLock = new ReentrantLock(); + + /** + * Returns the global family lock. + */ + public ReentrantLock getGlobalLock() { + return globalFamilyLock; + } + + /** + * Adds a FamilyTree to storage. + */ + public void addFamily(FamilyTree family) { + families.put(family.getLeaderId(), family); + family.forEach(member -> memberLookup.put(member.getCharacterId(), family)); + } + + /** + * Removes a FamilyTree from storage. + */ + public boolean removeFamily(FamilyTree family) { + if (!families.remove(family.getLeaderId(), family)) return false; + family.forEach(member -> memberLookup.remove(member.getCharacterId())); + return true; + } + + /** + * Retrieves a FamilyTree by its leader/root ID. + */ + public Optional getFamilyByLeaderId(int leaderId) { + return Optional.ofNullable(families.get(leaderId)); + } + + /** + * Retrieves a FamilyTree by a member's character ID (fast O(1) lookup). + */ + public Optional getTreeByMemberId(int characterId) { + return Optional.ofNullable(memberLookup.get(characterId)); + } + + /** + * Retrieves a FamilyMember by their character ID. + * + * @param characterId The character ID to search for + * @return Optional containing the FamilyMember if found, empty otherwise + */ + public Optional getFamilyMember(int characterId) { + return getTreeByMemberId(characterId) + .map(tree -> tree.getMember(characterId)); + } + + /** + * Updates the lookup table so each member in the given family tree + * is associated with that FamilyTree instance. This ensures quick + * reverse lookup from a character ID to the FamilyTree it belongs to. + * + * @param family the FamilyTree whose members should be registered + */ + public void updateFamilyTree(FamilyTree family){ + family.forEach(member -> memberLookup.put(member.getCharacterId(), family)); + } + + /** + * Removes a member from their family tree and cleans up the member lookup. + * + * If the member exists in a family, they will be removed from the + * corresponding FamilyTree and their entry in {@code memberLookup} will + * also be deleted. If the member is not found in any family, this method + * does nothing. + * + * @param characterId the ID of the member to remove + */ + public void removeMemberFromFamily(int characterId) { + FamilyTree tree = memberLookup.get(characterId); + if (tree == null) return; + + FamilyMember member = tree.getMember(characterId); + if (member == null) return; + + // Remove member from the family tree & lookup + tree.removeMember(characterId); + memberLookup.remove(characterId); + } + + /** + * Returns all FamilyTrees stored. + */ + public ConcurrentHashMap getAllFamilies() { + return families; + } +} diff --git a/src/main/java/kinoko/server/family/FamilyTree.java b/src/main/java/kinoko/server/family/FamilyTree.java new file mode 100644 index 00000000..58e07117 --- /dev/null +++ b/src/main/java/kinoko/server/family/FamilyTree.java @@ -0,0 +1,556 @@ +package kinoko.server.family; + +import kinoko.server.packet.OutPacket; +import kinoko.util.Lockable; +import kinoko.world.user.FamilyMember; + +import java.util.*; +import java.util.concurrent.locks.Lock; +import java.util.concurrent.locks.ReentrantLock; +import java.util.function.Consumer; + +/** + * In-memory family tree for a single leader, managing parent-child relationships. + * Supports adding/removing members, pedigree building, DFS traversal, and encoding + * family data for client packets. + */ +public final class FamilyTree implements Lockable { + + private final Lock lock = new ReentrantLock(); + + /** characterId → FamilyMember map */ + private final Map members = new HashMap<>(); + + /** Root leader (parentId = null) */ + private int leaderId; + + // ------------------------------------------------------------------------- + // Constructor + // ------------------------------------------------------------------------- + + public FamilyTree(FamilyMember leader) { + members.put(leader.getCharacterId(), leader); + this.leaderId = leader.getCharacterId(); + } + + // ------------------------------------------------------------------------- + // Member access + // ------------------------------------------------------------------------- + + public boolean hasMember(int characterId) { + return members.containsKey(characterId); + } + + public FamilyMember getMember(int characterId) { + return members.get(characterId); + } + + public int getLeaderId() { + return leaderId; + } + + public FamilyMember getLeader() { + return members.get(leaderId); + } + + // ------------------------------------------------------------------------- + // Add / remove members + // ------------------------------------------------------------------------- + public void changeLeader(FamilyMember newLeader){ + this.leaderId = newLeader.getCharacterId(); + }; + + /** + * Adds a new member as a child of the specified parent in this FamilyTree. + * + * If the parent does not exist in the tree or the member is already present, + * the method returns false. The member is added to the parent's list of children + * (duplicates are ignored) and to this tree's internal members map. + * + * Thread-safety: The method acquires a lock to prevent concurrent modifications. + * + * @param junior the FamilyMember to add + * @param parentId the character ID of the parent under whom the member should be added + * @return true if the member was successfully added, false otherwise + */ + public boolean addMember(FamilyMember junior, int parentId) { + lock(); + try { + if (!members.containsKey(parentId)) return false; + if (members.containsKey(junior.getCharacterId())) return false; + + FamilyMember parent = members.get(parentId); + parent.addChild(junior.getCharacterId()); // duplicates ignored. + members.put(junior.getCharacterId(), junior); + return true; + } finally { + unlock(); + } + } + + /** + * Removes a member and their entire subtree from this FamilyTree. + * + * If the member does not exist or is the leader of the tree, the method returns false. + * The member is first removed from their parent's list of children (if any), + * then the member and all their descendants are removed from the tree. + * + * Thread-safety: The method acquires a lock to prevent concurrent modifications. + * + * @param characterId the character ID of the member to remove + * @return true if the member was successfully removed, false otherwise + */ + public boolean removeMember(int characterId) { + lock(); + try { + if (!members.containsKey(characterId)) return false; + if (characterId == leaderId) return false; + + FamilyMember member = members.get(characterId); + FamilyMember parent = members.get(member.getParentId()); + + if (parent != null) { + parent.removeChild(characterId); + } + + member.setParentId(null); + + removeSubtree(characterId); + return true; + } finally { + unlock(); + } + } + + + /** + * Adds an entire subtree from another FamilyTree under a specified parent in this tree. + * + * The subtree is merged into this tree, preserving all parent-child relationships. + * The root of the subtree will become a child of the specified parent, and all + * descendants are added recursively. The member map of this tree is updated to + * include all members from the subtree. + * + * Thread-safety: This method locks the tree during the operation to prevent + * concurrent modifications. + * + * @param tree the FamilyTree containing the subtree to add + * @param parentId the character ID of the parent in this tree under which the subtree + * should be attached + * @throws IllegalArgumentException if the specified parent does not exist in this tree + */ + public void addSubTree(FamilyTree tree, int parentId) { + lock(); + try { + FamilyMember parent = getMember(parentId); + if (parent == null) { + throw new IllegalArgumentException("Parent not found in this tree: " + parentId); + } + + FamilyMember subtreeRoot = tree.getLeader(); + addMember(subtreeRoot, parentId); + + // marge all members of the subtree into this tree's member map + tree.forEach(member -> { + System.out.println(member.getCharacterId()); + if (member != subtreeRoot) { + addMember(member, member.getParentId()); + } + }); + } + finally { + unlock(); + } + } + + /** + * Recursively removes a subtree from this FamilyTree starting at the specified member. + * + * All descendants of the given member are removed first, then the member itself + * is removed from the members map. This method does not update any external lookups; + * it only affects this tree's internal member mapping. + * + * @param characterId the character ID of the root member of the subtree to remove + */ + private void removeSubtree(int characterId) { + FamilyMember member = members.get(characterId); + for (int childId : member.getChildren()) { + removeSubtree(childId); + } + members.remove(characterId); + } + + /** + * Creates a new FamilyTree rooted at the given character ID from this tree, + * including the member and all of its descendants. + * + * This is useful for isolating a subtree before removing it or moving it elsewhere. + * If moving it somewhere else, please reference extractAndRemoveSubTree to be concurrency safe. + * + * @param rootId the character ID of the new subtree root + * @return a new FamilyTree containing the root member and all descendants + * @throws IllegalArgumentException if rootId does not exist in this tree + */ + public FamilyTree createSubTree(int rootId) { + lock(); + try { + FamilyMember rootMember = getMember(rootId); + if (rootMember == null) { + throw new IllegalArgumentException("Character ID not found in this tree: " + rootId); + } + + FamilyTree subTree = new FamilyTree(rootMember); + addChildrenToSubTree(rootMember, subTree); + return subTree; + } finally { + unlock(); + } + } + + /** + * Atomically extracts and removes an entire subtree rooted at the given member ID. + * + * This operation is fully thread-safe and performed under a single tree-wide lock. + * Unlike calling createSubTree() and removeMember() separately, this method ensures + * both actions occur without any other modifications in between. + * + * Behavior details: + * - Validates that the rootId exists (via createSubTree(), which throws if not). + * - Builds a new FamilyTree containing the root member and all of its descendants. + * - Removes the root member—and thus its entire hierarchy—from this tree. + * - Returns the detached subtree, which can be moved or modified independently. + * + * Concurrency note: + * Although createSubTree() has its own locking, this method acquires the lock first, + * ensuring that both subtree creation and removal occur under the same lock scope. + * This prevents races where other threads could mutate the tree between the two steps. + * + * @param rootId the character ID at the root of the subtree to extract + * @return a new FamilyTree containing the extracted subtree + * @throws IllegalArgumentException if rootId does not exist in this tree + */ + public FamilyTree extractAndRemoveSubTree(int rootId) { + lock(); // lock the whole tree + try { + FamilyTree subTree = createSubTree(rootId); + removeMember(rootId); + return subTree; + } finally { + unlock(); + } + } + + /** + * Recursively adds children of a member to the given subtree. + * + * @param member the parent member whose children should be added + * @param subTree the FamilyTree to populate + */ + private void addChildrenToSubTree(FamilyMember member, FamilyTree subTree) { + for (int childId : member.getChildren()) { + FamilyMember child = getMember(childId); + if (child != null) { + subTree.addMember(child, child.getParentId()); + addChildrenToSubTree(child, subTree); + } + } + } + + // ------------------------------------------------------------------------- + // Charts / Pedigrees + // ------------------------------------------------------------------------- + /** + * Builds a pedigree list for the given viewee, representing their family hierarchy + * and immediate relatives in the format expected by the client. + * + * The returned list contains members in the following order: + * 1. The family leader (root of the family tree) + * 2. All senior members (ancestors) of the viewee, from the leader down to the + * direct parent + * 3. The viewee themselves + * 4. The viewee's siblings (other children of the same parent) + * 5. The viewee's juniors (direct children) and super-juniors (grandchildren), + * up to two layers deep + * + * This list is primarily used for encoding the family chart for the client. + * + * @param vieweeId the character ID of the member whose pedigree is being built + * @return a list of FamilyMember objects representing the viewee's family hierarchy, + * including the viewee, ancestors, siblings, and descendants up to two levels + */ + public List buildPedigree(int vieweeId) { + List pedigree = new ArrayList<>(); + FamilyMember viewee = getMember(vieweeId); + if (viewee == null) return pedigree; + + // 1. Leader + FamilyMember leader = getMember(getLeaderId()); + pedigree.add(leader); + + // 2. Seniors (walk up) + List seniors = new ArrayList<>(); + FamilyMember current = viewee; + while (current.getParentId() != null) { + FamilyMember parent = getMember(current.getParentId()); + if (parent == null) { + break; + } + + seniors.add(0, parent); // prepend + current = parent; + } + pedigree.addAll(seniors); + + // 3. Viewee + pedigree.add(viewee); + + Integer parentId = viewee.getParentId(); + if (parentId != null) { + FamilyMember parent = getMember(parentId); + if (parent != null) { + for (int siblingId : parent.getChildren()) { + if (siblingId != vieweeId) { + FamilyMember sibling = getMember(siblingId); + if (sibling != null) pedigree.add(sibling); + } + } + } + } + + + // 5. Juniors / super-juniors (two layers deep max) + List superJuniors = new ArrayList<>(); + for (int childId : viewee.getChildren()) { + FamilyMember junior = getMember(childId); + if (junior != null) { + pedigree.add(junior); + for (int grandChildId : junior.getChildren()) { + FamilyMember superJunior = getMember(grandChildId); + if (superJunior != null) superJuniors.add(superJunior); + } + } + } + pedigree.addAll(superJuniors); + + return pedigree; + } + + /** + * Returns the total number of seniors (ancestors) for a given viewee. + * + * @param vieweeId The character ID of the viewee. + * @return The total number of seniors above the viewee. + */ + public int getTotalSeniors(int vieweeId) { + int count = 0; + FamilyMember current = getMember(vieweeId); + if (current == null) return 0; + + while (current.getParentId() != null) { + FamilyMember parent = getMember(current.getParentId()); + if (parent == null) break; + count++; + current = parent; + } + + return count; + } + + // ------------------------------------------------------------------------- + // Traversal / ordering + // ------------------------------------------------------------------------- + + /** + * Returns ordered children (left/right) by rule: + * lower character ID = left child + */ + public List getOrderedChildren(int parentId) { + FamilyMember parent = members.get(parentId); + List result = new ArrayList<>(parent.getChildren()); + result.sort(Integer::compareTo); + return result; + } + + /** + * Performs a depth-first traversal of the family tree, starting from the leader, + * and applies the given Consumer to each FamilyMember. + * + * Traversal order: + * - Starts at the root (leader) + * - Visits each member before recursively visiting their children + * - Children are visited in the order returned by getOrderedChildren() + * + * This is useful for iterating over all members of the tree in a predictable + * hierarchical order (root → seniors → juniors → super-juniors). + * + * @param consumer a Consumer function that will be called for each FamilyMember + */ + public void forEach(Consumer consumer) { + traverse(leaderId, consumer); + } + + + /** + * Recursive helper for depth-first traversal. + * + * @param characterId the ID of the current member being visited + * @param c the Consumer to apply to each FamilyMember + */ + private void traverse(int characterId, Consumer c) { + FamilyMember m = members.get(characterId); + c.accept(m); + for (int childId : getOrderedChildren(characterId)) { + traverse(childId, c); + } + } + + // ------------------------------------------------------------------------- + // Packet encoding + // ------------------------------------------------------------------------- + + /** + * Encodes the family chart for a given viewee into the provided OutPacket. + * + * This method serializes the following components in order: + * 1. The viewee's character ID. + * 2. The list of family members in the pedigree (leader, seniors, siblings, juniors, and super-juniors), + * with each member encoded via `addPedigreeEntry`. + * 3. The statistics block (v83-like) containing key-value pairs such as total members, total seniors, + * and junior counts for super-juniors. + * 4. The privilege/entitlements block from the viewee's FamilyMember (key-value pairs of entitlements). + * 5. A final short indicating whether the "Add Junior" button should be enabled + * (enabled if the viewee has fewer than 2 children, disabled otherwise). + * + * Note: This method assumes the FamilyTree structure is thread-safe or externally synchronized. + * + * @param out the OutPacket to write the encoded family chart into + * @param vieweeId the character ID of the player viewing the chart + */ + public void encodeChart(OutPacket out, int vieweeId) { + List pedigree = buildPedigree(vieweeId); + + // Part 1: Viewee ID + out.encodeInt(vieweeId); + + // Part 2: The list of all family members in the chart + out.encodeInt(pedigree.size()); + for (FamilyMember member : pedigree) { + addPedigreeEntry(out, member); // Call the new, correct method + } + + // Part 3: The "Statistics" block. This is the v83-like block. + // Based on the disassembly, it reads a count, then key-value pairs. + // Let's replicate the common v83 structure for this. + Map stats = buildStatsMap(vieweeId, pedigree); + out.encodeInt(stats.size()); + for (Map.Entry entry : stats.entrySet()) { + out.encodeInt(entry.getKey()); + out.encodeInt(entry.getValue()); + } + + // Part 4: The "Privilege" block (Entitlements). + // Assuming you have an entitlements map on the viewee's FamilyMember object. + FamilyMember viewee = getMember(vieweeId); + Map entitlements = viewee != null ? viewee.getEntitlements() : Collections.emptyMap(); + out.encodeInt(entitlements.size()); + for (Map.Entry entry : entitlements.entrySet()) { + out.encodeInt(entry.getKey()); + out.encodeInt(entry.getValue()); + } + + // Part 5: The final short for enabling the "Add Junior" button. + if (viewee != null) { + out.encodeShort(viewee.getChildren().size() >= 2 ? 0 : 2); + } else { + out.encodeShort(0); + } + } + + /** + * Encodes a single FamilyMember's information into the given OutPacket, + * ensuring it matches the client's expected Decode format. + * + * The encoded data includes: + * 1. Character ID + * 2. Parent ID (0 if none) + * 3. Job (short) + * 4. Level (byte) + * 5. Online status (1 = online, 0 = offline, byte) + * 6. Reputation + * 7. Total reputation + * 8. Reputation needed to become a senior + * 9. Today's grandparent points (currently sent as 0) + * 10. Channel ID (-1 if offline) + * 11. Minutes online + * 12. Character name (String) + * + * @param out the OutPacket to write the encoded character data into + * @param member the FamilyMember whose data is being encoded + */ + private void addPedigreeEntry(OutPacket out, FamilyMember member) { + out.encodeInt(member.getCharacterId()); + out.encodeInt(member.getParentId() == null ? 0 : member.getParentId()); + out.encodeShort(member.getJob()); + out.encodeByte(member.getLevel()); + out.encodeByte(member.isOnline() ? 1 : 0); + out.encodeInt(member.getReputation()); + out.encodeInt(member.getTotalReputation()); + out.encodeInt(member.getReputationToSenior()); + out.encodeInt(0); // For nTodayGrandParentPoint, send 0 for now. + out.encodeInt(member.getChannelID()); + out.encodeInt(member.getMinutesOnline()); + out.encodeString(member.getName()); + } + + /** + * Builds a statistics map for a given viewee to be sent in the family chart packet. + * + * The map contains key-value pairs representing various family statistics: + * - Key -1: Total number of members in the family. + * - Key 0: Total number of seniors (ancestors) above the viewee. + * - Keys of super-juniors (grandchildren of the viewee): Number of children each has. + * + * This map is used in the packet encoding to provide the client with summary + * statistics about the viewee's position and relationships within the family. + * + * @param vieweeId the character ID of the viewee + * @param pedigree the pre-built pedigree list of FamilyMember objects + * @return a LinkedHashMap preserving insertion order with the computed statistics + */ + private Map buildStatsMap(int vieweeId, List pedigree) { + Map stats = new LinkedHashMap<>(); // Use LinkedHashMap to preserve order + stats.put(-1, members.size()); // Total members + stats.put(0, getTotalSeniors(vieweeId)); // Total seniors for viewee + + // Add junior counts for super-juniors (grandchildren of the viewee) + FamilyMember viewee = getMember(vieweeId); + if (viewee != null) { + for (int childId : viewee.getChildren()) { + FamilyMember junior = getMember(childId); + if (junior != null) { + for (int grandChildId : junior.getChildren()) { + FamilyMember superJunior = getMember(grandChildId); + if (superJunior != null) { + stats.put(superJunior.getCharacterId(), superJunior.getChildren().size()); + } + } + } + } + } + return stats; + } + + // ------------------------------------------------------------------------- + // Locking + // ------------------------------------------------------------------------- + + @Override + public void lock() { + lock.lock(); + } + + @Override + public void unlock() { + lock.unlock(); + } +} diff --git a/src/main/java/kinoko/server/netty/ChannelPacketHandler.java b/src/main/java/kinoko/server/netty/ChannelPacketHandler.java index bfa739dc..49092941 100644 --- a/src/main/java/kinoko/server/netty/ChannelPacketHandler.java +++ b/src/main/java/kinoko/server/netty/ChannelPacketHandler.java @@ -27,6 +27,7 @@ public final class ChannelPacketHandler extends PacketHandler { // User UserHandler.class, PartyHandler.class, + FamilyHandler.class, GuildHandler.class, FriendHandler.class, PetHandler.class, diff --git a/src/main/java/kinoko/server/node/CentralServerNode.java b/src/main/java/kinoko/server/node/CentralServerNode.java index 7f700733..2e4cc281 100644 --- a/src/main/java/kinoko/server/node/CentralServerNode.java +++ b/src/main/java/kinoko/server/node/CentralServerNode.java @@ -5,6 +5,8 @@ import io.netty.channel.socket.SocketChannel; import kinoko.database.DatabaseManager; import kinoko.packet.CentralPacket; +import kinoko.server.family.FamilyStorage; +import kinoko.server.family.FamilyTree; import kinoko.server.guild.Guild; import kinoko.server.guild.GuildMember; import kinoko.server.guild.GuildRank; @@ -22,6 +24,7 @@ import kinoko.server.party.PartyStorage; import kinoko.server.user.RemoteUser; import kinoko.server.user.UserStorage; +import kinoko.world.user.FamilyMember; import kinoko.world.user.User; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -29,14 +32,17 @@ import java.time.Duration; import java.time.Instant; import java.util.ArrayList; +import java.util.Collection; import java.util.List; import java.util.Optional; import java.util.concurrent.CompletableFuture; +import java.util.concurrent.locks.ReentrantLock; public final class CentralServerNode extends Node { private static final Logger log = LogManager.getLogger(CentralServerNode.class); private final ServerStorage serverStorage = new ServerStorage(); private final MigrationStorage migrationStorage = new MigrationStorage(); + private final FamilyStorage familyStorage = new FamilyStorage(); private final UserStorage userStorage = new UserStorage(); private final MessengerStorage messengerStorage = new MessengerStorage(); private final PartyStorage partyStorage = new PartyStorage(); @@ -88,6 +94,7 @@ public List getChannelServerNodes() { // MIGRATION METHODS ----------------------------------------------------------------------------------------------- + public boolean isOnline(int accountId) { return migrationStorage.isMigrating(accountId) || userStorage.getByAccountId(accountId).isPresent(); } @@ -242,6 +249,77 @@ public Optional getGuildById(int guildId) { } + // FAMILY METHODS -------------------------------------------------------------------------------------------------- + + public ReentrantLock getGlobalFamilyLock(){ + return familyStorage.getGlobalLock(); + } + + /** + * Loads all family trees from the database and adds them to the FamilyStorage. + * Each family tree is stored in memory and keyed by its leader ID. + */ + public void createAllFamilies() { + Collection families = DatabaseManager.familyAccessor().getAllFamilies(); + + for (FamilyTree tree : families) { + familyStorage.addFamily(tree); + } + } + + /** + * Retrieves a FamilyMember for the given character ID. + * Returns FamilyMember.EMPTY if the character is not part of any family. + * + * @param characterId the ID of the character to look up + * @return the FamilyMember instance or FamilyMember.EMPTY if not found + */ + public FamilyMember getFamilyInfo(int characterId) { + return familyStorage.getFamilyMember(characterId).orElse(FamilyMember.EMPTY); + } + + /** + * Retrieves the FamilyTree that contains the specified character. + * + * @param characterId the character ID whose family tree is being requested + * @return an Optional containing the FamilyTree if found, otherwise empty + */ + public Optional getFamilyTree(int characterId) { + return familyStorage.getTreeByMemberId(characterId); + } + + /** + * Registers a new FamilyTree in storage, making it available for + * lookups and relationship tracking. + * + * @param tree the FamilyTree to add + */ + public void addFamilyTree(FamilyTree tree) { + familyStorage.addFamily(tree); + } + + /** + * Updates the internal lookup mappings for all members in the given family tree. + * Note: this method currently calls itself recursively and should be replaced + * with the correct implementation (e.g., updating member lookup entries). + * + * @param family the FamilyTree whose member mappings should be refreshed + */ + public void updateFamilyTree(FamilyTree family) { + familyStorage.updateFamilyTree(family); // This is recursive and likely unintended. + } + + /** + * Removes a member from their family by delegating to the underlying FamilyStorage. + * This will remove the member from their FamilyTree and clean up the lookup mapping. + * Perfect for separation when the user has no juniors and is separating from their senior. + * + * @param characterId the ID of the member to remove + */ + public void removeMemberFromFamily(int characterId) { + familyStorage.removeMemberFromFamily(characterId); + } + // OVERRIDES ------------------------------------------------------------------------------------------------------- @Override diff --git a/src/main/java/kinoko/server/packet/OutPacket.java b/src/main/java/kinoko/server/packet/OutPacket.java index 38689a80..cbbf71f9 100644 --- a/src/main/java/kinoko/server/packet/OutPacket.java +++ b/src/main/java/kinoko/server/packet/OutPacket.java @@ -21,6 +21,10 @@ default void encodeByte(int value) { encodeByte((byte) value); } + default void encodeBool(boolean value) { // alias function + encodeByte(value); + } + void encodeShort(short value); default void encodeShort(int value) { diff --git a/src/main/java/kinoko/world/user/FamilyMember.java b/src/main/java/kinoko/world/user/FamilyMember.java new file mode 100644 index 00000000..02a133e5 --- /dev/null +++ b/src/main/java/kinoko/world/user/FamilyMember.java @@ -0,0 +1,177 @@ +package kinoko.world.user; + +import kinoko.server.Server; +import kinoko.server.family.FamilyTree; +import kinoko.server.packet.OutPacket; +import kinoko.util.Encodable; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Map; + +/** + * Represents a single character's family information. + * Combines persistent family tree data (characterId, parentId, children, etc.) + * with ephemeral user-specific data (todaysRep, entitlement usage). + */ +public final class FamilyMember implements Encodable { + + /** + * Default empty instance for characters not in a family. + */ + public static final FamilyMember EMPTY = new FamilyMember( + 0, "", 0, 0, 0, 0, 0, 0, null, Collections.emptyMap() + ); + + // ----------------------------- + // Persistent family data + // ----------------------------- + private final int characterId; + + // ----------------------------- + // Ephemeral/user-specific data + // ----------------------------- + private Integer parentId; + private String name; + private int level; + private int job; + private List children = new ArrayList<>(); + private int currentReputation; + private int totalReputation; + private int todaysReputation; + private int reputationToSenior; + private final Map entitlements; + private long lastSeenUnix; // unix timestamp in seconds + + public FamilyMember(int characterId, String name, int level, int job, int currentReputation, int totalReputation, + int todaysReputation, int reputationToSenior, Integer parentId, Map entitlements) { + this.characterId = characterId; + this.name = name; + this.level = level; + this.job = job; + + this.currentReputation = currentReputation; + this.totalReputation = totalReputation; + this.todaysReputation = todaysReputation; + this.reputationToSenior = reputationToSenior; + + this.parentId = parentId; + this.entitlements = entitlements; + } + + + public void updateUser(User user){ + this.name = user.getCharacterName(); + this.level = user.getLevel(); + this.job = user.getJob(); + } + + // ------------------------------------------------------------ + // Getters + // ------------------------------------------------------------ + + public int getCharacterId() { return characterId; } + public String getName() { return name; } + public int getLevel() { return level; } + public int getJob() { return job; } + public int getReputation() { return currentReputation; } + public int getTotalReputation() { return totalReputation; } + public int getReputationToSenior() { return reputationToSenior; } + public Integer getParentId() { return parentId; } + public void setParentId(Integer parentId) { + this.parentId = parentId; + } + + public List getChildren() { + return Collections.unmodifiableList(children); + } + + public int getChildrenCount() { + return children.size(); + } + + public int getTodaysRep() { return todaysReputation; } + public Map getEntitlements() { return entitlements; } + + // ------------------------------------------------------------ + // Family tree operations + // ------------------------------------------------------------ + + public void addChild(int childId) { + if (!children.contains(childId)) { + children.add(childId); + } + } + + public void removeChild(int childId) { + children.remove((Integer) childId); + } + + // ------------------------------------------------------------ + // Online operations + // ------------------------------------------------------------ + + + public boolean isOnline() { + return Server.getCentralServerNode() + .getUserByCharacterId(characterId) + .isPresent(); + } + + public int getMinutesOnline() { + return (int) ((System.currentTimeMillis() / 1000L - lastSeenUnix) / 60); + } + + public void updateLastLogin() { + lastSeenUnix = System.currentTimeMillis() / 1000L; + } + + public int getChannelID() { + return Server.getCentralServerNode() + .getUserByCharacterId(characterId) + .map(User::getChannelId) + .orElse(-1); // -1 if offline / not found + } + + // ------------------------------------------------------------ + // Packet encoding + // ------------------------------------------------------------ + + @Override + public void encode(OutPacket out) { + out.encodeInt(currentReputation); + out.encodeInt(totalReputation); + out.encodeInt(todaysReputation); + out.encodeShort((short) getChildrenCount()); + out.encodeShort(2); // max juniors + out.encodeShort(0); // unknown + out.encodeInt(parentId == null ? 0 : parentId); + out.encodeString( + isDefault() + ? null + : Server.getCentralServerNode() + .getFamilyTree(characterId) + .map(FamilyTree::getLeader) // get the leader member + .map(FamilyMember::getName) // get the leader's name + .map(name -> name + "'s") // append 's + .orElse(null) // fallback if anything is missing + ); + + // scrolling family message, set to null to be blank + // we can let the family leader modify this in the future. + out.encodeString(isDefault() ? "You have no family :(" : "You have a family :D"); + + out.encodeInt(entitlements.size()); + for (Map.Entry entry : entitlements.entrySet()) { + out.encodeInt(entry.getKey()); + out.encodeInt(entry.getValue()); + } + } + + // ------------------------------------------------------------ + // Utility + // ------------------------------------------------------------ + public boolean isDefault() { + return this == EMPTY; + } +} diff --git a/src/main/java/kinoko/world/user/User.java b/src/main/java/kinoko/world/user/User.java index c3717197..36d813b1 100644 --- a/src/main/java/kinoko/world/user/User.java +++ b/src/main/java/kinoko/world/user/User.java @@ -78,6 +78,7 @@ public final class User extends Life { private int messengerId; private PartyInfo partyInfo; private GuildInfo guildInfo; + private FamilyMember familyInfo; private Dialog dialog; private Dragon dragon; @@ -994,4 +995,15 @@ public void setId(int id) { public void systemMessage(String text, Object... args){ write(MessagePacket.system(text, args)); } + + public FamilyMember getFamilyInfo() { + return familyInfo; + } + + public void setFamilyInfo(FamilyMember familyInfo) { + this.familyInfo = familyInfo; + if (this.familyInfo != null){ + this.familyInfo.updateLastLogin(); + } + } } From b18d5df7a17c332fd77b9105b67ebbe35ac8097e Mon Sep 17 00:00:00 2001 From: MujyKun Date: Wed, 19 Nov 2025 22:12:35 -0500 Subject: [PATCH 73/83] Switched to family global lock --- .../java/kinoko/database/FamilyAccessor.java | 21 + .../postgresql/PostgresFamilyAccessor.java | 21 + .../postgresql/type/FamilyTreeDao.java | 12 + .../handler/stage/MigrationHandler.java | 9 +- .../kinoko/handler/user/FamilyHandler.java | 443 +++++++++--------- .../server/family/FamilyResultType.java | 1 - .../kinoko/server/family/FamilyStorage.java | 45 +- .../java/kinoko/server/family/FamilyTree.java | 323 ++++++------- .../kinoko/server/node/CentralServerNode.java | 227 +++++++-- .../util/exceptions/DumbDeveloperFound.java | 15 + src/main/java/kinoko/world/GameConstants.java | 4 + .../java/kinoko/world/user/FamilyMember.java | 19 +- 12 files changed, 680 insertions(+), 460 deletions(-) create mode 100644 src/main/java/kinoko/util/exceptions/DumbDeveloperFound.java diff --git a/src/main/java/kinoko/database/FamilyAccessor.java b/src/main/java/kinoko/database/FamilyAccessor.java index e14a9bb6..f238b344 100644 --- a/src/main/java/kinoko/database/FamilyAccessor.java +++ b/src/main/java/kinoko/database/FamilyAccessor.java @@ -13,7 +13,28 @@ default Collection getAllFamilies(){ throw new UnsupportedOperationException("This database must implement getting families"); }; + default void saveFamily(FamilyTree family){ + throw new UnsupportedOperationException("This database must implement saving a singular family"); + } + + default void saveFamilies(Collection families){ + throw new UnsupportedOperationException("This database must implement saving families"); + } + + /** + * Saves a collection of FamilyTree instances by delegating to {@link #saveFamilies(Collection)}. + * + * This method performs a null and empty check before calling {@code saveFamilies}, + * ensuring that no unnecessary operations are performed if the collection is empty. + * + * Note: This method is **not intended to be overridden** by implementing classes. + * However, it is a default method in the interface, so technically it **can** be overridden if necessary. + * + * @param families the collection of FamilyTree objects to save + */ default void saveAll(Collection families){ + if (families == null || families.isEmpty()) return; + saveFamilies(families); } } \ No newline at end of file diff --git a/src/main/java/kinoko/database/postgresql/PostgresFamilyAccessor.java b/src/main/java/kinoko/database/postgresql/PostgresFamilyAccessor.java index d7ebca55..271ef887 100644 --- a/src/main/java/kinoko/database/postgresql/PostgresFamilyAccessor.java +++ b/src/main/java/kinoko/database/postgresql/PostgresFamilyAccessor.java @@ -4,6 +4,7 @@ import kinoko.database.FamilyAccessor; import kinoko.database.postgresql.type.FamilyTreeDao; import kinoko.server.family.FamilyTree; +import kinoko.server.guild.Guild; import java.sql.Connection; import java.sql.SQLException; @@ -26,4 +27,24 @@ public Collection getAllFamilies(){ return Collections.emptySet(); } }; + + @Override + public void saveFamily(FamilyTree family){ + try (Connection conn = getConnection()) { + FamilyTreeDao.saveFamilyTree(conn, family); + } catch (SQLException e) { + e.printStackTrace(); + } + } + + @Override + public void saveFamilies(Collection families){ + try (Connection conn = getConnection()) { + FamilyTreeDao.saveAllFamilies(conn, families); + } catch (SQLException e) { + e.printStackTrace(); + } + } + + } diff --git a/src/main/java/kinoko/database/postgresql/type/FamilyTreeDao.java b/src/main/java/kinoko/database/postgresql/type/FamilyTreeDao.java index 2e358002..4b6d3e8c 100644 --- a/src/main/java/kinoko/database/postgresql/type/FamilyTreeDao.java +++ b/src/main/java/kinoko/database/postgresql/type/FamilyTreeDao.java @@ -73,4 +73,16 @@ public static Collection getAllFamilies(Connection conn) throws SQLE return familyTrees; } + + public static void saveFamilyTree(Connection conn, FamilyTree familyTree) throws SQLException { + for (FamilyMember member : familyTree.getAllMembers()) { + FamilyMemberDao.saveFamilyMember(conn, member); + } + } + + public static void saveAllFamilies(Connection conn, Collection families) throws SQLException { + for (FamilyTree familyTree : families) { + saveFamilyTree(conn, familyTree); + } + } } diff --git a/src/main/java/kinoko/handler/stage/MigrationHandler.java b/src/main/java/kinoko/handler/stage/MigrationHandler.java index ef9fc6b8..0e970cda 100644 --- a/src/main/java/kinoko/handler/stage/MigrationHandler.java +++ b/src/main/java/kinoko/handler/stage/MigrationHandler.java @@ -10,6 +10,7 @@ import kinoko.packet.stage.CashShopPacket; import kinoko.packet.stage.StagePacket; import kinoko.packet.user.UserLocal; +import kinoko.packet.world.FamilyPacket; import kinoko.packet.world.FriendPacket; import kinoko.packet.world.MemoPacket; import kinoko.packet.world.WvsContext; @@ -24,6 +25,7 @@ import kinoko.server.messenger.MessengerRequest; import kinoko.server.migration.MigrationInfo; import kinoko.server.migration.TransferInfo; +import kinoko.server.node.CentralServerNode; import kinoko.server.node.ChannelServerNode; import kinoko.server.node.Client; import kinoko.server.node.ServerExecutor; @@ -48,6 +50,7 @@ import java.time.Instant; import java.util.List; import java.util.Optional; +import java.util.concurrent.locks.ReentrantLock; public final class MigrationHandler { private static final Logger log = LogManager.getLogger(MigrationHandler.class); @@ -113,7 +116,11 @@ public static void handleMigrateIn(Client c, InPacket inPacket) { // Initialize User final User user = new User(c, characterData); - user.setFamilyInfo(Server.getCentralServerNode().getFamilyInfo(user.getId())); + + // Set User's Family Info and broadcast initial family packet. + CentralServerNode centralServerNode = Server.getCentralServerNode(); + user.setFamilyInfo(centralServerNode.getFamilyInfo(user.getId())); // under a lock + user.write(FamilyPacket.userFamilyInfo(user)); // no lock needed user.setMessengerId(migrationInfo.getMessengerId()); // this is required before user connect if (channelServerNode.isConnected(user)) { diff --git a/src/main/java/kinoko/handler/user/FamilyHandler.java b/src/main/java/kinoko/handler/user/FamilyHandler.java index 83edd6e8..479a718a 100644 --- a/src/main/java/kinoko/handler/user/FamilyHandler.java +++ b/src/main/java/kinoko/handler/user/FamilyHandler.java @@ -10,6 +10,7 @@ import kinoko.server.node.CentralServerNode; import kinoko.server.packet.InPacket; import kinoko.server.packet.OutPacket; +import kinoko.world.GameConstants; import kinoko.world.user.FamilyMember; import kinoko.world.user.User; import org.apache.logging.log4j.LogManager; @@ -22,36 +23,70 @@ public final class FamilyHandler { private static final Logger log = LogManager.getLogger(FamilyHandler.class); // FAMILY HANDLERS ------------------------------------------------------------------------------------------------- + + /** + * Handles a client's request for their family information. + * + * This method sends the user's FamilyMember information back to the client + * by encoding it into a FamilyInfoResult packet. No global family lock is required + * here because the user already holds a snapshot of their FamilyMember. + * + * Since this snapshot is local to the user and not shared mutable state, + * reading it and sending the packet is thread-safe without acquiring any locks. + * + * @param user the user requesting their family information + * @param inPacket the incoming packet containing the request (unused) + */ @Handler(InHeader.FamilyInfoRequest) public static void handleFamilyInfoRequest(User user, InPacket inPacket) { user.write(FamilyPacket.userFamilyInfo(user)); } + /** + * Handles a client's request for their family chart. + * + * This method generates a FamilyChart packet for the user by reading shared + * family data. The global family lock is acquired only while constructing + * the packet to ensure thread-safe access to shared FamilyTree and FamilyMember + * objects. Once the packet is created, the lock is released before sending + * it to the user, since writing the packet does not require access to shared data. + * + * If the user is not part of a family or the packet cannot be created, + * no packet is sent. + * + * @param user the user requesting their family chart + * @param inPacket the incoming packet containing the request (unused) + */ @Handler(InHeader.FamilyChartRequest) public static void handleFamilyChartRequest(User user, InPacket inPacket) { - OutPacket outPacket = FamilyPacket.userFamilyChart(user); - if (outPacket == null){ - return; + CentralServerNode centralServerNode = Server.getCentralServerNode(); + ReentrantLock lock = centralServerNode.getGlobalFamilyLock(); + lock.lock(); + + OutPacket outPacket; + try { + outPacket = FamilyPacket.userFamilyChart(user); + if (outPacket == null){ + return; + } + } + finally { + lock.unlock(); } + user.write(outPacket); - System.out.println("Handled Family Chart Request"); } /** - * Handles the result of a family join request when a target user responds - * to an invitation from a senior (inviter) to become their junior. + * Handles a family join response when a user accepts or rejects an invitation + * to become a junior of a senior. * - * This method performs the following steps: - * 1. Decodes the inviter ID, inviter name, and whether the invite was accepted from the packet. - * 2. Validates that the inviter exists and is online. - * 3. Initializes or retrieves FamilyMember objects for both the user and the senior. - * 4. Checks if the user is already a junior of another member; if so, the request is rejected. - * 5. Checks if the senior already has the maximum allowed number of juniors (2); if so, the request is rejected. - * 6. Sets the parent ID of the user to the inviter ID to link the hierarchy. - * 7. Ensures the senior has a FamilyTree in the central server node; if not, creates one. - * 8. Moves the user (or their existing subtree) under the senior in the FamilyTree. - * 9. Updates the central server's lookup for faster FamilyTree access. - * 10. Sends the appropriate response packets to both the senior and the user. + * This method: + * - Validates that the inviter exists and both users are eligible. + * - Locks the global family structure to safely update FamilyMember objects + * and FamilyTrees. + * - Updates parent/child relationships and moves any subtrees as needed. + * - Prepares response packets inside the lock, then sends them after unlocking. * * @param user the user responding to the family join request * @param inPacket the packet containing the join result data from the client @@ -72,98 +107,108 @@ public static void handleFamilyJoinResult(User user, InPacket inPacket) { User seniorUser = inviterUserOpt.get(); - FamilyMember userMember; - if (user.getFamilyInfo().isDefault()){ - userMember = new FamilyMember( - user.getCharacterId(), - user.getCharacterName(), - user.getLevel(), - user.getJob(), - 0, - 0, - 0, - 0, - inviterID, - Collections.emptyMap() - ); - } - else { - userMember = user.getFamilyInfo(); - if (userMember.getParentId() != null){ - user.write(FamilyPacket.of(FamilyResultType.AlreadyJuniorOfAnother, 0)); - return; - } - } + // Packets to send after unlocking + OutPacket outToUser; + OutPacket outToSenior; - FamilyMember seniorMember; - if (seniorUser.getFamilyInfo().isDefault()){ - seniorMember = new FamilyMember( - seniorUser.getCharacterId(), - seniorUser.getCharacterName(), - seniorUser.getLevel(), - seniorUser.getJob(), - 0, - 0, - 0, - 0, - null, - Collections.emptyMap() - ); - } - else { - seniorMember = seniorUser.getFamilyInfo(); - } + ReentrantLock lock = centralServerNode.getGlobalFamilyLock(); + lock.lock(); + try { + FamilyMember userMember; + + if (user.getFamilyInfo().isDefault()) { + userMember = new FamilyMember( + user.getCharacterId(), + user.getCharacterName(), + user.getLevel(), + user.getJob(), + 0, + 0, + 0, + 0, + inviterID, + Collections.emptyMap() + ); + } else { + userMember = user.getFamilyInfo(); + if (userMember.getParentId() != null) { + user.write(FamilyPacket.of(FamilyResultType.AlreadyJuniorOfAnother, 0)); + return; + } + } - if (seniorMember.getChildrenCount() >= 2){ - user.write(FamilyPacket.of(FamilyResultType.CannotAddJunior, 0)); - return; - } + FamilyMember seniorMember; + if (seniorUser.getFamilyInfo().isDefault()) { + seniorMember = new FamilyMember( + seniorUser.getCharacterId(), + seniorUser.getCharacterName(), + seniorUser.getLevel(), + seniorUser.getJob(), + 0, + 0, + 0, + 0, + null, + Collections.emptyMap() + ); + } else { + seniorMember = seniorUser.getFamilyInfo(); + } - userMember.setParentId(inviterID); + if (seniorMember.getChildrenCount() >= GameConstants.MAX_FAMILY_CHILDREN_COUNT) { + user.write(FamilyPacket.of(FamilyResultType.CannotAddJunior, 0)); + return; + } - seniorUser.setFamilyInfo(seniorMember); - user.setFamilyInfo(userMember); + userMember.setParentId(inviterID); - // Trees - Optional userTreeOpt = centralServerNode.getFamilyTree(user.getCharacterId()); - Optional seniorTreeOpt = centralServerNode.getFamilyTree(seniorUser.getCharacterId()); + seniorUser.setFamilyInfo(seniorMember); + user.setFamilyInfo(userMember); + // Trees + Optional userTreeOpt = centralServerNode.getFamilyTree(user.getCharacterId()); + Optional seniorTreeOpt = centralServerNode.getFamilyTree(seniorUser.getCharacterId()); + + FamilyTree seniorTree = seniorTreeOpt.orElseGet(() -> { + FamilyTree newTree = new FamilyTree(seniorMember); + centralServerNode.addFamilyTree(newTree); + return newTree; + }); + + // Move the user (or their subtree) under the senior + if (!seniorTree.hasMember(userMember.getCharacterId())) { + if (userTreeOpt.isPresent()) { + seniorTree.addSubTree(userTreeOpt.get(), inviterID); // has their own subtree + } else { + seniorTree.addMember(userMember, inviterID); // single member + } + } - FamilyTree seniorTree = seniorTreeOpt.orElseGet(() -> { - FamilyTree newTree = new FamilyTree(seniorMember); - centralServerNode.addFamilyTree(newTree); - return newTree; - }); + centralServerNode.updateFamilyTree(seniorTree); // update lookups - // Move the user (or their subtree) under the senior - if (!seniorTree.hasMember(userMember.getCharacterId())) { - if (userTreeOpt.isPresent()) { - // User has their own subtree - seniorTree.addSubTree(userTreeOpt.get(), inviterID); - } else { - // User is a single member - seniorTree.addMember(userMember, inviterID); - } + // prepare packets + outToSenior = FamilyPacket.createFamilyJoinRequestResult(user.getCharacterName(), accepted); + outToUser = FamilyPacket.createFamilyJoinAccepted(inviterName); + } + finally { + lock.unlock(); } - centralServerNode.updateFamilyTree(seniorTree); // update lookups + // send packets outside of lock + user.write(outToUser); + seniorUser.write(outToSenior); - seniorUser.write(FamilyPacket.createFamilyJoinRequestResult(user.getCharacterName(), accepted)); - user.write(FamilyPacket.createFamilyJoinAccepted(inviterName)); + updateFamilyDisplay(user); + updateFamilyDisplay(seniorUser); // not necessary, but is smoother if they have the dialog open. } /** - * Handles a request from a user to register another user as their junior in the family system. - * - * This method performs several validations before sending an invite: - * 1. Checks that the target user exists and is online. - * 2. Ensures both users are at least level 10. - * 3. Validates that the level difference between the inviter and target is no more than 50. - * 4. Confirms that the target user is not already a junior of another user. - * 5. Ensures that the inviter and target are not already in the same family. + * Handles a request to register another user as the sender's junior in the family system. * - * If all checks pass, the target user receives a family invite packet from the inviter. - * Otherwise, an appropriate FamilyResultType error is sent to the inviter. + * Validates that the target exists, meets level requirements, is not already a junior, + * and is not in the same family. Uses a global family lock to ensure thread-safe access + * to the family tree during validation and invite creation. If all checks pass, sends a + * family invite to the target. Otherwise, sends an appropriate error to the sender. * * @param user the user sending the junior registration request * @param inPacket the packet containing the target username @@ -173,6 +218,7 @@ public static void handleFamilyRegisterJunior(User user, InPacket inPacket) { String targetUsername = inPacket.decodeString(); CentralServerNode centralServerNode = Server.getCentralServerNode(); + Optional targetUserOpt = centralServerNode.getUserByCharacterName(targetUsername); if (targetUserOpt.isEmpty()){ @@ -183,14 +229,14 @@ public static void handleFamilyRegisterJunior(User user, InPacket inPacket) { User targetUser = targetUserOpt.get(); // Both users must be at least level 10 - if (user.getLevel() < 10 || targetUser.getLevel() < 10) { + if (user.getLevel() < GameConstants.MIN_FAMILY_LEVEL || targetUser.getLevel() < GameConstants.MIN_FAMILY_LEVEL) { user.write(FamilyPacket.of(FamilyResultType.JuniorMustBeOverLevel10, 0)); return; } - // Level gap check (must be within 50 levels) + // Level gap check (must be within x levels) int levelGap = Math.abs(user.getLevel() - targetUser.getLevel()); - if (levelGap > 50) { + if (levelGap > GameConstants.MAX_LEVEL_GAP_FOR_FAMILY) { user.write(FamilyPacket.of(FamilyResultType.LevelGapTooHigh, 0)); return; } @@ -200,15 +246,35 @@ public static void handleFamilyRegisterJunior(User user, InPacket inPacket) { return; } - // make sure both are not in the same family - Optional userTreeOpt = centralServerNode.getFamilyTree(user.getCharacterId()); - Optional targetTreeOpt = centralServerNode.getFamilyTree(targetUser.getCharacterId()); - if (userTreeOpt.isPresent() && targetTreeOpt.isPresent() && userTreeOpt.get() == targetTreeOpt.get()) { - user.write(FamilyPacket.of(FamilyResultType.SameFamily, 0)); - return; + OutPacket userPacket = null; + OutPacket targetPacket = null; + + ReentrantLock lock = centralServerNode.getGlobalFamilyLock(); + lock.lock(); + try { + // make sure both are not in the same family + Optional userTreeOpt = centralServerNode.getFamilyTree(user.getCharacterId()); + Optional targetTreeOpt = centralServerNode.getFamilyTree(targetUser.getCharacterId()); + if (userTreeOpt.isPresent() && targetTreeOpt.isPresent() && userTreeOpt.get() == targetTreeOpt.get()) { + userPacket = FamilyPacket.of(FamilyResultType.SameFamily, 0); // cannot invite. + } + else { + targetPacket = FamilyPacket.createFamilyInvite(user, targetUser); // can invite + } + } + finally { + lock.unlock(); } - targetUser.write(FamilyPacket.createFamilyInvite(user, targetUser)); + // send outside the lock + if (userPacket != null){ + user.write(userPacket); + updateFamilyDisplay(user); + } + if (targetPacket != null) { + targetUser.write(targetPacket); + updateFamilyDisplay(targetUser); // not necessary, but is smoother if they have the dialog open. + } } @@ -223,15 +289,31 @@ public static void handleFamilySummonResult(User user, InPacket inPacket) { System.out.println("Handled FamilySummonResult"); } + /** + * Handles a request to unregister a junior from the user's family. + * + * The global family lock is used while validating the user and junior, + * and while modifying the family trees to ensure thread-safe updates. + * Once packets are prepared, the lock is released before sending them + * to the user and junior, as writing packets does not require locking. + * + * If the junior is invalid, not a child of the user, or offline, the + * user receives an "IncorrectOrOffline" packet. + * + * @param user the user requesting to unregister a junior + * @param inPacket the incoming packet containing the junior ID + */ @Handler(InHeader.FamilyUnregisterJunior) public static void handleFamilyUnregisterJunior(User user, InPacket inPacket) { int juniorID = inPacket.decodeInt(); CentralServerNode centralServerNode = Server.getCentralServerNode(); + OutPacket userResultPacket; + Optional juniorUser = Optional.empty(); + ReentrantLock lock = centralServerNode.getGlobalFamilyLock(); lock.lock(); - try { Optional userTreeOpt = centralServerNode.getFamilyTree(user.getCharacterId()); @@ -241,28 +323,36 @@ public static void handleFamilyUnregisterJunior(User user, InPacket inPacket) { FamilyTree userTree = userTreeOpt.get(); FamilyTree juniorTree = juniorTreeOpt.get(); FamilyMember juniorMember = juniorTree.getMember(juniorID); - if (juniorMember.getParentId() != user.getCharacterId()) { - user.write(FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0)); - return; + Integer parentId = juniorMember.getParentId(); + if (parentId != null && !parentId.equals(user.getCharacterId())) { + userResultPacket = FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0); } + else { + // unregister junior and extract subtree + FamilyTree juniorSubTree = userTree.extractAndRemoveSubTree(juniorID); + centralServerNode.addFamilyTree(juniorSubTree); - // unregister junior and extract subtree - FamilyTree juniorSubTree = userTree.extractAndRemoveSubTree(juniorID); - centralServerNode.addFamilyTree(juniorSubTree); - - // todo: test with bigger trees - // todo: write to the target user if they're online. - user.write(FamilyPacket.unregisterJunior(juniorID)); - + userResultPacket = FamilyPacket.unregisterJunior(juniorID); + juniorUser = centralServerNode.getUserByCharacterId(juniorID); + } } else { // something went wrong - user.write(FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0)); - return; + userResultPacket = FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0); } } finally { lock.unlock(); } + user.write(userResultPacket); + + // Update Family Pedigrees and Information to the user and ex-junior client (if they are online). + updateFamilyDisplay(user); + + juniorUser.ifPresent(targetUser -> { + // let the user know they are no longer a junior 😢 + targetUser.systemMessage("You have been kicked out of your family by %s.", user.getCharacterName()); + updateFamilyDisplay(targetUser); // not necessary, but is smoother if they have the dialog open. + }); } @Handler({InHeader.FamilyUnregisterParent}) @@ -275,109 +365,16 @@ public static void handleFamilyUsePrivilege(User user, InPacket inPacket) { System.out.println("Handled FamilyUsePrivilege"); } -// -// @Handler(InHeader.PartyRequest) -// public static void handlePartyRequest(User user, InPacket inPacket) { -// final int type = inPacket.decodeByte(); -// final PartyRequestType requestType = PartyRequestType.getByValue(type); -// switch (requestType) { -// case CreateNewParty -> { -// // CField::SendCreateNewPartyMsg -// if (user.hasParty()) { -// user.write(PartyPacket.of(PartyResultType.CreateNewParty_AlreadyJoined)); -// return; -// } -// user.getConnectedServer().submitPartyRequest(user, PartyRequest.createNewParty()); -// } -// case WithdrawParty -> { -// // CField::SendWithdrawPartyMsg -// if (!user.hasParty()) { -// user.write(PartyPacket.of(PartyResultType.WithdrawParty_NotJoined)); -// return; -// } -// inPacket.decodeByte(); // hardcoded 0 -// user.getConnectedServer().submitPartyRequest(user, PartyRequest.withdrawParty()); -// } -// case JoinParty -> { -// // CWvsContext::OnPartyResult -// if (user.hasParty()) { -// user.write(PartyPacket.of(PartyResultType.JoinParty_AlreadyJoined)); -// return; -// } -// final int inviterId = inPacket.decodeInt(); -// inPacket.decodeByte(); // unknown byte from InviteParty -// user.getConnectedServer().submitPartyRequest(user, PartyRequest.joinParty(inviterId)); -// } -// case InviteParty -> { -// // CField::SendJoinPartyMsg -// if (user.hasParty() && !user.isPartyBoss()) { -// user.write(PartyPacket.serverMsg("You are not the leader of the party.")); -// return; -// } -// final String targetName = inPacket.decodeString(); -// user.getConnectedServer().submitPartyRequest(user, PartyRequest.invite(targetName)); -// } -// case KickParty -> { -// // CField::SendKickPartyMsg -// if (!user.isPartyBoss()) { -// user.write(PartyPacket.serverMsg("You are not the leader of the party.")); -// return; -// } -// final int targetId = inPacket.decodeInt(); -// user.getConnectedServer().submitPartyRequest(user, PartyRequest.kickParty(targetId)); -// } -// case ChangePartyBoss -> { -// // CField::SendChangePartyBossMsg -// if (!user.isPartyBoss()) { -// user.write(PartyPacket.serverMsg("You are not the leader of the party.")); -// return; -// } -// final int targetId = inPacket.decodeInt(); -// user.getConnectedServer().submitPartyRequest(user, PartyRequest.changePartyBoss(targetId, false)); -// } -// case null -> { -// log.error("Unknown party request type : {}", type); -// } -// default -> { -// log.error("Unhandled party request type : {}", requestType); -// } -// } -// } -// -// @Handler(InHeader.PartyResult) -// public static void handlePartyResult(User user, InPacket inPacket) { -// final int type = inPacket.decodeByte(); -// final PartyResultType resultType = PartyResultType.getByValue(type); -// switch (resultType) { -// case InviteParty_Sent, InviteParty_BlockedUser, InviteParty_AlreadyInvited, -// InviteParty_AlreadyInvitedByInviter, InviteParty_Rejected -> { -// final int inviterId = inPacket.decodeInt(); -// final String message = switch (resultType) { -// // These messages are from the client string pool, but are not used (except for InviteParty_Sent) -// case InviteParty_Sent, InviteParty_BlockedUser -> -// String.format("You have invited '%s' to your party.", user.getCharacterName()); -// case InviteParty_AlreadyInvited -> -// String.format("'%s' is taking care of another invitation.", user.getCharacterName()); -// case InviteParty_AlreadyInvitedByInviter -> -// String.format("You have already invited '%s' to your party.", user.getCharacterName()); -// case InviteParty_Rejected -> -// String.format("'%s' has declined the party request.", user.getCharacterName()); -// default -> { -// throw new IllegalStateException("Unexpected party result type"); -// } -// }; -// user.getConnectedServer().submitUserPacketReceive(inviterId, PartyPacket.serverMsg(message)); -// } -// case InviteParty_Accepted -> { -// final int inviterId = inPacket.decodeInt(); -// user.getConnectedServer().submitPartyRequest(user, PartyRequest.joinParty(inviterId)); -// } -// case null -> { -// log.error("Unknown party result type : {}", type); -// } -// default -> { -// log.error("Unhandled party result type : {}", resultType); -// } -// } -// } + /** + * Sends the latest family chart and family information to the specified user. + * + * This is a convenience method to avoid repeatedly calling + * handleFamilyChartRequest() and handleFamilyInfoRequest() together. + * + * @param user the user whose family data should be refreshed + */ + private static void updateFamilyDisplay(User user) { + handleFamilyChartRequest(user, null); + handleFamilyInfoRequest(user, null); + } } diff --git a/src/main/java/kinoko/server/family/FamilyResultType.java b/src/main/java/kinoko/server/family/FamilyResultType.java index ff1d50bc..cca612c1 100644 --- a/src/main/java/kinoko/server/family/FamilyResultType.java +++ b/src/main/java/kinoko/server/family/FamilyResultType.java @@ -2,7 +2,6 @@ public enum FamilyResultType { // Generic messages - EmptyFamily(0), EntitlementError(2), // Level too low, not eligible, etc. // Success operations diff --git a/src/main/java/kinoko/server/family/FamilyStorage.java b/src/main/java/kinoko/server/family/FamilyStorage.java index f56f4db3..28876545 100644 --- a/src/main/java/kinoko/server/family/FamilyStorage.java +++ b/src/main/java/kinoko/server/family/FamilyStorage.java @@ -2,6 +2,8 @@ import kinoko.world.user.FamilyMember; +import java.util.Collection; +import java.util.Collections; import java.util.Optional; import java.util.concurrent.ConcurrentHashMap; import java.util.concurrent.locks.ReentrantLock; @@ -18,7 +20,37 @@ public final class FamilyStorage { private final ReentrantLock globalFamilyLock = new ReentrantLock(); /** - * Returns the global family lock. + * Returns the global lock for the family storage. + * + * This lock should be used **only by external classes** (such as CentralServerNode or FamilyHandler) + * when performing operations that involve multiple reads/writes across FamilyStorage + * to ensure thread safety. + * + * Important guidelines for usage: + * 1. **Use exclusively for family modifications** – acquire the lock only while + * reading or modifying family data. Do not hold it for network operations, + * database I/O, or other slow tasks, as this lock is highly contended. + * 2. **Avoid combining with other locks** – never acquire this lock while holding + * another lock (or vice versa), as it can easily lead to deadlocks. + * 3. **Keep lock duration short** – perform only the necessary operations inside + * the lock scope to minimize contention, since this lock protects all families + * globally and is used frequently. + * + * Example usage: + * ReentrantLock lock = Server.getCentralServerNode().getGlobalFamilyLock(); + * lock.lock(); + * try { + * // perform only family modifications or reads + * FamilyMember member = familyStorage.getFamilyMember(characterId).orElse(FamilyMember.EMPTY); + * FamilyTree tree = familyStorage.getTreeByMemberId(characterId).orElse(null); + * } finally { + * lock.unlock(); + * } + * + * Internal methods of FamilyStorage do **not acquire this lock** themselves + * and assume that the caller handles synchronization if needed. + * + * @return the ReentrantLock protecting access to the family storage */ public ReentrantLock getGlobalLock() { return globalFamilyLock; @@ -105,4 +137,15 @@ public void removeMemberFromFamily(int characterId) { public ConcurrentHashMap getAllFamilies() { return families; } + + /** + * Returns a collection of all FamilyTree instances stored. + * + * Modifications to the returned collection do not affect the underlying map. + * + * @return an unmodifiable collection of all FamilyTree instances + */ + public Collection getAllFamilyTrees() { + return Collections.unmodifiableCollection(families.values()); + } } diff --git a/src/main/java/kinoko/server/family/FamilyTree.java b/src/main/java/kinoko/server/family/FamilyTree.java index 58e07117..86856027 100644 --- a/src/main/java/kinoko/server/family/FamilyTree.java +++ b/src/main/java/kinoko/server/family/FamilyTree.java @@ -1,23 +1,21 @@ package kinoko.server.family; import kinoko.server.packet.OutPacket; -import kinoko.util.Lockable; +import kinoko.util.exceptions.DumbDeveloperFound; import kinoko.world.user.FamilyMember; import java.util.*; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; import java.util.function.Consumer; /** - * In-memory family tree for a single leader, managing parent-child relationships. - * Supports adding/removing members, pedigree building, DFS traversal, and encoding - * family data for client packets. + * Represents an in-memory family tree rooted at a single leader, managing + * parent-child relationships. Supports adding and removing members, building + * pedigrees, performing DFS traversal, and encoding family data for client packets. + * + * Thread-safety: Individual FamilyTree instances do not require locks, as all + * modifications are protected by the global family lock in FamilyStorage. */ -public final class FamilyTree implements Lockable { - - private final Lock lock = new ReentrantLock(); - +public final class FamilyTree { /** characterId → FamilyMember map */ private final Map members = new HashMap<>(); @@ -45,6 +43,20 @@ public FamilyMember getMember(int characterId) { return members.get(characterId); } + /** + * Returns an unmodifiable collection of all members in this FamilyTree. + * + * The returned collection contains every FamilyMember in the tree, + * including the leader, seniors, juniors, and super-juniors. + * Attempts to modify the returned collection will throw + * an UnsupportedOperationException. + * + * @return an unmodifiable Collection of all FamilyMember instances in the tree + */ + public Collection getAllMembers() { + return Collections.unmodifiableCollection(members.values()); + } + public int getLeaderId() { return leaderId; } @@ -64,28 +76,25 @@ public void changeLeader(FamilyMember newLeader){ * Adds a new member as a child of the specified parent in this FamilyTree. * * If the parent does not exist in the tree or the member is already present, - * the method returns false. The member is added to the parent's list of children - * (duplicates are ignored) and to this tree's internal members map. + * the method returns false. Otherwise, the member is added to the parent's + * list of children (duplicates are ignored) and to this tree's internal members map. * - * Thread-safety: The method acquires a lock to prevent concurrent modifications. + * Note: If adding the child would exceed the maximum allowed children for the parent, + * a {@link kinoko.util.exceptions.DumbDeveloperFound} runtime exception is thrown. * * @param junior the FamilyMember to add * @param parentId the character ID of the parent under whom the member should be added * @return true if the member was successfully added, false otherwise + * @throws DumbDeveloperFound if internal family constraints are violated */ - public boolean addMember(FamilyMember junior, int parentId) { - lock(); - try { - if (!members.containsKey(parentId)) return false; - if (members.containsKey(junior.getCharacterId())) return false; - - FamilyMember parent = members.get(parentId); - parent.addChild(junior.getCharacterId()); // duplicates ignored. - members.put(junior.getCharacterId(), junior); - return true; - } finally { - unlock(); - } + public boolean addMember(FamilyMember junior, int parentId) throws DumbDeveloperFound { + if (!members.containsKey(parentId)) return false; + if (members.containsKey(junior.getCharacterId())) return false; + + FamilyMember parent = members.get(parentId); + parent.addChild(junior.getCharacterId()); // duplicates ignored, may throw DumbDeveloperFound + members.put(junior.getCharacterId(), junior); + return true; } /** @@ -95,31 +104,24 @@ public boolean addMember(FamilyMember junior, int parentId) { * The member is first removed from their parent's list of children (if any), * then the member and all their descendants are removed from the tree. * - * Thread-safety: The method acquires a lock to prevent concurrent modifications. - * * @param characterId the character ID of the member to remove * @return true if the member was successfully removed, false otherwise */ public boolean removeMember(int characterId) { - lock(); - try { - if (!members.containsKey(characterId)) return false; - if (characterId == leaderId) return false; + if (!members.containsKey(characterId)) return false; + if (characterId == leaderId) return false; - FamilyMember member = members.get(characterId); - FamilyMember parent = members.get(member.getParentId()); + FamilyMember member = members.get(characterId); + FamilyMember parent = members.get(member.getParentId()); - if (parent != null) { - parent.removeChild(characterId); - } + if (parent != null) { + parent.removeChild(characterId); + } - member.setParentId(null); + member.setParentId(null); - removeSubtree(characterId); - return true; - } finally { - unlock(); - } + removeSubtree(characterId); + return true; } @@ -131,44 +133,34 @@ public boolean removeMember(int characterId) { * descendants are added recursively. The member map of this tree is updated to * include all members from the subtree. * - * Thread-safety: This method locks the tree during the operation to prevent - * concurrent modifications. - * * @param tree the FamilyTree containing the subtree to add * @param parentId the character ID of the parent in this tree under which the subtree * should be attached * @throws IllegalArgumentException if the specified parent does not exist in this tree */ - public void addSubTree(FamilyTree tree, int parentId) { - lock(); - try { - FamilyMember parent = getMember(parentId); - if (parent == null) { - throw new IllegalArgumentException("Parent not found in this tree: " + parentId); - } + public void addSubTree(FamilyTree tree, int parentId) throws IllegalArgumentException{ + FamilyMember parent = getMember(parentId); + if (parent == null) { + throw new IllegalArgumentException("Parent not found in this tree: " + parentId); + } - FamilyMember subtreeRoot = tree.getLeader(); - addMember(subtreeRoot, parentId); + FamilyMember subtreeRoot = tree.getLeader(); + addMember(subtreeRoot, parentId); - // marge all members of the subtree into this tree's member map - tree.forEach(member -> { - System.out.println(member.getCharacterId()); - if (member != subtreeRoot) { - addMember(member, member.getParentId()); - } - }); - } - finally { - unlock(); - } + // marge all members of the subtree into this tree's member map + tree.forEach(member -> { + if (member != subtreeRoot) { + addMember(member, member.getParentId()); + } + }); } /** * Recursively removes a subtree from this FamilyTree starting at the specified member. * * All descendants of the given member are removed first, then the member itself - * is removed from the members map. This method does not update any external lookups; - * it only affects this tree's internal member mapping. + * is removed from the members map. This method only affects this tree's internal + * member mapping and does not update any external lookups. * * @param characterId the character ID of the root member of the subtree to remove */ @@ -184,67 +176,52 @@ private void removeSubtree(int characterId) { * Creates a new FamilyTree rooted at the given character ID from this tree, * including the member and all of its descendants. * - * This is useful for isolating a subtree before removing it or moving it elsewhere. - * If moving it somewhere else, please reference extractAndRemoveSubTree to be concurrency safe. + * Useful for isolating a subtree before removing it or moving it elsewhere. + * This method does not modify this tree; it only constructs a new FamilyTree + * containing the specified root and its descendants. * * @param rootId the character ID of the new subtree root * @return a new FamilyTree containing the root member and all descendants * @throws IllegalArgumentException if rootId does not exist in this tree */ public FamilyTree createSubTree(int rootId) { - lock(); - try { - FamilyMember rootMember = getMember(rootId); - if (rootMember == null) { - throw new IllegalArgumentException("Character ID not found in this tree: " + rootId); - } - - FamilyTree subTree = new FamilyTree(rootMember); - addChildrenToSubTree(rootMember, subTree); - return subTree; - } finally { - unlock(); + FamilyMember rootMember = getMember(rootId); + if (rootMember == null) { + throw new IllegalArgumentException("Character ID not found in this tree: " + rootId); } + + FamilyTree subTree = new FamilyTree(rootMember); + addChildrenToSubTree(rootMember, subTree); + return subTree; } /** - * Atomically extracts and removes an entire subtree rooted at the given member ID. - * - * This operation is fully thread-safe and performed under a single tree-wide lock. - * Unlike calling createSubTree() and removeMember() separately, this method ensures - * both actions occur without any other modifications in between. - * - * Behavior details: - * - Validates that the rootId exists (via createSubTree(), which throws if not). - * - Builds a new FamilyTree containing the root member and all of its descendants. - * - Removes the root member—and thus its entire hierarchy—from this tree. - * - Returns the detached subtree, which can be moved or modified independently. + * Extracts and removes an entire subtree rooted at the given member ID. * - * Concurrency note: - * Although createSubTree() has its own locking, this method acquires the lock first, - * ensuring that both subtree creation and removal occur under the same lock scope. - * This prevents races where other threads could mutate the tree between the two steps. + * This method creates a new FamilyTree containing the root member and all + * of its descendants, and then removes the root member (and its entire + * hierarchy) from this tree. The detached subtree can then be moved or + * modified independently. * * @param rootId the character ID at the root of the subtree to extract * @return a new FamilyTree containing the extracted subtree * @throws IllegalArgumentException if rootId does not exist in this tree */ public FamilyTree extractAndRemoveSubTree(int rootId) { - lock(); // lock the whole tree - try { - FamilyTree subTree = createSubTree(rootId); - removeMember(rootId); - return subTree; - } finally { - unlock(); - } + FamilyTree subTree = createSubTree(rootId); + removeMember(rootId); + return subTree; } /** - * Recursively adds children of a member to the given subtree. + * Recursively adds the children of a given member to the specified subtree. + * + * This method traverses the hierarchy starting from the given parent member, + * adding each child (and their descendants) to the provided FamilyTree. + * It preserves parent-child relationships within the new subtree. * - * @param member the parent member whose children should be added - * @param subTree the FamilyTree to populate + * @param member the parent member whose children (and their descendants) should be added + * @param subTree the FamilyTree to populate with the subtree members */ private void addChildrenToSubTree(FamilyMember member, FamilyTree subTree) { for (int childId : member.getChildren()) { @@ -260,34 +237,33 @@ private void addChildrenToSubTree(FamilyMember member, FamilyTree subTree) { // Charts / Pedigrees // ------------------------------------------------------------------------- /** - * Builds a pedigree list for the given viewee, representing their family hierarchy + * Builds a pedigree list for the given member, representing their family hierarchy * and immediate relatives in the format expected by the client. * * The returned list contains members in the following order: * 1. The family leader (root of the family tree) - * 2. All senior members (ancestors) of the viewee, from the leader down to the - * direct parent + * 2. All senior members (ancestors) of the viewee, from the leader down to the direct parent * 3. The viewee themselves * 4. The viewee's siblings (other children of the same parent) * 5. The viewee's juniors (direct children) and super-juniors (grandchildren), * up to two layers deep * - * This list is primarily used for encoding the family chart for the client. + * This is used primarily for encoding the family chart for the client. * * @param vieweeId the character ID of the member whose pedigree is being built - * @return a list of FamilyMember objects representing the viewee's family hierarchy, - * including the viewee, ancestors, siblings, and descendants up to two levels + * @return a list of FamilyMember objects representing the viewee's hierarchy, + * including ancestors, siblings, and descendants up to two levels */ public List buildPedigree(int vieweeId) { List pedigree = new ArrayList<>(); FamilyMember viewee = getMember(vieweeId); if (viewee == null) return pedigree; - // 1. Leader + // Leader FamilyMember leader = getMember(getLeaderId()); pedigree.add(leader); - // 2. Seniors (walk up) + // Seniors (walk up) List seniors = new ArrayList<>(); FamilyMember current = viewee; while (current.getParentId() != null) { @@ -301,7 +277,7 @@ public List buildPedigree(int vieweeId) { } pedigree.addAll(seniors); - // 3. Viewee + // Viewee pedigree.add(viewee); Integer parentId = viewee.getParentId(); @@ -318,7 +294,7 @@ public List buildPedigree(int vieweeId) { } - // 5. Juniors / super-juniors (two layers deep max) + // Juniors / super-juniors (two layers deep max) List superJuniors = new ArrayList<>(); for (int childId : viewee.getChildren()) { FamilyMember junior = getMember(childId); @@ -336,10 +312,10 @@ public List buildPedigree(int vieweeId) { } /** - * Returns the total number of seniors (ancestors) for a given viewee. + * Returns the total number of senior members (ancestors) above the specified member. * - * @param vieweeId The character ID of the viewee. - * @return The total number of seniors above the viewee. + * @param vieweeId the character ID of the member + * @return the number of ancestors above the member in the family tree */ public int getTotalSeniors(int vieweeId) { int count = 0; @@ -361,8 +337,11 @@ public int getTotalSeniors(int vieweeId) { // ------------------------------------------------------------------------- /** - * Returns ordered children (left/right) by rule: - * lower character ID = left child + * Returns the children of the specified parent in a deterministic order. + * Ordering rule: lower character ID comes first (left child), higher ID comes second (right child). + * + * @param parentId the character ID of the parent + * @return a list of child character IDs, sorted by ID */ public List getOrderedChildren(int parentId) { FamilyMember parent = members.get(parentId); @@ -373,17 +352,16 @@ public List getOrderedChildren(int parentId) { /** * Performs a depth-first traversal of the family tree, starting from the leader, - * and applies the given Consumer to each FamilyMember. + * applying the given Consumer to each FamilyMember in hierarchical order. * * Traversal order: - * - Starts at the root (leader) - * - Visits each member before recursively visiting their children - * - Children are visited in the order returned by getOrderedChildren() + * 1. Leader (root) + * 2. Seniors → viewee → juniors → super-juniors + * 3. Children are visited in the order returned by getOrderedChildren() * - * This is useful for iterating over all members of the tree in a predictable - * hierarchical order (root → seniors → juniors → super-juniors). + * Useful for iterating over all members in a predictable tree order. * - * @param consumer a Consumer function that will be called for each FamilyMember + * @param consumer a Consumer function applied to each FamilyMember */ public void forEach(Consumer consumer) { traverse(leaderId, consumer); @@ -391,7 +369,7 @@ public void forEach(Consumer consumer) { /** - * Recursive helper for depth-first traversal. + * Helper method for recursive depth-first traversal. * * @param characterId the ID of the current member being visited * @param c the Consumer to apply to each FamilyMember @@ -413,15 +391,15 @@ private void traverse(int characterId, Consumer c) { * * This method serializes the following components in order: * 1. The viewee's character ID. - * 2. The list of family members in the pedigree (leader, seniors, siblings, juniors, and super-juniors), + * 2. The list of family members in the pedigree (leader, seniors, siblings, juniors, super-juniors), * with each member encoded via `addPedigreeEntry`. - * 3. The statistics block (v83-like) containing key-value pairs such as total members, total seniors, - * and junior counts for super-juniors. - * 4. The privilege/entitlements block from the viewee's FamilyMember (key-value pairs of entitlements). + * 3. The statistics block containing total members, total seniors, and grandchild counts. + * 4. The privilege/entitlements block from the viewee's FamilyMember. * 5. A final short indicating whether the "Add Junior" button should be enabled * (enabled if the viewee has fewer than 2 children, disabled otherwise). * - * Note: This method assumes the FamilyTree structure is thread-safe or externally synchronized. + * Note: This method assumes that the FamilyTree is externally synchronized if + * concurrent access is possible. * * @param out the OutPacket to write the encoded family chart into * @param vieweeId the character ID of the player viewing the chart @@ -429,18 +407,18 @@ private void traverse(int characterId, Consumer c) { public void encodeChart(OutPacket out, int vieweeId) { List pedigree = buildPedigree(vieweeId); - // Part 1: Viewee ID + // Viewee ID out.encodeInt(vieweeId); - // Part 2: The list of all family members in the chart + // The list of all family members in the chart out.encodeInt(pedigree.size()); for (FamilyMember member : pedigree) { addPedigreeEntry(out, member); // Call the new, correct method } - // Part 3: The "Statistics" block. This is the v83-like block. - // Based on the disassembly, it reads a count, then key-value pairs. - // Let's replicate the common v83 structure for this. + // The "Statistics" block. This is the v83-like block. + // Based on the v95 disassembly, it reads a count, then key-value pairs. + // Let's replicate the common v83 structure for this from HeavenMS. Map stats = buildStatsMap(vieweeId, pedigree); out.encodeInt(stats.size()); for (Map.Entry entry : stats.entrySet()) { @@ -448,7 +426,7 @@ public void encodeChart(OutPacket out, int vieweeId) { out.encodeInt(entry.getValue()); } - // Part 4: The "Privilege" block (Entitlements). + // The "Privilege" block (Entitlements). // Assuming you have an entitlements map on the viewee's FamilyMember object. FamilyMember viewee = getMember(vieweeId); Map entitlements = viewee != null ? viewee.getEntitlements() : Collections.emptyMap(); @@ -458,7 +436,7 @@ public void encodeChart(OutPacket out, int vieweeId) { out.encodeInt(entry.getValue()); } - // Part 5: The final short for enabling the "Add Junior" button. + // The final short for enabling the "Add Junior" button. if (viewee != null) { out.encodeShort(viewee.getChildren().size() >= 2 ? 0 : 2); } else { @@ -467,25 +445,14 @@ public void encodeChart(OutPacket out, int vieweeId) { } /** - * Encodes a single FamilyMember's information into the given OutPacket, - * ensuring it matches the client's expected Decode format. - * - * The encoded data includes: - * 1. Character ID - * 2. Parent ID (0 if none) - * 3. Job (short) - * 4. Level (byte) - * 5. Online status (1 = online, 0 = offline, byte) - * 6. Reputation - * 7. Total reputation - * 8. Reputation needed to become a senior - * 9. Today's grandparent points (currently sent as 0) - * 10. Channel ID (-1 if offline) - * 11. Minutes online - * 12. Character name (String) - * - * @param out the OutPacket to write the encoded character data into - * @param member the FamilyMember whose data is being encoded + * Encodes a single FamilyMember into the packet in the format expected by the client. + * + * Fields include character ID, parent ID, job, level, online status, reputation, + * total reputation, reputation to senior, grandparent points, channel, minutes online, + * and character name. + * + * @param out the OutPacket to write into + * @param member the FamilyMember to encode */ private void addPedigreeEntry(OutPacket out, FamilyMember member) { out.encodeInt(member.getCharacterId()); @@ -503,19 +470,17 @@ private void addPedigreeEntry(OutPacket out, FamilyMember member) { } /** - * Builds a statistics map for a given viewee to be sent in the family chart packet. + * Builds a statistics map for a viewee, containing: + * - Key -1: Total members in the family + * - Key 0: Total seniors above the viewee + * - Super-juniors (grandchildren): Key = characterId, Value = number of children * - * The map contains key-value pairs representing various family statistics: - * - Key -1: Total number of members in the family. - * - Key 0: Total number of seniors (ancestors) above the viewee. - * - Keys of super-juniors (grandchildren of the viewee): Number of children each has. - * - * This map is used in the packet encoding to provide the client with summary - * statistics about the viewee's position and relationships within the family. + * This map is used in the family chart packet to provide summary info about + * the viewee's position in the family. * * @param vieweeId the character ID of the viewee - * @param pedigree the pre-built pedigree list of FamilyMember objects - * @return a LinkedHashMap preserving insertion order with the computed statistics + * @param pedigree pre-built list of FamilyMember objects in the pedigree + * @return LinkedHashMap preserving insertion order with computed statistics */ private Map buildStatsMap(int vieweeId, List pedigree) { Map stats = new LinkedHashMap<>(); // Use LinkedHashMap to preserve order @@ -539,18 +504,4 @@ private Map buildStatsMap(int vieweeId, List ped } return stats; } - - // ------------------------------------------------------------------------- - // Locking - // ------------------------------------------------------------------------- - - @Override - public void lock() { - lock.lock(); - } - - @Override - public void unlock() { - lock.unlock(); - } } diff --git a/src/main/java/kinoko/server/node/CentralServerNode.java b/src/main/java/kinoko/server/node/CentralServerNode.java index 2e4cc281..86dfd6a8 100644 --- a/src/main/java/kinoko/server/node/CentralServerNode.java +++ b/src/main/java/kinoko/server/node/CentralServerNode.java @@ -38,6 +38,24 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.locks.ReentrantLock; +/** + * Represents the central server node responsible for coordinating all channel servers, + * user data, parties, guilds, messengers, and family information. + * + * This class provides **high-level, thread-safe access** to shared server data via + * wrapper methods. All internal storage objects (FamilyStorage, UserStorage, GuildStorage, + * MessengerStorage, PartyStorage, MigrationStorage, ServerStorage) are **private** and + * should **not be accessed directly** by external classes. + * + * Instead, external code should always interact with data through the provided + * CentralServerNode methods, which handle synchronization, validation, and safe + * concurrent access. This design ensures thread safety and encapsulates the + * internal implementation details, keeping the storage classes simple and fast. + * + * High-level operations, such as modifying families, creating parties, or + * updating guilds, acquire the necessary locks internally and expose only safe + * interfaces for external use. + */ public final class CentralServerNode extends Node { private static final Logger log = LogManager.getLogger(CentralServerNode.class); private final ServerStorage serverStorage = new ServerStorage(); @@ -250,74 +268,168 @@ public Optional getGuildById(int guildId) { // FAMILY METHODS -------------------------------------------------------------------------------------------------- + // High-level family operations for CentralServerNode + // + // These methods provide thread-safe, high-level access to family data, such as + // retrieving members or adding/updating family trees + // Each method acquires the global family lock to ensure safe concurrent access. + // + // Note: The underlying FamilyStorage methods themselves are **not** thread-safe. + // Only these high-level wrapper methods acquire the lock. Direct calls to + // familyStorage should not be made without proper synchronization. + // + // This design ensures that all external access to family data through CentralServerNode + // is safe, while keeping the internal FamilyStorage implementation simple and fast. + /** + * Returns the global lock used to synchronize access to the family storage. + * + * This lock protects all operations on the shared family data structures, + * such as adding, removing, or retrieving FamilyMember instances. + * Since the underlying familyStorage is mutable and may be accessed concurrently + * by multiple threads, any read or write operation should acquire this lock + * to avoid race conditions or inconsistent data. + * + * Important usage notes: + * 1. **Use only for family data operations** – do not hold this lock for network + * writes, database I/O, or other slow tasks. Keep the critical section short. + * 2. **Avoid combining with other locks** – acquiring this lock alongside + * other locks can lead to deadlocks; follow a consistent lock acquisition order. + * 3. **External responsibility** – the underlying FamilyStorage methods do not + * acquire this lock themselves. Any multi-step operation that reads or + * modifies family data should acquire the lock externally. + * + * Example usage: + * getGlobalFamilyLock().lock(); + * try { + * FamilyMember member = familyStorage.getFamilyMember(characterId).orElse(FamilyMember.EMPTY); + * FamilyTree tree = familyStorage.getTreeByMemberId(characterId).orElse(null); + * } finally { + * getGlobalFamilyLock().unlock(); + * } + * + * Note: Even if some call sites already hold this lock before calling + * methods like getFamilyInfo(), acquiring the lock here ensures safety and + * prevents accidental concurrent access elsewhere in the code. + * + * @return the ReentrantLock protecting all family operations + */ public ReentrantLock getGlobalFamilyLock(){ return familyStorage.getGlobalLock(); } /** - * Loads all family trees from the database and adds them to the FamilyStorage. - * Each family tree is stored in memory and keyed by its leader ID. + * Loads all family trees from the database and stores them in memory. + * + * This method retrieves all families via the DatabaseManager and adds each + * FamilyTree to the FamilyStorage, keyed by the family leader's ID. + * + * The operation is performed under the global family lock to ensure thread-safe + * access to the shared FamilyStorage. This prevents concurrent modifications + * that could lead to inconsistent or corrupted family data. + * + * Usage of the global lock ensures that any other thread accessing or modifying + * family data will be properly synchronized during this operation. */ public void createAllFamilies() { - Collection families = DatabaseManager.familyAccessor().getAllFamilies(); + getGlobalFamilyLock().lock(); + try { + Collection families = DatabaseManager.familyAccessor().getAllFamilies(); - for (FamilyTree tree : families) { - familyStorage.addFamily(tree); + for (FamilyTree tree : families) { + familyStorage.addFamily(tree); + } + } + finally { + getGlobalFamilyLock().unlock(); } } /** - * Retrieves a FamilyMember for the given character ID. - * Returns FamilyMember.EMPTY if the character is not part of any family. + * Retrieves the FamilyMember associated with the given character ID. + * + * This method returns FamilyMember.EMPTY if the character is not part of any family. + * Access to the underlying familyStorage is synchronized using the global family lock + * to ensure thread safety, preventing race conditions or inconsistent reads when + * other threads may be modifying the family data concurrently. + * + * Even if some callers already hold the global lock, acquiring the lock here ensures + * that this method is safe to call from anywhere without requiring external synchronization. * * @param characterId the ID of the character to look up - * @return the FamilyMember instance or FamilyMember.EMPTY if not found + * @return the FamilyMember instance corresponding to the characterId, or FamilyMember.EMPTY if not found */ public FamilyMember getFamilyInfo(int characterId) { - return familyStorage.getFamilyMember(characterId).orElse(FamilyMember.EMPTY); + getGlobalFamilyLock().lock(); + try { + return familyStorage.getFamilyMember(characterId).orElse(FamilyMember.EMPTY); + } + finally { + getGlobalFamilyLock().unlock(); + } } /** * Retrieves the FamilyTree that contains the specified character. * + * This method looks up the family tree associated with the given character ID. + * Access to the underlying familyStorage is synchronized using the global family lock + * to ensure thread-safe reads, preventing race conditions if other threads + * are concurrently modifying the family data. + * * @param characterId the character ID whose family tree is being requested - * @return an Optional containing the FamilyTree if found, otherwise empty + * @return an Optional containing the FamilyTree if the character is part of a family, + * or an empty Optional if not found */ public Optional getFamilyTree(int characterId) { - return familyStorage.getTreeByMemberId(characterId); + getGlobalFamilyLock().lock(); + try { + return familyStorage.getTreeByMemberId(characterId); + } + finally { + getGlobalFamilyLock().unlock(); + } } /** - * Registers a new FamilyTree in storage, making it available for - * lookups and relationship tracking. + * Registers a new FamilyTree in storage, making it available for lookups + * and family relationship tracking. + * + * This method adds the given FamilyTree to the shared familyStorage. + * The operation is performed under the global family lock to ensure thread-safe + * modification, preventing concurrent access issues when other threads are + * reading or modifying family data. * * @param tree the FamilyTree to add */ public void addFamilyTree(FamilyTree tree) { - familyStorage.addFamily(tree); + getGlobalFamilyLock().lock(); + try { + familyStorage.addFamily(tree); + } + finally { + getGlobalFamilyLock().unlock(); + } } /** - * Updates the internal lookup mappings for all members in the given family tree. - * Note: this method currently calls itself recursively and should be replaced - * with the correct implementation (e.g., updating member lookup entries). + * Updates the shared family storage to reflect the latest state of the given FamilyTree. * - * @param family the FamilyTree whose member mappings should be refreshed - */ - public void updateFamilyTree(FamilyTree family) { - familyStorage.updateFamilyTree(family); // This is recursive and likely unintended. - } - - /** - * Removes a member from their family by delegating to the underlying FamilyStorage. - * This will remove the member from their FamilyTree and clean up the lookup mapping. - * Perfect for separation when the user has no juniors and is separating from their senior. + * This method registers each member of the FamilyTree in the internal lookup table, + * allowing fast retrieval of a FamilyTree by any character ID. Access is synchronized + * with the global family lock to ensure thread-safe updates while other threads + * may be reading or modifying family data. * - * @param characterId the ID of the member to remove + * @param family the FamilyTree whose members should be registered in storage */ - public void removeMemberFromFamily(int characterId) { - familyStorage.removeMemberFromFamily(characterId); + public void updateFamilyTree(FamilyTree family) { + getGlobalFamilyLock().lock(); + try { + familyStorage.updateFamilyTree(family); // This is recursive and likely unintended. + } + finally { + getGlobalFamilyLock().unlock(); + } } // OVERRIDES ------------------------------------------------------------------------------------------------------- @@ -339,9 +451,7 @@ protected void initChannel(SocketChannel ch) { log.info("Central server listening on port {}", port); // Wait for child node connections - final Instant start = Instant.now(); - initializeFuture.join(); - log.info("All servers connected in {} milliseconds", Duration.between(start, Instant.now()).toMillis()); + logDuration("Connecting All Servers", initializeFuture::join); // Complete initialization for login server node final RemoteServerNode loginServerNode = serverStorage.getLoginServerNode().orElseThrow(); @@ -350,22 +460,49 @@ protected void initChannel(SocketChannel ch) { @Override public void shutdown() throws InterruptedException { - // Save All Guilds - DatabaseManager.guildAccessor().saveAll(guildStorage.getAllGuilds()); - - // Shutdown login server node final Instant start = Instant.now(); - serverStorage.getLoginServerNode().ifPresent((serverNode) -> serverNode.write(CentralPacket.shutdownRequest())); - - // Shutdown channel server nodes - for (RemoteServerNode serverNode : serverStorage.getRemoteChannelServerNodes()) { - serverNode.write(CentralPacket.shutdownRequest()); - } - shutdownFuture.join(); - log.info("All servers disconnected in {} milliseconds", Duration.between(start, Instant.now()).toMillis()); + logDuration("Saving all guilds", () -> { + DatabaseManager.guildAccessor().saveAll(guildStorage.getAllGuilds()); + } + ); + + logDuration("Saving all families", () -> { + DatabaseManager.familyAccessor().saveAll(familyStorage.getAllFamilyTrees()); + }); + + logDuration("Disconnecting All Servers", () -> { + // Shutdown login server node + serverStorage.getLoginServerNode().ifPresent((serverNode) -> serverNode.write(CentralPacket.shutdownRequest())); + + // Shutdown channel server nodes + for (RemoteServerNode serverNode : serverStorage.getRemoteChannelServerNodes()) { + serverNode.write(CentralPacket.shutdownRequest()); + } + shutdownFuture.join(); + }); // Close central server centralServerFuture.channel().close().sync(); log.info("Central server closed"); } + + /** + * Executes the given action and logs the time it took to complete. + * + * This is a utility method to measure and report the duration of a specific task. + * The elapsed time is calculated in milliseconds from the start to the end of the action. + * The action itself is executed synchronously in the current thread. + * + * Example usage: + * logDuration("Saving all guilds", () -> guildAccessor.saveAll(guilds)); + * + * @param taskName a descriptive name for the task being measured; used in the log message + * @param action a Runnable representing the code block whose duration is to be measured + */ + public void logDuration(String taskName, Runnable action) { + Instant start = Instant.now(); + action.run(); + long millis = Duration.between(start, Instant.now()).toMillis(); + log.info("{} completed in {} milliseconds", taskName, millis); + } } diff --git a/src/main/java/kinoko/util/exceptions/DumbDeveloperFound.java b/src/main/java/kinoko/util/exceptions/DumbDeveloperFound.java new file mode 100644 index 00000000..237fc534 --- /dev/null +++ b/src/main/java/kinoko/util/exceptions/DumbDeveloperFound.java @@ -0,0 +1,15 @@ +package kinoko.util.exceptions; + +/** + * Thrown when a situation occurs that is clearly the result of a developer's mistake + * rather than user input or runtime conditions. Typically indicates that an internal + * assumption, invariant, or system design contract has been violated. + * + * This exception is unchecked (extends RuntimeException) because it represents + * a logic or design error that should be fixed in code, not handled at runtime. + */ +public class DumbDeveloperFound extends RuntimeException { + public DumbDeveloperFound(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/world/GameConstants.java b/src/main/java/kinoko/world/GameConstants.java index 18617e51..ea304572 100644 --- a/src/main/java/kinoko/world/GameConstants.java +++ b/src/main/java/kinoko/world/GameConstants.java @@ -110,6 +110,10 @@ public final class GameConstants { public static final int DROP_REMAIN_ON_GROUND_TIME = 120; public static final double DROP_MONEY_PROB = 0.60; + // FAMILY CONSTANTS ------------------------------------------------------------------------------------------------ + public static final int MIN_FAMILY_LEVEL = 10; // min level required for a character to be in a family. + public static final int MAX_LEVEL_GAP_FOR_FAMILY = 50; // max allowed level difference between a senior and junior. + public static final int MAX_FAMILY_CHILDREN_COUNT = 2; // max allowed juniors // REACTOR CONSTANTS ----------------------------------------------------------------------------------------------- diff --git a/src/main/java/kinoko/world/user/FamilyMember.java b/src/main/java/kinoko/world/user/FamilyMember.java index 02a133e5..0847d371 100644 --- a/src/main/java/kinoko/world/user/FamilyMember.java +++ b/src/main/java/kinoko/world/user/FamilyMember.java @@ -4,6 +4,9 @@ import kinoko.server.family.FamilyTree; import kinoko.server.packet.OutPacket; import kinoko.util.Encodable; +import kinoko.util.exceptions.DumbDeveloperFound; +import kinoko.world.GameConstants; + import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -97,10 +100,20 @@ public int getChildrenCount() { // Family tree operations // ------------------------------------------------------------ - public void addChild(int childId) { - if (!children.contains(childId)) { - children.add(childId); + public void addChild(int childId) throws DumbDeveloperFound{ + if (children.contains(childId)) { + return; // Already a child, nothing to do + } + + if (getChildrenCount() >= GameConstants.MAX_FAMILY_CHILDREN_COUNT) { + // Cannot add more children, fail fast before modifying state + throw new DumbDeveloperFound( + "FamilyMember " + getCharacterId() + " exceeded max juniors: " + + GameConstants.MAX_FAMILY_CHILDREN_COUNT + " (attempted to add child " + childId + ")" + ); } + + children.add(childId); } public void removeChild(int childId) { From bdffed6dccf55ee7af9165c00fb0ef6767b8dbea Mon Sep 17 00:00:00 2001 From: MujyKun Date: Thu, 20 Nov 2025 01:47:54 -0500 Subject: [PATCH 74/83] Added entitlements --- .../postgresql/setup/SchemaUpdater.java | 91 ++++++------ .../postgresql/type/FamilyMemberDao.java | 3 +- .../handler/stage/MigrationHandler.java | 1 + .../kinoko/handler/user/FamilyHandler.java | 105 +++++++++++++- .../kinoko/packet/world/FamilyPacket.java | 88 ++++++++---- src/main/java/kinoko/server/Server.java | 53 +++---- .../server/family/FamilyEntitlement.java | 89 ++++++++++++ .../kinoko/server/family/FamilyRequest.java | 133 ------------------ .../server/family/FamilyRequestType.java | 31 ---- .../server/family/FamilyResultType.java | 6 - .../java/kinoko/server/family/FamilyTree.java | 18 ++- .../kinoko/server/node/CentralServerNode.java | 32 +---- .../java/kinoko/util/ThrowingRunnable.java | 16 +++ src/main/java/kinoko/util/Timing.java | 89 ++++++++++++ ....java => DumbDeveloperFoundException.java} | 4 +- .../java/kinoko/world/user/FamilyMember.java | 84 +++++++++-- 16 files changed, 517 insertions(+), 326 deletions(-) create mode 100644 src/main/java/kinoko/server/family/FamilyEntitlement.java delete mode 100644 src/main/java/kinoko/server/family/FamilyRequest.java delete mode 100644 src/main/java/kinoko/server/family/FamilyRequestType.java create mode 100644 src/main/java/kinoko/util/ThrowingRunnable.java create mode 100644 src/main/java/kinoko/util/Timing.java rename src/main/java/kinoko/util/exceptions/{DumbDeveloperFound.java => DumbDeveloperFoundException.java} (79%) diff --git a/src/main/java/kinoko/database/postgresql/setup/SchemaUpdater.java b/src/main/java/kinoko/database/postgresql/setup/SchemaUpdater.java index bf0840d2..08af998a 100644 --- a/src/main/java/kinoko/database/postgresql/setup/SchemaUpdater.java +++ b/src/main/java/kinoko/database/postgresql/setup/SchemaUpdater.java @@ -1,64 +1,73 @@ package kinoko.database.postgresql.setup; +import kinoko.server.Server; +import kinoko.util.Timing; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + import java.nio.file.*; import java.sql.*; import java.io.IOException; public class SchemaUpdater { private static final String UPDATES_DIR = "src/main/java/kinoko/database/postgresql/setup/updates"; + private static final Logger log = LogManager.getLogger(SchemaUpdater.class); - public static void run(Connection connection) throws SQLException, IOException { - long startTime = System.nanoTime(); // start timing - - // get current version - int currentVersion = getSchemaVersion(connection); - System.out.println("Current schema version: " + currentVersion); + public static void run(Connection connection) throws SQLException { + Timing.logDurationThrowing("Schema updater", () -> { + int currentVersion = getSchemaVersion(connection); // get current version - while (true) { - int nextVersion = currentVersion + 1; - Path nextFile = Path.of(UPDATES_DIR, nextVersion + ".sql"); + log.info("Current schema version: {}", currentVersion); - if (!Files.exists(nextFile)) { - System.out.println("No update file found for version " + nextVersion + ". Schema is up-to-date."); - break; - } + while (true) { + int nextVersion = currentVersion + 1; + Path nextFile = Path.of(UPDATES_DIR, nextVersion + ".sql"); - System.out.println("Applying schema update: " + nextFile.getFileName()); + if (!Files.exists(nextFile)) { + log.info("No update file found for version {}. Schema is up-to-date.", nextVersion); + break; + } - String sql = Files.readString(nextFile); + log.info("Applying schema update: {}", nextFile.getFileName()); - try { - // execute migration - connection.setAutoCommit(false); - try (Statement stmt = connection.createStatement()) { - stmt.execute(sql); + String sql; + try { + sql = Files.readString(nextFile); // attempt to read the SQL update file from disk + } catch (IOException e) { + // Wrap any IOException as a SQLException so it can be handled + // in the same catch block using logDurationThrowing. + throw new SQLException("Failed to read SQL update file: " + nextFile.getFileName(), e); } - // increment schema version (inside same transaction) - try (PreparedStatement ps = connection.prepareStatement( - "SELECT versioning.increment_schema_version(?)")) { - ps.setInt(1, currentVersion); - ps.executeQuery(); - } + try { + // execute migration + connection.setAutoCommit(false); + try (Statement stmt = connection.createStatement()) { + stmt.execute(sql); + } - connection.commit(); - currentVersion = nextVersion; + // increment schema version (inside same transaction) + try (PreparedStatement ps = connection.prepareStatement( + "SELECT versioning.increment_schema_version(?)")) { + ps.setInt(1, currentVersion); + ps.executeQuery(); + } - System.out.println("✅ Successfully applied version " + currentVersion); - } catch (SQLException e) { - connection.rollback(); - System.err.println("❌ Failed to apply schema update " + nextFile.getFileName() + ": " + e.getMessage()); - break; - } finally { - connection.setAutoCommit(true); - } - } + connection.commit(); + currentVersion = nextVersion; - long endTime = System.nanoTime(); // end timing - long durationMs = (endTime - startTime) / 1_000_000; // convert to milliseconds - System.out.println("Final schema version: " + currentVersion); - System.out.println("Schema updater completed in " + durationMs + " ms"); + log.info("Successfully applied version {}", currentVersion); + } catch (SQLException e) { + connection.rollback(); + log.error("Failed to apply schema update {}: {}", nextFile.getFileName(), e.getMessage()); + break; + } finally { + connection.setAutoCommit(true); + } + } + log.info("Final schema version: {}", currentVersion); + }, log); } private static int getSchemaVersion(Connection connection) throws SQLException { diff --git a/src/main/java/kinoko/database/postgresql/type/FamilyMemberDao.java b/src/main/java/kinoko/database/postgresql/type/FamilyMemberDao.java index 6a5c1a7b..1dcf0b33 100644 --- a/src/main/java/kinoko/database/postgresql/type/FamilyMemberDao.java +++ b/src/main/java/kinoko/database/postgresql/type/FamilyMemberDao.java @@ -58,8 +58,7 @@ public static List getAllFamilyMembers(Connection conn) throws SQL totalReputation, 0, repsToSenior, - parentId, - Collections.emptyMap() + parentId ); members.add(member); diff --git a/src/main/java/kinoko/handler/stage/MigrationHandler.java b/src/main/java/kinoko/handler/stage/MigrationHandler.java index 0e970cda..4c313498 100644 --- a/src/main/java/kinoko/handler/stage/MigrationHandler.java +++ b/src/main/java/kinoko/handler/stage/MigrationHandler.java @@ -121,6 +121,7 @@ public static void handleMigrateIn(Client c, InPacket inPacket) { CentralServerNode centralServerNode = Server.getCentralServerNode(); user.setFamilyInfo(centralServerNode.getFamilyInfo(user.getId())); // under a lock user.write(FamilyPacket.userFamilyInfo(user)); // no lock needed + user.write(FamilyPacket.loadFamilyEntitlements()); user.setMessengerId(migrationInfo.getMessengerId()); // this is required before user connect if (channelServerNode.isConnected(user)) { diff --git a/src/main/java/kinoko/handler/user/FamilyHandler.java b/src/main/java/kinoko/handler/user/FamilyHandler.java index 479a718a..1f53b265 100644 --- a/src/main/java/kinoko/handler/user/FamilyHandler.java +++ b/src/main/java/kinoko/handler/user/FamilyHandler.java @@ -126,8 +126,7 @@ public static void handleFamilyJoinResult(User user, InPacket inPacket) { 0, 0, 0, - inviterID, - Collections.emptyMap() + inviterID ); } else { userMember = user.getFamilyInfo(); @@ -148,8 +147,7 @@ public static void handleFamilyJoinResult(User user, InPacket inPacket) { 0, 0, 0, - null, - Collections.emptyMap() + null ); } else { seniorMember = seniorUser.getFamilyInfo(); @@ -259,7 +257,7 @@ public static void handleFamilyRegisterJunior(User user, InPacket inPacket) { userPacket = FamilyPacket.of(FamilyResultType.SameFamily, 0); // cannot invite. } else { - targetPacket = FamilyPacket.createFamilyInvite(user, targetUser); // can invite + targetPacket = FamilyPacket.createFamilyInvite(user); // can invite } } finally { @@ -349,15 +347,108 @@ public static void handleFamilyUnregisterJunior(User user, InPacket inPacket) { updateFamilyDisplay(user); juniorUser.ifPresent(targetUser -> { - // let the user know they are no longer a junior 😢 + // let the user know they are an orphan 😢 targetUser.systemMessage("You have been kicked out of your family by %s.", user.getCharacterName()); updateFamilyDisplay(targetUser); // not necessary, but is smoother if they have the dialog open. }); } + /** + * Handles a request from a user to unregister themselves from their parent (senior) in the family system. + * + * The client triggers this handler using the {@link InHeader#FamilyUnregisterParent} packet. + * This request removes the user from their parent's family tree and creates a separate family tree + * for the user if necessary. + * + * Thread safety is ensured using the global family lock while validating the user and parent, + * and while modifying the family trees. + * + * Key behaviors: + * - If the user has no parent or their parent tree cannot be found, the user receives an + * {@link FamilyResultType#IncorrectOrOffline} response. + * - If successful, the user's subtree is extracted from the parent's tree and added as a new tree. + * - The user receives a success packet notifying them they have been removed from the parent's family. + * - If the parent is online, they are notified via a system message that the user has left their family. + * + * Note: The incoming packet contains no additional data beyond the header. The server + * determines the parent to unregister by looking up the user's current family tree. + * + * @param user the user requesting to unregister from their parent + * @param inPacket the incoming packet (header only; no payload is used) + */ @Handler({InHeader.FamilyUnregisterParent}) public static void handleFamilyUnregisterParent(User user, InPacket inPacket) { - System.out.println("Handled FamilyUnregisterParent"); + CentralServerNode centralServerNode = Server.getCentralServerNode(); + + OutPacket userResultPacket; + Optional parentUser; + + Integer parentId = null; + ReentrantLock lock = centralServerNode.getGlobalFamilyLock(); + lock.lock(); + try { + Optional userTreeOpt = centralServerNode.getFamilyTree(user.getCharacterId()); + + if (userTreeOpt.isPresent()) { + FamilyTree userTree = userTreeOpt.get(); + FamilyMember userMember = userTree.getMember(user.getCharacterId()); + parentId = userMember.getParentId(); + + if (parentId == null) { + userResultPacket = FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0); + } + else { + Optional parentTreeOpt = centralServerNode.getFamilyTree(parentId); + + if (parentTreeOpt.isPresent()) { + FamilyTree parentTree = parentTreeOpt.get(); + + if (parentTree != userTree){ + // Should never occur if coded correctly, identical to DumbDeveloperFoundException. + log.error( + "Family tree mismatch detected: user [{}] (ID: {}) parent (ID: {}) " + + "trees do not match. UserTree={}, ParentTree={}", + user.getCharacterName(), + user.getCharacterId(), + parentId, + userTree.getLeaderId(), + parentTree.getLeaderId() + ); + userResultPacket = FamilyPacket.of(FamilyResultType.DifferentFamily, 0); + } + else { + // the junior's current tree becomes a new, separate family tree + FamilyTree userNewTree = parentTree.extractAndRemoveSubTree(user.getCharacterId()); + centralServerNode.addFamilyTree(userNewTree); + + // notify the user of the success + userResultPacket = FamilyPacket.unregisterJunior(user.getCharacterId()); + } + } else { + // data inconsistency, parent's tree not found + userResultPacket = FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0); + } + } + } else { // user's own tree not found, something is wrong + userResultPacket = FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0); + } + } finally { + lock.unlock(); + } + + user.write(userResultPacket); + + // Update the family pedigree and info for the user who just left. + updateFamilyDisplay(user); + + if (parentId != null) { // let the senior know. + parentUser = centralServerNode.getUserByCharacterId(parentId); + + parentUser.ifPresent(targetUser -> { + targetUser.systemMessage("%s has left your family.", user.getCharacterName()); + updateFamilyDisplay(targetUser); // not necessary, but is smoother if they have the dialog open. + }); + } } @Handler({InHeader.FamilyUsePrivilege}) diff --git a/src/main/java/kinoko/packet/world/FamilyPacket.java b/src/main/java/kinoko/packet/world/FamilyPacket.java index eb269154..abf8f255 100644 --- a/src/main/java/kinoko/packet/world/FamilyPacket.java +++ b/src/main/java/kinoko/packet/world/FamilyPacket.java @@ -1,6 +1,7 @@ package kinoko.packet.world; import kinoko.server.Server; +import kinoko.server.family.FamilyEntitlement; import kinoko.server.family.FamilyResultType; import kinoko.server.family.FamilyTree; import kinoko.server.header.OutHeader; @@ -11,16 +12,6 @@ import java.util.Optional; public final class FamilyPacket { - // CWvsContext::OnFamilyResult ------------------------------------------------------------------------------------- - public static OutPacket registerJuniorSuccess(User leader, User junior) { - final OutPacket outPacket = FamilyPacket.of(FamilyResultType.RegisterJunior_Success, 0); - outPacket.encodeInt(junior.getCharacterId()); - outPacket.encodeString(junior.getCharacterName()); - outPacket.encodeInt(junior.getLevel()); - outPacket.encodeInt(junior.getJob()); - return outPacket; - } - /** * CWvsContext::OnFamilyJoinRequest * Builds a Family Join Request / Invite packet. @@ -37,10 +28,9 @@ public static OutPacket registerJuniorSuccess(User leader, User junior) { * String requesterName * * @param senior The user sending the invite (senior) - * @param targetUser The user being invited (junior) * @return The encoded packet to send to the client */ - public static OutPacket createFamilyInvite(User senior, User targetUser) { + public static OutPacket createFamilyInvite(User senior) { final OutPacket outPacket = OutPacket.of(OutHeader.FamilyJoinRequest); outPacket.encodeInt(senior.getCharacterId()); @@ -80,25 +70,33 @@ public static OutPacket createFamilyJoinAccepted(String seniorName) { return outPacket; } + /** + * Creates a packet notifying the client that a junior has been unregistered + * (removed) from their Family. + * + * This packet encodes the {@link FamilyResultType#UnregisterJunior} result + * followed by the character ID of the junior being removed. The client uses + * this to update the Family UI and internal Family state. + * + * @param juniorId the character ID of the junior to unregister + * @return an {@link OutPacket} ready to be sent to the client + */ public static OutPacket unregisterJunior(int juniorId) { final OutPacket outPacket = FamilyPacket.of(FamilyResultType.UnregisterJunior, 0); outPacket.encodeInt(juniorId); return outPacket; } - public static OutPacket summonJunior(User leader, User junior) { - final OutPacket outPacket = FamilyPacket.of(FamilyResultType.SummonJunior, 0); - outPacket.encodeInt(junior.getCharacterId()); - outPacket.encodeString(junior.getCharacterName()); - return outPacket; - } - - public static OutPacket entitlementError(String message) { - final OutPacket outPacket = FamilyPacket.of(FamilyResultType.EntitlementError, 0); - outPacket.encodeString(message != null ? message : ""); - return outPacket; - } - + /** + * Creates a packet containing the calling user's current Family information. + * + * If the user has no associated {@link FamilyMember} data, a default + * {@link FamilyMember#EMPTY} instance is encoded instead. This packet is sent + * to the client to update its Family UI and internal Family state. + * + * @param user the user whose Family information should be encoded + * @return an {@link OutPacket} containing the user's Family info + */ public static OutPacket userFamilyInfo(User user) { FamilyMember familyInfo = user.getFamilyInfo(); if (familyInfo == null){ @@ -109,6 +107,19 @@ public static OutPacket userFamilyInfo(User user) { return outPacket; } + /** + * Creates a packet containing the Family Chart (pedigree) information + * for the specified user. + * + * If the user belongs to a FamilyTree, this method encodes the user's + * position and related family structure using + * {@link FamilyTree#encodeChart(OutPacket, int)}. + * If the user is not part of any FamilyTree, this method returns {@code null}. + * + * @param user the user whose Family Chart should be encoded + * @return an {@link OutPacket} with the encoded family chart, or {@code null} + * if the user is not in a family + */ public static OutPacket userFamilyChart(User user) { Optional optionalTree = Server.getCentralServerNode().getFamilyTree(user.getCharacterId()); final OutPacket outPacket = OutPacket.of(OutHeader.FamilyChartResult); @@ -124,6 +135,33 @@ public static OutPacket userFamilyChart(User user) { return outPacket; } + /** + * CWvsContext::OnFamilyPrivilegeList + * Creates a packet containing all family entitlements and their details. + * + * This packet is used to send entitlement information to the client, including: + * - type: whether the entitlement affects the player individually (1) or the whole family (2) + * - repCost: reputation points required to use the entitlement + * - usageLimit: maximum number of times the entitlement can be used + * - name: display name of the entitlement + * - description: detailed effect description + * + * @return an OutPacket containing all encoded FamilyEntitlement data + */ + public static OutPacket loadFamilyEntitlements() { + final OutPacket outPacket = OutPacket.of(OutHeader.FamilyPrivilegeList); + outPacket.encodeInt(FamilyEntitlement.values().length); + for (FamilyEntitlement entitlement : FamilyEntitlement.values()) { + outPacket.encodeByte(entitlement.getType()); + outPacket.encodeInt(entitlement.getRepCost()); + outPacket.encodeInt(entitlement.getUsageLimit()); + outPacket.encodeString(entitlement.getName()); + outPacket.encodeString(entitlement.getDescription()); + } + + return outPacket; + } + /** * Builds a Family Result packet. * diff --git a/src/main/java/kinoko/server/Server.java b/src/main/java/kinoko/server/Server.java index b30aa517..034e1402 100644 --- a/src/main/java/kinoko/server/Server.java +++ b/src/main/java/kinoko/server/Server.java @@ -13,9 +13,8 @@ import kinoko.util.crypto.MapleCrypto; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; +import static kinoko.util.Timing.logDuration; -import java.time.Duration; -import java.time.Instant; public final class Server { private static final Logger log = LogManager.getLogger(Server.class); @@ -25,24 +24,23 @@ public static void main(String[] args) throws Exception { Server.initialize(); } - private static void initialize() throws Exception { // Initialize providers - Instant start = Instant.now(); - ItemProvider.initialize(); // Character.wz + Item.wz - SkillProvider.initialize(); // Skill.wz + Morph.wz - MapProvider.initialize(); // Map.wz - MobProvider.initialize(); // Mob.wz - NpcProvider.initialize(); // Npc.wz - ReactorProvider.initialize(); // Reactor.wz - QuestProvider.initialize(); // Quest.wz - StringProvider.initialize(); // String.wz - EtcProvider.initialize(); // Etc.wz - ShopProvider.initialize(); // data/shop - RewardProvider.initialize(); // data/reward - CashShop.initialize(); // data/cash - System.gc(); - log.info("Loaded providers in {} milliseconds", Duration.between(start, Instant.now()).toMillis()); + logDuration("Loading providers", () -> { + ItemProvider.initialize(); // Character.wz + Item.wz + SkillProvider.initialize(); // Skill.wz + Morph.wz + MapProvider.initialize(); // Map.wz + MobProvider.initialize(); // Mob.wz + NpcProvider.initialize(); // Npc.wz + ReactorProvider.initialize(); // Reactor.wz + QuestProvider.initialize(); // Quest.wz + StringProvider.initialize(); // String.wz + EtcProvider.initialize(); // Etc.wz + ShopProvider.initialize(); // data/shop + RewardProvider.initialize(); // data/reward + CashShop.initialize(); // data/cash + System.gc(); + }, log); // Initialize server classes MapleCrypto.initialize(); @@ -50,27 +48,19 @@ private static void initialize() throws Exception { CommandProcessor.initialize(); // Initialize database - start = Instant.now(); - DatabaseManager.initialize(); - log.info("Loaded database connection in {} milliseconds", Duration.between(start, Instant.now()).toMillis()); + logDuration("Loaded database connection", DatabaseManager::initialize, log); // Initialize ranks - start = Instant.now(); - RankManager.initialize(); - log.info("Loaded ranks in {} milliseconds", Duration.between(start, Instant.now()).toMillis()); + logDuration("Loaded ranks", RankManager::initialize, log); // Initialize scripts - start = Instant.now(); - ScriptDispatcher.initialize(); - log.info("Loaded scripts in {} milliseconds", Duration.between(start, Instant.now()).toMillis()); + logDuration("Loaded scripts", ScriptDispatcher::initialize, log); // Initialize nodes centralServerNode = new CentralServerNode(ServerConstants.CENTRAL_PORT); - start = Instant.now(); - centralServerNode.createAllFamilies(); - log.info("Loaded families in {} milliseconds", Duration.between(start, Instant.now()).toMillis()); - + // Initialize families + logDuration("Loaded families", centralServerNode::createAllFamilies, log); ServerExecutor.submitService(() -> { try { @@ -126,3 +116,4 @@ public static CentralServerNode getCentralServerNode() { return centralServerNode; } } + diff --git a/src/main/java/kinoko/server/family/FamilyEntitlement.java b/src/main/java/kinoko/server/family/FamilyEntitlement.java new file mode 100644 index 00000000..75588ea0 --- /dev/null +++ b/src/main/java/kinoko/server/family/FamilyEntitlement.java @@ -0,0 +1,89 @@ +package kinoko.server.family; + + +/** + * Represents a Family Entitlement in the server. + * + * Each entitlement defines a privilege or buff that a player or family can use. + * + * Fields: + * - usageLimit: the maximum number of times this entitlement can be used. + * - repCost: the cost in reputation points to use this entitlement. + * - expiresAfterMinutes: how long the effect lasts in minutes. + * - name: display name of the entitlement. + * - description: detailed description of the effect. + * - type (byte): determines the target of the entitlement: + * 1 = affects the player individually (self or single target) + * 2 = affects all family members (group-wide effect) + * + * Example usage: + * FamilyEntitlement.FAMILY_EXP.getType(); // returns 2 + */ +public enum FamilyEntitlement { + FAMILY_REUNION(1, 100, "Family Reunion", + "[Target] Me\n[Effect] Teleport directly to the Family member of your choice.", + 1440, (byte) 1), + + SUMMON_FAMILY(1, 200, "Summon Family", + "[Target] 1 Family member\n[Effect] Summon a Family member of choice to the map you're in.", + 1440, (byte) 1), + + FAMILY_HASTE(1, 500, "Quicker Together", + "[Target] All Family Members\n[Effect] All family members, regardless of map, " + + "are blessed with Family Haste.", + 1440, (byte) 2), + + FAMILY_EXP(1, 5000, "A Better Experience", + "[Target] All Family Members\n[Effect] For 15 minutes, all family members receive 1.2x experience, " + + "regardless of map.", + 1440, (byte) 2), + + FAMILY_DROP(1, 5000, "All The Drops", + "[Target] All Family Members\n[Effect] For 15 minutes, " + + "all family members receive 1.2x drop rate, regardless of map.", + 1440, (byte) 2), + + SELF_DROP_1_5(1, 8000, "My Drop Rate 1.5x (15 min)", + "[Target] Me\n[Time] 15 min\n[Effect] Monster drop rate will be increased #c1.5x#.", + 1440, (byte) 1), + + SELF_EXP_1_5(1, 8000, "My EXP 1.5x (15 min)", + "[Target] Me\n[Time] 15 min\n[Effect] EXP earned from hunting will be increased #c1.5x#.", + 1440, (byte) 1); + + private final int usageLimit, repCost, expiresAfterMinutes; + private final String name, description; + private final byte type; + + FamilyEntitlement(int usageLimit, int repCost, String name, String description, int expiresAfterMinutes, byte type) { + this.usageLimit = usageLimit; + this.repCost = repCost; + this.name = name; + this.description = description; + this.expiresAfterMinutes = expiresAfterMinutes; + this.type = type; + } + + public int getUsageLimit() { + return usageLimit; + } + + public int getRepCost() { + return repCost; + } + + public int getExpiresAfterMinutes(){ return expiresAfterMinutes;} + + public String getName() { + return name; + } + + public String getDescription() { + return description; + } + + public byte getType(){ + return type; + } +} + diff --git a/src/main/java/kinoko/server/family/FamilyRequest.java b/src/main/java/kinoko/server/family/FamilyRequest.java deleted file mode 100644 index ab749f92..00000000 --- a/src/main/java/kinoko/server/family/FamilyRequest.java +++ /dev/null @@ -1,133 +0,0 @@ -package kinoko.server.family; - -import kinoko.server.header.CentralHeader; -import kinoko.server.packet.InPacket; -import kinoko.server.packet.OutPacket; -import kinoko.util.Encodable; - -/** - * Utility class for {@link CentralHeader#PartyRequest} - */ -public final class FamilyRequest implements Encodable { - private final FamilyRequestType requestType; - private int partyId; - private int characterId; - private String characterName; - private boolean isDisconnect; - - FamilyRequest(FamilyRequestType requestType) { - this.requestType = requestType; - } - - public FamilyRequestType getRequestType() { - return requestType; - } - - public int getPartyId() { - return partyId; - } - - public int getCharacterId() { - return characterId; - } - - public String getCharacterName() { - return characterName; - } - - public boolean isDisconnect() { - return isDisconnect; - } - - @Override - public void encode(OutPacket outPacket) { - outPacket.encodeByte(requestType.getValue()); - switch (requestType) { - case LoadParty -> { - outPacket.encodeInt(partyId); - } - case CreateNewParty, WithdrawParty -> { - // no encodes - } - case JoinParty, KickParty -> { - outPacket.encodeInt(characterId); - } - case InviteParty -> { - outPacket.encodeString(characterName); - } - case ChangePartyBoss -> { - outPacket.encodeInt(characterId); - outPacket.encodeByte(isDisconnect); - } - } - } - - public static FamilyRequest decode(InPacket inPacket) { - final int type = inPacket.decodeByte(); - final FamilyRequest request = new FamilyRequest(FamilyRequestType.getByValue(type)); - switch (request.requestType) { - case LoadParty -> { - request.partyId = inPacket.decodeInt(); - } - case CreateNewParty, WithdrawParty -> { - // no decodes - } - case JoinParty, KickParty -> { - request.characterId = inPacket.decodeInt(); - } - case InviteParty -> { - request.characterName = inPacket.decodeString(); - } - case ChangePartyBoss -> { - request.characterId = inPacket.decodeInt(); - request.isDisconnect = inPacket.decodeBoolean(); - } - case null -> { - throw new IllegalStateException(String.format("Unknown party request type %d", type)); - } - default -> { - throw new IllegalStateException(String.format("Unhandled party request type %d", type)); - } - } - return request; - } - - public static FamilyRequest loadParty(int partyId) { - final FamilyRequest request = new FamilyRequest(FamilyRequestType.LoadParty); - request.partyId = partyId; - return request; - } - - public static FamilyRequest createNewParty() { - return new FamilyRequest(FamilyRequestType.CreateNewParty); - } - - public static FamilyRequest withdrawParty() { - return new FamilyRequest(FamilyRequestType.WithdrawParty); - } - - public static FamilyRequest joinParty(int inviterId) { - final FamilyRequest request = new FamilyRequest(FamilyRequestType.JoinParty); - request.characterId = inviterId; - return request; - } - - public static FamilyRequest invite(String characterName) { - final FamilyRequest request = new FamilyRequest(FamilyRequestType.InviteParty); - request.characterName = characterName; - return request; - } - - public static FamilyRequest kickParty(int targetId) { - final FamilyRequest request = new FamilyRequest(FamilyRequestType.KickParty); - request.characterId = targetId; - return request; - } - - public static FamilyRequest changePartyBoss(int targetId, boolean isDisconnect) { - final FamilyRequest request = new FamilyRequest(FamilyRequestType.ChangePartyBoss); - request.characterId = targetId; - request.isDisconnect = isDisconnect; - return request; - } -} diff --git a/src/main/java/kinoko/server/family/FamilyRequestType.java b/src/main/java/kinoko/server/family/FamilyRequestType.java deleted file mode 100644 index 4fa8bed6..00000000 --- a/src/main/java/kinoko/server/family/FamilyRequestType.java +++ /dev/null @@ -1,31 +0,0 @@ -package kinoko.server.family; - -public enum FamilyRequestType { - // PartyReq - LoadParty(0), - CreateNewParty(1), - WithdrawParty(2), - JoinParty(3), - InviteParty(4), - KickParty(5), - ChangePartyBoss(6); - - private final int value; - - FamilyRequestType(int value) { - this.value = value; - } - - public final int getValue() { - return value; - } - - public static FamilyRequestType getByValue(int value) { - for (FamilyRequestType type : values()) { - if (type.getValue() == value) { - return type; - } - } - return null; - } -} diff --git a/src/main/java/kinoko/server/family/FamilyResultType.java b/src/main/java/kinoko/server/family/FamilyResultType.java index cca612c1..22516cfa 100644 --- a/src/main/java/kinoko/server/family/FamilyResultType.java +++ b/src/main/java/kinoko/server/family/FamilyResultType.java @@ -1,14 +1,8 @@ package kinoko.server.family; public enum FamilyResultType { - // Generic messages - EntitlementError(2), // Level too low, not eligible, etc. - // Success operations - UnregisterJunior(1), // Junior removed / family ties severed - RegisterJunior_Success(10), // Successfully registered a junior - SummonJunior(12), // Summoning a junior // Error / warning messages CannotAddJunior(64), // 0x40 diff --git a/src/main/java/kinoko/server/family/FamilyTree.java b/src/main/java/kinoko/server/family/FamilyTree.java index 86856027..2585b0e7 100644 --- a/src/main/java/kinoko/server/family/FamilyTree.java +++ b/src/main/java/kinoko/server/family/FamilyTree.java @@ -1,7 +1,7 @@ package kinoko.server.family; import kinoko.server.packet.OutPacket; -import kinoko.util.exceptions.DumbDeveloperFound; +import kinoko.util.exceptions.DumbDeveloperFoundException; import kinoko.world.user.FamilyMember; import java.util.*; @@ -80,14 +80,14 @@ public void changeLeader(FamilyMember newLeader){ * list of children (duplicates are ignored) and to this tree's internal members map. * * Note: If adding the child would exceed the maximum allowed children for the parent, - * a {@link kinoko.util.exceptions.DumbDeveloperFound} runtime exception is thrown. + * a {@link DumbDeveloperFoundException} runtime exception is thrown. * * @param junior the FamilyMember to add * @param parentId the character ID of the parent under whom the member should be added * @return true if the member was successfully added, false otherwise - * @throws DumbDeveloperFound if internal family constraints are violated + * @throws DumbDeveloperFoundException if internal family constraints are violated */ - public boolean addMember(FamilyMember junior, int parentId) throws DumbDeveloperFound { + public boolean addMember(FamilyMember junior, int parentId) throws DumbDeveloperFoundException { if (!members.containsKey(parentId)) return false; if (members.containsKey(junior.getCharacterId())) return false; @@ -427,13 +427,11 @@ public void encodeChart(OutPacket out, int vieweeId) { } // The "Privilege" block (Entitlements). - // Assuming you have an entitlements map on the viewee's FamilyMember object. FamilyMember viewee = getMember(vieweeId); - Map entitlements = viewee != null ? viewee.getEntitlements() : Collections.emptyMap(); - out.encodeInt(entitlements.size()); - for (Map.Entry entry : entitlements.entrySet()) { - out.encodeInt(entry.getKey()); - out.encodeInt(entry.getValue()); + if (viewee != null) { + viewee.encodeEntitlements(out, true); + } else { + out.encodeInt(0); // If viewee is null, encode 0 entitlements } // The final short for enabling the "Add Junior" button. diff --git a/src/main/java/kinoko/server/node/CentralServerNode.java b/src/main/java/kinoko/server/node/CentralServerNode.java index 86dfd6a8..7d17843f 100644 --- a/src/main/java/kinoko/server/node/CentralServerNode.java +++ b/src/main/java/kinoko/server/node/CentralServerNode.java @@ -5,6 +5,7 @@ import io.netty.channel.socket.SocketChannel; import kinoko.database.DatabaseManager; import kinoko.packet.CentralPacket; +import kinoko.server.Server; import kinoko.server.family.FamilyStorage; import kinoko.server.family.FamilyTree; import kinoko.server.guild.Guild; @@ -38,6 +39,8 @@ import java.util.concurrent.CompletableFuture; import java.util.concurrent.locks.ReentrantLock; +import static kinoko.util.Timing.logDuration; + /** * Represents the central server node responsible for coordinating all channel servers, * user data, parties, guilds, messengers, and family information. @@ -451,7 +454,7 @@ protected void initChannel(SocketChannel ch) { log.info("Central server listening on port {}", port); // Wait for child node connections - logDuration("Connecting All Servers", initializeFuture::join); + logDuration("Connecting All Servers", initializeFuture::join, log); // Complete initialization for login server node final RemoteServerNode loginServerNode = serverStorage.getLoginServerNode().orElseThrow(); @@ -460,15 +463,14 @@ protected void initChannel(SocketChannel ch) { @Override public void shutdown() throws InterruptedException { - final Instant start = Instant.now(); logDuration("Saving all guilds", () -> { DatabaseManager.guildAccessor().saveAll(guildStorage.getAllGuilds()); - } + }, log ); logDuration("Saving all families", () -> { DatabaseManager.familyAccessor().saveAll(familyStorage.getAllFamilyTrees()); - }); + }, log); logDuration("Disconnecting All Servers", () -> { // Shutdown login server node @@ -479,30 +481,10 @@ public void shutdown() throws InterruptedException { serverNode.write(CentralPacket.shutdownRequest()); } shutdownFuture.join(); - }); + }, log); // Close central server centralServerFuture.channel().close().sync(); log.info("Central server closed"); } - - /** - * Executes the given action and logs the time it took to complete. - * - * This is a utility method to measure and report the duration of a specific task. - * The elapsed time is calculated in milliseconds from the start to the end of the action. - * The action itself is executed synchronously in the current thread. - * - * Example usage: - * logDuration("Saving all guilds", () -> guildAccessor.saveAll(guilds)); - * - * @param taskName a descriptive name for the task being measured; used in the log message - * @param action a Runnable representing the code block whose duration is to be measured - */ - public void logDuration(String taskName, Runnable action) { - Instant start = Instant.now(); - action.run(); - long millis = Duration.between(start, Instant.now()).toMillis(); - log.info("{} completed in {} milliseconds", taskName, millis); - } } diff --git a/src/main/java/kinoko/util/ThrowingRunnable.java b/src/main/java/kinoko/util/ThrowingRunnable.java new file mode 100644 index 00000000..7ddd5bc2 --- /dev/null +++ b/src/main/java/kinoko/util/ThrowingRunnable.java @@ -0,0 +1,16 @@ +package kinoko.util; + +/** + * A functional interface similar to Runnable that allows checked exceptions to be thrown. + * + * This interface can be used in contexts where a block of code needs to be executed + * (such as a lambda) and may throw a checked exception. It is especially useful in + * utility methods like Timing.logDurationThrowing where you want to measure execution time + * while still allowing exceptions to propagate. + * + * @param the type of checked exception that may be thrown + */ +@FunctionalInterface +public interface ThrowingRunnable { + void run() throws E; +} \ No newline at end of file diff --git a/src/main/java/kinoko/util/Timing.java b/src/main/java/kinoko/util/Timing.java new file mode 100644 index 00000000..85458abd --- /dev/null +++ b/src/main/java/kinoko/util/Timing.java @@ -0,0 +1,89 @@ +package kinoko.util; + +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; + +import java.time.Instant; + +public final class Timing { + private static final Logger log = LogManager.getLogger(Timing.class); + + private Timing() { + // prevent instantiation + } + + /** + * Returns the current time as a Unix timestamp in seconds. + * + * @return the current Unix timestamp in seconds + */ + public static long nowSeconds() { + return Instant.now().getEpochSecond(); + } + + /** + * Returns the number of seconds in one day (24 hours). + * + * This is a convenience method for time calculations, such as + * tracking entitlement usage or other daily limits. + * + * @return the number of seconds in 24 hours (86400) + */ + public static long daySeconds() { + return 86400; + } + + /** + * Returns the current time in milliseconds. + * + * @return current time in milliseconds + */ + public static long nowMillis() { + return Instant.now().toEpochMilli(); + } + + /** + * Executes the given action and logs the time it took to complete. + * Uses Timing's internal logger. + * + * @param taskName a descriptive name for the task being measured + * @param action a Runnable representing the code block whose duration is to be measured + */ + public static void logDuration(String taskName, Runnable action) { + logDuration(taskName, action, log); + } + + /** + * Executes the given action and logs the time it took to complete using the provided logger. + * + * @param taskName a descriptive name for the task being measured + * @param action a Runnable representing the code block whose duration is to be measured + * @param logger the Logger to use for logging + */ + public static void logDuration(String taskName, Runnable action, Logger logger) { + long start = System.nanoTime(); + action.run(); + long elapsedMillis = (System.nanoTime() - start) / 1_000_000; // simpler conversion + logger.info("{} completed in {} milliseconds", taskName, elapsedMillis); + } + + /** + * Executes the given action and logs the time it took to complete using the provided logger. + * + * This method measures the elapsed time of the action in milliseconds and logs a message + * indicating the task name and duration. Unlike the standard logDuration method, this + * version logDurationThrowing allows the action to throw checked exceptions, which will propagate to the caller. + * + * @param taskName a descriptive name for the task being measured + * @param action a ThrowingRunnable representing the code block whose duration is to be measured + * @param logger the Logger to use for logging the duration + * @param the type of checked exception that the action may throw + * @throws E if the action throws a checked exception + */ + public static void logDurationThrowing(String taskName, ThrowingRunnable action, Logger logger) throws E { + long start = System.nanoTime(); + action.run(); // can throw E + long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + logger.info("{} completed in {} milliseconds", taskName, elapsedMillis); + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/util/exceptions/DumbDeveloperFound.java b/src/main/java/kinoko/util/exceptions/DumbDeveloperFoundException.java similarity index 79% rename from src/main/java/kinoko/util/exceptions/DumbDeveloperFound.java rename to src/main/java/kinoko/util/exceptions/DumbDeveloperFoundException.java index 237fc534..d6878cba 100644 --- a/src/main/java/kinoko/util/exceptions/DumbDeveloperFound.java +++ b/src/main/java/kinoko/util/exceptions/DumbDeveloperFoundException.java @@ -8,8 +8,8 @@ * This exception is unchecked (extends RuntimeException) because it represents * a logic or design error that should be fixed in code, not handled at runtime. */ -public class DumbDeveloperFound extends RuntimeException { - public DumbDeveloperFound(String message) { +public class DumbDeveloperFoundException extends RuntimeException { + public DumbDeveloperFoundException(String message) { super(message); } } \ No newline at end of file diff --git a/src/main/java/kinoko/world/user/FamilyMember.java b/src/main/java/kinoko/world/user/FamilyMember.java index 0847d371..b2b10fca 100644 --- a/src/main/java/kinoko/world/user/FamilyMember.java +++ b/src/main/java/kinoko/world/user/FamilyMember.java @@ -1,16 +1,19 @@ package kinoko.world.user; import kinoko.server.Server; +import kinoko.server.family.FamilyEntitlement; import kinoko.server.family.FamilyTree; import kinoko.server.packet.OutPacket; import kinoko.util.Encodable; -import kinoko.util.exceptions.DumbDeveloperFound; +import kinoko.util.Timing; +import kinoko.util.exceptions.DumbDeveloperFoundException; import kinoko.world.GameConstants; import java.util.ArrayList; import java.util.Collections; import java.util.List; import java.util.Map; +import java.util.concurrent.ConcurrentHashMap; /** * Represents a single character's family information. @@ -23,7 +26,8 @@ public final class FamilyMember implements Encodable { * Default empty instance for characters not in a family. */ public static final FamilyMember EMPTY = new FamilyMember( - 0, "", 0, 0, 0, 0, 0, 0, null, Collections.emptyMap() + 0, "", 0, 0, 0, + 0, 0, 0, null ); // ----------------------------- @@ -43,11 +47,13 @@ public final class FamilyMember implements Encodable { private int totalReputation; private int todaysReputation; private int reputationToSenior; - private final Map entitlements; private long lastSeenUnix; // unix timestamp in seconds + private final Map usedEntitlements = new ConcurrentHashMap<>(); + private final Map> entitlementUsageLog = new ConcurrentHashMap<>(); + public FamilyMember(int characterId, String name, int level, int job, int currentReputation, int totalReputation, - int todaysReputation, int reputationToSenior, Integer parentId, Map entitlements) { + int todaysReputation, int reputationToSenior, Integer parentId) { this.characterId = characterId; this.name = name; this.level = level; @@ -59,7 +65,6 @@ public FamilyMember(int characterId, String name, int level, int job, int curren this.reputationToSenior = reputationToSenior; this.parentId = parentId; - this.entitlements = entitlements; } @@ -94,20 +99,19 @@ public int getChildrenCount() { } public int getTodaysRep() { return todaysReputation; } - public Map getEntitlements() { return entitlements; } // ------------------------------------------------------------ // Family tree operations // ------------------------------------------------------------ - public void addChild(int childId) throws DumbDeveloperFound{ + public void addChild(int childId) throws DumbDeveloperFoundException { if (children.contains(childId)) { return; // Already a child, nothing to do } if (getChildrenCount() >= GameConstants.MAX_FAMILY_CHILDREN_COUNT) { // Cannot add more children, fail fast before modifying state - throw new DumbDeveloperFound( + throw new DumbDeveloperFoundException( "FamilyMember " + getCharacterId() + " exceeded max juniors: " + GameConstants.MAX_FAMILY_CHILDREN_COUNT + " (attempted to add child " + childId + ")" ); @@ -120,6 +124,16 @@ public void removeChild(int childId) { children.remove((Integer) childId); } + /** + * Attempt to use a Family Entitlement. + * @param entitlement The entitlement to use + */ + public void useEntitlement(FamilyEntitlement entitlement) { + long now = Timing.nowSeconds(); + entitlementUsageLog.computeIfAbsent(entitlement, k -> new ArrayList<>()).add(now); + usedEntitlements.put(entitlement, now); // mark as used + } + // ------------------------------------------------------------ // Online operations // ------------------------------------------------------------ @@ -172,12 +186,52 @@ public void encode(OutPacket out) { // scrolling family message, set to null to be blank // we can let the family leader modify this in the future. - out.encodeString(isDefault() ? "You have no family :(" : "You have a family :D"); + out.encodeString(hasFamily() ? "You have a family :D" : "You have no family :("); + encodeEntitlements(out, false); + } + + /** + * Encodes all family entitlements into the given packet and cleans up expired ones. + * + * Behavior depends on the `forChart` flag: + * - If `forChart` is false: encodes whether the entitlement is currently active (1) or inactive (0), + * based on the last usage and the entitlement's expiration time. + * - If `forChart` is true: encodes how many times the entitlement was used in the last 24 hours, + * for purposes like pedigree charts. + * + * This method also removes expired entitlements from the usedEntitlements map, + * and cleans up old timestamps in the usage log to avoid unbounded growth. + * + * @param out the OutPacket to write the entitlement data into + * @param forChart whether to encode usage count (true) or active/inactive status (false) + */ + public void encodeEntitlements(OutPacket out, boolean forChart) { + out.encodeInt(FamilyEntitlement.values().length); + + long now = Timing.nowSeconds(); + + for (FamilyEntitlement entitlement : FamilyEntitlement.values()) { + Long lastUsed = usedEntitlements.get(entitlement); + boolean isUsed = false; + + // expiry check + if (lastUsed != null) { + long expirySeconds = entitlement.getExpiresAfterMinutes() * 60L; + if (now - lastUsed > expirySeconds) { + usedEntitlements.remove(entitlement); + } else { + isUsed = true; + } + } - out.encodeInt(entitlements.size()); - for (Map.Entry entry : entitlements.entrySet()) { - out.encodeInt(entry.getKey()); - out.encodeInt(entry.getValue()); + // count usages in the last 24 hours + List usageList = entitlementUsageLog.getOrDefault(entitlement, Collections.emptyList()); + long usageCount = usageList.stream().filter(ts -> now - ts <= Timing.daySeconds()).count(); + + usageList.removeIf(ts -> now - ts > Timing.daySeconds()); // clean up old timestamps + + out.encodeInt(entitlement.ordinal()); + out.encodeInt(forChart ? (int) usageCount : (isUsed ? 1 : 0)); } } @@ -187,4 +241,8 @@ public void encode(OutPacket out) { public boolean isDefault() { return this == EMPTY; } + + public boolean hasFamily() { + return !isDefault() && (getChildrenCount() > 0 || getParentId() != null); + } } From 03337beb3381acfc6d3e98038429070b37c96a43 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sat, 22 Nov 2025 05:07:50 -0500 Subject: [PATCH 75/83] Added Timing class, family EXP/DROP multipliers, and global rate changer --- .../handler/stage/MigrationHandler.java | 2 +- .../kinoko/handler/user/FamilyHandler.java | 193 ++++++++++++++- .../java/kinoko/handler/user/UserHandler.java | 7 +- .../kinoko/packet/world/FamilyPacket.java | 41 ++- src/main/java/kinoko/server/ServerConfig.java | 18 ++ .../server/command/gm/KillMobsCommand.java | 2 +- .../server/family/FamilyEntitlement.java | 48 ++-- .../java/kinoko/server/family/FamilyTree.java | 102 ++++++++ src/main/java/kinoko/util/Timing.java | 13 +- .../exceptions/InvalidInputException.java | 11 + src/main/java/kinoko/world/field/Field.java | 5 + src/main/java/kinoko/world/field/mob/Mob.java | 25 +- .../java/kinoko/world/user/FamilyMember.java | 233 +++++++++++++++++- src/main/java/kinoko/world/user/User.java | 56 +++++ 14 files changed, 697 insertions(+), 59 deletions(-) create mode 100644 src/main/java/kinoko/util/exceptions/InvalidInputException.java diff --git a/src/main/java/kinoko/handler/stage/MigrationHandler.java b/src/main/java/kinoko/handler/stage/MigrationHandler.java index 4c313498..59769deb 100644 --- a/src/main/java/kinoko/handler/stage/MigrationHandler.java +++ b/src/main/java/kinoko/handler/stage/MigrationHandler.java @@ -121,7 +121,7 @@ public static void handleMigrateIn(Client c, InPacket inPacket) { CentralServerNode centralServerNode = Server.getCentralServerNode(); user.setFamilyInfo(centralServerNode.getFamilyInfo(user.getId())); // under a lock user.write(FamilyPacket.userFamilyInfo(user)); // no lock needed - user.write(FamilyPacket.loadFamilyEntitlements()); + user.write(FamilyPacket.loadFamilyEntitlements(!user.getFamilyInfo().hasFamily())); user.setMessengerId(migrationInfo.getMessengerId()); // this is required before user connect if (channelServerNode.isConnected(user)) { diff --git a/src/main/java/kinoko/handler/user/FamilyHandler.java b/src/main/java/kinoko/handler/user/FamilyHandler.java index 1f53b265..0cbe2ee4 100644 --- a/src/main/java/kinoko/handler/user/FamilyHandler.java +++ b/src/main/java/kinoko/handler/user/FamilyHandler.java @@ -4,19 +4,22 @@ import kinoko.packet.world.FamilyPacket; import kinoko.server.Server; import java.util.concurrent.locks.ReentrantLock; + +import kinoko.server.family.FamilyEntitlement; import kinoko.server.family.FamilyResultType; import kinoko.server.family.FamilyTree; import kinoko.server.header.InHeader; import kinoko.server.node.CentralServerNode; import kinoko.server.packet.InPacket; import kinoko.server.packet.OutPacket; +import kinoko.util.exceptions.DumbDeveloperFoundException; +import kinoko.util.exceptions.InvalidInputException; import kinoko.world.GameConstants; import kinoko.world.user.FamilyMember; import kinoko.world.user.User; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import java.util.Collections; import java.util.Optional; public final class FamilyHandler { @@ -194,9 +197,11 @@ public static void handleFamilyJoinResult(User user, InPacket inPacket) { // send packets outside of lock user.write(outToUser); - seniorUser.write(outToSenior); - + user.write(FamilyPacket.loadFamilyEntitlements(!user.getFamilyInfo().hasFamily())); updateFamilyDisplay(user); + + seniorUser.write(outToSenior); + seniorUser.write(FamilyPacket.loadFamilyEntitlements(!seniorUser.getFamilyInfo().hasFamily())); updateFamilyDisplay(seniorUser); // not necessary, but is smoother if they have the dialog open. } @@ -278,13 +283,81 @@ public static void handleFamilyRegisterJunior(User user, InPacket inPacket) { @Handler(InHeader.FamilySetPrecept) public static void handleFamilySetPrecept(User user, InPacket inPacket) { - System.out.println("Handled FamilySetPrecept"); + String message = inPacket.decodeString(); + String feedbackMessage; + + if (!user.getFamilyInfo().isLeader()) { // no longer in the same family as when they used the precept. + user.write(FamilyPacket.of(FamilyResultType.DifferentFamily, 0)); + return; + } + + CentralServerNode centralServerNode = Server.getCentralServerNode(); + ReentrantLock lock = centralServerNode.getGlobalFamilyLock(); + lock.lock(); + try { + Optional userTreeOpt = centralServerNode.getFamilyTree(user.getCharacterId()); + if (userTreeOpt.isPresent()) { + FamilyTree userTree = userTreeOpt.get(); + userTree.setFamilyMessage(message); + feedbackMessage = "The family message has been set to: " + message; + } else { + feedbackMessage = "Failed to set your family message to: " + message; + } + } finally { + lock.unlock(); + } + + user.systemMessage(feedbackMessage); + updateFamilyDisplay(user); } + /** + * Handles a user's response to a Family Summon request. + * + * A requester may only have one active summon request at a time. If the + * responder rejects the request, the requester is refunded the entitlement. + * If the responder accepts but is offline, the responder is notified and the + * summon fails. If accepted and both users are online, the responder is + * teleported to the requester. If the responder never replies, the requester + * does not receive an entitlement refund. + */ @Handler(InHeader.FamilySummonResult) public static void handleFamilySummonResult(User user, InPacket inPacket) { - System.out.println("Handled FamilySummonResult"); + String requesterName = inPacket.decodeString(); + boolean accepted = inPacket.decodeBoolean(); + + User requester = Server.getCentralServerNode() + .getUserByCharacterName(requesterName) + .orElse(null); + + // --- Accepted but requester offline --- + if (accepted && requester == null) { + user.write(FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0)); + return; + } + + // --- Rejected --- + if (!accepted) { + // Requester online -> refund them + if (requester != null) { + requester.getFamilyInfo().rollbackEntitlementUsage(FamilyEntitlement.SUMMON_FAMILY); + requester.systemMessage(user.getCharacterName() + " has rejected your summon invite."); + } + + // Notify the rejecting user + user.systemMessage("You have rejected " + requesterName + "'s summon invitation!"); + return; + } + + // --- Accepted and requester online --- + // TODO: Restrict player from teleporting to maps that aren't allowed. + // Not a popup, "The summons has failed. Your current location or state does not allow a summons." + // requester.write(FamilyPacket.of(FamilyResultType.SummonFailed, 0)); + + user.warpTo(requester); + user.systemMessage("You have teleported to " + requesterName); + requester.systemMessage(user.getCharacterName() + " has been teleported to you."); } /** @@ -342,6 +415,8 @@ public static void handleFamilyUnregisterJunior(User user, InPacket inPacket) { } user.write(userResultPacket); + // update the entitlements the user sees. + user.write(FamilyPacket.loadFamilyEntitlements(!user.getFamilyInfo().hasFamily())); // Update Family Pedigrees and Information to the user and ex-junior client (if they are online). updateFamilyDisplay(user); @@ -349,6 +424,7 @@ public static void handleFamilyUnregisterJunior(User user, InPacket inPacket) { juniorUser.ifPresent(targetUser -> { // let the user know they are an orphan 😢 targetUser.systemMessage("You have been kicked out of your family by %s.", user.getCharacterName()); + targetUser.write(FamilyPacket.loadFamilyEntitlements(!targetUser.getFamilyInfo().hasFamily())); updateFamilyDisplay(targetUser); // not necessary, but is smoother if they have the dialog open. }); } @@ -437,6 +513,8 @@ public static void handleFamilyUnregisterParent(User user, InPacket inPacket) { } user.write(userResultPacket); + // update the entitlements the user sees. + user.write(FamilyPacket.loadFamilyEntitlements(!user.getFamilyInfo().hasFamily())); // Update the family pedigree and info for the user who just left. updateFamilyDisplay(user); @@ -451,9 +529,112 @@ public static void handleFamilyUnregisterParent(User user, InPacket inPacket) { } } + /** + * Handles a user's request to use a Family Entitlement. + * + * - Checks if the user is in a family; resets entitlements if not. + * - Attempts to use the entitlement via `tryUseEntitlementWithRollback`, + * which handles cooldowns, invalid targets, and rollback automatically. + * - Applies the entitlement effects depending on its type: + * - Type 1: target-based (e.g., FAMILY_REUNION, SUMMON_FAMILY) + * - Type 2: self or family-wide effects (e.g., FAMILY_HASTE, SELF_EXP_1_5) + * + * All exceptions related to spam or invalid input are handled inside + * `tryUseEntitlementWithRollback`. + * + * @param user the user using the entitlement + * @param inPacket packet containing entitlement usage info + */ @Handler({InHeader.FamilyUsePrivilege}) public static void handleFamilyUsePrivilege(User user, InPacket inPacket) { - System.out.println("Handled FamilyUsePrivilege"); + int entitlementId = inPacket.decodeInt(); + FamilyEntitlement entitlement = FamilyEntitlement.values()[entitlementId]; + + FamilyMember userMember = user.getFamilyInfo(); + if (!userMember.hasFamily()){ // A user should not see entitlements if they are not in a family. + user.write(FamilyPacket.of(FamilyResultType.DifferentFamily, 0)); // User has no family. + user.write(FamilyPacket.loadFamilyEntitlements(true)); // reset the entitlements they can see. + user.systemMessage("To use an entitlement, you must have a family!"); + return; + } + + if (!userMember.canUse(entitlement)){ // protect entitlements from spammers. + // Sending a fake popup box, so they can't spam it as much if they have a high latency to the server. + user.write(FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0)); + return; + }; + + // use the entitlement and roll it back later if failed, this helps prevents spamming and hackers + boolean success = userMember.tryUseEntitlementWithRollback(entitlement, () -> { + handleFamilyInfoRequest(user, null); // will disable the button for the user. + + User targetUser = null; + + if (entitlement.getType() == 1) { // this entitlement requires a character name input + String targetName = inPacket.decodeString(); + targetUser = Server.getCentralServerNode() + .getUserByCharacterName(targetName) + .orElse(null); + if (targetUser == null) { + user.write(FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0)); + throw new InvalidInputException("Entitlement use blocked by invalid target user"); + } + + // Type 1 Cases + switch (entitlement) { + case FAMILY_REUNION: + // TODO: Restrict player from teleporting to maps that aren't allowed. + + // if the user is in the cash shop, the user will be teleported to the map they are in. + user.warpTo(targetUser); + user.systemMessage("Teleported to %s's location.", targetName); + break; + case SUMMON_FAMILY: + // TODO: Restrict player from teleporting to maps that aren't allowed. + // Not a popup, "The summons has failed. Your current location or state does not allow a summons." + // user.write(FamilyPacket.of(FamilyResultType.SummonFailed, 0)); + // throw new InvalidInputException("Entitlement use blocked by restricted map."); + targetUser.write(FamilyPacket.createSummonRequest(user)); + break; + default: + break; + + } + } + else { // Type 2 cases + switch (entitlement) { + case FAMILY_HASTE: + System.out.println("Applying FAMILY_HASTE to all family members"); + // TODO: apply haste buff + break; + + case FAMILY_EXP: + System.out.println("Applying FAMILY_EXP to all family members"); + // TODO: apply exp boost + break; + + case FAMILY_DROP: + System.out.println("Applying FAMILY_DROP to all family members"); + // TODO: apply drop boost + break; + + case SELF_DROP_1_5: + System.out.println("Applying SELF_DROP_1_5 to user"); + // TODO: apply self drop boost + break; + + case SELF_EXP_1_5: + System.out.println("Applying SELF_EXP_1_5 to user"); + // TODO: apply self exp boost + break; + default: + System.out.println("Unhandled FamilyEntitlement: " + entitlement); + break; + } + } + }); + +// System.out.println("Handled FamilyUsePrivilege: " + entitlement + ", target: " + targetUser); } /** diff --git a/src/main/java/kinoko/handler/user/UserHandler.java b/src/main/java/kinoko/handler/user/UserHandler.java index 8d6f9ef1..3a2e3903 100644 --- a/src/main/java/kinoko/handler/user/UserHandler.java +++ b/src/main/java/kinoko/handler/user/UserHandler.java @@ -465,7 +465,8 @@ public static void handleUserChangeSlotPositionRequest(User user, InPacket inPac // Move exclusive body part equip item to inventory final BodyPart exclusiveBodyPart = ItemConstants.getExclusiveEquipItemBodyPart(secondInventory, item.getItemId(), isCash); if (exclusiveBodyPart != null) { - final Item exclusiveEquipItem = secondInventory.getItem(exclusiveBodyPart.getValue() + (isCash ? BodyPart.CASH_BASE.getValue() : 0)); + final int exclusiveItemPosition = exclusiveBodyPart.getValue() + (isCash ? BodyPart.CASH_BASE.getValue() : 0); + final Item exclusiveEquipItem = secondInventory.getItem(exclusiveItemPosition); final Optional availablePositionResult = InventoryManager.getAvailablePosition(im.getEquipInventory()); if (availablePositionResult.isEmpty()) { log.error("No room in inventory remove exclusive equip item body part item ID {} in position {}", exclusiveEquipItem.getItemId(), exclusiveBodyPart); @@ -473,11 +474,11 @@ public static void handleUserChangeSlotPositionRequest(User user, InPacket inPac return; } final int availablePosition = availablePositionResult.get(); - if (!secondInventory.removeItem(exclusiveBodyPart.getValue(), exclusiveEquipItem)) { + if (!secondInventory.removeItem(exclusiveItemPosition, exclusiveEquipItem)) { throw new IllegalStateException("Could not remove exclusive equip item"); } im.getEquipInventory().putItem(availablePosition, exclusiveEquipItem); - user.write(WvsContext.inventoryOperation(InventoryOperation.position(InventoryType.EQUIP, -exclusiveBodyPart.getValue(), availablePosition), false)); // client uses negative index for equipped + user.write(WvsContext.inventoryOperation(InventoryOperation.position(InventoryType.EQUIP, -exclusiveItemPosition, availablePosition), false)); // client uses negative index for equipped } // Handle items binded on equip if (itemInfo.isEquipTradeBlock() && !item.hasAttribute(ItemAttribute.EQUIP_BINDED)) { diff --git a/src/main/java/kinoko/packet/world/FamilyPacket.java b/src/main/java/kinoko/packet/world/FamilyPacket.java index abf8f255..20cca2a6 100644 --- a/src/main/java/kinoko/packet/world/FamilyPacket.java +++ b/src/main/java/kinoko/packet/world/FamilyPacket.java @@ -1,5 +1,6 @@ package kinoko.packet.world; +import kinoko.provider.StringProvider; import kinoko.server.Server; import kinoko.server.family.FamilyEntitlement; import kinoko.server.family.FamilyResultType; @@ -9,6 +10,8 @@ import kinoko.world.user.FamilyMember; import kinoko.world.user.User; +import java.util.Arrays; +import java.util.Collections; import java.util.Optional; public final class FamilyPacket { @@ -41,6 +44,33 @@ public static OutPacket createFamilyInvite(User senior) { return outPacket; } + + /** + * CWvsContext::OnFamilySummonRequest + * Builds a Family Join Request / Invite packet. + * + * This packet prompts the client with a yes/no dialog asking + * the target player to accept a family invitation. + * + * Packet Structure (v95): + * byte OutHeader.FamilyResult (we reuse FamilyResult for simplicity) + * int FamilyResultType (use a dedicated type like RegisterJunior_Invite) + * int leaderCharacterId + * int leaderJob + * String leaderName + * String requesterName + * + * @param senior The user sending the invite (senior) + * @return The encoded packet to send to the client + */ + public static OutPacket createSummonRequest(User requestor) { + final OutPacket outPacket = OutPacket.of(OutHeader.FamilySummonRequest); + outPacket.encodeString(requestor.getCharacterName()); + outPacket.encodeString(requestor.getField().getName()); + + return outPacket; + } + /** * Creates a packet to inform the original inviter of the family join result. * This packet is sent to the senior character after the junior has responded. @@ -146,12 +176,17 @@ public static OutPacket userFamilyChart(User user) { * - name: display name of the entitlement * - description: detailed effect description * + * If the `empty` parameter is true, the packet will indicate that there are + * zero entitlements and no entitlement details will be included. This is + * useful for cases where the client should see an empty entitlement list (user has no family). + * + * @param empty if true, sends an empty entitlement list; if false, includes all FamilyEntitlement values * @return an OutPacket containing all encoded FamilyEntitlement data */ - public static OutPacket loadFamilyEntitlements() { + public static OutPacket loadFamilyEntitlements(boolean empty) { final OutPacket outPacket = OutPacket.of(OutHeader.FamilyPrivilegeList); - outPacket.encodeInt(FamilyEntitlement.values().length); - for (FamilyEntitlement entitlement : FamilyEntitlement.values()) { + outPacket.encodeInt(empty ? 0 : FamilyEntitlement.values().length); + for (FamilyEntitlement entitlement : empty ? Collections.emptyList() : Arrays.asList(FamilyEntitlement.values())) { outPacket.encodeByte(entitlement.getType()); outPacket.encodeInt(entitlement.getRepCost()); outPacket.encodeInt(entitlement.getUsageLimit()); diff --git a/src/main/java/kinoko/server/ServerConfig.java b/src/main/java/kinoko/server/ServerConfig.java index 99c9b974..0cd53967 100644 --- a/src/main/java/kinoko/server/ServerConfig.java +++ b/src/main/java/kinoko/server/ServerConfig.java @@ -7,6 +7,10 @@ public final class ServerConfig { public static final boolean TESPIA = Util.getEnv("TESPIA", true); // DEV ENV + public static int EXP_RATE = Util.getEnv("EXP_RATE", 1); + public static int MESO_RATE = Util.getEnv("MESO_RATE", 1); + public static int DROP_RATE = Util.getEnv("DROP_RATE", 1); + public static final int WORLD_ID = Util.getEnv("WORLD_ID", 0); public static final String WORLD_NAME = Util.getEnv("WORLD_NAME", "Kinoko"); public static final int CHANNELS_PER_WORLD = Util.getEnv("CHANNEL_COUNT", 5); @@ -32,4 +36,18 @@ public final class ServerConfig { public static final String STAFF_COMMAND_PREFIX = Util.getEnv("STAFF_COMMAND_PREFIX", "!"); public static final boolean DEBUG_MODE = Util.getEnv("DEBUG_MODE", true); public static final boolean PLAIN_TRAFFIC = Util.getEnv("PLAIN_TRAFFIC", false); + + + public static void setExpRate(int rate) { + EXP_RATE = Math.max(1, rate); + } + + public static void setMesoRate(int rate) { + MESO_RATE = Math.max(1, rate); + } + + public static void setDropRate(int rate) { + DROP_RATE = Math.max(1, rate); + } + } diff --git a/src/main/java/kinoko/server/command/gm/KillMobsCommand.java b/src/main/java/kinoko/server/command/gm/KillMobsCommand.java index b7a240ed..d361f44e 100644 --- a/src/main/java/kinoko/server/command/gm/KillMobsCommand.java +++ b/src/main/java/kinoko/server/command/gm/KillMobsCommand.java @@ -10,7 +10,7 @@ */ public final class KillMobsCommand { - @Command("killmobs") + @Command({"killmobs", "killall"}) public static void killMobs(User user, String[] args) { user.getField().getMobPool().forEach(mob -> { if (mob.getHp() > 0) { diff --git a/src/main/java/kinoko/server/family/FamilyEntitlement.java b/src/main/java/kinoko/server/family/FamilyEntitlement.java index 75588ea0..820fe3bf 100644 --- a/src/main/java/kinoko/server/family/FamilyEntitlement.java +++ b/src/main/java/kinoko/server/family/FamilyEntitlement.java @@ -9,59 +9,71 @@ * Fields: * - usageLimit: the maximum number of times this entitlement can be used. * - repCost: the cost in reputation points to use this entitlement. - * - expiresAfterMinutes: how long the effect lasts in minutes. + * - usageResetAfterMinutes: When the entitlement usage can be reset (typically 24 hours) * - name: display name of the entitlement. * - description: detailed description of the effect. - * - type (byte): determines the target of the entitlement: - * 1 = affects the player individually (self or single target) - * 2 = affects all family members (group-wide effect) - * - * Example usage: - * FamilyEntitlement.FAMILY_EXP.getType(); // returns 2 + * - type (byte): determines how the entitlement is applied: + * 1 = can be used on a specific family member (requires a FamilyMember input) + * 2 = does not require a specific target (applies automatically or globally) + * - modifier (double): The modifier of a base stat. + * - expiresAfterMinutes (int): Minutes until the buff ends. */ public enum FamilyEntitlement { FAMILY_REUNION(1, 100, "Family Reunion", "[Target] Me\n[Effect] Teleport directly to the Family member of your choice.", - 1440, (byte) 1), + 1440, (byte) 1, null, null), SUMMON_FAMILY(1, 200, "Summon Family", "[Target] 1 Family member\n[Effect] Summon a Family member of choice to the map you're in.", - 1440, (byte) 1), + 1440, (byte) 1, null, null), FAMILY_HASTE(1, 500, "Quicker Together", "[Target] All Family Members\n[Effect] All family members, regardless of map, " + "are blessed with Family Haste.", - 1440, (byte) 2), + 1440, (byte) 2, null, null), FAMILY_EXP(1, 5000, "A Better Experience", "[Target] All Family Members\n[Effect] For 15 minutes, all family members receive 1.2x experience, " + "regardless of map.", - 1440, (byte) 2), + 1440, (byte) 2, 1.2, 15), FAMILY_DROP(1, 5000, "All The Drops", "[Target] All Family Members\n[Effect] For 15 minutes, " + "all family members receive 1.2x drop rate, regardless of map.", - 1440, (byte) 2), + 1440, (byte) 2, 1.2, 15), SELF_DROP_1_5(1, 8000, "My Drop Rate 1.5x (15 min)", "[Target] Me\n[Time] 15 min\n[Effect] Monster drop rate will be increased #c1.5x#.", - 1440, (byte) 1), + 1440, (byte) 2, 1.5, 15), SELF_EXP_1_5(1, 8000, "My EXP 1.5x (15 min)", "[Target] Me\n[Time] 15 min\n[Effect] EXP earned from hunting will be increased #c1.5x#.", - 1440, (byte) 1); + 1440, (byte) 2, 1.5, 15); - private final int usageLimit, repCost, expiresAfterMinutes; + private final int usageLimit, repCost, usageResetAfterMinutes; + private final Integer expiresAfterMinutes; + private final Double modifier; private final String name, description; private final byte type; - FamilyEntitlement(int usageLimit, int repCost, String name, String description, int expiresAfterMinutes, byte type) { + FamilyEntitlement(int usageLimit, int repCost, String name, String description, + int usageResetAfterMinutes, byte type, Double modifier, Integer expiresAfterMinutes) { this.usageLimit = usageLimit; this.repCost = repCost; this.name = name; this.description = description; - this.expiresAfterMinutes = expiresAfterMinutes; + this.usageResetAfterMinutes = usageResetAfterMinutes; this.type = type; + this.modifier = modifier; + this.expiresAfterMinutes = expiresAfterMinutes; + + } + public Integer getExpiresAfterMinutes() { + return expiresAfterMinutes; + } + + public Double getModifier() { + return modifier; } public int getUsageLimit() { @@ -72,7 +84,7 @@ public int getRepCost() { return repCost; } - public int getExpiresAfterMinutes(){ return expiresAfterMinutes;} + public int getUsageResetAfterMinutes(){ return usageResetAfterMinutes;} public String getName() { return name; diff --git a/src/main/java/kinoko/server/family/FamilyTree.java b/src/main/java/kinoko/server/family/FamilyTree.java index 2585b0e7..44698bb5 100644 --- a/src/main/java/kinoko/server/family/FamilyTree.java +++ b/src/main/java/kinoko/server/family/FamilyTree.java @@ -1,6 +1,7 @@ package kinoko.server.family; import kinoko.server.packet.OutPacket; +import kinoko.util.Timing; import kinoko.util.exceptions.DumbDeveloperFoundException; import kinoko.world.user.FamilyMember; @@ -19,6 +20,10 @@ public final class FamilyTree { /** characterId → FamilyMember map */ private final Map members = new HashMap<>(); + private final Map activeEntitlements = new HashMap<>(); + + private String familyMessage; + /** Root leader (parentId = null) */ private int leaderId; @@ -65,6 +70,98 @@ public FamilyMember getLeader() { return members.get(leaderId); } + public String getFamilyMessage() { + return familyMessage; + } + + /** + * Sets the family tree's message and broadcasts it to all members. + * + * Updates the tree-level message (`familyMessage`) and then propagates + * it to each member in the tree so everyone sees the updated message. + * + * @param message the new family message to set + */ + public void setFamilyMessage(String message){ + familyMessage = message; + broadcastFamilyMessage(); + } + + /** + * Sets the family message for the entire family tree. + * + * Iterates over all members and updates each member's local message + * as well as the tree's global family message. + * + */ + private void broadcastFamilyMessage() { + forEach(member -> member.setFamilyMessage(familyMessage)); // Update each member + } + + /** + * Activates a given family entitlement for the user. + * + * The entitlement will be active for its defined duration in minutes, converted to seconds. + * The expiration time is stored in the activeEntitlements map. + * + * @param ent the FamilyEntitlement to activate + */ + public void activateEntitlement(FamilyEntitlement ent) { + long expiresMinutes = ent.getExpiresAfterMinutes(); + long expireAt = Timing.nowSeconds() + expiresMinutes * 60; + + activeEntitlements.put(ent, expireAt); + } + + /** + * Checks if a given family entitlement is currently active for the user. + * + * If the entitlement has expired (based on seconds), it is automatically removed from + * the activeEntitlements map. + * + * @param ent the FamilyEntitlement to check + * @return true if the entitlement is active and not expired, false otherwise + */ + public boolean isEntitlementActive(FamilyEntitlement ent) { + Long expireAt = activeEntitlements.get(ent); + if (expireAt == null) return false; + + if (expireAt < Timing.nowSeconds()) { + activeEntitlements.remove(ent); // expired + return false; + } + + return true; + } + + /** + * Retrieves the active family experience (EXP) modifier for this user. + * + * Checks if the FAMILY_EXP entitlement is active. If it is, returns its modifier (e.g., 1.2), + * otherwise returns the default value of 1.0. + * + * @return the current active family EXP modifier, with 1.0 as the default + */ + public double getExpModifier() { + return isEntitlementActive(FamilyEntitlement.FAMILY_EXP) + ? FamilyEntitlement.FAMILY_EXP.getModifier() + : 1.0; + } + + /** + * Retrieves the active family drop rate modifier for this user. + * + * Checks if the FAMILY_DROP entitlement is active. If it is, returns its modifier (e.g., 1.2), + * otherwise returns the default value of 1.0. + * + * @return the current active family drop modifier, with 1.0 as the default + */ + public double getDropModifier() { + return isEntitlementActive(FamilyEntitlement.FAMILY_DROP) + ? FamilyEntitlement.FAMILY_DROP.getModifier() + : 1.0; + } + // ------------------------------------------------------------------------- // Add / remove members // ------------------------------------------------------------------------- @@ -94,6 +191,10 @@ public boolean addMember(FamilyMember junior, int parentId) throws DumbDeveloper FamilyMember parent = members.get(parentId); parent.addChild(junior.getCharacterId()); // duplicates ignored, may throw DumbDeveloperFound members.put(junior.getCharacterId(), junior); + + if (familyMessage != null){ + junior.setFamilyMessage(familyMessage); + } return true; } @@ -118,6 +219,7 @@ public boolean removeMember(int characterId) { parent.removeChild(characterId); } + member.setFamilyMessage(null); member.setParentId(null); removeSubtree(characterId); diff --git a/src/main/java/kinoko/util/Timing.java b/src/main/java/kinoko/util/Timing.java index 85458abd..2d63c86b 100644 --- a/src/main/java/kinoko/util/Timing.java +++ b/src/main/java/kinoko/util/Timing.java @@ -7,6 +7,7 @@ public final class Timing { private static final Logger log = LogManager.getLogger(Timing.class); + public static final long DAY_SECONDS = 86400; private Timing() { // prevent instantiation @@ -21,18 +22,6 @@ public static long nowSeconds() { return Instant.now().getEpochSecond(); } - /** - * Returns the number of seconds in one day (24 hours). - * - * This is a convenience method for time calculations, such as - * tracking entitlement usage or other daily limits. - * - * @return the number of seconds in 24 hours (86400) - */ - public static long daySeconds() { - return 86400; - } - /** * Returns the current time in milliseconds. * diff --git a/src/main/java/kinoko/util/exceptions/InvalidInputException.java b/src/main/java/kinoko/util/exceptions/InvalidInputException.java new file mode 100644 index 00000000..3e1fcab4 --- /dev/null +++ b/src/main/java/kinoko/util/exceptions/InvalidInputException.java @@ -0,0 +1,11 @@ +package kinoko.util.exceptions; + +/** + * Thrown when the input is invalid, + * for example a target user does not exist. + */ +public class InvalidInputException extends RuntimeException { + public InvalidInputException(String message) { + super(message); + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/world/field/Field.java b/src/main/java/kinoko/world/field/Field.java index 496e872a..3583c064 100644 --- a/src/main/java/kinoko/world/field/Field.java +++ b/src/main/java/kinoko/world/field/Field.java @@ -5,6 +5,7 @@ import kinoko.provider.MapProvider; import kinoko.provider.NpcProvider; import kinoko.provider.ReactorProvider; +import kinoko.provider.StringProvider; import kinoko.provider.map.*; import kinoko.provider.npc.NpcImitateData; import kinoko.provider.npc.NpcTemplate; @@ -132,6 +133,10 @@ public Optional getRandomStartPoint() { return randomStartPoint.or(() -> getPortalById(0)); } + public String getName(){ + return StringProvider.getMapName(this.getFieldId()); + } + public Optional getNearestStartPoint(int x, int y) { double nearestDistance = Double.MAX_VALUE; PortalInfo nearestPortal = null; diff --git a/src/main/java/kinoko/world/field/mob/Mob.java b/src/main/java/kinoko/world/field/mob/Mob.java index 0364c7c5..a9acfc83 100644 --- a/src/main/java/kinoko/world/field/mob/Mob.java +++ b/src/main/java/kinoko/world/field/mob/Mob.java @@ -16,6 +16,9 @@ import kinoko.provider.skill.SkillInfo; import kinoko.provider.skill.SkillStat; import kinoko.script.party.HenesysPQ; +import kinoko.server.ServerConfig; +import kinoko.server.family.Family; +import kinoko.server.family.FamilyTree; import kinoko.server.node.ServerExecutor; import kinoko.server.packet.OutPacket; import kinoko.util.BitFlag; @@ -211,11 +214,15 @@ public void setMobType(int mobType) { } public int getExp() { + int baseExp = template.getExp(); + if (getMobStat().hasOption(MobTemporaryStat.Showdown)) { final double multiplier = (getMobStat().getOption(MobTemporaryStat.Showdown).nOption + 100) / 100.0; - return (int) (template.getExp() * multiplier); + baseExp = (int) (baseExp * multiplier); } - return template.getExp(); + baseExp *= ServerConfig.EXP_RATE; + + return baseExp; } public boolean isSlowUsed() { @@ -525,6 +532,12 @@ private void distributeExp() { finalPartyBonus = (int) (finalPartyBonus * multiplier); } } + // Family EXP modifier + final double familyMultiplier = user.getFamilyEXPModifier(); + finalExp = (int) (finalExp * familyMultiplier); + finalPartyBonus = (int) (finalPartyBonus * familyMultiplier); + + // give exp if (finalExp + finalPartyBonus > 0) { user.addExp(finalExp + finalPartyBonus); user.write(MessagePacket.incExp(finalExp, finalPartyBonus, user == highestDamageDone, false)); @@ -588,6 +601,11 @@ private Optional createDrop(User owner, Reward reward) { final double multiplier = (getMobStat().getOption(MobTemporaryStat.Showdown).nOption + 100) / 100.0; probability = probability * multiplier; } + + probability *= owner.getFamilyDropModifier(); + + probability = Math.min(probability * ServerConfig.DROP_RATE, 1.0); + if (!Util.succeedDouble(probability)) { return Optional.empty(); } @@ -609,6 +627,9 @@ private Optional createDrop(User owner, Reward reward) { final double multiplier = (owner.getSecondaryStat().getOption(CharacterTemporaryStat.MesoUpByItem).nOption + 100) / 100.0; money = (int) (money * multiplier); } + + money = Math.max(money * ServerConfig.MESO_RATE, 1); + return Optional.of(owner.hasParty() ? Drop.money(DropOwnType.PARTYOWN, this, money, owner.getPartyId()) : Drop.money(DropOwnType.USEROWN, this, money, owner.getCharacterId()) diff --git a/src/main/java/kinoko/world/user/FamilyMember.java b/src/main/java/kinoko/world/user/FamilyMember.java index b2b10fca..cf055b9c 100644 --- a/src/main/java/kinoko/world/user/FamilyMember.java +++ b/src/main/java/kinoko/world/user/FamilyMember.java @@ -7,12 +7,12 @@ import kinoko.util.Encodable; import kinoko.util.Timing; import kinoko.util.exceptions.DumbDeveloperFoundException; +import kinoko.util.exceptions.InvalidInputException; import kinoko.world.GameConstants; +import org.apache.logging.log4j.LogManager; +import org.apache.logging.log4j.Logger; -import java.util.ArrayList; -import java.util.Collections; -import java.util.List; -import java.util.Map; +import java.util.*; import java.util.concurrent.ConcurrentHashMap; /** @@ -21,7 +21,7 @@ * with ephemeral user-specific data (todaysRep, entitlement usage). */ public final class FamilyMember implements Encodable { - + private static final Logger log = LogManager.getLogger(FamilyMember.class); /** * Default empty instance for characters not in a family. */ @@ -50,6 +50,8 @@ public final class FamilyMember implements Encodable { private long lastSeenUnix; // unix timestamp in seconds private final Map usedEntitlements = new ConcurrentHashMap<>(); private final Map> entitlementUsageLog = new ConcurrentHashMap<>(); + private final Map activeEntitlements = new HashMap<>(); + private String familyMessage; public FamilyMember(int characterId, String name, int level, int job, int currentReputation, int totalReputation, @@ -89,6 +91,12 @@ public void updateUser(User user){ public void setParentId(Integer parentId) { this.parentId = parentId; } + public String getFamilyMessage(){ + if (this.familyMessage != null) { + return this.familyMessage; + } + return hasFamily() ? "Welcome!" : "You have no family :("; + } public List getChildren() { return Collections.unmodifiableList(children); @@ -100,6 +108,10 @@ public int getChildrenCount() { public int getTodaysRep() { return todaysReputation; } + public void setFamilyMessage(String message){ + this.familyMessage = message; + } + // ------------------------------------------------------------ // Family tree operations // ------------------------------------------------------------ @@ -129,11 +141,156 @@ public void removeChild(int childId) { * @param entitlement The entitlement to use */ public void useEntitlement(FamilyEntitlement entitlement) { + // TODO: use rep points long now = Timing.nowSeconds(); entitlementUsageLog.computeIfAbsent(entitlement, k -> new ArrayList<>()).add(now); usedEntitlements.put(entitlement, now); // mark as used } + /** + * Attempts to use a Family Entitlement optimistically, running the given action, + * and rolls back the usage if the action fails. + * + * Behavior: + * - Marks the entitlement as used immediately to prevent concurrent spamming. + * - Executes the provided action (the effect of using the entitlement). + * - If the action fails (throws or indicates failure), removes the usage mark + * to allow the entitlement to be retried later. + * + * This ensures cooldown enforcement while allowing safe rollback in case of + * invalid or unsuccessful entitlement usage, and helps prevent multiple threads + * from concurrently abusing the same entitlement. + * + * @param entitlement The Family Entitlement to attempt using. + * @param action A Runnable representing the operation to perform for this entitlement. + * If this action fails, the entitlement usage will be rolled back. + * @return true if the action successfully ran and the entitlement remains marked as used, + * false if the action failed and the usage was rolled back. + */ + public boolean tryUseEntitlementWithRollback(FamilyEntitlement entitlement, Runnable action) { + // TODO: use rep points + long now = Timing.nowSeconds(); + usedEntitlements.put(entitlement, now); + entitlementUsageLog.computeIfAbsent(entitlement, k -> new ArrayList<>()).add(now); + + boolean success = false; + try { + action.run(); // whatever the entitlement is supposed to do + success = true; + } + catch (InvalidInputException ignored) {} + finally { + if (!success) { + // undo usage + rollbackEntitlementUsage(entitlement, now); + usedEntitlements.remove(entitlement); + List list = entitlementUsageLog.get(entitlement); + if (list != null) list.remove(now); + } + } + return success; + } + + /** + * Rolls back a previously recorded entitlement usage. + * Removes the usage timestamp from both the active usage map + * and the usage history list. + * + * @param entitlement The entitlement to undo usage for. + * @param timestamp The timestamp that was originally recorded. + */ + public void rollbackEntitlementUsage(FamilyEntitlement entitlement, long timestamp) { + // TODO: refund REP Points + usedEntitlements.remove(entitlement); + + List history = entitlementUsageLog.get(entitlement); + if (history != null) { + history.remove(timestamp); + if (history.isEmpty()) { + entitlementUsageLog.remove(entitlement); + } + } + } + + /** + * Rolls back the most recent usage of the given entitlement. + * Removes the latest timestamp from both the active usage map + * and the logged usage history. + * + * This is useful when the caller does not know or care about + * the exact timestamp that was recorded. + * + * @param entitlement The entitlement to roll back. + */ + public void rollbackEntitlementUsage(FamilyEntitlement entitlement) { + // TODO: refund REP POINTS + // remove from active usage map + usedEntitlements.remove(entitlement); + + // Remove the most recent timestamp from history + List history = entitlementUsageLog.get(entitlement); + if (history != null && !history.isEmpty()) { + history.removeLast(); + if (history.isEmpty()) { + entitlementUsageLog.remove(entitlement); + } + } + } + + /** + * Checks if the given entitlement can be used by this family member. + * + * @param entitlement The entitlement to check + * @return true if the entitlement is usable (not currently active/expired), false otherwise + */ + public synchronized boolean canUse(FamilyEntitlement entitlement) { + Long lastUsed = usedEntitlements.get(entitlement); + if (lastUsed == null) { + return true; // never used, so it's available + } + + long now = Timing.nowSeconds(); + long expirySeconds = entitlement.getUsageResetAfterMinutes() * 60L; + + // Usable if enough time has passed since last usage + return now - lastUsed > expirySeconds; + } + + /** + * Activates a given family entitlement for the user. + * + * Calculates the expiration time based on the entitlement's duration (in minutes) + * and stores it in the activeEntitlements map. + * + * @param ent the FamilyEntitlement to activate + */ + public void activateEntitlement(FamilyEntitlement ent) { + long expiresMinutes = ent.getExpiresAfterMinutes(); + long expireAt = Timing.nowSeconds() + expiresMinutes * 60; + + activeEntitlements.put(ent, expireAt); + } + + /** + * Checks if a given family entitlement is currently active for the user. + * + * If the entitlement has expired, it is automatically removed from the activeEntitlements map. + * + * @param ent the FamilyEntitlement to check + * @return true if the entitlement is active and not expired, false otherwise + */ + public boolean isEntitlementActive(FamilyEntitlement ent) { + Long expireAt = activeEntitlements.get(ent); + if (expireAt == null) return false; + + if (expireAt < Timing.nowSeconds()) { + activeEntitlements.remove(ent); // expired + return false; + } + + return true; + } + // ------------------------------------------------------------ // Online operations // ------------------------------------------------------------ @@ -170,9 +327,9 @@ public void encode(OutPacket out) { out.encodeInt(totalReputation); out.encodeInt(todaysReputation); out.encodeShort((short) getChildrenCount()); - out.encodeShort(2); // max juniors - out.encodeShort(0); // unknown - out.encodeInt(parentId == null ? 0 : parentId); + out.encodeShort(GameConstants.MAX_FAMILY_CHILDREN_COUNT); // max juniors + out.encodeShort(0); // unknown, wTotalChildCount + out.encodeInt(parentId == null ? getCharacterId() : parentId); out.encodeString( isDefault() ? null @@ -185,8 +342,7 @@ public void encode(OutPacket out) { ); // scrolling family message, set to null to be blank - // we can let the family leader modify this in the future. - out.encodeString(hasFamily() ? "You have a family :D" : "You have no family :("); + out.encodeString(getFamilyMessage()); encodeEntitlements(out, false); } @@ -216,7 +372,7 @@ public void encodeEntitlements(OutPacket out, boolean forChart) { // expiry check if (lastUsed != null) { - long expirySeconds = entitlement.getExpiresAfterMinutes() * 60L; + long expirySeconds = entitlement.getUsageResetAfterMinutes() * 60L; if (now - lastUsed > expirySeconds) { usedEntitlements.remove(entitlement); } else { @@ -226,9 +382,9 @@ public void encodeEntitlements(OutPacket out, boolean forChart) { // count usages in the last 24 hours List usageList = entitlementUsageLog.getOrDefault(entitlement, Collections.emptyList()); - long usageCount = usageList.stream().filter(ts -> now - ts <= Timing.daySeconds()).count(); + long usageCount = usageList.stream().filter(ts -> now - ts <= Timing.DAY_SECONDS).count(); - usageList.removeIf(ts -> now - ts > Timing.daySeconds()); // clean up old timestamps + usageList.removeIf(ts -> now - ts > Timing.DAY_SECONDS); // clean up old timestamps out.encodeInt(entitlement.ordinal()); out.encodeInt(forChart ? (int) usageCount : (isUsed ? 1 : 0)); @@ -238,11 +394,62 @@ public void encodeEntitlements(OutPacket out, boolean forChart) { // ------------------------------------------------------------ // Utility // ------------------------------------------------------------ + /** + * Checks if this family instance represents the default/empty family. + * + * @return true if this is the EMPTY family instance, false otherwise + */ public boolean isDefault() { return this == EMPTY; } + /** + * Determines if the user belongs to a family with either children or a parent. + * + * @return true if the user has a family, false if they are in the default family + * or have no family members + */ public boolean hasFamily() { return !isDefault() && (getChildrenCount() > 0 || getParentId() != null); } + + /** + * Checks if the user is the leader of their family. + * + * A leader is defined as a user in a family without a parent. + * + * @return true if the user is a family leader, false otherwise + */ + public boolean isLeader(){ + return !isDefault() && getParentId() == null; + } + + /** + * Retrieves the personal experience (EXP) modifier for this user. + * + * If the SELF_EXP_1_5 entitlement is active, returns its modifier (e.g., 1.5), + * otherwise returns 1.0. + * + * @return the active personal EXP modifier, with 1.0 as the default + */ + public double getExpModifier() { + return isEntitlementActive(FamilyEntitlement.SELF_EXP_1_5) + ? FamilyEntitlement.SELF_EXP_1_5.getModifier() + : 1.0; + } + + + /** + * Retrieves the personal drop rate modifier for this user. + * + * If the SELF_DROP_1_5 entitlement is active, returns its modifier (e.g., 1.5), + * otherwise returns 1.0. + * + * @return the active personal drop modifier, with 1.0 as the default + */ + public double getDropModifier() { + return isEntitlementActive(FamilyEntitlement.SELF_DROP_1_5) + ? FamilyEntitlement.SELF_DROP_1_5.getModifier() + : 1.0; + } } diff --git a/src/main/java/kinoko/world/user/User.java b/src/main/java/kinoko/world/user/User.java index 36d813b1..94b372de 100644 --- a/src/main/java/kinoko/world/user/User.java +++ b/src/main/java/kinoko/world/user/User.java @@ -16,11 +16,14 @@ import kinoko.provider.map.Foothold; import kinoko.provider.map.PortalInfo; import kinoko.provider.skill.SkillStat; +import kinoko.server.Server; import kinoko.server.dialog.Dialog; import kinoko.server.dialog.ScriptDialog; import kinoko.server.dialog.miniroom.MiniRoom; import kinoko.server.event.EventType; +import kinoko.server.family.FamilyTree; import kinoko.server.guild.GuildRank; +import kinoko.server.node.CentralServerNode; import kinoko.server.node.ChannelServerNode; import kinoko.server.node.Client; import kinoko.server.node.ServerExecutor; @@ -1000,6 +1003,59 @@ public FamilyMember getFamilyInfo() { return familyInfo; } + public Optional getFamilyTree() { + return Server.getCentralServerNode().getFamilyTree(this.getCharacterId()); + } + + /** + * Calculates the effective drop rate modifier for the user, taking into account + * both their personal family drop modifier and the modifiers from their family tree. + * + * The method ensures that the returned modifier is never less than 1.0. + * + * @return the highest applicable drop modifier between personal and family values, + * with a minimum of 1.0 + */ + public double getFamilyDropModifier() { + double personalModifier = 1.0; + double familyModifier = 1.0; + if (this.familyInfo != null && this.familyInfo.hasFamily()){ + personalModifier = this.familyInfo.getDropModifier(); + } + + Optional userTreeOpt = getFamilyTree(); + if (userTreeOpt.isPresent()) { + familyModifier = userTreeOpt.get().getDropModifier(); + } + + return Math.max(1.0, Math.max(personalModifier, familyModifier)); + } + + /** + * Calculates the effective experience (EXP) modifier for the user, considering + * both their personal family EXP modifier and any modifiers from their family tree. + * + * The method ensures that the returned modifier is never less than 1.0. + * + * @return the highest applicable EXP modifier between personal and family values, + * with a minimum of 1.0 + */ + public double getFamilyEXPModifier() { + double personalModifier = 1.0; + double familyModifier = 1.0; + + if (this.familyInfo != null && this.familyInfo.hasFamily()) { + personalModifier = this.familyInfo.getExpModifier(); + } + + Optional userTreeOpt = getFamilyTree(); + if (userTreeOpt.isPresent()) { + familyModifier = userTreeOpt.get().getExpModifier(); + } + + return Math.max(1.0, Math.max(personalModifier, familyModifier)); + } + public void setFamilyInfo(FamilyMember familyInfo) { this.familyInfo = familyInfo; if (this.familyInfo != null){ From fa57a3732f6da955f8a03ec43d45a69d972a83b3 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sun, 23 Nov 2025 00:37:01 -0500 Subject: [PATCH 76/83] Added SuperGM/GM Skills --- .../kinoko/handler/user/AttackHandler.java | 2 +- .../kinoko/handler/user/FamilyHandler.java | 52 ++++-- .../java/kinoko/packet/user/UserRemote.java | 2 + .../java/kinoko/packet/world/AdminPacket.java | 26 +++ .../kinoko/packet/world/FamilyPacket.java | 8 +- .../server/command/jrgm/HideCommand.java | 11 +- .../server/command/jrgm/UnHideCommand.java | 20 -- .../server/command/tester/MaxCommand.java | 6 +- .../java/kinoko/server/family/Family.java | 175 ------------------ .../server/family/FamilyEntitlement.java | 26 ++- .../kinoko/server/family/FamilyStorage.java | 34 +--- .../java/kinoko/server/family/FamilyTree.java | 24 ++- .../kinoko/server/node/CentralServerNode.java | 104 +++++------ .../kinoko/server/user/AdminResultType.java | 132 +++++++++++++ src/main/java/kinoko/util/Timing.java | 30 ++- src/main/java/kinoko/world/GameConstants.java | 4 + src/main/java/kinoko/world/field/Field.java | 20 ++ .../java/kinoko/world/field/UserPool.java | 51 ++++- src/main/java/kinoko/world/field/mob/Mob.java | 2 - .../kinoko/world/job/explorer/Magician.java | 3 +- src/main/java/kinoko/world/job/staff/GM.java | 54 ++++++ .../java/kinoko/world/job/staff/SuperGM.java | 90 +++++++++ src/main/java/kinoko/world/skill/Skill.java | 12 +- .../kinoko/world/skill/SkillConstants.java | 16 ++ .../kinoko/world/skill/SkillProcessor.java | 54 +++++- .../java/kinoko/world/user/FamilyMember.java | 8 +- src/main/java/kinoko/world/user/User.java | 62 ++++++- 27 files changed, 678 insertions(+), 350 deletions(-) create mode 100644 src/main/java/kinoko/packet/world/AdminPacket.java delete mode 100644 src/main/java/kinoko/server/command/jrgm/UnHideCommand.java delete mode 100644 src/main/java/kinoko/server/family/Family.java create mode 100644 src/main/java/kinoko/server/user/AdminResultType.java create mode 100644 src/main/java/kinoko/world/job/staff/GM.java create mode 100644 src/main/java/kinoko/world/job/staff/SuperGM.java diff --git a/src/main/java/kinoko/handler/user/AttackHandler.java b/src/main/java/kinoko/handler/user/AttackHandler.java index fed6dbea..6c579f75 100644 --- a/src/main/java/kinoko/handler/user/AttackHandler.java +++ b/src/main/java/kinoko/handler/user/AttackHandler.java @@ -940,7 +940,7 @@ private static void handleWindWalk(User user) { } private static void handleInfiltrate(User user) { - if (!user.getSecondaryStat().hasOption(CharacterTemporaryStat.Sneak)) { + if (!user.getSecondaryStat().hasOption(CharacterTemporaryStat.Sneak) || user.isHidden()) { return; } user.resetTemporaryStat(user.getSecondaryStat().getOption(CharacterTemporaryStat.Sneak).rOption); diff --git a/src/main/java/kinoko/handler/user/FamilyHandler.java b/src/main/java/kinoko/handler/user/FamilyHandler.java index 0cbe2ee4..2aed12df 100644 --- a/src/main/java/kinoko/handler/user/FamilyHandler.java +++ b/src/main/java/kinoko/handler/user/FamilyHandler.java @@ -12,7 +12,6 @@ import kinoko.server.node.CentralServerNode; import kinoko.server.packet.InPacket; import kinoko.server.packet.OutPacket; -import kinoko.util.exceptions.DumbDeveloperFoundException; import kinoko.util.exceptions.InvalidInputException; import kinoko.world.GameConstants; import kinoko.world.user.FamilyMember; @@ -570,6 +569,15 @@ public static void handleFamilyUsePrivilege(User user, InPacket inPacket) { User targetUser = null; + + Optional familyTreeOpt = user.getFamilyTree(); + if (familyTreeOpt.isEmpty()) { + user.write(FamilyPacket.of(FamilyResultType.DifferentFamily, 0)); + return; // exit if user has no family tree + } + + FamilyTree familyTree = familyTreeOpt.get(); + if (entitlement.getType() == 1) { // this entitlement requires a character name input String targetName = inPacket.decodeString(); targetUser = Server.getCentralServerNode() @@ -609,32 +617,52 @@ public static void handleFamilyUsePrivilege(User user, InPacket inPacket) { break; case FAMILY_EXP: - System.out.println("Applying FAMILY_EXP to all family members"); - // TODO: apply exp boost + familyTree.activateEntitlement(FamilyEntitlement.FAMILY_EXP); + familyTree.broadcastSystemMessage( + "%s has activated the %s buff (%sx EXP) for %d minutes.", + user.getCharacterName(), + FamilyEntitlement.FAMILY_EXP.getName(), + FamilyEntitlement.FAMILY_EXP.getModifier(), + FamilyEntitlement.FAMILY_EXP.getExpiresAfterMinutes() + ); break; case FAMILY_DROP: - System.out.println("Applying FAMILY_DROP to all family members"); - // TODO: apply drop boost + familyTree.activateEntitlement(FamilyEntitlement.FAMILY_DROP); + familyTree.broadcastSystemMessage( + "%s has activated the %s buff (%sx DROP) for %d minutes.", + user.getCharacterName(), + FamilyEntitlement.FAMILY_DROP.getName(), + FamilyEntitlement.FAMILY_DROP.getModifier(), + FamilyEntitlement.FAMILY_DROP.getExpiresAfterMinutes() + ); break; case SELF_DROP_1_5: - System.out.println("Applying SELF_DROP_1_5 to user"); - // TODO: apply self drop boost + userMember.activateEntitlement(FamilyEntitlement.SELF_DROP_1_5); + user.systemMessage(String.format( + "You have activated the %s buff (%sx DROP) for %d minutes!", + FamilyEntitlement.SELF_DROP_1_5.getName(), + FamilyEntitlement.SELF_DROP_1_5.getModifier(), + FamilyEntitlement.SELF_DROP_1_5.getExpiresAfterMinutes() + )); break; case SELF_EXP_1_5: - System.out.println("Applying SELF_EXP_1_5 to user"); - // TODO: apply self exp boost + userMember.activateEntitlement(FamilyEntitlement.SELF_EXP_1_5); + user.systemMessage(String.format( + "You have activated the %s buff (%sx EXP) for %d minutes!", + FamilyEntitlement.SELF_EXP_1_5.getName(), + FamilyEntitlement.SELF_EXP_1_5.getModifier(), + FamilyEntitlement.SELF_EXP_1_5.getExpiresAfterMinutes() + )); break; default: - System.out.println("Unhandled FamilyEntitlement: " + entitlement); + log.error("Unhandled FamilyEntitlement: {}", entitlement); break; } } }); - -// System.out.println("Handled FamilyUsePrivilege: " + entitlement + ", target: " + targetUser); } /** diff --git a/src/main/java/kinoko/packet/user/UserRemote.java b/src/main/java/kinoko/packet/user/UserRemote.java index 835b588b..dfbf35df 100644 --- a/src/main/java/kinoko/packet/user/UserRemote.java +++ b/src/main/java/kinoko/packet/user/UserRemote.java @@ -7,6 +7,7 @@ import kinoko.world.job.explorer.Bowman; import kinoko.world.job.explorer.Thief; import kinoko.world.job.resistance.WildHunter; +import kinoko.world.job.staff.SuperGM; import kinoko.world.skill.*; import kinoko.world.user.CharacterData; import kinoko.world.user.GuildInfo; @@ -14,6 +15,7 @@ import kinoko.world.user.effect.Effect; import kinoko.world.user.stat.CharacterTemporaryStat; import kinoko.world.user.stat.SecondaryStat; +import kinoko.world.user.stat.TemporaryStatOption; public final class UserRemote { // CUserPool::OnUserRemotePacket ----------------------------------------------------------------------------------- diff --git a/src/main/java/kinoko/packet/world/AdminPacket.java b/src/main/java/kinoko/packet/world/AdminPacket.java new file mode 100644 index 00000000..a74187a1 --- /dev/null +++ b/src/main/java/kinoko/packet/world/AdminPacket.java @@ -0,0 +1,26 @@ +package kinoko.packet.world; + +import kinoko.server.Server; +import kinoko.server.family.FamilyEntitlement; +import kinoko.server.family.FamilyResultType; +import kinoko.server.family.FamilyTree; +import kinoko.server.header.OutHeader; +import kinoko.server.packet.OutPacket; +import kinoko.world.user.FamilyMember; +import kinoko.world.user.User; + +import java.util.Arrays; +import java.util.Collections; +import java.util.Optional; + +public final class AdminPacket { + public static OutPacket getAdminEffect(int type, byte mode) { + final OutPacket outPacket = OutPacket.of(OutHeader.AdminResult); + + outPacket.encodeByte(type); + outPacket.encodeByte(mode); + + return outPacket; + } + +} diff --git a/src/main/java/kinoko/packet/world/FamilyPacket.java b/src/main/java/kinoko/packet/world/FamilyPacket.java index 20cca2a6..8e1eba63 100644 --- a/src/main/java/kinoko/packet/world/FamilyPacket.java +++ b/src/main/java/kinoko/packet/world/FamilyPacket.java @@ -60,13 +60,13 @@ public static OutPacket createFamilyInvite(User senior) { * String leaderName * String requesterName * - * @param senior The user sending the invite (senior) + * @param requester The user sending the invite (senior) * @return The encoded packet to send to the client */ - public static OutPacket createSummonRequest(User requestor) { + public static OutPacket createSummonRequest(User requester) { final OutPacket outPacket = OutPacket.of(OutHeader.FamilySummonRequest); - outPacket.encodeString(requestor.getCharacterName()); - outPacket.encodeString(requestor.getField().getName()); + outPacket.encodeString(requester.getCharacterName()); + outPacket.encodeString(requester.getField().getName()); return outPacket; } diff --git a/src/main/java/kinoko/server/command/jrgm/HideCommand.java b/src/main/java/kinoko/server/command/jrgm/HideCommand.java index ac1dfeca..05c122ae 100644 --- a/src/main/java/kinoko/server/command/jrgm/HideCommand.java +++ b/src/main/java/kinoko/server/command/jrgm/HideCommand.java @@ -8,17 +8,12 @@ public final class HideCommand { /** - * Activates GM Hide mode (makes you invisible to players). + * Toggles GM Hide mode (makes you visible/invisible to players). * Usage: !hide */ @Command("hide") public static void hide(User user, String[] args) { - final int GM_HIDE_SKILL_ID = 9101004; - - // Apply the GM Hide buff using DarkSight - // nOption = 1 (value), rOption = skillId, tOption = 0 (permanent until unhide) - final TemporaryStatOption option = TemporaryStatOption.of(1, GM_HIDE_SKILL_ID, 0); - user.setTemporaryStat(CharacterTemporaryStat.DarkSight, option); - user.systemMessage("You are now invisible to players."); + user.hide(!user.isHidden(), false); + user.systemMessage(user.isHidden() ? "You are now invisible to players." : "You are now visible to players."); } } diff --git a/src/main/java/kinoko/server/command/jrgm/UnHideCommand.java b/src/main/java/kinoko/server/command/jrgm/UnHideCommand.java deleted file mode 100644 index d9a62143..00000000 --- a/src/main/java/kinoko/server/command/jrgm/UnHideCommand.java +++ /dev/null @@ -1,20 +0,0 @@ -package kinoko.server.command.jrgm; - -import kinoko.packet.world.MessagePacket; -import kinoko.server.command.Command; -import kinoko.world.user.User; - -public final class UnHideCommand { - /** - * Deactivates GM Hide mode (makes you visible again). - * Usage: !unhide - */ - @Command("unhide") - public static void unhide(User user, String[] args) { - final int GM_HIDE_SKILL_ID = 9101004; - - // Remove the GM Hide buff - user.resetTemporaryStat(GM_HIDE_SKILL_ID); - user.systemMessage("You are now visible to players."); - } -} diff --git a/src/main/java/kinoko/server/command/tester/MaxCommand.java b/src/main/java/kinoko/server/command/tester/MaxCommand.java index 155503a9..a5fdd837 100644 --- a/src/main/java/kinoko/server/command/tester/MaxCommand.java +++ b/src/main/java/kinoko/server/command/tester/MaxCommand.java @@ -65,9 +65,9 @@ public static void max(User user, String[] args) { // Add skills List skillRecords = new ArrayList<>(); for (int skillRoot : JobConstants.getSkillRootFromJob(user.getJob())) { - if (JobConstants.isBeginnerJob(skillRoot)) { - continue; - } +// if (JobConstants.isBeginnerJob(skillRoot)) { +// continue; +// } Job job = Job.getById(skillRoot); for (SkillInfo si : SkillProvider.getSkillsForJob(job)) { SkillRecord skillRecord = new SkillRecord(si.getSkillId()); diff --git a/src/main/java/kinoko/server/family/Family.java b/src/main/java/kinoko/server/family/Family.java deleted file mode 100644 index 5296147d..00000000 --- a/src/main/java/kinoko/server/family/Family.java +++ /dev/null @@ -1,175 +0,0 @@ -package kinoko.server.family; - -import kinoko.server.packet.OutPacket; -import kinoko.server.user.RemoteTownPortal; -import kinoko.server.user.RemoteUser; -import kinoko.util.Encodable; -import kinoko.util.Lockable; -import kinoko.world.GameConstants; -import kinoko.world.user.PartyInfo; - -import java.util.*; -import java.util.concurrent.locks.Lock; -import java.util.concurrent.locks.ReentrantLock; -import java.util.function.Consumer; - -/** - * Party instance managed by CentralServerNode. RemoteUser instances managed by this object should always be pointing to - * the instance stored in UserStorage. - */ -public final class Family implements Encodable, Lockable { - private static final RemoteUser EMPTY_MEMBER = new RemoteUser(0, 0, "", 0, 0, GameConstants.CHANNEL_OFFLINE, GameConstants.UNDEFINED_FIELD_ID, 0, 0, 0, RemoteTownPortal.EMPTY); - private final Lock lock = new ReentrantLock(); - private final int partyId; - private final List partyMembers; - private final Map partyInvites; // invitee ID -> inviter ID - private int partyBossId; - - public Family(int partyId, RemoteUser remoteUser) { - this.partyId = partyId; - this.partyMembers = new ArrayList<>(GameConstants.PARTY_MAX); - this.partyMembers.add(remoteUser); - this.partyInvites = new HashMap<>(); - this.partyBossId = remoteUser.getCharacterId(); - } - - public int getPartyId() { - return partyId; - } - - public boolean canAddMember(RemoteUser remoteUser) { - if (partyMembers.size() >= GameConstants.PARTY_MAX) { - return false; - } - for (RemoteUser member : partyMembers) { - if (member.getCharacterId() == remoteUser.getCharacterId()) { - return false; - } - } - return true; - } - - public boolean addMember(RemoteUser remoteUser) { - if (!canAddMember(remoteUser)) { - return false; - } - partyMembers.add(remoteUser); - return true; - } - - public boolean removeMember(RemoteUser remoteUser) { - return partyMembers.removeIf((member) -> member.getCharacterId() == remoteUser.getCharacterId()); - } - - public void registerInvite(int inviterId, int targetId) { - partyInvites.put(targetId, inviterId); - } - - public boolean unregisterInvite(int inviterId, int targetId) { - return partyInvites.remove(targetId) == inviterId; - } - - public int getPartyBossId() { - return partyBossId; - } - - public boolean setPartyBossId(int currentBossId, int newBossId) { - if (partyBossId != 0 && partyBossId != currentBossId) { - return false; - } - if (!hasMember(newBossId)) { - return false; - } - this.partyBossId = newBossId; - return true; - } - - public Optional getMember(int characterId) { - return partyMembers.stream() - .filter((member) -> member.getCharacterId() == characterId) - .findFirst(); - } - - public int getMemberIndex(RemoteUser remoteUser) { - for (int i = 0; i < GameConstants.PARTY_MAX; i++) { - if (i >= partyMembers.size()) { - break; - } - if (partyMembers.get(i).getCharacterId() == remoteUser.getCharacterId()) { - return i + 1; // used for affectedMemberBitMap - } - } - return 0; - } - - public boolean hasMember(int characterId) { - return getMember(characterId).isPresent(); - } - - public void updateMember(RemoteUser remoteUser) { - for (int i = 0; i < GameConstants.PARTY_MAX; i++) { - if (i >= partyMembers.size()) { - break; - } - if (partyMembers.get(i).getCharacterId() == remoteUser.getCharacterId()) { - partyMembers.set(i, remoteUser); - } - } - } - - public PartyInfo createInfo(RemoteUser remoteUser) { - return new PartyInfo(partyId, getMemberIndex(remoteUser), partyBossId == remoteUser.getCharacterId()); - } - - public void forEachMember(Consumer consumer) { - for (RemoteUser member : partyMembers) { - consumer.accept(member); - } - } - - private void forEachMemberForPartyData(Consumer consumer) { - for (int i = 0; i < GameConstants.PARTY_MAX; i++) { - if (i < partyMembers.size()) { - consumer.accept(partyMembers.get(i)); - } else { - consumer.accept(EMPTY_MEMBER); - } - } - } - - @Override - public String toString() { - return "Party{" + - "partyId=" + partyId + - ", partyMembers=" + partyMembers + - ", partyBossId=" + partyBossId + - '}'; - } - - @Override - public void encode(OutPacket outPacket) { - // PARTYDATA::Decode (378) - forEachMemberForPartyData((member) -> outPacket.encodeInt(member.getCharacterId())); // adwCharacterID - forEachMemberForPartyData((member) -> outPacket.encodeString(member.getCharacterName(), 13)); // asCharacterName - forEachMemberForPartyData((member) -> outPacket.encodeInt(member.getJob())); // anJob - forEachMemberForPartyData((member) -> outPacket.encodeInt(member.getLevel())); // anLevel - forEachMemberForPartyData((member) -> outPacket.encodeInt(member.getChannelId())); // anChannelID - outPacket.encodeInt(partyBossId); // dwPartyBossCharacterID - forEachMemberForPartyData((member) -> outPacket.encodeInt(member.getFieldId())); // adwFieldID - forEachMemberForPartyData((member) -> member.getTownPortal().encode(outPacket)); // aTownPortal - forEachMemberForPartyData((member) -> outPacket.encodeInt(0)); // aPQReward - forEachMemberForPartyData((member) -> outPacket.encodeInt(0)); // aPQRewardType - outPacket.encodeInt(0); // dwPQRewardMobTemplateID - outPacket.encodeInt(0); // bPQReward - } - - @Override - public void lock() { - lock.lock(); - } - - @Override - public void unlock() { - lock.unlock(); - } -} diff --git a/src/main/java/kinoko/server/family/FamilyEntitlement.java b/src/main/java/kinoko/server/family/FamilyEntitlement.java index 820fe3bf..552ecd72 100644 --- a/src/main/java/kinoko/server/family/FamilyEntitlement.java +++ b/src/main/java/kinoko/server/family/FamilyEntitlement.java @@ -1,6 +1,8 @@ package kinoko.server.family; +import kinoko.util.Timing; + /** * Represents a Family Entitlement in the server. * @@ -21,34 +23,34 @@ public enum FamilyEntitlement { FAMILY_REUNION(1, 100, "Family Reunion", "[Target] Me\n[Effect] Teleport directly to the Family member of your choice.", - 1440, (byte) 1, null, null), + Timing.DAY_MINUTES, (byte) 1, null, null), SUMMON_FAMILY(1, 200, "Summon Family", "[Target] 1 Family member\n[Effect] Summon a Family member of choice to the map you're in.", - 1440, (byte) 1, null, null), + Timing.DAY_MINUTES, (byte) 1, null, null), FAMILY_HASTE(1, 500, "Quicker Together", "[Target] All Family Members\n[Effect] All family members, regardless of map, " + "are blessed with Family Haste.", - 1440, (byte) 2, null, null), + Timing.DAY_MINUTES, (byte) 2, null, null), FAMILY_EXP(1, 5000, "A Better Experience", "[Target] All Family Members\n[Effect] For 15 minutes, all family members receive 1.2x experience, " + "regardless of map.", - 1440, (byte) 2, 1.2, 15), + Timing.DAY_MINUTES, (byte) 2, 1.2, 15), FAMILY_DROP(1, 5000, "All The Drops", "[Target] All Family Members\n[Effect] For 15 minutes, " + "all family members receive 1.2x drop rate, regardless of map.", - 1440, (byte) 2, 1.2, 15), + Timing.DAY_MINUTES, (byte) 2, 1.2, 15), SELF_DROP_1_5(1, 8000, "My Drop Rate 1.5x (15 min)", "[Target] Me\n[Time] 15 min\n[Effect] Monster drop rate will be increased #c1.5x#.", - 1440, (byte) 2, 1.5, 15), + Timing.DAY_MINUTES, (byte) 2, 1.5, 15), SELF_EXP_1_5(1, 8000, "My EXP 1.5x (15 min)", "[Target] Me\n[Time] 15 min\n[Effect] EXP earned from hunting will be increased #c1.5x#.", - 1440, (byte) 2, 1.5, 15); + Timing.DAY_MINUTES, (byte) 2, 1.5, 15); private final int usageLimit, repCost, usageResetAfterMinutes; private final Integer expiresAfterMinutes; @@ -97,5 +99,15 @@ public String getDescription() { public byte getType(){ return type; } + + public String getStatName() { + if (this == FAMILY_EXP || this == SELF_EXP_1_5) { + return "EXP"; + } + if (this == FAMILY_DROP || this == SELF_DROP_1_5) { + return "DROP"; + } + return ""; // e.g. teleport/haste/etc + } } diff --git a/src/main/java/kinoko/server/family/FamilyStorage.java b/src/main/java/kinoko/server/family/FamilyStorage.java index 28876545..812ce4cc 100644 --- a/src/main/java/kinoko/server/family/FamilyStorage.java +++ b/src/main/java/kinoko/server/family/FamilyStorage.java @@ -20,37 +20,13 @@ public final class FamilyStorage { private final ReentrantLock globalFamilyLock = new ReentrantLock(); /** - * Returns the global lock for the family storage. + * Returns the global lock for family storage. * - * This lock should be used **only by external classes** (such as CentralServerNode or FamilyHandler) - * when performing operations that involve multiple reads/writes across FamilyStorage - * to ensure thread safety. + * Acquire this lock when performing multiple reads/writes on FamilyStorage. + * Keep the scope short, avoid I/O or network operations while locked, and + * never combine with other locks to prevent deadlocks. * - * Important guidelines for usage: - * 1. **Use exclusively for family modifications** – acquire the lock only while - * reading or modifying family data. Do not hold it for network operations, - * database I/O, or other slow tasks, as this lock is highly contended. - * 2. **Avoid combining with other locks** – never acquire this lock while holding - * another lock (or vice versa), as it can easily lead to deadlocks. - * 3. **Keep lock duration short** – perform only the necessary operations inside - * the lock scope to minimize contention, since this lock protects all families - * globally and is used frequently. - * - * Example usage: - * ReentrantLock lock = Server.getCentralServerNode().getGlobalFamilyLock(); - * lock.lock(); - * try { - * // perform only family modifications or reads - * FamilyMember member = familyStorage.getFamilyMember(characterId).orElse(FamilyMember.EMPTY); - * FamilyTree tree = familyStorage.getTreeByMemberId(characterId).orElse(null); - * } finally { - * lock.unlock(); - * } - * - * Internal methods of FamilyStorage do **not acquire this lock** themselves - * and assume that the caller handles synchronization if needed. - * - * @return the ReentrantLock protecting access to the family storage + * @return the ReentrantLock protecting family storage */ public ReentrantLock getGlobalLock() { return globalFamilyLock; diff --git a/src/main/java/kinoko/server/family/FamilyTree.java b/src/main/java/kinoko/server/family/FamilyTree.java index 44698bb5..6465068b 100644 --- a/src/main/java/kinoko/server/family/FamilyTree.java +++ b/src/main/java/kinoko/server/family/FamilyTree.java @@ -1,9 +1,13 @@ package kinoko.server.family; +import kinoko.server.Server; +import kinoko.server.node.CentralServerNode; import kinoko.server.packet.OutPacket; import kinoko.util.Timing; import kinoko.util.exceptions.DumbDeveloperFoundException; +import kinoko.world.GameConstants; import kinoko.world.user.FamilyMember; +import kinoko.world.user.User; import java.util.*; import java.util.function.Consumer; @@ -98,6 +102,20 @@ private void broadcastFamilyMessage() { forEach(member -> member.setFamilyMessage(familyMessage)); // Update each member } + public void broadcastSystemMessage(String message, Object... args) { + CentralServerNode centralServerNode = Server.getCentralServerNode(); + + String formattedMessage = String.format(message, args); + + // get all character IDs directly from the members map and get their User objects if they are online. + List characterIds = new ArrayList<>(members.keySet()); + List users = centralServerNode.getUsersByCharacterIds(characterIds); + + for (User user : users) { + user.systemMessage("[FAMILY] " + formattedMessage); + } + } + /** * Activates a given family entitlement for the user. * @@ -108,7 +126,7 @@ private void broadcastFamilyMessage() { */ public void activateEntitlement(FamilyEntitlement ent) { long expiresMinutes = ent.getExpiresAfterMinutes(); - long expireAt = Timing.nowSeconds() + expiresMinutes * 60; + long expireAt = Timing.nowSeconds() + expiresMinutes * Timing.SECONDS_IN_MINUTE; activeEntitlements.put(ent, expireAt); } @@ -145,7 +163,7 @@ public boolean isEntitlementActive(FamilyEntitlement ent) { public double getExpModifier() { return isEntitlementActive(FamilyEntitlement.FAMILY_EXP) ? FamilyEntitlement.FAMILY_EXP.getModifier() - : 1.0; + : GameConstants.DEFAULT_FAMILY_EXP_MODIFIER; } /** @@ -159,7 +177,7 @@ public double getExpModifier() { public double getDropModifier() { return isEntitlementActive(FamilyEntitlement.FAMILY_DROP) ? FamilyEntitlement.FAMILY_DROP.getModifier() - : 1.0; + : GameConstants.DEFAULT_FAMILY_DROP_MODIFIER; } // ------------------------------------------------------------------------- diff --git a/src/main/java/kinoko/server/node/CentralServerNode.java b/src/main/java/kinoko/server/node/CentralServerNode.java index 7d17843f..eb50e5c4 100644 --- a/src/main/java/kinoko/server/node/CentralServerNode.java +++ b/src/main/java/kinoko/server/node/CentralServerNode.java @@ -30,34 +30,19 @@ import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; -import java.time.Duration; -import java.time.Instant; -import java.util.ArrayList; -import java.util.Collection; -import java.util.List; -import java.util.Optional; +import java.util.*; import java.util.concurrent.CompletableFuture; import java.util.concurrent.locks.ReentrantLock; import static kinoko.util.Timing.logDuration; /** - * Represents the central server node responsible for coordinating all channel servers, - * user data, parties, guilds, messengers, and family information. + * Central server node coordinating channels, users, parties, guilds, messengers, and families. * - * This class provides **high-level, thread-safe access** to shared server data via - * wrapper methods. All internal storage objects (FamilyStorage, UserStorage, GuildStorage, - * MessengerStorage, PartyStorage, MigrationStorage, ServerStorage) are **private** and - * should **not be accessed directly** by external classes. - * - * Instead, external code should always interact with data through the provided - * CentralServerNode methods, which handle synchronization, validation, and safe - * concurrent access. This design ensures thread safety and encapsulates the - * internal implementation details, keeping the storage classes simple and fast. - * - * High-level operations, such as modifying families, creating parties, or - * updating guilds, acquire the necessary locks internally and expose only safe - * interfaces for external use. + * Provides high-level, thread-safe access to shared data via wrapper methods. + * Internal storage (FamilyStorage, UserStorage, etc.) is private and must not be accessed directly. + * External code should use CentralServerNode methods, which handle synchronization and validation, + * ensuring thread safety while keeping storage classes simple and fast. */ public final class CentralServerNode extends Node { private static final Logger log = LogManager.getLogger(CentralServerNode.class); @@ -157,6 +142,35 @@ public Optional getUserByCharacterId(int characterId) { ); } + /** + * Batch retrieves User objects for the given list of character IDs. + * + * This method returns only users that are currently online (connected to a channel). + * + * @param characterIds the list of character IDs to look up + * @return a list of User objects corresponding to the IDs found + */ + public List getUsersByCharacterIds(List characterIds) { + List result = new ArrayList<>(); + if (characterIds.isEmpty()) { + return result; + } + + // Convert to a set for faster contains() checks + var idSet = Set.copyOf(characterIds); + + // Iterate through all connected channel servers + for (ChannelServerNode channelNode : getChannelServerNodes()) { + for (User user : channelNode.getConnectedUsers()) { + if (idSet.contains(user.getCharacterId())) { + result.add(user); + } + } + } + + return result; + } + /** * Returns the actual User object for a character name, if connected. * Looks up the RemoteUser by name, finds their channel server, then fetches the User from that channel. @@ -271,49 +285,17 @@ public Optional getGuildById(int guildId) { // FAMILY METHODS -------------------------------------------------------------------------------------------------- - // High-level family operations for CentralServerNode - // - // These methods provide thread-safe, high-level access to family data, such as - // retrieving members or adding/updating family trees - // Each method acquires the global family lock to ensure safe concurrent access. - // - // Note: The underlying FamilyStorage methods themselves are **not** thread-safe. - // Only these high-level wrapper methods acquire the lock. Direct calls to - // familyStorage should not be made without proper synchronization. - // - // This design ensures that all external access to family data through CentralServerNode - // is safe, while keeping the internal FamilyStorage implementation simple and fast. + // High-level thread-safe family operations for CentralServerNode. + // These methods acquire the global family lock to safely read or modify family data. + // FamilyStorage methods themselves are not thread-safe; direct calls must be synchronized. + // This ensures safe external access while keeping FamilyStorage fast and simple. /** - * Returns the global lock used to synchronize access to the family storage. - * - * This lock protects all operations on the shared family data structures, - * such as adding, removing, or retrieving FamilyMember instances. - * Since the underlying familyStorage is mutable and may be accessed concurrently - * by multiple threads, any read or write operation should acquire this lock - * to avoid race conditions or inconsistent data. - * - * Important usage notes: - * 1. **Use only for family data operations** – do not hold this lock for network - * writes, database I/O, or other slow tasks. Keep the critical section short. - * 2. **Avoid combining with other locks** – acquiring this lock alongside - * other locks can lead to deadlocks; follow a consistent lock acquisition order. - * 3. **External responsibility** – the underlying FamilyStorage methods do not - * acquire this lock themselves. Any multi-step operation that reads or - * modifies family data should acquire the lock externally. - * - * Example usage: - * getGlobalFamilyLock().lock(); - * try { - * FamilyMember member = familyStorage.getFamilyMember(characterId).orElse(FamilyMember.EMPTY); - * FamilyTree tree = familyStorage.getTreeByMemberId(characterId).orElse(null); - * } finally { - * getGlobalFamilyLock().unlock(); - * } + * Returns the global lock for FamilyStorage. * - * Note: Even if some call sites already hold this lock before calling - * methods like getFamilyInfo(), acquiring the lock here ensures safety and - * prevents accidental concurrent access elsewhere in the code. + * Acquire this lock when reading or modifying family data. Keep the scope short, + * avoid I/O or network operations while locked, and never combine with other locks. + * FamilyStorage methods do not lock internally; external synchronization is required. * * @return the ReentrantLock protecting all family operations */ diff --git a/src/main/java/kinoko/server/user/AdminResultType.java b/src/main/java/kinoko/server/user/AdminResultType.java new file mode 100644 index 00000000..7840b2ac --- /dev/null +++ b/src/main/java/kinoko/server/user/AdminResultType.java @@ -0,0 +1,132 @@ +package kinoko.server.user; + +/** + * Represents the various server responses for administrative actions. + * The values correspond to the cases in the CField::OnAdminResult function. + */ +public enum AdminResultType { + /** + * Response for a successful character block. (e.g., /block command) + * Message: "You have successfully blocked access." + */ + BLOCK_SUCCESS(4), + + /** + * Response for a successful character unblock. + * Message: "The unblocking has been successful." + */ + UNBLOCK_SUCCESS(5), + + /** + * Response after attempting to remove a character from rankings. + * Can be either success or failure (invalid name). + */ + RANK_REMOVE_RESPONSE(6), + + /** + * Handles admin chat messages or notices sent to the player. + */ + ADMIN_CHAT(11), + + /** + * Sets the character's visibility status (hide/show). + * A value of 1 hides the character, 0 shows them. + */ + SET_HIDE_STATUS(18), + + /** + * Response for a hired merchant search, providing the location. + */ + FIND_HIRED_MERCHANT_RESPONSE(21), + + /** + * Forces the client to reload the mini-map. + */ + RELOAD_MINIMAP(40), + + /** + * Toggles the mini-map display off. + */ + TOGGLE_MINIMAP(41), + + /** + * A generic response indicating a request has failed. + * Message: "Your request failed." + */ + REQUEST_FAILED(42), + + /** + * Response after sending a warning to a user (success or failure). + */ + WARN_RESPONSE(43), + + /** + * Displays a decoded string in the chat log (style 11). + */ + DISPLAY_MESSAGE_1(51), + + /** + * Displays a decoded string in the chat log (style 11). + */ + DISPLAY_MESSAGE_2(52), + + /** + * Displays a decoded string in the chat log (style 11). + */ + DISPLAY_MESSAGE_3(53), + + /** + * Displays a decoded string in the chat log (style 11). + */ + DISPLAY_MESSAGE_4(54), + + /** + * Displays a decoded string in the chat log (style 11). + */ + DISPLAY_MESSAGE_5(55), + + /** + * Displays a decoded string in the chat log (style 11). + */ + DISPLAY_MESSAGE_6(56), + + /** + * Displays a decoded string in the chat log (style 11). + */ + DISPLAY_MESSAGE_7(57), + + /** + * Displays a decoded string in the chat log (style 12). + */ + DISPLAY_MESSAGE_8(58), + + /** + * Displays a decoded string in the chat log (style 12). + */ + DISPLAY_MESSAGE_9(71), + + /** + * Displays a decoded string in the chat log (style 11). + */ + DISPLAY_MESSAGE_10(72); + + + private final int value; + + AdminResultType(int value) { + this.value = value; + } + + public int getValue() { + return value; + } + + public static AdminResultType getByValue(int value) { + for (AdminResultType type : values()) { + if (type.getValue() == value) { + return type; + } + } + return null; + } +} \ No newline at end of file diff --git a/src/main/java/kinoko/util/Timing.java b/src/main/java/kinoko/util/Timing.java index 2d63c86b..8d5f3d1a 100644 --- a/src/main/java/kinoko/util/Timing.java +++ b/src/main/java/kinoko/util/Timing.java @@ -5,9 +5,19 @@ import java.time.Instant; + +/** + * Utility class for time operations and measuring durations. + * + * Provides methods for current time, unit conversions, and timing/logging code execution. + * Helps remove magic numbers across the codebase. All methods are static; the class cannot be instantiated. + */ public final class Timing { private static final Logger log = LogManager.getLogger(Timing.class); - public static final long DAY_SECONDS = 86400; + public static final int DAY_SECONDS = 86400; + public static final int DAY_MINUTES = 1440; + public static final int SECONDS_IN_MINUTE = 60; + public static final long NANOS_IN_MILLI = 1_000_000L; private Timing() { // prevent instantiation @@ -52,7 +62,7 @@ public static void logDuration(String taskName, Runnable action) { public static void logDuration(String taskName, Runnable action, Logger logger) { long start = System.nanoTime(); action.run(); - long elapsedMillis = (System.nanoTime() - start) / 1_000_000; // simpler conversion + long elapsedMillis = (System.nanoTime() - start) / NANOS_IN_MILLI; // simpler conversion logger.info("{} completed in {} milliseconds", taskName, elapsedMillis); } @@ -72,7 +82,21 @@ public static void logDuration(String taskName, Runnable action, Logger logger) public static void logDurationThrowing(String taskName, ThrowingRunnable action, Logger logger) throws E { long start = System.nanoTime(); action.run(); // can throw E - long elapsedMillis = (System.nanoTime() - start) / 1_000_000; + long elapsedMillis = (System.nanoTime() - start) / NANOS_IN_MILLI; logger.info("{} completed in {} milliseconds", taskName, elapsedMillis); } + + /** + * Converts a duration in seconds to whole minutes, rounding down. + * + * Example: + * 90 seconds -> 1 minute + * 59 seconds -> 0 minutes + * + * @param seconds the duration in seconds + * @return the equivalent number of minutes + */ + public static int secondsToMinutes(long seconds) { + return (int) (seconds / SECONDS_IN_MINUTE); + } } \ No newline at end of file diff --git a/src/main/java/kinoko/world/GameConstants.java b/src/main/java/kinoko/world/GameConstants.java index ea304572..8846ca51 100644 --- a/src/main/java/kinoko/world/GameConstants.java +++ b/src/main/java/kinoko/world/GameConstants.java @@ -114,6 +114,10 @@ public final class GameConstants { public static final int MIN_FAMILY_LEVEL = 10; // min level required for a character to be in a family. public static final int MAX_LEVEL_GAP_FOR_FAMILY = 50; // max allowed level difference between a senior and junior. public static final int MAX_FAMILY_CHILDREN_COUNT = 2; // max allowed juniors + public static final double DEFAULT_FAMILY_DROP_MODIFIER = 1.0; // max allowed juniors + public static final double DEFAULT_FAMILY_EXP_MODIFIER = 1.0; // max allowed juniors + public static final double DEFAULT_FAMILY_PERSONAL_DROP_MODIFIER = 1.0; // max allowed juniors + public static final double DEFAULT_FAMILY_PERSONAL_EXP_MODIFIER = 1.0; // max allowed juniors // REACTOR CONSTANTS ----------------------------------------------------------------------------------------------- diff --git a/src/main/java/kinoko/world/field/Field.java b/src/main/java/kinoko/world/field/Field.java index 3583c064..5de6426a 100644 --- a/src/main/java/kinoko/world/field/Field.java +++ b/src/main/java/kinoko/world/field/Field.java @@ -24,6 +24,7 @@ import kinoko.world.field.npc.Npc; import kinoko.world.field.reactor.Reactor; import kinoko.world.user.User; +import kinoko.world.user.stat.AdminLevel; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -344,6 +345,25 @@ public void broadcastPacket(OutPacket outPacket, User except) { userPool.broadcastPacket(outPacket, except); } + + /** + * Broadcasts a packet to all users in the field who are GMs/admins. + * + * @param outPacket The packet to send to GM users. + */ + public void broadcastToGMs(OutPacket outPacket) { + userPool.broadcastPacketToGMs(outPacket); + } + + /** + * Broadcasts a packet to all users in the field who are not GMs/admins. + * + * @param outPacket The packet to send to non-GM users. + */ + public void broadcastToNonGMs(OutPacket outPacket) { + userPool.broadcastPacketToNonGMs(outPacket); + } + public boolean hasUser() { return !userPool.isEmpty(); } diff --git a/src/main/java/kinoko/world/field/UserPool.java b/src/main/java/kinoko/world/field/UserPool.java index 2368c61c..f62a507b 100644 --- a/src/main/java/kinoko/world/field/UserPool.java +++ b/src/main/java/kinoko/world/field/UserPool.java @@ -21,6 +21,7 @@ import kinoko.world.skill.SkillProcessor; import kinoko.world.user.Pet; import kinoko.world.user.User; +import kinoko.world.user.stat.AdminLevel; import kinoko.world.user.stat.CharacterTemporaryStat; import java.time.Instant; @@ -43,6 +44,9 @@ public Optional getByCharacterName(String name) { public synchronized void addUser(User user) { // Update client with existing users in pool forEach((existingUser) -> { + if (existingUser.isHidden() && !user.getAdminLevel().isAtLeast(AdminLevel.JR_GM)){ + return; + } user.write(UserPacket.userEnterField(existingUser)); for (Pet pet : existingUser.getPets()) { user.write(PetPacket.petActivated(existingUser, pet)); @@ -65,7 +69,13 @@ public synchronized void addUser(User user) { // Add user to pool addObject(user); - broadcastPacket(UserPacket.userEnterField(user), user); + + if (user.isHidden()) { + broadcastPacketToGMs(UserPacket.userEnterField(user), user); + } + else { + broadcastPacket(UserPacket.userEnterField(user), user); + } // Add user pets for (Pet pet : user.getPets()) { @@ -325,6 +335,45 @@ public void broadcastPacket(OutPacket outPacket, User except) { }); } + /** + * Broadcasts a packet to all GM/admin users in the field. + * + * @param outPacket The packet to send to GM users. + */ + public void broadcastPacketToGMs(OutPacket outPacket) { + broadcastPacketToGMs(outPacket, null); + } + + /** + * Broadcasts a packet to all GM/admin users in the field. + * + * @param outPacket The packet to send to GM users. + */ + public void broadcastPacketToGMs(OutPacket outPacket, User except) { + forEach(user -> { + if (except != null && user.getCharacterId() == except.getCharacterId()) { + return; + } + + if (user.getAdminLevel().isAtLeast(AdminLevel.JR_GM)) { + user.write(outPacket); + } + }); + } + + /** + * Broadcasts a packet to all non-GM users in the field. + * + * @param outPacket The packet to send to non-GM users. + */ + public void broadcastPacketToNonGMs(OutPacket outPacket) { + forEach(user -> { + if (!user.getAdminLevel().isAtLeast(AdminLevel.JR_GM)) { + user.write(outPacket); + } + }); + } + public Optional getNearestUser(FieldObject fieldObject) { return fieldObject.getNearestObject(getObjects()); } diff --git a/src/main/java/kinoko/world/field/mob/Mob.java b/src/main/java/kinoko/world/field/mob/Mob.java index a9acfc83..724e5365 100644 --- a/src/main/java/kinoko/world/field/mob/Mob.java +++ b/src/main/java/kinoko/world/field/mob/Mob.java @@ -17,8 +17,6 @@ import kinoko.provider.skill.SkillStat; import kinoko.script.party.HenesysPQ; import kinoko.server.ServerConfig; -import kinoko.server.family.Family; -import kinoko.server.family.FamilyTree; import kinoko.server.node.ServerExecutor; import kinoko.server.packet.OutPacket; import kinoko.util.BitFlag; diff --git a/src/main/java/kinoko/world/job/explorer/Magician.java b/src/main/java/kinoko/world/job/explorer/Magician.java index fbdaaeb8..2fa2f2ec 100644 --- a/src/main/java/kinoko/world/job/explorer/Magician.java +++ b/src/main/java/kinoko/world/job/explorer/Magician.java @@ -230,8 +230,7 @@ public static void handleSkill(User user, Skill skill) { return; case RESURRECTION: if (user.getHp() <= 0) { - user.setHp(user.getMaxHp()); - user.setMp(user.getMaxMp()); + user.heal(); } return; case SUMMON_DRAGON: diff --git a/src/main/java/kinoko/world/job/staff/GM.java b/src/main/java/kinoko/world/job/staff/GM.java new file mode 100644 index 00000000..68701104 --- /dev/null +++ b/src/main/java/kinoko/world/job/staff/GM.java @@ -0,0 +1,54 @@ +package kinoko.world.job.staff; + +import kinoko.packet.field.FieldPacket; +import kinoko.packet.user.UserLocal; +import kinoko.packet.user.UserPacket; +import kinoko.packet.user.UserRemote; +import kinoko.provider.SkillProvider; +import kinoko.provider.skill.SkillInfo; +import kinoko.provider.skill.SkillStat; +import kinoko.util.Util; +import kinoko.world.field.Field; +import kinoko.world.field.OpenGate; +import kinoko.world.field.affectedarea.AffectedArea; +import kinoko.world.field.affectedarea.AffectedAreaType; +import kinoko.world.field.mob.Mob; +import kinoko.world.field.mob.MobStatOption; +import kinoko.world.field.mob.MobTemporaryStat; +import kinoko.world.field.summoned.Summoned; +import kinoko.world.field.summoned.SummonedAssistType; +import kinoko.world.field.summoned.SummonedMoveAbility; +import kinoko.world.skill.Attack; +import kinoko.world.skill.Skill; +import kinoko.world.skill.SkillConstants; +import kinoko.world.skill.SkillProcessor; +import kinoko.world.user.User; +import kinoko.world.user.effect.Effect; +import kinoko.world.user.stat.CharacterTemporaryStat; +import kinoko.world.user.stat.DiceInfo; +import kinoko.world.user.stat.TemporaryStatOption; + +import java.time.Instant; +import java.time.temporal.ChronoUnit; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public final class GM extends SkillProcessor { + public static final int HASTE_NORMAL = 9001000; + public static final int SUPER_DRAGON_ROAR = 9001001; + public static final int TELEPORT = 9001002; + + + public static void handleSkill(User user, Skill skill) { + final SkillInfo si = SkillProvider.getSkillInfoById(skill.skillId).orElseThrow(); + final int skillId = skill.skillId; + final int slv = skill.slv; + + final Field field = user.getField(); + switch (skillId) { + } + log.error("Unhandled skill {}", skill.skillId); + } + +} diff --git a/src/main/java/kinoko/world/job/staff/SuperGM.java b/src/main/java/kinoko/world/job/staff/SuperGM.java new file mode 100644 index 00000000..43e6e63c --- /dev/null +++ b/src/main/java/kinoko/world/job/staff/SuperGM.java @@ -0,0 +1,90 @@ +package kinoko.world.job.staff; + +import kinoko.provider.SkillProvider; +import kinoko.provider.skill.SkillInfo; +import kinoko.provider.skill.SkillStat; +import kinoko.world.field.Field; +import kinoko.world.skill.Skill; +import kinoko.world.skill.SkillProcessor; +import kinoko.world.user.User; +import kinoko.world.user.stat.CharacterTemporaryStat; + +import java.util.Map; +import java.util.Set; + +public final class SuperGM extends SkillProcessor { + public static final int HEAL_DISPEL = 9101000; + public static final int HASTE_SUPER = 9101001; + public static final int HOLY_SYMBOL = 9101002; + public static final int BLESS = 9101003; + public static final int HIDE = 9101004; + public static final int RESURRECTION = 9101005; + public static final int SUPER_DRAGON_ROAR = 9101006; + public static final int TELEPORT = 9101007; + public static final int HYPER_BODY = 9101008; + + public static void handleSkill(User user, Skill skill) { + final SkillInfo si = SkillProvider.getSkillInfoById(skill.skillId).orElseThrow(); + final int skillId = skill.skillId; + final int slv = skill.slv; + + final Field field = user.getField(); + switch (skillId) { + // COMMON + case HYPER_BODY: + applyFieldBuff(skill, field, + Map.of( + CharacterTemporaryStat.MaxHP, si.getValue(SkillStat.x, slv), + CharacterTemporaryStat.MaxMP, si.getValue(SkillStat.x, slv) + ), + si.getDuration(slv)); + return; + case HASTE_SUPER: + applyFieldBuff(skill, field, + Map.of( + CharacterTemporaryStat.Speed, si.getValue(SkillStat.speed, slv), + CharacterTemporaryStat.Jump, si.getValue(SkillStat.jump, slv) + ), + si.getDuration(slv)); + return; + case BLESS: + applyFieldBuff(skill, field, + Map.of( + CharacterTemporaryStat.PAD, si.getValue(SkillStat.pad, slv), + CharacterTemporaryStat.MAD, si.getValue(SkillStat.mad, slv), + CharacterTemporaryStat.PDD, si.getValue(SkillStat.pdd, slv), + CharacterTemporaryStat.MDD, si.getValue(SkillStat.mdd, slv), + CharacterTemporaryStat.ACC, si.getValue(SkillStat.acc, slv), + CharacterTemporaryStat.EVA, si.getValue(SkillStat.eva, slv) + ), + si.getDuration(slv)); + return; + case HOLY_SYMBOL: + applyFieldBuff(skill, field, + Map.of( + CharacterTemporaryStat.HolySymbol, si.getValue(SkillStat.x, slv) + ), + si.getDuration(slv)); + return; + case HEAL_DISPEL: + resetFieldTemporaryStats(skill, field, Set.of( + CharacterTemporaryStat.Poison, + CharacterTemporaryStat.Seal, + CharacterTemporaryStat.Darkness, + CharacterTemporaryStat.Weakness, + CharacterTemporaryStat.Curse, + CharacterTemporaryStat.Slow + )); + skill.forEachAffectedUser(field, User::heal, false); + return; + case RESURRECTION: + skill.forEachAffectedUser(field, User::heal, false); + return; + case HIDE: + user.hide(!user.isHidden(), false); + return; + } + log.error("Unhandled skill {}", skill.skillId); + } + +} \ No newline at end of file diff --git a/src/main/java/kinoko/world/skill/Skill.java b/src/main/java/kinoko/world/skill/Skill.java index d9112866..ec597558 100644 --- a/src/main/java/kinoko/world/skill/Skill.java +++ b/src/main/java/kinoko/world/skill/Skill.java @@ -60,7 +60,15 @@ public void forEachAffectedMember(User caster, Field field, Consumer consu }); } - public void forEachAffectedUser(Field field, Consumer consumer) { + /** + * Executes a given action on all users affected by this skill in the specified field. + * + * @param field The field containing the users. + * @param consumer A lambda function or Consumer defining the action to perform on each affected user. + * @param mustBeAlive If true, only users with HP > 0 (alive) will be affected; if false, all targeted users are + * affected regardless of HP. + */ + public void forEachAffectedUser(Field field, Consumer consumer, boolean mustBeAlive) { // Echo of Hero, GM Buffs if (targetIds == null) { return; @@ -71,7 +79,7 @@ public void forEachAffectedUser(Field field, Consumer consumer) { continue; } final User user = userResult.get(); - if (user.getHp() > 0) { + if (!mustBeAlive || user.getHp() > 0) { consumer.accept(user); } } diff --git a/src/main/java/kinoko/world/skill/SkillConstants.java b/src/main/java/kinoko/world/skill/SkillConstants.java index 781b4295..ad6d7513 100644 --- a/src/main/java/kinoko/world/skill/SkillConstants.java +++ b/src/main/java/kinoko/world/skill/SkillConstants.java @@ -12,6 +12,8 @@ import kinoko.world.job.resistance.Citizen; import kinoko.world.job.resistance.Mechanic; import kinoko.world.job.resistance.WildHunter; +import kinoko.world.job.staff.GM; +import kinoko.world.job.staff.SuperGM; import kinoko.world.user.stat.CharacterTemporaryStat; import java.util.List; @@ -223,6 +225,8 @@ public static boolean isTeleportSkill(int skillId) { case BlazeWizard.TELEPORT: case Evan.TELEPORT: case BattleMage.TELEPORT: + case GM.TELEPORT: + case SuperGM.TELEPORT: return true; default: return false; @@ -270,6 +274,18 @@ public static boolean isNoCooltimeSkill(int skillId) { } } + /** + * Returns whether the given skill is a field/map wide buff skill. + * Field skills affect the entire map. + */ + public static boolean isFieldSkill(int skillId) { + return switch (skillId) { + case SuperGM.HEAL_DISPEL, SuperGM.HASTE_SUPER, SuperGM.HOLY_SYMBOL, SuperGM.BLESS, SuperGM.RESURRECTION, + SuperGM.HYPER_BODY -> true; + default -> false; + }; + } + public static boolean isPartySkill(int skillId) { if (skillId == Magician.HEAL) { // CUserLocal::DoActiveSkill_Heal diff --git a/src/main/java/kinoko/world/skill/SkillProcessor.java b/src/main/java/kinoko/world/skill/SkillProcessor.java index e75e95ed..6e1bad41 100644 --- a/src/main/java/kinoko/world/skill/SkillProcessor.java +++ b/src/main/java/kinoko/world/skill/SkillProcessor.java @@ -26,6 +26,8 @@ import kinoko.world.job.resistance.Citizen; import kinoko.world.job.resistance.Mechanic; import kinoko.world.job.resistance.WildHunter; +import kinoko.world.job.staff.GM; +import kinoko.world.job.staff.SuperGM; import kinoko.world.user.User; import kinoko.world.user.effect.Effect; import kinoko.world.user.stat.CharacterTemporaryStat; @@ -35,9 +37,11 @@ import java.time.Instant; import java.time.temporal.ChronoUnit; +import java.util.HashMap; import java.util.Map; import java.util.Optional; import java.util.Set; +import java.util.function.Consumer; public abstract class SkillProcessor { protected static final Logger log = LogManager.getLogger(SkillProcessor.class); @@ -143,6 +147,39 @@ public static void processAttack(User user, Mob mob, Attack attack, int delay) { // PROCESS SKILL --------------------------------------------------------------------------------------------------- + /** + * Apply a field-wide buff with multiple stats to all affected users. + * + * @param skill The skill being applied. + * @param field The field where the buff takes effect. + * @param statValues Map of CharacterTemporaryStat → Integer value. + * @param duration Duration of the buff in milliseconds (or whatever your system uses). + */ + protected static void applyFieldBuff(Skill skill, Field field, + Map statValues, int duration) { + skill.forEachAffectedUser(field, other -> { + Map tempStats = new HashMap<>(); + for (Map.Entry entry : statValues.entrySet()) { + tempStats.put(entry.getKey(), TemporaryStatOption.of(entry.getValue(), skill.skillId, duration)); + } + other.setTemporaryStat(tempStats); + + other.write(UserLocal.effect(Effect.skillAffected(skill.skillId, skill.slv))); + field.broadcastPacket(UserRemote.effect(other, Effect.skillAffected(skill.skillId, skill.slv)), other); + }, true); + } + + /** + * Reset specific temporary stats for all users in a field. + * + * @param field The field where the reset takes effect. + * @param statsToReset Set of CharacterTemporaryStat to remove. + */ + protected static void resetFieldTemporaryStats(Skill skill, Field field, Set statsToReset) { + skill.forEachAffectedUser(field, other -> { + other.resetTemporaryStat(statsToReset); + }, true); + } public static void processSkill(User user, Skill skill) { final SkillInfo si = SkillProvider.getSkillInfoById(skill.skillId).orElseThrow(); @@ -192,12 +229,9 @@ public static void processSkill(User user, Skill skill) { case Aran.ECHO_OF_HERO: case Evan.HEROS_ECHO: case Citizen.HEROS_ECHO: - user.setTemporaryStat(CharacterTemporaryStat.MaxLevelBuff, TemporaryStatOption.of(si.getValue(SkillStat.x, slv), skillId, si.getDuration(slv))); - skill.forEachAffectedUser(field, (other) -> { - other.setTemporaryStat(CharacterTemporaryStat.MaxLevelBuff, TemporaryStatOption.of(si.getValue(SkillStat.x, slv), skillId, si.getDuration(slv))); - other.write(UserLocal.effect(Effect.skillAffected(skill.skillId, skill.slv))); - field.broadcastPacket(UserRemote.effect(other, Effect.skillAffected(skill.skillId, skill.slv)), other); - }); + applyFieldBuff(skill, field, + Map.of(CharacterTemporaryStat.MaxLevelBuff, si.getValue(SkillStat.x, slv)), + si.getDuration(slv)); return; // COPY SKILLS --------------------------------------------------------------------------------------------- @@ -314,6 +348,7 @@ public static void processSkill(User user, Skill skill) { case Thief.HASTE_SHAD: case Thief.SELF_HASTE: case NightWalker.HASTE: + case GM.HASTE_NORMAL: user.setTemporaryStat(Map.of( CharacterTemporaryStat.Speed, TemporaryStatOption.of(si.getValue(SkillStat.speed, slv), skillId, si.getDuration(slv)), CharacterTemporaryStat.Jump, TemporaryStatOption.of(si.getValue(SkillStat.jump, slv), skillId, si.getDuration(slv)) @@ -454,6 +489,7 @@ public static void processSkill(User user, Skill skill) { return; } + // CLASS SPECIFIC SKILLS --------------------------------------------------------------------------------------- final int skillRoot = SkillConstants.getSkillRoot(skill.skillId); switch (Job.getById(skillRoot)) { @@ -505,6 +541,12 @@ public static void processSkill(User user, Skill skill) { case MECHANIC_1, MECHANIC_2, MECHANIC_3, MECHANIC_4 -> { Mechanic.handleSkill(user, skill); } +// case GM -> { +// GM.handleSKill(user, skill); +// } + case SUPER_GM -> { + SuperGM.handleSkill(user, skill); + } default -> { log.error("Unhandled skill {}", skill.skillId); } diff --git a/src/main/java/kinoko/world/user/FamilyMember.java b/src/main/java/kinoko/world/user/FamilyMember.java index cf055b9c..92f66ddd 100644 --- a/src/main/java/kinoko/world/user/FamilyMember.java +++ b/src/main/java/kinoko/world/user/FamilyMember.java @@ -303,11 +303,11 @@ public boolean isOnline() { } public int getMinutesOnline() { - return (int) ((System.currentTimeMillis() / 1000L - lastSeenUnix) / 60); + return Timing.secondsToMinutes(Timing.nowSeconds() - lastSeenUnix); } public void updateLastLogin() { - lastSeenUnix = System.currentTimeMillis() / 1000L; + lastSeenUnix = Timing.nowSeconds(); } public int getChannelID() { @@ -435,7 +435,7 @@ public boolean isLeader(){ public double getExpModifier() { return isEntitlementActive(FamilyEntitlement.SELF_EXP_1_5) ? FamilyEntitlement.SELF_EXP_1_5.getModifier() - : 1.0; + : GameConstants.DEFAULT_FAMILY_PERSONAL_EXP_MODIFIER; } @@ -450,6 +450,6 @@ public double getExpModifier() { public double getDropModifier() { return isEntitlementActive(FamilyEntitlement.SELF_DROP_1_5) ? FamilyEntitlement.SELF_DROP_1_5.getModifier() - : 1.0; + : GameConstants.DEFAULT_FAMILY_PERSONAL_DROP_MODIFIER; } } diff --git a/src/main/java/kinoko/world/user/User.java b/src/main/java/kinoko/world/user/User.java index 94b372de..1529be6f 100644 --- a/src/main/java/kinoko/world/user/User.java +++ b/src/main/java/kinoko/world/user/User.java @@ -5,7 +5,9 @@ import kinoko.packet.stage.StagePacket; import kinoko.packet.user.PetPacket; import kinoko.packet.user.UserLocal; +import kinoko.packet.user.UserPacket; import kinoko.packet.user.UserRemote; +import kinoko.packet.world.AdminPacket; import kinoko.packet.world.FriendPacket; import kinoko.packet.world.MessagePacket; import kinoko.packet.world.WvsContext; @@ -29,6 +31,7 @@ import kinoko.server.node.ServerExecutor; import kinoko.server.packet.OutPacket; import kinoko.server.party.PartyRequest; +import kinoko.server.user.AdminResultType; import kinoko.server.user.RemoteUser; import kinoko.util.BitFlag; import kinoko.world.GameConstants; @@ -40,6 +43,7 @@ import kinoko.world.field.summoned.SummonedLeaveType; import kinoko.world.item.InventoryManager; import kinoko.world.item.Item; +import kinoko.world.job.staff.SuperGM; import kinoko.world.quest.QuestManager; import kinoko.world.skill.PassiveSkillData; import kinoko.world.skill.SkillConstants; @@ -95,6 +99,7 @@ public final class User extends Life { private boolean inTransfer; private List cooldowns = new ArrayList<>(); private Instant nextCheckItemExpire; + private boolean hidden; public User(Client client, CharacterData characterData) { this.client = client; @@ -462,6 +467,43 @@ public void heal(){ setMp(getMaxMp()); } + public void hide(boolean hide, boolean isLoggingIn) { + this.hidden = hide; + + SecondaryStat ss = getSecondaryStat(); + CharacterTemporaryStat stat = CharacterTemporaryStat.Sneak; + + BitFlag flag = BitFlag.from( + Set.of(stat), + CharacterTemporaryStat.FLAG_SIZE + ); + + ss.getTemporaryStats().put( + stat, + TwoStateTemporaryStat.ofTwoState(stat, 1, SuperGM.HIDE, 0) + ); + + if (hide){ + write(AdminPacket.getAdminEffect(AdminResultType.SET_HIDE_STATUS.getValue(), (byte) 1)); + if (!isLoggingIn) { + getField().broadcastToNonGMs(UserPacket.userLeaveField(this)); + } + // let GMs see that they are hidden with Sneak / Dark Sight + // We do not want to broadcast this to our own user, otherwise they cannot use skills in dark sight. + getField().getUserPool().broadcastPacketToGMs(UserRemote.temporaryStatSet(this, ss, flag), this); + } + else { // unhide + write(AdminPacket.getAdminEffect(AdminResultType.SET_HIDE_STATUS.getValue(), (byte) 0)); + ss.getTemporaryStats().remove(CharacterTemporaryStat.Sneak); + getField().getUserPool().broadcastPacketToGMs(UserRemote.temporaryStatReset(this, flag), this); + getField().broadcastToNonGMs(UserPacket.userEnterField(this)); + } + } + + public boolean isHidden() { + return hidden; + } + public void kill() { setHp(0); } @@ -1017,8 +1059,8 @@ public Optional getFamilyTree() { * with a minimum of 1.0 */ public double getFamilyDropModifier() { - double personalModifier = 1.0; - double familyModifier = 1.0; + double personalModifier = GameConstants.DEFAULT_FAMILY_PERSONAL_DROP_MODIFIER; + double familyModifier = GameConstants.DEFAULT_FAMILY_DROP_MODIFIER; if (this.familyInfo != null && this.familyInfo.hasFamily()){ personalModifier = this.familyInfo.getDropModifier(); } @@ -1028,7 +1070,7 @@ public double getFamilyDropModifier() { familyModifier = userTreeOpt.get().getDropModifier(); } - return Math.max(1.0, Math.max(personalModifier, familyModifier)); + return Math.max(GameConstants.DEFAULT_FAMILY_DROP_MODIFIER, Math.max(personalModifier, familyModifier)); } /** @@ -1041,8 +1083,8 @@ public double getFamilyDropModifier() { * with a minimum of 1.0 */ public double getFamilyEXPModifier() { - double personalModifier = 1.0; - double familyModifier = 1.0; + double personalModifier = GameConstants.DEFAULT_FAMILY_PERSONAL_EXP_MODIFIER; + double familyModifier = GameConstants.DEFAULT_FAMILY_EXP_MODIFIER; if (this.familyInfo != null && this.familyInfo.hasFamily()) { personalModifier = this.familyInfo.getExpModifier(); @@ -1053,12 +1095,18 @@ public double getFamilyEXPModifier() { familyModifier = userTreeOpt.get().getExpModifier(); } - return Math.max(1.0, Math.max(personalModifier, familyModifier)); + return Math.max(GameConstants.DEFAULT_FAMILY_EXP_MODIFIER, Math.max(personalModifier, familyModifier)); } + /** + * Sets the user's FamilyMember info and updates the last login time + * if the user has been a part of a family before. + * + * @param familyInfo the FamilyMember data to assign + */ public void setFamilyInfo(FamilyMember familyInfo) { this.familyInfo = familyInfo; - if (this.familyInfo != null){ + if (this.familyInfo != null && !this.familyInfo.isDefault()){ this.familyInfo.updateLastLogin(); } } From cb2a188b124e101b6d9077c4bf5cb81cd15a4915 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sun, 23 Nov 2025 01:07:31 -0500 Subject: [PATCH 77/83] Added family haste --- .../kinoko/handler/user/FamilyHandler.java | 27 ++++++++++-- .../server/family/FamilyEntitlement.java | 2 +- src/main/java/kinoko/world/job/staff/GM.java | 44 ------------------- .../kinoko/world/skill/SkillProcessor.java | 3 -- 4 files changed, 25 insertions(+), 51 deletions(-) diff --git a/src/main/java/kinoko/handler/user/FamilyHandler.java b/src/main/java/kinoko/handler/user/FamilyHandler.java index 2aed12df..64502c25 100644 --- a/src/main/java/kinoko/handler/user/FamilyHandler.java +++ b/src/main/java/kinoko/handler/user/FamilyHandler.java @@ -14,6 +14,9 @@ import kinoko.server.packet.OutPacket; import kinoko.util.exceptions.InvalidInputException; import kinoko.world.GameConstants; +import kinoko.world.job.staff.GM; +import kinoko.world.skill.Skill; +import kinoko.world.skill.SkillProcessor; import kinoko.world.user.FamilyMember; import kinoko.world.user.User; import org.apache.logging.log4j.LogManager; @@ -463,7 +466,6 @@ public static void handleFamilyUnregisterParent(User user, InPacket inPacket) { lock.lock(); try { Optional userTreeOpt = centralServerNode.getFamilyTree(user.getCharacterId()); - if (userTreeOpt.isPresent()) { FamilyTree userTree = userTreeOpt.get(); FamilyMember userMember = userTree.getMember(user.getCharacterId()); @@ -612,8 +614,27 @@ public static void handleFamilyUsePrivilege(User user, InPacket inPacket) { else { // Type 2 cases switch (entitlement) { case FAMILY_HASTE: - System.out.println("Applying FAMILY_HASTE to all family members"); - // TODO: apply haste buff + // Note: Avoid using a field buff, we don't want to give the buff to non-family. + final int hasteSkillId = GM.HASTE_NORMAL; + final int slv = 1; + Skill hasteSkill = new Skill(); + hasteSkill.skillId = hasteSkillId; + hasteSkill.slv = slv; + + familyTree.forEach(familyUserToBuff -> { + Server.getCentralServerNode() + .getUserByCharacterId(familyUserToBuff.getCharacterId()) + .ifPresent(userToBuff -> { + SkillProcessor.processSkill(userToBuff, hasteSkill); + }); + }); + familyTree.broadcastSystemMessage( + "%s has activated the %s buff for %d minutes.", + user.getCharacterName(), + FamilyEntitlement.FAMILY_HASTE.getName(), + FamilyEntitlement.FAMILY_HASTE.getModifier(), + FamilyEntitlement.FAMILY_HASTE.getExpiresAfterMinutes() + ); break; case FAMILY_EXP: diff --git a/src/main/java/kinoko/server/family/FamilyEntitlement.java b/src/main/java/kinoko/server/family/FamilyEntitlement.java index 552ecd72..30e9d149 100644 --- a/src/main/java/kinoko/server/family/FamilyEntitlement.java +++ b/src/main/java/kinoko/server/family/FamilyEntitlement.java @@ -32,7 +32,7 @@ public enum FamilyEntitlement { FAMILY_HASTE(1, 500, "Quicker Together", "[Target] All Family Members\n[Effect] All family members, regardless of map, " + "are blessed with Family Haste.", - Timing.DAY_MINUTES, (byte) 2, null, null), + Timing.DAY_MINUTES, (byte) 2, null, 15), FAMILY_EXP(1, 5000, "A Better Experience", "[Target] All Family Members\n[Effect] For 15 minutes, all family members receive 1.2x experience, " + diff --git a/src/main/java/kinoko/world/job/staff/GM.java b/src/main/java/kinoko/world/job/staff/GM.java index 68701104..ba00aa6b 100644 --- a/src/main/java/kinoko/world/job/staff/GM.java +++ b/src/main/java/kinoko/world/job/staff/GM.java @@ -1,54 +1,10 @@ package kinoko.world.job.staff; -import kinoko.packet.field.FieldPacket; -import kinoko.packet.user.UserLocal; -import kinoko.packet.user.UserPacket; -import kinoko.packet.user.UserRemote; -import kinoko.provider.SkillProvider; -import kinoko.provider.skill.SkillInfo; -import kinoko.provider.skill.SkillStat; -import kinoko.util.Util; -import kinoko.world.field.Field; -import kinoko.world.field.OpenGate; -import kinoko.world.field.affectedarea.AffectedArea; -import kinoko.world.field.affectedarea.AffectedAreaType; -import kinoko.world.field.mob.Mob; -import kinoko.world.field.mob.MobStatOption; -import kinoko.world.field.mob.MobTemporaryStat; -import kinoko.world.field.summoned.Summoned; -import kinoko.world.field.summoned.SummonedAssistType; -import kinoko.world.field.summoned.SummonedMoveAbility; -import kinoko.world.skill.Attack; -import kinoko.world.skill.Skill; -import kinoko.world.skill.SkillConstants; import kinoko.world.skill.SkillProcessor; -import kinoko.world.user.User; -import kinoko.world.user.effect.Effect; -import kinoko.world.user.stat.CharacterTemporaryStat; -import kinoko.world.user.stat.DiceInfo; -import kinoko.world.user.stat.TemporaryStatOption; -import java.time.Instant; -import java.time.temporal.ChronoUnit; -import java.util.List; -import java.util.Map; -import java.util.Set; public final class GM extends SkillProcessor { public static final int HASTE_NORMAL = 9001000; public static final int SUPER_DRAGON_ROAR = 9001001; public static final int TELEPORT = 9001002; - - - public static void handleSkill(User user, Skill skill) { - final SkillInfo si = SkillProvider.getSkillInfoById(skill.skillId).orElseThrow(); - final int skillId = skill.skillId; - final int slv = skill.slv; - - final Field field = user.getField(); - switch (skillId) { - } - log.error("Unhandled skill {}", skill.skillId); - } - } diff --git a/src/main/java/kinoko/world/skill/SkillProcessor.java b/src/main/java/kinoko/world/skill/SkillProcessor.java index 6e1bad41..36ce55c1 100644 --- a/src/main/java/kinoko/world/skill/SkillProcessor.java +++ b/src/main/java/kinoko/world/skill/SkillProcessor.java @@ -541,9 +541,6 @@ public static void processSkill(User user, Skill skill) { case MECHANIC_1, MECHANIC_2, MECHANIC_3, MECHANIC_4 -> { Mechanic.handleSkill(user, skill); } -// case GM -> { -// GM.handleSKill(user, skill); -// } case SUPER_GM -> { SuperGM.handleSkill(user, skill); } From a4886f8bdfb4645af0309afee6a0f2e3323a34f0 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sun, 23 Nov 2025 05:20:57 -0500 Subject: [PATCH 78/83] Separation cleanup --- .../kinoko/handler/user/FamilyHandler.java | 254 ++++++++++++------ src/main/java/kinoko/world/GameConstants.java | 2 +- .../java/kinoko/world/user/FamilyMember.java | 38 ++- src/main/java/kinoko/world/user/User.java | 1 - .../kinoko/world/user/stat/CharacterStat.java | 3 - 5 files changed, 213 insertions(+), 85 deletions(-) diff --git a/src/main/java/kinoko/handler/user/FamilyHandler.java b/src/main/java/kinoko/handler/user/FamilyHandler.java index 64502c25..515b300d 100644 --- a/src/main/java/kinoko/handler/user/FamilyHandler.java +++ b/src/main/java/kinoko/handler/user/FamilyHandler.java @@ -2,6 +2,7 @@ import kinoko.handler.Handler; import kinoko.packet.world.FamilyPacket; +import kinoko.packet.world.WvsContext; import kinoko.server.Server; import java.util.concurrent.locks.ReentrantLock; @@ -14,11 +15,13 @@ import kinoko.server.packet.OutPacket; import kinoko.util.exceptions.InvalidInputException; import kinoko.world.GameConstants; +import kinoko.world.item.InventoryManager; import kinoko.world.job.staff.GM; import kinoko.world.skill.Skill; import kinoko.world.skill.SkillProcessor; import kinoko.world.user.FamilyMember; import kinoko.world.user.User; +import kinoko.world.user.stat.Stat; import org.apache.logging.log4j.LogManager; import org.apache.logging.log4j.Logger; @@ -432,101 +435,42 @@ public static void handleFamilyUnregisterJunior(User user, InPacket inPacket) { } /** - * Handles a request from a user to unregister themselves from their parent (senior) in the family system. + * Handles a user's request to leave their parent's family. * - * The client triggers this handler using the {@link InHeader#FamilyUnregisterParent} packet. - * This request removes the user from their parent's family tree and creates a separate family tree - * for the user if necessary. + * This removes the user from their parent's family tree and creates a separate tree + * if necessary. Costs (mesos for the junior, reputation loss for senior/grand-senior) + * are applied before separation. Thread safety is ensured via the global family lock. * - * Thread safety is ensured using the global family lock while validating the user and parent, - * and while modifying the family trees. + * If the user has no parent, or the parent tree is invalid, an error packet is sent. + * On success, the junior is notified, and the parent (if online) is informed. * - * Key behaviors: - * - If the user has no parent or their parent tree cannot be found, the user receives an - * {@link FamilyResultType#IncorrectOrOffline} response. - * - If successful, the user's subtree is extracted from the parent's tree and added as a new tree. - * - The user receives a success packet notifying them they have been removed from the parent's family. - * - If the parent is online, they are notified via a system message that the user has left their family. - * - * Note: The incoming packet contains no additional data beyond the header. The server - * determines the parent to unregister by looking up the user's current family tree. - * - * @param user the user requesting to unregister from their parent - * @param inPacket the incoming packet (header only; no payload is used) + * @param user The user requesting to unregister from their parent + * @param inPacket The incoming packet (header only; no payload) */ @Handler({InHeader.FamilyUnregisterParent}) public static void handleFamilyUnregisterParent(User user, InPacket inPacket) { CentralServerNode centralServerNode = Server.getCentralServerNode(); + FamilyUnregisterResult result; - OutPacket userResultPacket; - Optional parentUser; - - Integer parentId = null; ReentrantLock lock = centralServerNode.getGlobalFamilyLock(); lock.lock(); try { - Optional userTreeOpt = centralServerNode.getFamilyTree(user.getCharacterId()); - if (userTreeOpt.isPresent()) { - FamilyTree userTree = userTreeOpt.get(); - FamilyMember userMember = userTree.getMember(user.getCharacterId()); - parentId = userMember.getParentId(); - - if (parentId == null) { - userResultPacket = FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0); - } - else { - Optional parentTreeOpt = centralServerNode.getFamilyTree(parentId); - - if (parentTreeOpt.isPresent()) { - FamilyTree parentTree = parentTreeOpt.get(); - - if (parentTree != userTree){ - // Should never occur if coded correctly, identical to DumbDeveloperFoundException. - log.error( - "Family tree mismatch detected: user [{}] (ID: {}) parent (ID: {}) " + - "trees do not match. UserTree={}, ParentTree={}", - user.getCharacterName(), - user.getCharacterId(), - parentId, - userTree.getLeaderId(), - parentTree.getLeaderId() - ); - userResultPacket = FamilyPacket.of(FamilyResultType.DifferentFamily, 0); - } - else { - // the junior's current tree becomes a new, separate family tree - FamilyTree userNewTree = parentTree.extractAndRemoveSubTree(user.getCharacterId()); - centralServerNode.addFamilyTree(userNewTree); - - // notify the user of the success - userResultPacket = FamilyPacket.unregisterJunior(user.getCharacterId()); - } - } else { - // data inconsistency, parent's tree not found - userResultPacket = FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0); - } - } - } else { // user's own tree not found, something is wrong - userResultPacket = FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0); - } + result = processFamilyUnregister(user, centralServerNode); } finally { lock.unlock(); } - user.write(userResultPacket); - // update the entitlements the user sees. + user.write(result.resultPacket()); user.write(FamilyPacket.loadFamilyEntitlements(!user.getFamilyInfo().hasFamily())); - - // Update the family pedigree and info for the user who just left. updateFamilyDisplay(user); - if (parentId != null) { // let the senior know. - parentUser = centralServerNode.getUserByCharacterId(parentId); - - parentUser.ifPresent(targetUser -> { - targetUser.systemMessage("%s has left your family.", user.getCharacterName()); - updateFamilyDisplay(targetUser); // not necessary, but is smoother if they have the dialog open. - }); + Integer parentId = result.parentId(); + if (parentId != null) { + centralServerNode.getUserByCharacterId(parentId) + .ifPresent(parentUser -> { + parentUser.systemMessage("%s has left your family.", user.getCharacterName()); + updateFamilyDisplay(parentUser); + }); } } @@ -684,6 +628,9 @@ public static void handleFamilyUsePrivilege(User user, InPacket inPacket) { } } }); + if (success) { + updateFamilyDisplay(user); // to update the rep amount. + } } /** @@ -698,4 +645,157 @@ private static void updateFamilyDisplay(User user) { handleFamilyChartRequest(user, null); handleFamilyInfoRequest(user, null); } + + /** + * Calculates the mesos a junior must pay to leave their family. + * + * @param juniorLevel Level of the junior leaving the family + * @param seniorLevel Level of the senior the junior is leaving + * @return The total mesos cost required for separation + */ + private static int getFamilySeparationMeso(int juniorLevel, int seniorLevel) { + int levelDiff = Math.abs(juniorLevel - seniorLevel); + return 2500 * levelDiff + levelDiff * levelDiff; + } + + /** + * Calculates the reputation loss for the senior (direct parent) + * when a junior leaves the family. + * + * @param juniorLevel Level of the junior leaving + * @return The reputation points the senior loses + */ + private static int getSeniorRepLoss(int juniorLevel) { + int repCost = juniorLevel / 20; // integer division + repCost += 10; + repCost *= juniorLevel; + repCost *= 2; + return repCost; + } + + /** + * Calculates the reputation loss for the senior's senior (grandparent) + * when a junior leaves the family. This is half of the direct senior's loss. + * + * @param juniorLevel Level of the junior leaving + * @return The reputation points the grand-senior loses + */ + private static int getGrandSeniorRepLoss(int juniorLevel) { + return getSeniorRepLoss(juniorLevel) / 2; + } + + /** + * Applies all separation costs when a junior leaves their senior. + * + * This deducts mesos from the junior and applies reputation penalties to both + * the senior and (if applicable) the senior's parent. If the junior cannot + * afford the meso cost, no changes are applied. + * + * @param user The junior who is leaving the family. + * @param parentUser The senior being separated from. + * @return An Optional containing the meso cost if the junior could not afford it, + * or Optional.empty() on successful cost application. + */ + private static Optional applySeparation(User user, User parentUser) { + CentralServerNode centralServerNode = Server.getCentralServerNode(); + int juniorLevel = user.getLevel(); + int seniorRepLoss = getSeniorRepLoss(juniorLevel); + int grandSeniorRepLoss = getGrandSeniorRepLoss(juniorLevel); + + int seniorLevel = parentUser.getLevel(); + int mesoCost = getFamilySeparationMeso(juniorLevel, seniorLevel); + + InventoryManager inventoryManager = user.getInventoryManager(); + boolean success = inventoryManager.addMoney(-mesoCost); + + if (!success) { + return Optional.of(mesoCost); + } + + // exceptional case where we send a packet to a user while in the family lock. + user.write(WvsContext.statChanged(Stat.MONEY, inventoryManager.getMoney(), false)); + + Integer grandSeniorParentId = parentUser.getFamilyInfo().getParentId(); + if (grandSeniorParentId != null) { + FamilyMember grandSeniorMember = centralServerNode.getFamilyInfo(grandSeniorParentId); + grandSeniorMember.useRep(grandSeniorRepLoss); + } + + parentUser.getFamilyInfo().useRep(seniorRepLoss); + return Optional.empty(); + } + + record FamilyUnregisterResult( + OutPacket resultPacket, + Integer parentId + ) {} + + private static FamilyUnregisterResult result(FamilyResultType type, int arg, Integer parentId) { + return new FamilyUnregisterResult(FamilyPacket.of(type, arg), parentId); + } + + private static FamilyUnregisterResult success(Integer parentId, int userId) { + return new FamilyUnregisterResult(FamilyPacket.unregisterJunior(userId), parentId); + } + + private static FamilyUnregisterResult processFamilyUnregister( + User user, + CentralServerNode centralServerNode + ) { + final int userId = user.getCharacterId(); + + FamilyTree userTree = centralServerNode.getFamilyTree(userId).orElse(null); + if (userTree == null) { + return result(FamilyResultType.IncorrectOrOffline, 0, null); + } + + FamilyMember userMember = userTree.getMember(userId); + Integer parentId = userMember.getParentId(); + if (parentId == null) { + return result(FamilyResultType.IncorrectOrOffline, 0, null); + } + + FamilyTree parentTree = centralServerNode.getFamilyTree(parentId).orElse(null); + if (parentTree == null) { + return result(FamilyResultType.IncorrectOrOffline, 0, parentId); + } + + if (parentTree != userTree) { + log.error( + "Family tree mismatch detected: user [{}] (ID: {}) parent (ID: {}) " + + "trees do not match. UserTree={}, ParentTree={}", + user.getCharacterName(), + userId, + parentId, + userTree.getLeaderId(), + parentTree.getLeaderId() + ); + return result(FamilyResultType.DifferentFamily, 0, parentId); + } + + Optional parentUserOpt = centralServerNode.getUserByCharacterId(parentId); + + // applySeparation returns Optional where: + // - present = NOT ENOUGH mesos (cost) + // - empty = success + // but .orElse(Optional.of(-1)) introduces a special error case + Optional mesoCostOpt = parentUserOpt + .map(parentUser -> applySeparation(user, parentUser)) + .orElse(Optional.of(-1)); + + if (mesoCostOpt.isPresent()) { + int cost = mesoCostOpt.get(); + + if (cost == -1) { + return result(FamilyResultType.IncorrectOrOffline, 0, parentId); + } + return result(FamilyResultType.SeparationNotEnoughMesos1, cost, parentId); + } + + // Perform the actual extraction + FamilyTree newTree = parentTree.extractAndRemoveSubTree(userId); + centralServerNode.addFamilyTree(newTree); + + return success(parentId, userId); + } } diff --git a/src/main/java/kinoko/world/GameConstants.java b/src/main/java/kinoko/world/GameConstants.java index 8846ca51..942d5e06 100644 --- a/src/main/java/kinoko/world/GameConstants.java +++ b/src/main/java/kinoko/world/GameConstants.java @@ -112,7 +112,7 @@ public final class GameConstants { // FAMILY CONSTANTS ------------------------------------------------------------------------------------------------ public static final int MIN_FAMILY_LEVEL = 10; // min level required for a character to be in a family. - public static final int MAX_LEVEL_GAP_FOR_FAMILY = 50; // max allowed level difference between a senior and junior. + public static final int MAX_LEVEL_GAP_FOR_FAMILY = 20; // max allowed level difference between a senior and junior. public static final int MAX_FAMILY_CHILDREN_COUNT = 2; // max allowed juniors public static final double DEFAULT_FAMILY_DROP_MODIFIER = 1.0; // max allowed juniors public static final double DEFAULT_FAMILY_EXP_MODIFIER = 1.0; // max allowed juniors diff --git a/src/main/java/kinoko/world/user/FamilyMember.java b/src/main/java/kinoko/world/user/FamilyMember.java index 92f66ddd..40f6ec40 100644 --- a/src/main/java/kinoko/world/user/FamilyMember.java +++ b/src/main/java/kinoko/world/user/FamilyMember.java @@ -147,6 +147,31 @@ public void useEntitlement(FamilyEntitlement entitlement) { usedEntitlements.put(entitlement, now); // mark as used } + /** + * Modifies the reputation of this user. + * + * @param amount The amount of reputation to add (can be negative to reduce reputation) + * @param increaseTotal If true, also increases the total and today's reputation counters + * + */ + public void addRep(int amount, boolean increaseTotal) { + currentReputation = Math.max(currentReputation + amount, 0); + if (increaseTotal){ + totalReputation += amount; + todaysReputation += amount; + } + } + + /** + * Reduces the current reputation of this user by a specified amount. + * + * @param amount The amount of reputation to subtract + */ + public void useRep(int amount) { + currentReputation = Math.max(currentReputation - amount, 0); + } + + /** * Attempts to use a Family Entitlement optimistically, running the given action, * and rolls back the usage if the action fails. @@ -168,13 +193,19 @@ public void useEntitlement(FamilyEntitlement entitlement) { * false if the action failed and the usage was rolled back. */ public boolean tryUseEntitlementWithRollback(FamilyEntitlement entitlement, Runnable action) { - // TODO: use rep points + boolean success = false; + + if (currentReputation < entitlement.getRepCost()){ + return success; + } + + long now = Timing.nowSeconds(); usedEntitlements.put(entitlement, now); entitlementUsageLog.computeIfAbsent(entitlement, k -> new ArrayList<>()).add(now); - boolean success = false; try { + currentReputation -= entitlement.getRepCost(); action.run(); // whatever the entitlement is supposed to do success = true; } @@ -182,6 +213,7 @@ public boolean tryUseEntitlementWithRollback(FamilyEntitlement entitlement, Runn finally { if (!success) { // undo usage + currentReputation += entitlement.getRepCost(); rollbackEntitlementUsage(entitlement, now); usedEntitlements.remove(entitlement); List list = entitlementUsageLog.get(entitlement); @@ -329,7 +361,7 @@ public void encode(OutPacket out) { out.encodeShort((short) getChildrenCount()); out.encodeShort(GameConstants.MAX_FAMILY_CHILDREN_COUNT); // max juniors out.encodeShort(0); // unknown, wTotalChildCount - out.encodeInt(parentId == null ? getCharacterId() : parentId); + out.encodeInt(hasFamily() ? (parentId != null ? parentId : getCharacterId()) : -1); out.encodeString( isDefault() ? null diff --git a/src/main/java/kinoko/world/user/User.java b/src/main/java/kinoko/world/user/User.java index 1529be6f..d31b4239 100644 --- a/src/main/java/kinoko/world/user/User.java +++ b/src/main/java/kinoko/world/user/User.java @@ -25,7 +25,6 @@ import kinoko.server.event.EventType; import kinoko.server.family.FamilyTree; import kinoko.server.guild.GuildRank; -import kinoko.server.node.CentralServerNode; import kinoko.server.node.ChannelServerNode; import kinoko.server.node.Client; import kinoko.server.node.ServerExecutor; diff --git a/src/main/java/kinoko/world/user/stat/CharacterStat.java b/src/main/java/kinoko/world/user/stat/CharacterStat.java index 4b45d8fb..e5a4c862 100644 --- a/src/main/java/kinoko/world/user/stat/CharacterStat.java +++ b/src/main/java/kinoko/world/user/stat/CharacterStat.java @@ -9,9 +9,6 @@ import java.util.EnumMap; import java.util.HashMap; import java.util.Map; -import java.sql.*; -import java.util.HashMap; -import java.util.Map; public final class CharacterStat implements Encodable { private int id; From 5bab7ecda0eea7c8c77c55dc1baaf9f3276c638e Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sun, 23 Nov 2025 05:59:27 -0500 Subject: [PATCH 79/83] Finished cleanup of separation logic --- .../kinoko/handler/user/FamilyHandler.java | 95 ++++++++++++------- .../server/command/supergm/MesoCommand.java | 2 +- 2 files changed, 62 insertions(+), 35 deletions(-) diff --git a/src/main/java/kinoko/handler/user/FamilyHandler.java b/src/main/java/kinoko/handler/user/FamilyHandler.java index 515b300d..5232ecda 100644 --- a/src/main/java/kinoko/handler/user/FamilyHandler.java +++ b/src/main/java/kinoko/handler/user/FamilyHandler.java @@ -4,6 +4,8 @@ import kinoko.packet.world.FamilyPacket; import kinoko.packet.world.WvsContext; import kinoko.server.Server; + +import java.util.Objects; import java.util.concurrent.locks.ReentrantLock; import kinoko.server.family.FamilyEntitlement; @@ -381,38 +383,27 @@ public static void handleFamilySummonResult(User user, InPacket inPacket) { */ @Handler(InHeader.FamilyUnregisterJunior) public static void handleFamilyUnregisterJunior(User user, InPacket inPacket) { - int juniorID = inPacket.decodeInt(); + int juniorId = inPacket.decodeInt(); CentralServerNode centralServerNode = Server.getCentralServerNode(); OutPacket userResultPacket; - Optional juniorUser = Optional.empty(); + Optional juniorUserOpt = Optional.empty(); ReentrantLock lock = centralServerNode.getGlobalFamilyLock(); lock.lock(); try { + FamilyUnregisterResult result = processFamilyUnregister( + Objects.requireNonNull(centralServerNode.getUserByCharacterId(juniorId).orElse(null)), + user + ); - Optional userTreeOpt = centralServerNode.getFamilyTree(user.getCharacterId()); - Optional juniorTreeOpt = centralServerNode.getFamilyTree(juniorID); - - if (userTreeOpt.isPresent() && juniorTreeOpt.isPresent()) { - FamilyTree userTree = userTreeOpt.get(); - FamilyTree juniorTree = juniorTreeOpt.get(); - FamilyMember juniorMember = juniorTree.getMember(juniorID); - Integer parentId = juniorMember.getParentId(); - if (parentId != null && !parentId.equals(user.getCharacterId())) { - userResultPacket = FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0); - } - else { - // unregister junior and extract subtree - FamilyTree juniorSubTree = userTree.extractAndRemoveSubTree(juniorID); - centralServerNode.addFamilyTree(juniorSubTree); - - userResultPacket = FamilyPacket.unregisterJunior(juniorID); - juniorUser = centralServerNode.getUserByCharacterId(juniorID); - } - } else { // something went wrong + // If the parent is not the user requesting, override with error + if (result.parentId() != null && !result.parentId().equals(user.getCharacterId())) { userResultPacket = FamilyPacket.of(FamilyResultType.IncorrectOrOffline, 0); + } else { + userResultPacket = result.resultPacket(); + juniorUserOpt = centralServerNode.getUserByCharacterId(juniorId); } } finally { @@ -426,7 +417,7 @@ public static void handleFamilyUnregisterJunior(User user, InPacket inPacket) { // Update Family Pedigrees and Information to the user and ex-junior client (if they are online). updateFamilyDisplay(user); - juniorUser.ifPresent(targetUser -> { + juniorUserOpt.ifPresent(targetUser -> { // let the user know they are an orphan 😢 targetUser.systemMessage("You have been kicked out of your family by %s.", user.getCharacterName()); targetUser.write(FamilyPacket.loadFamilyEntitlements(!targetUser.getFamilyInfo().hasFamily())); @@ -455,7 +446,7 @@ public static void handleFamilyUnregisterParent(User user, InPacket inPacket) { ReentrantLock lock = centralServerNode.getGlobalFamilyLock(); lock.lock(); try { - result = processFamilyUnregister(user, centralServerNode); + result = processFamilyUnregister(user, user); } finally { lock.unlock(); } @@ -696,7 +687,7 @@ private static int getGrandSeniorRepLoss(int juniorLevel) { * @return An Optional containing the meso cost if the junior could not afford it, * or Optional.empty() on successful cost application. */ - private static Optional applySeparation(User user, User parentUser) { + private static Optional applySeparation(User user, User parentUser, User payer) { CentralServerNode centralServerNode = Server.getCentralServerNode(); int juniorLevel = user.getLevel(); int seniorRepLoss = getSeniorRepLoss(juniorLevel); @@ -705,7 +696,7 @@ private static Optional applySeparation(User user, User parentUser) { int seniorLevel = parentUser.getLevel(); int mesoCost = getFamilySeparationMeso(juniorLevel, seniorLevel); - InventoryManager inventoryManager = user.getInventoryManager(); + InventoryManager inventoryManager = payer.getInventoryManager(); boolean success = inventoryManager.addMoney(-mesoCost); if (!success) { @@ -713,7 +704,7 @@ private static Optional applySeparation(User user, User parentUser) { } // exceptional case where we send a packet to a user while in the family lock. - user.write(WvsContext.statChanged(Stat.MONEY, inventoryManager.getMoney(), false)); + payer.write(WvsContext.statChanged(Stat.MONEY, inventoryManager.getMoney(), false)); Integer grandSeniorParentId = parentUser.getFamilyInfo().getParentId(); if (grandSeniorParentId != null) { @@ -725,24 +716,60 @@ private static Optional applySeparation(User user, User parentUser) { return Optional.empty(); } + /** + * Represents the result of attempting to unregister a user from their parent in a family tree. + * Contains the packet to send to the client and the parentId (or null if none). + */ record FamilyUnregisterResult( OutPacket resultPacket, Integer parentId ) {} + /** + * Creates a FamilyUnregisterResult for an error or general result. + * + * @param type The type of result (e.g., IncorrectOrOffline, SeparationNotEnoughMesos1) + * @param arg An integer argument associated with the result type + * @param parentId The ID of the parent user, or null if not applicable + * @return A new FamilyUnregisterResult containing the generated packet and parentId + */ private static FamilyUnregisterResult result(FamilyResultType type, int arg, Integer parentId) { return new FamilyUnregisterResult(FamilyPacket.of(type, arg), parentId); } + /** + * Creates a FamilyUnregisterResult representing a successful family unregistration. + * + * @param parentId The ID of the parent user, or null if not applicable + * @param userId The ID of the user who successfully left the family + * @return A FamilyUnregisterResult containing the success packet and parentId + */ private static FamilyUnregisterResult success(Integer parentId, int userId) { return new FamilyUnregisterResult(FamilyPacket.unregisterJunior(userId), parentId); } - private static FamilyUnregisterResult processFamilyUnregister( - User user, - CentralServerNode centralServerNode - ) { - final int userId = user.getCharacterId(); + /** + * Processes a request for a user to unregister from their parent's family tree. + * + * Steps performed: + * 1. Validates that the user's family tree exists. + * 2. Checks that the user has a parent and that the parent tree is valid. + * 3. Verifies that the parent tree matches the user's tree to prevent inconsistencies. + * 4. Applies separation costs (mesos and reputation penalties) if applicable. + * - If the junior is leaving voluntarily, mesos are deducted from the junior. + * - If the senior is kicking out the junior, mesos are deducted from the senior. + * 5. Extracts the user into a new family tree if separation succeeds. + * + * This function does not handle sending packets to the client; it returns a + * FamilyUnregisterResult for further processing. + * + * @param junior The junior leaving the family + * @param payer The user who will pay the mesos cost (junior for voluntary leave, senior for kick) + * @return A FamilyUnregisterResult containing the packet to send and the parentId for notifications + */ + private static FamilyUnregisterResult processFamilyUnregister(User junior, User payer) { + CentralServerNode centralServerNode = Server.getCentralServerNode(); + final int userId = junior.getCharacterId(); FamilyTree userTree = centralServerNode.getFamilyTree(userId).orElse(null); if (userTree == null) { @@ -764,7 +791,7 @@ private static FamilyUnregisterResult processFamilyUnregister( log.error( "Family tree mismatch detected: user [{}] (ID: {}) parent (ID: {}) " + "trees do not match. UserTree={}, ParentTree={}", - user.getCharacterName(), + junior.getCharacterName(), userId, parentId, userTree.getLeaderId(), @@ -780,7 +807,7 @@ private static FamilyUnregisterResult processFamilyUnregister( // - empty = success // but .orElse(Optional.of(-1)) introduces a special error case Optional mesoCostOpt = parentUserOpt - .map(parentUser -> applySeparation(user, parentUser)) + .map(parentUser -> applySeparation(junior, parentUser, payer)) .orElse(Optional.of(-1)); if (mesoCostOpt.isPresent()) { diff --git a/src/main/java/kinoko/server/command/supergm/MesoCommand.java b/src/main/java/kinoko/server/command/supergm/MesoCommand.java index 43777e31..92ea6775 100644 --- a/src/main/java/kinoko/server/command/supergm/MesoCommand.java +++ b/src/main/java/kinoko/server/command/supergm/MesoCommand.java @@ -19,7 +19,7 @@ public final class MesoCommand { * @param user the target user whose mesos will be set * @param args the command arguments, where args[1] should be the amount of mesos */ - @Command(value = {"meso", "money"}) + @Command(value = {"meso", "money", "mesos"}) @Arguments("amount") public static void meso(User user, String[] args) { try { From 33249a9ad5e80480def3fb9f7869a6745fb405c2 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sun, 23 Nov 2025 06:02:19 -0500 Subject: [PATCH 80/83] packing up family --- src/main/java/kinoko/handler/user/FamilyHandler.java | 6 +----- 1 file changed, 1 insertion(+), 5 deletions(-) diff --git a/src/main/java/kinoko/handler/user/FamilyHandler.java b/src/main/java/kinoko/handler/user/FamilyHandler.java index 5232ecda..505d8024 100644 --- a/src/main/java/kinoko/handler/user/FamilyHandler.java +++ b/src/main/java/kinoko/handler/user/FamilyHandler.java @@ -657,11 +657,7 @@ private static int getFamilySeparationMeso(int juniorLevel, int seniorLevel) { * @return The reputation points the senior loses */ private static int getSeniorRepLoss(int juniorLevel) { - int repCost = juniorLevel / 20; // integer division - repCost += 10; - repCost *= juniorLevel; - repCost *= 2; - return repCost; + return (juniorLevel / 20 + 10) * juniorLevel * 2; } /** From 8e67420af76259b18891230eb700008b79c1adec Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sun, 23 Nov 2025 19:35:05 -0500 Subject: [PATCH 81/83] Finalizing Family changes --- .../kinoko/handler/user/FamilyHandler.java | 3 +- .../kinoko/packet/world/FamilyPacket.java | 20 +++++ .../java/kinoko/server/ServerConstants.java | 6 ++ src/main/java/kinoko/util/Timing.java | 16 ++++ src/main/java/kinoko/world/GameConstants.java | 9 +-- src/main/java/kinoko/world/field/mob/Mob.java | 33 +++++++++ .../java/kinoko/world/user/FamilyMember.java | 73 ++++++++++++++++++- src/main/java/kinoko/world/user/User.java | 11 ++- .../kinoko/world/user/stat/CharacterStat.java | 17 +++++ 9 files changed, 177 insertions(+), 11 deletions(-) diff --git a/src/main/java/kinoko/handler/user/FamilyHandler.java b/src/main/java/kinoko/handler/user/FamilyHandler.java index 505d8024..88f6bffb 100644 --- a/src/main/java/kinoko/handler/user/FamilyHandler.java +++ b/src/main/java/kinoko/handler/user/FamilyHandler.java @@ -8,6 +8,7 @@ import java.util.Objects; import java.util.concurrent.locks.ReentrantLock; +import kinoko.server.ServerConstants; import kinoko.server.family.FamilyEntitlement; import kinoko.server.family.FamilyResultType; import kinoko.server.family.FamilyTree; @@ -246,7 +247,7 @@ public static void handleFamilyRegisterJunior(User user, InPacket inPacket) { // Level gap check (must be within x levels) int levelGap = Math.abs(user.getLevel() - targetUser.getLevel()); - if (levelGap > GameConstants.MAX_LEVEL_GAP_FOR_FAMILY) { + if (levelGap > ServerConstants.MAX_LEVEL_GAP_FOR_FAMILY) { user.write(FamilyPacket.of(FamilyResultType.LevelGapTooHigh, 0)); return; } diff --git a/src/main/java/kinoko/packet/world/FamilyPacket.java b/src/main/java/kinoko/packet/world/FamilyPacket.java index 8e1eba63..2bc3b535 100644 --- a/src/main/java/kinoko/packet/world/FamilyPacket.java +++ b/src/main/java/kinoko/packet/world/FamilyPacket.java @@ -197,6 +197,26 @@ public static OutPacket loadFamilyEntitlements(boolean empty) { return outPacket; } + /** + * Creates a packet notifying the client that they have gained family reputation. + * + * Packet structure: + * - int amount of reputation gained + * - str name of the junior whose action caused the gain + * + * This is used when a senior receives rep from their junior's activity. + * + * @param amount The amount of reputation gained. + * @param juniorName The name of the junior who generated the reputation. + * @return An OutPacket containing the reputation gain notification. + */ + public static OutPacket sendRepGain(int amount, String juniorName) { + final OutPacket outPacket = OutPacket.of(OutHeader.FamilyFamousPointIncResult); + outPacket.encodeInt(amount); + outPacket.encodeString(juniorName); + return outPacket; + } + /** * Builds a Family Result packet. * diff --git a/src/main/java/kinoko/server/ServerConstants.java b/src/main/java/kinoko/server/ServerConstants.java index c95d53ed..3bb8d7b2 100644 --- a/src/main/java/kinoko/server/ServerConstants.java +++ b/src/main/java/kinoko/server/ServerConstants.java @@ -33,5 +33,11 @@ public final class ServerConstants { public static final String DATABASE_DATACENTER = Util.getEnv("DB_DATACENTER","datacenter1"); public static final String DATABASE_PROFILE = Util.getEnv("DB_PROFILE_ONE","profile_one"); + + // --------------- Family ----------------- + public static final int MAX_LEVEL_GAP_FOR_FAMILY = 20; // max allowed level difference between a senior and junior. + public static final int FAMILY_REP_PER_KILL = 3; + public static final int FAMILY_REP_PER_BOSS_KILL = 20; + public static final int FAMILY_REP_PER_LEVEL_UP = 200; } diff --git a/src/main/java/kinoko/util/Timing.java b/src/main/java/kinoko/util/Timing.java index 8d5f3d1a..31e785f6 100644 --- a/src/main/java/kinoko/util/Timing.java +++ b/src/main/java/kinoko/util/Timing.java @@ -99,4 +99,20 @@ public static void logDurationThrowing(String taskName, Th public static int secondsToMinutes(long seconds) { return (int) (seconds / SECONDS_IN_MINUTE); } + + /** + * Returns today's date as an integer in the format YYYYMMDD. + * + * Example: + * 2025-11-23 -> 20251123 + * + * Useful for daily comparisons without dealing with full date objects. + * + * @return today's date in YYYYMMDD format + */ + public static int todayYmd() { + return java.time.LocalDate.now().getYear() * 10000 + + java.time.LocalDate.now().getMonthValue() * 100 + + java.time.LocalDate.now().getDayOfMonth(); + } } \ No newline at end of file diff --git a/src/main/java/kinoko/world/GameConstants.java b/src/main/java/kinoko/world/GameConstants.java index 942d5e06..c0ca82a0 100644 --- a/src/main/java/kinoko/world/GameConstants.java +++ b/src/main/java/kinoko/world/GameConstants.java @@ -112,12 +112,11 @@ public final class GameConstants { // FAMILY CONSTANTS ------------------------------------------------------------------------------------------------ public static final int MIN_FAMILY_LEVEL = 10; // min level required for a character to be in a family. - public static final int MAX_LEVEL_GAP_FOR_FAMILY = 20; // max allowed level difference between a senior and junior. public static final int MAX_FAMILY_CHILDREN_COUNT = 2; // max allowed juniors - public static final double DEFAULT_FAMILY_DROP_MODIFIER = 1.0; // max allowed juniors - public static final double DEFAULT_FAMILY_EXP_MODIFIER = 1.0; // max allowed juniors - public static final double DEFAULT_FAMILY_PERSONAL_DROP_MODIFIER = 1.0; // max allowed juniors - public static final double DEFAULT_FAMILY_PERSONAL_EXP_MODIFIER = 1.0; // max allowed juniors + public static final double DEFAULT_FAMILY_DROP_MODIFIER = 1.0; + public static final double DEFAULT_FAMILY_EXP_MODIFIER = 1.0; + public static final double DEFAULT_FAMILY_PERSONAL_DROP_MODIFIER = 1.0; + public static final double DEFAULT_FAMILY_PERSONAL_EXP_MODIFIER = 1.0; // REACTOR CONSTANTS ----------------------------------------------------------------------------------------------- diff --git a/src/main/java/kinoko/world/field/mob/Mob.java b/src/main/java/kinoko/world/field/mob/Mob.java index 724e5365..a7045fff 100644 --- a/src/main/java/kinoko/world/field/mob/Mob.java +++ b/src/main/java/kinoko/world/field/mob/Mob.java @@ -16,7 +16,10 @@ import kinoko.provider.skill.SkillInfo; import kinoko.provider.skill.SkillStat; import kinoko.script.party.HenesysPQ; +import kinoko.server.Server; import kinoko.server.ServerConfig; +import kinoko.server.ServerConstants; +import kinoko.server.node.CentralServerNode; import kinoko.server.node.ServerExecutor; import kinoko.server.packet.OutPacket; import kinoko.util.BitFlag; @@ -33,6 +36,7 @@ import kinoko.world.job.explorer.Thief; import kinoko.world.job.resistance.WildHunter; import kinoko.world.quest.QuestRecord; +import kinoko.world.user.FamilyMember; import kinoko.world.user.User; import kinoko.world.user.stat.CharacterTemporaryStat; @@ -553,6 +557,8 @@ private void distributeExp() { user.write(MessagePacket.questRecord(questProgressResult.get())); user.validateStat(); } + + giveFamilyRep(user); } } @@ -702,6 +708,33 @@ public String toString() { return String.format("Mob { %d, oid : %d, hp : %d, mp : %d, controller : %s }", getTemplateId(), getId(), getHp(), getMp(), getController() != null ? getController().getCharacterName() : "null"); } + /** + * Grants family reputation to the user's senior chain, but not to the user themselves. + * + * Logic: + * - If the user is not in a family, no reputation is awarded. + * - If the mob has extremely low HP (such as fake or scripted mobs), no reputation is awarded. + * - Determines the reputation amount based on whether the mob is a boss or a normal mob. + * - Reputation is applied only to the user's senior and recursively to higher ancestors. + * The user who killed the mob does NOT gain any reputation from this method. + * + * @param user The user who killed the mob, whose senior chain will receive reputation. + */ + private void giveFamilyRep(User user){ + FamilyMember userMember = user.getFamilyInfo(); + if (!userMember.hasFamily()) { + return; + } + + if (getMaxHp() <= 1) { + return; // don't count low hp mobs. + } + + int repGain = isBoss() ? ServerConstants.FAMILY_REP_PER_BOSS_KILL : ServerConstants.FAMILY_REP_PER_KILL; + userMember.addRepToSenior(repGain, true, true, user.getCharacterName()); + } + + @Override public void encode(OutPacket outPacket) { // CMob::Init diff --git a/src/main/java/kinoko/world/user/FamilyMember.java b/src/main/java/kinoko/world/user/FamilyMember.java index 40f6ec40..00494813 100644 --- a/src/main/java/kinoko/world/user/FamilyMember.java +++ b/src/main/java/kinoko/world/user/FamilyMember.java @@ -1,5 +1,6 @@ package kinoko.world.user; +import kinoko.packet.world.FamilyPacket; import kinoko.server.Server; import kinoko.server.family.FamilyEntitlement; import kinoko.server.family.FamilyTree; @@ -52,6 +53,8 @@ public final class FamilyMember implements Encodable { private final Map> entitlementUsageLog = new ConcurrentHashMap<>(); private final Map activeEntitlements = new HashMap<>(); private String familyMessage; + private int lastReputationDay; // stores YYYYMMDD of last rep gain + public FamilyMember(int characterId, String name, int level, int job, int currentReputation, int totalReputation, @@ -84,7 +87,9 @@ public void updateUser(User user){ public String getName() { return name; } public int getLevel() { return level; } public int getJob() { return job; } - public int getReputation() { return currentReputation; } + public int getReputation() { + return currentReputation; + } public int getTotalReputation() { return totalReputation; } public int getReputationToSenior() { return reputationToSenior; } public Integer getParentId() { return parentId; } @@ -106,7 +111,10 @@ public int getChildrenCount() { return children.size(); } - public int getTodaysRep() { return todaysReputation; } + public int getTodaysRep() { + resetDailyReputationIfNeeded(); + return todaysReputation; + } public void setFamilyMessage(String message){ this.familyMessage = message; @@ -158,7 +166,66 @@ public void addRep(int amount, boolean increaseTotal) { currentReputation = Math.max(currentReputation + amount, 0); if (increaseTotal){ totalReputation += amount; + resetDailyReputationIfNeeded(); todaysReputation += amount; + + + } + } + + /** + * Resets the user's daily reputation if a new day has started. + * + * This method compares the stored lastReputationDay with today's date. + * If they differ, it updates lastReputationDay to today and sets todaysReputation to 0. + * Should be called before modifying or accessing daily reputation to ensure accuracy. + */ + private void resetDailyReputationIfNeeded(){ + if (lastReputationDay != Timing.todayYmd()){ + lastReputationDay = Timing.todayYmd(); + todaysReputation = 0; + } + } + + /** + * Adds reputation to this member's senior and optionally propagates further up the family tree. + * + * Logic: + * - If this member has no parent, nothing happens. + * - If the senior's level is lower than this member's and the rep amount is positive, + * the amount is halved before being applied. + * - Reputation is added to the senior, tracked in this member's "reputationToSenior", + * and then recursively passed upward (unless disabled). + * + * @param amount The reputation amount to grant. + * @param includeGrandSenior Whether reputation should continue propagating past the direct senior. + * @param countRepToSenior Whether this reputation should be tracked in this member's reputationToSenior field. + * @param juniorName The user's name responsible for getting this reputation. + */ + public void addRepToSenior(int amount, boolean includeGrandSenior, boolean countRepToSenior, String juniorName){ + if (parentId == null) { + return; + } + + FamilyMember parentMember = Server.getCentralServerNode().getFamilyInfo(parentId); + if (parentMember.isDefault()){ + return; + } + + int finalAmount = (parentMember.getLevel() < getLevel() && amount > 0) ? amount / 2 : amount; + parentMember.addRep(finalAmount, true); + if (parentMember.isOnline()) { + Server.getCentralServerNode() + .getUserByCharacterId(parentMember.getCharacterId()) + .ifPresent(u -> u.write(FamilyPacket.sendRepGain(finalAmount, juniorName))); + } + + if (countRepToSenior) { + reputationToSenior += finalAmount; + } + + if (includeGrandSenior) { + parentMember.addRepToSenior(finalAmount, false, false, juniorName); } } @@ -357,7 +424,7 @@ public int getChannelID() { public void encode(OutPacket out) { out.encodeInt(currentReputation); out.encodeInt(totalReputation); - out.encodeInt(todaysReputation); + out.encodeInt(getTodaysRep()); out.encodeShort((short) getChildrenCount()); out.encodeShort(GameConstants.MAX_FAMILY_CHILDREN_COUNT); // max juniors out.encodeShort(0); // unknown, wTotalChildCount diff --git a/src/main/java/kinoko/world/user/User.java b/src/main/java/kinoko/world/user/User.java index d31b4239..2e3d8dd3 100644 --- a/src/main/java/kinoko/world/user/User.java +++ b/src/main/java/kinoko/world/user/User.java @@ -19,6 +19,7 @@ import kinoko.provider.map.PortalInfo; import kinoko.provider.skill.SkillStat; import kinoko.server.Server; +import kinoko.server.ServerConstants; import kinoko.server.dialog.Dialog; import kinoko.server.dialog.ScriptDialog; import kinoko.server.dialog.miniroom.MiniRoom; @@ -539,13 +540,19 @@ public void addExp(int exp) { if (addExpResult.containsKey(Stat.LEVEL)) { getField().broadcastPacket(UserRemote.effect(this, Effect.levelUp()), this); validateStat(); - setHp(getMaxHp()); - setMp(getMaxMp()); + heal(); getConnectedServer().notifyUserUpdate(this); // Max level if (getLevel() == GameConstants.getLevelMax(getJob())) { getCharacterData().setMaxLevelTime(Instant.now()); } + // reminder that a null check is not needed here, because if they've had a family before, + // it would be a unique instance. + if (familyInfo.hasFamily()){ + familyInfo.addRepToSenior(ServerConstants.FAMILY_REP_PER_LEVEL_UP, true, + true, getCharacterName()); + familyInfo.updateUser(this); // update level + } } } diff --git a/src/main/java/kinoko/world/user/stat/CharacterStat.java b/src/main/java/kinoko/world/user/stat/CharacterStat.java index e5a4c862..035ac80f 100644 --- a/src/main/java/kinoko/world/user/stat/CharacterStat.java +++ b/src/main/java/kinoko/world/user/stat/CharacterStat.java @@ -1,14 +1,17 @@ package kinoko.world.user.stat; +import kinoko.server.Server; import kinoko.server.packet.OutPacket; import kinoko.util.Encodable; import kinoko.util.Util; import kinoko.world.GameConstants; import kinoko.world.job.JobConstants; +import kinoko.world.user.User; import java.util.EnumMap; import java.util.HashMap; import java.util.Map; +import java.util.Optional; public final class CharacterStat implements Encodable { private int id; @@ -83,6 +86,10 @@ public int getId() { return id; } + public int getCharacterId(){ // alias func + return id; + } + public AdminLevel getAdminLevel() { return adminLevel; } @@ -149,6 +156,12 @@ public short getJob() { public void setJob(short job) { this.job = job; + + getUser().ifPresent(user -> { + if (user.getFamilyInfo().hasFamily()) { + user.getFamilyInfo().updateUser(user); + } + }); } public short getSubJob() { @@ -460,6 +473,10 @@ public long getCumulativeExp() { return levelExp + getExp(); } + public Optional getUser(){ + return Server.getCentralServerNode().getUserByCharacterId(getCharacterId()); + } + @Override public void encode(OutPacket outPacket) { outPacket.encodeInt(id); // dwCharacterID From 6621787c8150ceddc75b1d409284628cfd7a5297 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Sun, 23 Nov 2025 20:01:28 -0500 Subject: [PATCH 82/83] reset rep to senior when leaving a family --- src/main/java/kinoko/handler/user/FamilyHandler.java | 1 + src/main/java/kinoko/world/user/FamilyMember.java | 4 ++++ 2 files changed, 5 insertions(+) diff --git a/src/main/java/kinoko/handler/user/FamilyHandler.java b/src/main/java/kinoko/handler/user/FamilyHandler.java index 88f6bffb..478719ee 100644 --- a/src/main/java/kinoko/handler/user/FamilyHandler.java +++ b/src/main/java/kinoko/handler/user/FamilyHandler.java @@ -818,6 +818,7 @@ private static FamilyUnregisterResult processFamilyUnregister(User junior, User // Perform the actual extraction FamilyTree newTree = parentTree.extractAndRemoveSubTree(userId); + junior.getFamilyInfo().resetRepToSenior(); centralServerNode.addFamilyTree(newTree); return success(parentId, userId); diff --git a/src/main/java/kinoko/world/user/FamilyMember.java b/src/main/java/kinoko/world/user/FamilyMember.java index 00494813..3f53b481 100644 --- a/src/main/java/kinoko/world/user/FamilyMember.java +++ b/src/main/java/kinoko/world/user/FamilyMember.java @@ -551,4 +551,8 @@ public double getDropModifier() { ? FamilyEntitlement.SELF_DROP_1_5.getModifier() : GameConstants.DEFAULT_FAMILY_PERSONAL_DROP_MODIFIER; } + + public void resetRepToSenior() { + reputationToSenior = 0; + } } From b33da2f4054c29a7aebcda4640d7fc8e16467587 Mon Sep 17 00:00:00 2001 From: MujyKun Date: Mon, 24 Nov 2025 02:07:20 -0500 Subject: [PATCH 83/83] removed llm comments --- .../kinoko/server/dialog/miniroom/PersonalShop.java | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java b/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java index 0a219126..0d825255 100644 --- a/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java +++ b/src/main/java/kinoko/server/dialog/miniroom/PersonalShop.java @@ -155,35 +155,28 @@ public void handlePacket(User user, MiniRoomProtocol mrp, InPacket inPacket) { user.dispose(); return; } - // Do transaction (ACID-compliant order: validate, execute reversible ops, commit) - // Save original quantity for rollback - final int originalQuantity = item.getItem().getQuantity(); - // Create buy item (no state modification yet) + final int originalQuantity = item.getItem().getQuantity(); final Item buyItem = new Item(item.getItem()); buyItem.setItemSn(owner.getNextItemSn()); buyItem.setQuantity((short) totalCount); - // Step 1: Deduct buyer's money (reversible) if (!im.addMoney((int) -totalPrice)) { user.write(MiniRoomPacket.PlayerShop.buyResult(PlayerShopBuyResult.NoMoney)); user.dispose(); return; } - // Step 2: Add item to buyer (reversible) final Optional> addItemResult = im.addItem(buyItem); if (addItemResult.isEmpty()) { - // Rollback: Return money to buyer im.addMoney((int) totalPrice); user.write(MiniRoomPacket.PlayerShop.buyResult(PlayerShopBuyResult.NoSlot)); user.dispose(); return; } - // Step 3: Add money to seller (reversible) if (!owner.getInventoryManager().addMoney(moneyForOwner)) { - // Rollback: Remove item from buyer, return money + // rollback: Remove item from buyer, return money im.removeItem(buyItem.getItemId(), totalCount); im.addMoney((int) totalPrice); user.write(MiniRoomPacket.PlayerShop.buyResult(PlayerShopBuyResult.OverPrice)); @@ -191,7 +184,6 @@ public void handlePacket(User user, MiniRoomProtocol mrp, InPacket inPacket) { return; } - // Step 4: FINALLY modify shop quantity (commit point - no rollback after this) item.getItem().setQuantity((short) (originalQuantity - totalCount)); // Update clients user.write(WvsContext.statChanged(Stat.MONEY, im.getMoney(), false));