diff --git a/CHANGELOG.md b/CHANGELOG.md index fc7205a..f1b02ed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,127 @@ ## Unreleased +## Version 1.5.0 + +### New Features + +#### Combat + ++ Fights with mobs now happen in RPG numbers. You have 100 health drawn over your ten hearts, a level-1 zombie has 100, + and hits come from your stats. + + **Health**, **Defense**, **Damage**, **Strength**, **Crit Chance** and **Crit Damage**. Your armor gives Defense, + your weapon Damage, and the Strength effect Strength. Jump attacks add to your crit chance. + + See yours, and where each comes from, with `/tritown stats` or by clicking your profile in the main menu. Add a + name to see another player's. + + Your health and Defense sit above your hotbar, and every hit you land floats its damage up from the mob. + + At level 1 with vanilla gear the game plays exactly as vanilla does. + + None of it applies between players: a fight between players, or with a player's pet, is vanilla. So is everything + the world does to you, such as falling, lava and drowning. ++ Wild mobs are now levelled by where they spawn. Each ring further from a world's spawn is harder, the Nether and the + End start higher, and night and going below Y 0 add to it. + + A level grows a mob's health, its hits and the experience it drops. Looking at a levelled mob shows its level and + health. + + Town claims are always level 1, and so is every mob from a spawner, an egg or a command, so farms work as they do + in vanilla. Traps and fire only ever hurt a mob as much as they would at level 1. + + The sidebar's wilderness line shows the level where you stand. ++ Wild mobs can now spawn as **elites** (★, from level 5) and **champions** (★★, from level 15): much tougher, harder + hitting, and with one to three affixes named on the mob. + + Armored, Frenzied, Vampiric, Enraged, Molten, Frostbound, Venomous, Volatile (explodes a moment after dying), + Summoner, Blinking (steps in behind you) and Warded (shrugs off arrows until hit in melee). + + Champions glow, and ranked mobs always show their nameplate. Bosses such as the Warden never spawn ranked. ++ Wild mobs now drop **materials** from level 3: Grave Dust, Bone Shards, Ember Cores, Void Fragments and ten more, one + for each family of mob. Elites and champions always drop theirs, along with **essence** whose grade follows the + mob's level. + + A new stat, **Magic Find**, makes a normal mob's material likelier. Looting gives some. + + Only a mob that spawned in the wild drops any of it, and only when players dealt at least half its damage, so traps + and lava pits earn nothing. The drops are the killer's. + + Materials look like the vanilla item they are modelled on but are inert: they cannot be crafted with, placed or + used. They and gear are written in one language for everyone, set by the new `item-language` in `config.yml` + (by default the same as `language`); shop prices, messages and the storage overview still name them in each + player's own. + + Administrators hand them out with `/tritown item give [amount]` (`tritown.item.admin`), and set + which mob drops what in `plugins/TriTown/content/mobs.yml`. ++ Added **gear**: ten tiers, each made for mobs up to six times its level, in six rarities from common to mythic. + + An armor set for every tier — Gravewalker, Bonecaller, Widowsilk, Tidecaller, Emberforged, Warhide, Ashen + Knight, Riftwalker, Voidstride and Starfall — and a weapon for every tier, with bows, a crossbow and a trident + among them. + + A tier is worth about twice the one before, and every piece of a slot and tier is worth the same however its + stats are shared out. Gear never wears out. + + Between players a piece is exactly the vanilla item it is made of, and its tooltip says which. + + Champions sometimes drop a finished piece of their tier. ++ Added **the Forge**, opened with `/tritown forge` or from the main menu. It crafts gear from materials, essence and + money at a random rarity, and on the piece in your hand it can: + + **Upgrade** it, up to five stars of 4% more stats each; + + **Refine** it one step of rarity, up to legendary — mythic only ever drops; + + **Reforge** it with a new named bonus such as Sharp or Titanic; + + **Salvage** it for essence. ++ Added **custom mobs**: thirteen of the server's own, who now and then take the place of a wild mob of their kind. + + The Gravewalker, Crypt Ghoul, Bonecaller Sentry, Frost Revenant, Widow Stalker, Silkweaver and Hexbinder in the + Overworld, the Tidecaller in its seas, the Crimson Warhog, Pyromancer and Ashen Legionnaire in the Nether, and + the Voidstalker and Starborn Sentinel in the End. + + Each has its own name on its nameplate, its own size and speed, affixes it always has, and a look of its own — + many wear the gear of their region, which they sometimes drop. + + They are tougher than their kind and drop more of its materials besides. Voidstalkers and Starborn Sentinels + drop **Rift Shells**, which until now no mob could. + + Bonecaller Sentries, Tidecallers, Crimson Warhogs and Voidstalkers drop **trophies** — Hollow Marrow, Abyssal + Scales, Warhog Hide and Null Shards — which the Bonecaller, Tidecaller, Warhide and Riftwalker armor of tiers + 2, 4, 6 and 8 is forged from. + + Each fights with **abilities** of its own: Leap, Slam, Charge, Volley, Fireball, Meteor, Storm, Ensnare, Hook, + Summon, Bulwark, Frost Nova, Miasma and Drain. Every one is warned of with particles and sound before it lands, + breaks no blocks, and only ever hurts players. + + Like every wild mob, they never spawn in a town, from a spawner or from an egg. They may still be elites or + champions. + + Server owners tune them in `plugins/TriTown/content/bestiary.yml`, or turn them off with `mobs.custom.enabled`. + `/tritown mob spawn [level]` calls one up to look at, dropping nothing extra, and `/tritown mob kinds` + lists them (`tritown.mob.admin`). ++ Added **bosses**: the Grave Lord, the Broodmother, the Tide Tyrant, the Cinder Warlord and the Void Herald. + + Each is summoned with its **sigil**, which the custom mobs of its region now and then drop. Raise one in the + right place — the Broodmother answers only deep underground, the Tide Tyrant only from the water — and after a + short ritual the boss rises in front of you. Sigils never work in a town. + + A boss fights in an arena around where it rose, with a boss bar for everyone nearby, abilities of its own, and + phases as its health falls: it calls minions, grows new affixes and learns new tricks. It gives up and leaves if + nobody stays to fight it. + + Everyone who dealt at least a tenth of its health gets **their own share** of the spoils, dropped where it fell + for them alone: materials, trophies, essence, perhaps a piece of gear of its tier, and perhaps a piece of its + **signature gear** — ten pieces that only bosses drop, such as the Gravelord's Crown and the Eclipse Scythe, and + which can drop as mythic. + + Server owners tune them in `bestiary.yml`, switch them off with `mobs.bosses.enabled`, and see each against a + kit of its tier with `/tritown mob bosses`. `/tritown mob spawn ` calls one up to test, dropping nothing. ++ Added the **bestiary**, opened with `/tritown bestiary` or from the main menu: a page for every custom mob and boss. + + A page stays blank, but for where to look, until you slay one. Then it shows what it is, where it lives or how + it is summoned, its abilities and affixes, how many you have slain, and which of its own loot you have found. + + A star marks every 10, 100 and 1,000 of one you slay. ++ Added three stats: **Speed** (capped by `combat.speed-cap`, since it is the one players feel), **Vitality** (more from + regeneration and healing potions) and **Magic Find**, which now also helps a champion's gear drop. ++ Server owners tune every piece in `plugins/TriTown/content/gear.yml`, and gear's budget and the Forge's prices in + `balance.yml`. A change reaches every piece already out there. `/tritown item give [rarity]` hands out + gear, and `/tritown mob balance` now shows each level against a full kit of the gear made for it. + ++ Server owners tune it all in `plugins/TriTown/content/balance.yml`, and set the rings per kind of world, or per world, + under `mobs.levels` in `config.yml`. `/tritown mob balance` shows what the numbers make of each level, and + `/tritown mob level` shows the level where you stand and why (`tritown.mob.admin`). ++ Added the sidebar markers `%health%`, `%max_health%`, `%defense%`, `%mob_level%` and `%danger%`. + +#### Economy + ++ The Forge is a new money sink, listed as **The Forge** in the admin panel's economy breakdown and in `/eco history`. + +### Improvements + +#### Main Menu + ++ The leaderboard and the list of players to trade with or pay now fill from the top-left like every other list, + instead of gathering a short page in the middle. + +### Technical Details + +#### Misc + ++ Removed `LangTranslator`. Paper never translates anything inside an item for each player, so gear and content items + are written in the item language through `Lang.item`, stamped with `Lang.itemRevision`, and redrawn by `ItemRedraw` + from `ItemRefreshListener` (formerly `GearRefreshListener`), on reload, and over every shop's items. ++ Removed `PagedLayout.CENTERED` and `GUIFrame.centeredSlots`: every paged list fills from the top-left. + ## Version 1.4.0 ### New Features diff --git a/CLAUDE.md b/CLAUDE.md index 00cfa59..224f557 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -68,18 +68,24 @@ The server console reads commands from the terminal running Gradle. src/main/kotlin/net/trilleo/mc/plugins/tritown/ ├── Main.kt # Plugin entry point (Main.instance, Main.reload()) ├── commands/ # Sub-commands (auto-registered) -│ ├── admin/ economy/ info/ menu/ news/ protection/ -│ └── moderation/ scoreboard/ shop/ storage/ trade/ +│ ├── admin/ adventure/ economy/ info/ items/ menu/ mobs/ +│ └── moderation/ news/ protection/ scoreboard/ shop/ storage/ trade/ +├── combat/ # Combat: stats, DamageMath, the health lens, the HUD, indicators (not scanned) ├── config/ # PluginConfig (typed config.yml wrapper), EconomySettings +├── content/ # The content files and their parsers, ContentRegistry, content items (not scanned) ├── data/ # JSON-persisted PlayerData / ServerData and their managers ├── economy/ # The economy: ledger, accounts, currencies, Vault provider, statistics, │ # storage (not scanned) ├── enums/ # AccountType, FlowCategory, StatsWindow, TransactionType, FillMode, … +├── gear/ # Gear: its data, stats, drawing and refreshing, the Forge and its costs (not scanned) ├── guis/ # GUIs (auto-registered, extend PluginGUI / PagedPluginGUI); admin/ is the panel, -│ # menu/ the main menu, news/ the news; ConfirmGUI asks before the irreversible +│ # menu/ the main menu, news/ the news, forge/ the Forge, bestiary/ the bestiary; +│ # ConfirmGUI asks before the irreversible ├── items/ # Custom items (auto-registered, extend PluginItem) ├── listeners/ # Event listeners, including Towny events (auto-registered) ├── menu/ # The main menu item and the invariant that keeps it unique (not scanned) +├── mobs/ # Mobs: levels, ranks and affixes, custom mobs and abilities, bosses (boss/), +│ # nameplates, loot (not scanned) ├── news/ # Server news: posts, storage, read state, notifications (not scanned) ├── protection/ # Item protection: drop owners, drop windows, container and entity claims │ # (not scanned) @@ -94,6 +100,8 @@ src/main/kotlin/net/trilleo/mc/plugins/tritown/ # ChatPrompt, CountdownUtil, TeamUtil, TagUtil, PDCUtil, GameRuleUtil, AtomicFile src/main/resources/ ├── config.yml plugin.yml +├── content/ # balance.yml, items.yml, mobs.yml, gear.yml, bestiary.yml — what the combat layer is +│ # made of and tuned with └── lang/ # en_US.yml, zh_CN.yml — every player-facing string ``` @@ -125,6 +133,12 @@ accepting a `JavaPlugin`. See [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md) - **Never write a player-facing string in Kotlin.** Every message, item name, lore line and menu title comes from `sender.tr("key")` / `player.tr("key", "name" to value)`. A hardcoded sentence is a bug, even a short one. +- **Text on an item is written in the item language** with `Lang.item(key)`, never a player's `tr`. Paper translates + chat, titles and entity names per player but never anything inside an item, so an item is written once, in the + language `item-language` names, which keeps every copy identical so it stacks. Stamp what you drew with + `Lang.itemRevision` and redraw through `ItemRedraw` when it changes; anything that keeps its own copy of a TriTown + item (as the shops do) redraws it too. Text one player reads *about* an item — a price, a message — names it in their + language through `GearText`. - `Lang` loads `plugins/TriTown/lang/.yml` (copied from `src/main/resources/lang/` on first start). With `language: auto` in `config.yml` each player gets the file matching their client locale (`zh_tw` → `zh_CN` by prefix), falling back to `en_US`. The console, and anything without a player behind it, uses the configured language. @@ -134,7 +148,9 @@ accepting a `JavaPlugin`. See [docs/DEVELOPER_GUIDE.md](docs/DEVELOPER_GUIDE.md) - Keys are grouped by area: `command.*` per command, `common.*` for shared lines, `money.*` for the economy, `gui.*` per menu. Reuse an existing key before adding one. - Key names must appear as whole string literals (`tr(if (credit) "a.credit" else "a.debit")`, not `"a.$state"`) so - `LangFilesTest` can see them. The only runtime-built keys are `command.*` (the help list) and `money.source.*`. + `LangFilesTest` can see them. The only runtime-built keys are `command.*` (the help list), `money.source.*`, and + `item.*`, `gear.item.*`, `gear.reforge.*` and `mob.kind.*` for content, which `ContentFilesTest` holds to the + content files instead. - Placeholder names are lowercase letters only (`{name}`, `{balance}`), which is what the test checks for. - A GUI declares `titleKey` and its title is translated for the viewer; override `title(player)` when the title carries live data. @@ -344,6 +360,82 @@ The main menu (`guis/menu`) is how players reach TriTown, opened from the menu i `NewsRender.material`. - **Every editor click re-checks `tritown.news.manage`**, not only the command that opened the menu. +## Working with Combat + +**Fights with mobs are in RPG numbers; fights between players are vanilla.** See [Combat](docs/DEVELOPER_GUIDE.md#combat). + +- **Vanilla health is the truth.** An entity's RPG health is always `vanillaHealth / vanillaMax × rpgMax`. Never store + RPG health anywhere, and never change a player's or a mob's vanilla `MAX_HEALTH`: everything vanilla does with + health — regeneration, potions, totems, death — works only because the two cannot drift apart. +- **Never touch a hit between players.** `DamageListener` leaves player→player hits, a pet biting a player, and + everything the world does to a player exactly as vanilla has them. Anything new that fires on a hit must skip players + too, or it becomes a PvP advantage. +- **Every formula lives in `DamageMath`**, which is plain Kotlin. Listeners turn an event into a call and the result + back into vanilla damage (`DamageMath.toVanilla`); they never do arithmetic of their own. A change that fails + `BalanceSimulationTest` changes how the game feels, so it is a decision, not a fix-up. +- **Numbers belong in `content/balance.yml`**, never in Kotlin. A new tunable goes in `Balance` with its default, + `BalanceParser`, and the bundled file — `BalanceParserTest` fails when the file and `Balance.DEFAULT` disagree. +- **Build stats only in `StatSources`**, and read them through `PlayerStats`. Anything new that changes a player's stats + must invalidate their sheet (see `StatListener`). +- **Only wild spawns are levelled.** A mob from a spawner, an egg or a command has no profile and is level 1, and town + claims are always level 1, so vanilla farms keep working. Read a level through `MobProfiles.level`. +- **Environment damage to a mob is level-1 units** (`DamageMath.environmentHit`), so no trap outgrows level 1. Keep it + that way for any new source of damage a player does not deal by hand. +- **Extra loot only ever comes from `MobLoot`**: an eligible mob, killed by a player, with players' damage over + `player-share`. Mobs never drop money — a new faucet belongs in a shop the owner prices. +- **A content item stays an echo shard with an `item_model`**, whose one use `ContentItemListener` refuses. A new base + item would need every vanilla use of it refused first. +- **Every affix effect lives in `AffixEffects`**, and is triggered by a mob. A new affix goes there, in `Affix`, and in + both language files, and must never let a player's hit on a player change. +- **A mob's nameplate is shared by every viewer**, so its frame is rendered in the configured language (`Lang.tr(null, + …)`) and a mob's kind is a `` tag the client fills in. Setting a name must never make a mob persistent. + +## Working with Custom Mobs + +**The server's own mobs live in `content/bestiary.yml`.** See [Custom mobs](docs/DEVELOPER_GUIDE.md#custom-mobs). + +- **A mob stores its kind, never its stats.** `MobProfile.kind` is an id; what it is worth is read from the catalog + every time. Never write a kind's health, damage or Defense onto a mob. +- **`MobPower` is the only place a profile becomes a multiplier or Defense.** Rank, kind and affixes meet there, and + `CombatHealth` and `DamageListener` only ask it. A new source of toughness goes there. +- **Never change a mob's vanilla `MAX_HEALTH` to make it bigger.** A kind's size is `MobPower.health`; its scale, + speed and footing are named attribute modifiers that `MobKinds.refresh` can put on again. +- **A costume is only a look.** Dress a mob through `MobKinds.dress`, which empties every piece's attribute modifiers + and sets its drop chance to 0. Gear only ever leaves a mob through `MobLoot`. +- **A custom mob is only ever a wild one**, rolled in `MobListener.onSpawn`, or one TriTown calls up itself through + `MobSetup.spawn` — which is never eligible for loot. +- **Custom mobs change no blocks**, and neither may anything new they do. +- **Every ability lives in `AbilityEffects`**, and hurts only players, only as the mob (a `DamageSource` it caused), + so the pipeline scales it and it can never touch a hit between players. A new ability goes there, in `Ability`, and + in the `abilities` block of `balance.yml` with its default in `Balance`, and always warns before it lands. +- **Bosses go through `mobs/boss/`.** `BossSummons` is the only way one is summoned — every refusal asked before the + sigil is taken — and `BossFights` the only thing that runs a fight. Only TriTown moves a boss, through + `MobSetup.teleport`; `BossListener` refuses every other teleport and portal. +- **A boss's loot is personal**, rolled per contributor in `MobLoot.bossRewards` and never added to its death drops. + A piece of gear with no recipe is signature gear: only the loot that names it drops it. +- **A new boss is held to `BossBalanceTest`**, and its kind needs vanilla numbers there. +- **The bestiary only reads.** `BestiaryRecords` is credited from `MobListener.onDeath` and `BossFights.defeated` + alone; the menu never changes a record, and nothing a record holds is a reward. + +## Working with Gear + +**A piece of gear is what it is, never what it is worth.** See [Gear](docs/DEVELOPER_GUIDE.md#gear) and +[The Forge](docs/DEVELOPER_GUIDE.md#the-forge). + +- **Gear stores its identity, never its stats.** `GearData` is an id, a rarity, stars, rolls and a reforge; + `GearStats.of` works the stats out every time, so retuning reaches every piece already out there. Never write a + stat onto an item. +- **Every piece of a slot and tier is worth the same.** A definition gives weights, never numbers, and the budget in + `balance.yml` does the rest. `GearStatsTest` and `BalanceSimulationTest` hold the budget to the design. +- **Go through `Gear`** to make, read, save or refresh a piece, and through `Forge` for anything a player pays for. + `Forge` asks everything that can refuse first, charges, and only then takes and changes — the `ShopTrade` order. +- **Draw gear with `GearRender`**, which writes only item-language text and plain numbers and leaves enchantments + and anvil names alone. A redraw must never lose what a player gave a piece. +- **Between players a piece is its base item.** Its own vanilla armor and attack damage never count as RPG stats, and + nothing new may make a piece stronger against a player than the vanilla item it is made of. +- **Mythic only ever drops.** The Forge refines no higher than `refine-cap`, and `craft-odds` never lists mythic. +- **Money moves as `SOURCE_GEAR`** with a `money.reason.gear-*` reason, filed under `FlowCategory.GEAR`. + ## Versioning & Releases - `plugin_version` in [gradle.properties](gradle.properties) is the single source of truth for the plugin version. It @@ -389,5 +481,7 @@ or `Fix` commit carries its own changelog entry. See [docs/COMMIT_STRUCTURE.md]( - **Escape player-written text** — town names, boards, and other player input must be escaped (`MiniMessage.miniMessage().escapeTags(...)`) before being embedded in MiniMessage. - **Build items with the DSL** — use `itemStack { }` for GUI and custom items and `LoreUtil` for wrapped lore. +- **Lists fill from the top-left** — a paged list uses `PagedLayout.FRAMED` (or `FULL`), never a centred layout. Only a + row of buttons is centred, with `GUIFrame.spacedColumns` or `packedColumns`. - **No manual registration** — never edit `plugin.yml` commands/listeners. The auto-registration system handles everything. diff --git a/README.md b/README.md index 868228a..6a41cd9 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ run `/tt menu`, for everything TriTown offers in one place. Your profile sits at leaderboard rank, town and nation — and below it are your town at a glance with a shortcut into [TownyMenu](https://github.com/Trilleo/TownyMenu), your storage, the global shop, a list of the players near enough to trade with (anyone waiting for your answer first), a list of players to pay, the richest players as heads, the server's vital signs, -the server news, a sidebar switch, and the admin panel for those allowed it. Anything switched off on the server, or that you may not +the Forge, the server news, your bestiary, a sidebar switch, and the admin panel for those allowed it. Anything switched off on the server, or that you may not use, is simply left out, and what remains is centred. The item cannot be moved, dropped, stored, crafted with or handed to anything, a copy made any other way is deleted within a second, and it is taken off you when you log out, so there is nothing to duplicate and nothing left behind if TriTown is ever removed. @@ -63,6 +63,21 @@ cannot be spent behind their back, and anything changing on the table clears bot swapped out after somebody has agreed to it. Everything comes straight back if either of you closes the menu, walks away or disconnects. +**Fights worth gearing up for.** Mobs in the wild are levelled by where they spawn: every ring further from spawn is +harder, the Nether and the End start higher, and night and the deep add to it. Health and hits are in RPG numbers — +your 100 health is drawn over your ten hearts, a level-1 zombie has 100 and a level-30 one over 3,000 — and your +Defense, Damage, Strength and crits come from what you wear and hold. Some mobs spawn as elites and champions, with +affixes that make them fight differently, and the server's own mobs — the Gravewalker in its grave-gear, the Crypt +Ghoul, the Widow Stalker, the Voidstalker and more — take the place of wild mobs now and then, tougher, with abilities +of their own that they always warn you of — leaps, charges, volleys, meteors, webs, frost novas — and loot of their +own. Five bosses — the Grave Lord, the Broodmother, the Tide Tyrant, the Cinder Warlord and the Void Herald — are +summoned with sigils those mobs drop, fought in arenas with phases and boss bars, and share their loot, signature gear +among it, with everyone who helped. Wild mobs drop materials. At the Forge those become gear in ten tiers +and six rarities, which you upgrade, refine and reforge as you push further out. `/tt stats` shows your stats and where +each comes from, your health and Defense sit above your hotbar, and every hit you land floats its damage up from the +mob. At level 1 with vanilla gear it plays exactly like vanilla. Town claims are always level 1 and mobs from spawners +stay vanilla, so farms keep working, and players fighting each other are left entirely to vanilla. + **Server news.** Update notes, written in game and read from the main menu or with `/tt news`. A post is a title and categories of short entries — a line or two about one change each, tagged New, Changed, Fixed, Removed or Note — read straight off the menu, or as a book when it runs long. Players hear about what they have missed: a list of unread posts, @@ -73,8 +88,8 @@ players will see it, pin it, and publish it when it is ready. Every title and en server's languages, and players read their own. **An admin panel.** `/tt admin` opens a menu that reads the server back to you. The economy section shows how much -currency exists and who holds it, what created it and what removed it — new players, shops, Towny, administrators or -storage pages or another plugin — with the net drift per day, how unevenly wealth is spread, how fast money circulates, and a chart of +currency exists and who holds it, what created it and what removed it — new players, shops, Towny, administrators, +storage pages, the Forge or another plugin — with the net drift per day, how unevenly wealth is spread, how fast money circulates, and a chart of the window drawn as columns. Read any of it over the last day, week or month, or over everything on record. Every shop's takings are in there too, next to the economy they act on, and every player's storage — how full it is, how many pages were bought — which you can open and look through, online or not. @@ -82,7 +97,9 @@ many pages were bought — which you can open and look through, online or not. **English and Simplified Chinese.** Every message, menu and item TriTown shows is translated. By default each player sees whichever of the two their Minecraft client is set to, and everyone else sees English. Set `language` in `config.yml` to `en_US` or `zh_CN` to pick one for the whole server, or edit the files in `plugins/TriTown/lang/` to -reword anything — including adding a language of your own. +reword anything — including adding a language of your own. Items are the one exception: the server cannot show an +item's text to each player differently, so gear and materials are written in one language for everyone, set by +`item-language` (by default the same as `language`, or English when that is `auto`). Alongside it, TriTown provides the plugin framework and Towny integration that the server's custom town features are built on. @@ -147,6 +164,10 @@ itself, are still anyone's. | `/tt storage` | Open your storage | | `/tt admin [section]` | Open the admin panel (OP only) | | `/tt protection ` | See or clear who owns what you look at (OP only) | +| `/tt stats [player]` | See your combat stats, or another player's | +| `/tt mob ` | Inspect mob levels and the balance (OP only) | +| `/tt item ` | Hand out the server's own items and gear (OP only) | +| `/tt forge` | Craft gear, and upgrade, refine, reforge or salvage it | `/eco` takes `give`, `take` and `set` (` [currency]`), `reset ` back to the starting balance, `info ` for an account's details, `history [player]` to browse recorded transactions in a menu, and `flush` to @@ -175,6 +196,13 @@ builder place and fill the containers the storage replaces. `release` clears that claim; both need `tritown.protection.admin`. `tritown.protection.bypass` lets staff open, take from and break anything a player has claimed. +`/tt stats` opens your combat stats — or, with a name, those of anyone online — each broken down by where it comes +from. It has no permission node; whether it runs at all is `combat.enabled`. `/tt mob level` says what level mobs +spawning where you stand would be, and why, and `/tt mob balance` prints what the balance in force makes of each level; +both need `tritown.mob.admin`. `/tt item give [amount]` hands out a material or essence, `/tt item give + [rarity]` a piece of gear, and `/tt item list` names them all; both need `tritown.item.admin`. `/tt forge` +opens the Forge and has no permission node; whether it runs is `combat.enabled`. + `/tt admin` opens the panel itself, and `economy`, `shops` or `storage` opens that section directly. Opening the panel needs `tritown.admin`; the sections need `tritown.admin.economy`, `tritown.admin.shops` and `tritown.admin.storage` on top of it. @@ -190,6 +218,7 @@ version stays available as `/tritown:balance` and so on. |:------------------------------------------|:-------------------|:--------------------------------------------------------------------------------| | `message-prefix` | — | MiniMessage prefix shown before plugin messages | | `language` | `auto` | `auto` follows each player's client, or a language id such as `zh_CN` | +| `item-language` | `auto` | The one language gear and materials are written in; `auto` follows `language` | | `main-menu.item.enabled` | `true` | Keep the menu item in the last hotbar slot of every player | | `main-menu.item.material` | `NETHER_STAR` | What the menu item is; any item works | | `economy.enabled` | `true` | Turn the economy off entirely | @@ -239,6 +268,18 @@ version stays available as `/tritown:balance` and so on. | `item-protection.enabled` | `true` | Items only change hands through a trade | | `item-protection.disabled-worlds` | `[]` | Worlds where nothing is protected | | `item-protection.` | `true` | Each of `drops`, `actions`, `mob-loot`, `projectiles`, `containers`, `entities`, `explosion-guard` | +| `combat.enabled` | `true` | Turn the combat layer off: hits stay vanilla and mobs are not levelled | +| `combat.disabled-worlds` | `[]` | Worlds where hits stay vanilla and mobs are not levelled | +| `combat.hud.action-bar` | `true` | Show each player's health and Defense above the hotbar | +| `combat.hud.damage-indicators` | `true` | Float each hit's damage up from the mob it hit | +| `combat.hud.indicator-limit` | `64` | The most damage indicators that may exist at once | +| `combat.speed-cap` | `30` | The most Speed, in percent, that makes a player faster | +| `mobs.levels.night-bonus` / `.depth-bonus` | `2` | Levels an Overworld mob gains at night, and below Y 0 | +| `mobs.levels.` | see below | Each kind of world's `base`, `spawn-radius`, `ring-width`, `per-ring` and `cap` | +| `mobs.levels.worlds.` | — | Rings of a world's own, by name | +| `mobs.xp-per-level` | `0.02` | Extra experience a mob drops per level above 1 | +| `mobs.nameplates` | `true` | Show a levelled mob's level and health when a player looks at it | +| `mobs.ranks.exempt` | bosses | Kinds of mob that never spawn as elites or champions | | `towns.founding-credit` | `100.0` | Credit only `/t new` can spend, given once to players without a town; `0` is off | | `news.enabled` | `true` | Turn the server news off entirely | | `news.join-message.enabled` | `true` | List a player's unread posts a moment after they join | @@ -352,6 +393,149 @@ in money is paid across in one payment, recorded in the transaction log like any Closing the menu calls the trade off and everything goes straight back. So does walking too far apart, disconnecting, or the server stopping. +### Combat + +Every player and every mob has an RPG health pool drawn over their vanilla hearts. The pool is always exactly as full +as the hearts are, so potions, regeneration, totems and deaths all work as they always have; only the numbers change. +Hits between a player and a mob, or between two mobs, are worked out from stats: + +- **Health** is your pool, 100 to start. **Defense** takes a share off every mob's hit — 100 halves it. **Damage** + comes from your weapon, **Strength** adds to every hit in percent (the Strength effect gives some), and **Crit + Chance** and **Crit Damage** decide how often a hit lands harder and by how much; jump attacks add to your crit + chance. **Speed** moves you faster, **Vitality** makes regeneration and healing potions give back more, and **Magic + Find** makes rare drops likelier. Vanilla armor and weapons count, and so does gear. +- None of it applies between players. A hit from one player to another, or from a player's pet, is left to vanilla, and + so is everything the world does to you: falling, lava, drowning. The one stat players feel is Speed, which is capped + (`combat.speed-cap`) short of a Speed II potion. + +Mobs are levelled by where they spawn. Each kind of world has a spawn area where mobs stay at its base level, and +rings around it, each harder than the one inside: + +| World | Base | Spawn area | Each ring | Cap | +|:----------|:-----|:-------------------|:---------------------|:----| +| Overworld | 1 | 300 blocks | 500 blocks, +2 levels | 30 | +| Nether | 4 | 64 blocks | 64 blocks, +2 levels | 45 | +| End | 8 | the main island (1,000) | 500 blocks, +3 levels | 60 | + +Night and being below Y 0 each add 2 in the Overworld. A level grows a mob's health and hits, and the experience it +drops. Town claims are always level 1, and so is every mob from a spawner, an egg or a command, so farms keep working as +they do in vanilla. Levels are kept with the mob, so they survive restarts. + +From level 5 a wild mob can spawn as an **elite** (★), and from level 15 as a **champion** (★★): several times the +health, harder hits, and one to three affixes, named on the mob and shown by the particles around it — Armored, +Frenzied, Vampiric, Enraged, Molten, Frostbound, Venomous, Volatile, Summoner, Blinking or Warded. Champions glow. +Bosses such as the Warden never spawn ranked (`mobs.ranks.exempt`). + +From level 3, wild mobs drop **materials** on top of their usual loot — Grave Dust from zombies, Ember Cores from +blazes, Void Fragments from endermen, fourteen in all — and elites and champions always do, along with **essence** +whose grade follows their level. A champion now and then drops a finished piece of gear. Magic Find makes the chancier +drops likelier; Looting gives some. Only a mob that spawned in the wild drops any of it, and only when players dealt at +least half its damage, so a trap or a lava pit earns nothing. What drops is the killer's, like the rest of the mob's +loot. Materials are inert: they look like the vanilla item they are modelled on, but cannot be crafted with, placed or +used. Mobs never drop money. + +### Custom mobs + +The server has mobs of its own, defined in `plugins/TriTown/content/bestiary.yml`. Now and then one takes the place of +a wild mob of its kind — only ever a wild one, so never in a town, from a spawner or from an egg: + +| Mob | Is a | Lives | Fights with | +|:------------------|:----------------|:-------------------------------|:----------------------------------| +| Gravewalker | zombie | the Overworld, levels 3–14 | Leap; wears the Gravewalker set | +| Crypt Ghoul | husk | deserts, 6–16 | Leap; small, fast and Vampiric | +| Bonecaller Sentry | skeleton | the Overworld, 6–16 | Volley; wears the Bonecaller set | +| Frost Revenant | stray | the cold, 10–22 | Frost Nova; a Frostbite Edge | +| Widow Stalker | spider | above Y 40, 12–24 | Ensnare, Leap | +| Silkweaver | spider | below Y 0, 14–26 | Miasma; Venomous | +| Hexbinder | witch | swamps, 12–30 | Drain, Ensnare | +| Tidecaller | drowned | the seas, 16–30 | Hook; a Tidecaller Trident | +| Crimson Warhog | hoglin | the Nether, 18–36 | Charge | +| Pyromancer | blaze | the Nether, 22–40 | Fireball, Meteor; Molten | +| Ashen Legionnaire | wither skeleton | the Nether, 30–45 | Charge, Slam; the Ashen set | +| Voidstalker | enderman | the End, 30–60 | Hook, Drain; Blinking | +| Starborn Sentinel | enderman | the outer End, 48–60 | Storm, Bulwark; Warded | + +Each wears its own name on its nameplate, is tougher than its kind, and may still be an elite or a champion. Its +abilities always warn you with particles and sound before they land, break no blocks, and only ever hurt players. Its +costume is only a look: what it wears gives it no armor and never drops, though some drop a finished piece of the gear +they wear now and then. They drop more of their kind's materials, and four drop **trophies** — Hollow Marrow, Abyssal +Scales, Warhog Hide and Null Shards — that the armor of tiers 2, 4, 6 and 8 is forged from. Voidstalkers and Starborn +Sentinels are where Rift Shells come from. + +`mobs.custom.enabled` turns them off. `/tt mob spawn [level]` calls one up to look at (it drops nothing extra), +and `/tt mob kinds` lists them. + +### Bosses + +Five bosses are summoned by players, each with a **sigil** that the custom mobs of its region now and then drop. +Right-click one where its boss answers and, after a short ritual, the boss rises a few blocks in front of you: + +| Boss | Level | Summoned | Its sigil drops from | Its signature gear | +|:---------------|:------|:--------------------------|:-----------------------------------|:-------------------------------------| +| Grave Lord | 12 | the Overworld's wilds | Gravewalkers, Crypt Ghouls | Gravelord's Crown, Sepulcher Cleaver | +| Broodmother | 24 | underground, below Y 40 | Widow Stalkers, Silkweavers | Broodfang, Widowmantle | +| Tide Tyrant | 30 | from the Overworld's water | Tidecallers, Hexbinders | Tyrant's Harpoon, Crown of Tides | +| Cinder Warlord | 42 | the Nether | Pyromancers, Ashen Legionnaires | Warlord's Brand, Cinder Aegis | +| Void Herald | 60 | the End | Voidstalkers, Starborn Sentinels | Eclipse Scythe, Herald's Crown | + +A boss fights in an arena around where it rose, glowing, with a boss bar for everyone nearby. As its health falls it +enters new phases — calling minions, gaining affixes, learning abilities — and it gives up and leaves if nobody stays in +its arena. It is built for a group: alone, in gear of its tier, it takes over a hundred swings. + +Everyone who dealt at least a tenth of its health gets **their own share** of its loot, with their own Magic Find, +dropped where it fell for them alone (or straight into their inventory if they are far away): its region's materials, +trophies and essence, perhaps a finished piece of its tier, and perhaps its **signature gear**, which only bosses drop +and which can be mythic. Sigils never work in a town, and a boss never teleports, despawns or leaves its world. + +`mobs.bosses.enabled` turns summoning off, and `mobs.bosses.announce-range` sets who hears of a boss. `/tt mob bosses` +shows each boss against one player in a kit of its tier, and `/tt mob spawn ` calls one up to test. + +### The bestiary + +`/tt bestiary`, or **Bestiary** in the main menu, keeps a page for every custom mob and boss. Until you slay one, its +page is blank but for where to look. After that it shows what it is, where it lives or how it is summoned, its +abilities and affixes, how many you have slain — with a star at 10, 100 and 1,000 — and which of its own loot you have +found so far. + +### Gear and the Forge + +**Gear** comes in ten tiers, each made for mobs up to six times its level — tier 5 is for level 25 to 30 — and in six +rarities, common to mythic. A tier is worth about twice the one before; a rarity step is worth far less, so the tier is +what you climb and the rarity what you chase. Every piece of a slot and tier is worth the same, however its stats are +shared out. Gear never wears out. Between players, a piece is exactly the vanilla item it is made of — a tier-7 blade +forged from a netherite sword fights players as a netherite sword — and its tooltip says so. + +The starting set is an armor set for every tier — Gravewalker, Bonecaller, Widowsilk, Tidecaller, Emberforged, +Warhide, Ashen Knight, Riftwalker, Voidstride and Starfall — and a weapon for every tier, bows and a crossbow and a +trident among them. The Bonecaller, Tidecaller, Warhide and Riftwalker sets take trophies that only custom mobs drop. + +**The Forge** (`/tt forge`, or the main menu) crafts a piece from materials, essence and money, at a random rarity. Hold +a piece in your main hand and the Forge can also: + +- **Upgrade** it: up to five stars, each worth 4% more stats, each costing more than the last; +- **Refine** it: one step of rarity, up to legendary — mythic only ever drops; +- **Reforge** it: a new named reforge, such as Sharp or Titanic, adding a little of its own; +- **Salvage** it: break it down for good into essence. + +Every step asks first. Upgrading and refining take the essence of the piece's tier, so improving gear means fighting at +its level. The money the Forge takes leaves the economy, and the admin panel shows it under **The Forge**. + +Everything is tuned in files of its own in `plugins/TriTown/content/`, which `/tt reload` reads again: + +- `balance.yml` — what everything is worth: the lens (RPG health per vanilla half-heart, 5), your base stats, how much + a level grows a mob, ranks and affixes, what vanilla gear counts as, gear's budget, and what the Forge charges. +- `mobs.yml` — which mob drops which material, and how often. +- `items.yml` — the materials and essence: how each looks and how rare it is. +- `gear.yml` — every piece of gear: its slot, tier, base item, look, how it shares out its stats, and its recipe; and + the reforges. Retuning a piece reaches every copy already out there. +- `bestiary.yml` — the custom mobs and bosses: what each is, where it lives or is summoned, how much tougher than its + kind, its affixes, abilities and phases, what it wears and what it drops; and how boss fights and their loot go. + Retuning one reaches every one already out there. What each ability does is in `balance.yml`. + +Names are in the language files under `item` and `gear`. `/tt mob balance` shows what your numbers make of each level — +a zombie against a full kit of the tier made for it — before anyone fights. A value that cannot be used falls back to +the bundled default, and the console says which. + ### The sidebar Each board under `scoreboard.boards` has a `priority`, a `condition`, and a list of `lines`. A player sees the diff --git a/docs/DEVELOPER_GUIDE.md b/docs/DEVELOPER_GUIDE.md index f9bb643..0abf285 100644 --- a/docs/DEVELOPER_GUIDE.md +++ b/docs/DEVELOPER_GUIDE.md @@ -2,7 +2,7 @@ This guide explains how to create **commands**, **listeners**, **GUIs**, **tasks**, **custom items**, **recipes**, work with **translations** and the **configuration** system using TriTown's registration system, and how to build on -**Towny**, the **Vault economy**, the **admin panel**, **shops**, **player trades**, **personal storage**, **item protection**, the **news** and the **sidebar**. Commands, listeners, GUIs, tasks, custom items, and recipes all +**Towny**, the **Vault economy**, the **admin panel**, **shops**, **player trades**, **personal storage**, **item protection**, the **news**, **combat** and the **sidebar**. Commands, listeners, GUIs, tasks, custom items, and recipes all follow the same pattern: extend a base class (or implement an interface), place the file in the correct package, and the plugin handles the rest automatically at startup. The configuration system provides typed access to `config.yml` values. @@ -415,11 +415,11 @@ pure arithmetic apart from `pane` and `draw`, and `GUIFrameTest` pins it down. | `draw(inventory, slots)` | Fills every slot not in `slots` with the border | | `spacedColumns(count)` | Columns for up to 4 buttons spread with a gap between each, centred (`4`; `3, 5`; …) | | `packedColumns(count)` | Columns for up to 7 items side by side, centred; an even count leaves column 4 empty | -| `centeredSlots(rows, count)` | Slots for `count` items in rows of seven, last row packed, the block centred vertically | A menu of buttons whose set depends on the viewer — a permission, a feature switched off — should lay each row out with `spacedColumns` from the buttons it actually has, rather than fixing slots and leaving a hole where one is missing. -`MainMenuGUI` and `AdminPanelGUI` both do this. +`MainMenuGUI` and `AdminPanelGUI` both do this. Centring is for a row of buttons only: a list — anything paged — fills +from the top-left, so its items keep their places as it grows. ### ConfirmGUI @@ -532,20 +532,13 @@ For example, a 6-row GUI provides 45 content slots per page (rows 1–5). |:-----------------------|:-----------------------|:-------------------------------------------------------------------------------| | `PagedLayout.FULL` | 45 | Every slot above the navigation row is content | | `PagedLayout.FRAMED` | 28 | Content is inset by one slot on every side, with a black glass border round it | -| `PagedLayout.CENTERED` | 28 | Framed, and a page that is not full keeps its items centred inside the border | -A framed menu carries the border into its navigation row too, so the whole edge is one colour rather than changing -where the controls start. - -`CENTERED` is for lists that are often short, such as the players online. A full page looks exactly like `FRAMED`; a -page of a few items puts them in the middle of the menu rather than in its top-left corner, with the last row packed -around the centre column (see [`GUIFrame`](#guiframe)). It only changes `LIST` mode — `SET` positions are explicit. +Either way a page fills in reading order from its top-left slot, however few items it holds. A framed menu carries the +border into its navigation row too, so the whole edge is one colour rather than changing where the controls start. **Do not index `getItems` by the raw slot.** Under a framed layout a slot is not a position in that list, because the -border sits between them, and on a centred page the slot an item lands in depends on how many share the page. Use -`contentIndex(event, page)`, which reads the slots as they were drawn for that viewer and returns the position or -`null` when the slot is not content (an empty slot inside a `FULL` or `FRAMED` area still has a position, one past the -end of the list): +border sits between them. Use `contentIndex(event, page)`, which returns the position or `null` when the slot is not +content (an empty slot inside the content area still has a position, one past the end of the list): ```kotlin override fun onContentClick(event: InventoryClickEvent, page: Int) { @@ -1133,8 +1126,13 @@ list's command descriptions and a transaction's recorded source. - **Escape player-written text** with `MiniMessage.miniMessage().escapeTags(...)` before passing it as an argument. Arguments are inserted verbatim. - **Write keys as whole string literals.** `tr(if (credit) "gui.history.credit" else "gui.history.debit")` is fine; - `tr("gui.history.$state")` is not, because the test below cannot see it. `command.*` (the help list) and - `money.source.*` are the only runtime-built keys. + `tr("gui.history.$state")` is not, because the test below cannot see it. `command.*` (the help list), + `money.source.*` and `item.*` (content items, held to `items.yml` by `ContentFilesTest`) are the only runtime-built + keys. +- **Text on an item is written in the item language** with `Lang.item(key)`, and the item carries a stamp so + `ItemRedraw` can redraw it when the language changes. Paper does not translate anything inside an item for each + player (it does for chat, titles and entity names), so an item is written once, in the language `item-language` + names, and every copy stays identical and stacks. See [Lang](UTILITY_GUIDE.md#lang). - **Placeholder names are lowercase letters only** — `{name}`, `{balance}`, `{pages}`. - **Server-log messages stay English.** `logger.info`/`warning`/`severe` are for the owner, not the player. - Keys are grouped by area: `command.*` per command, `common.*` for shared lines, `money.*` for the economy, `gui.*` @@ -2501,13 +2499,15 @@ Every one of them opens at six rows. `MainMenuGUI` puts the profile alone at the up to four with `GUIFrame.spacedColumns`. **A button for something switched off or not permitted is left out, not greyed**, and each row is centred on what remains: the global shop only when shops are on and the viewer passes its gate, the storage only while `StorageManager.isAvailable`, trading only when `player-trades.enabled`, pay and the leaderboard only with the economy running and the -command's permission (`CommandRegistrar.canRun`), the news only while `NewsManager.isAvailable`, the sidebar switch +command's permission (`CommandRegistrar.canRun`), the news only while `NewsManager.isAvailable`, the Forge and the +bestiary only where combat is on (the bestiary only when `bestiary.yml` defines a mob), the sidebar switch only while the sidebar runs, the admin panel only with `tritown.admin`, and the TownyMenu shortcut only when TownyMenu is enabled. Empty rows are dropped, so the menu -never has a hole in it. The two list menus use `PagedLayout.CENTERED` and keep *Back* and one extra button either side +never has a hole in it. The two list menus use `PagedLayout.FRAMED` and keep *Back* and one extra button either side of the page number (`MenuRender.BACK_OFFSET`, `EXTRA_OFFSET`). **The menu points the way; it does not do the work.** Each button runs the command or opens the menu that already owns -the action — `/trades`, `/tritown storage`, `/trade `, `/pay `, `/tritown news` through `CommandRegistrar.run`, the admin panel, +the action — `/trades`, `/tritown storage`, `/trade `, `/pay `, `/tritown news`, `/tritown forge`, +`/tritown bestiary` through `CommandRegistrar.run`, the admin panel, TownyMenu through its own `/townymenu` — so there is exactly one place each rule and message lives. Paying asks for the amount with `ChatPrompt` and hands it to `/pay`, which validates it. @@ -3386,6 +3386,484 @@ checks on each click as well as the command. `NewsSettings` is a snapshot swapped in whole on a reload. The posts themselves are never re-read on a reload; every change to them is already on disk. +## Combat + +Fights with mobs happen in RPG numbers: players and mobs have health pools in the hundreds and thousands, and hits come +from stats. The core lives in `combat/` (stats, the pipeline's maths, the HUD) and `mobs/` (levels, profiles, custom +mobs, nameplates), neither of which is scanned. The listeners are `listeners/combat/DamageListener`, `StatListener`, +`listeners/mobs/MobListener` and `BossListener`, the tasks are `tasks/combat/CombatHudTask` and `tasks/mobs/MobTurnTask`, +the menus are `guis/adventure/StatsGUI` and `guis/bestiary/BestiaryGUI`, and the commands are +`commands/adventure/StatsCommand` (`/tritown stats`), `BestiaryCommand` (`/tritown bestiary`) and +`commands/mobs/MobCommand` (`/tritown mob`). + +### Vanilla health is the truth + +Every living entity has an **RPG pool** (`CombatHealth.max`): a player's is their Health stat, a mob's is its vanilla +maximum × the lens × its level's growth. **Current RPG health is never stored.** It is always +`vanillaHealth / vanillaMax × rpgMax`, so the pool is exactly as full as the entity's hearts. + +That one rule is why so little had to be built. Vanilla keeps doing what it does: saving health, natural regeneration, +potions, totems, `/kill` and death, killer credit and drops. Nothing can drift out of step because there is nothing +else to keep in step. **Never change a player's or a mob's vanilla `MAX_HEALTH`, and never store RPG health.** + +The **lens** (`balance.yml`, default 5) is RPG health per vanilla half-heart at level 1. Every hit and every pool is +multiplied by it alike, so level-1 play with vanilla gear is vanilla at any lens. That is the balance anchor. + +### The pipeline + +`DamageListener.onDamage` runs at `HIGH` on every `EntityDamageEvent` in a world where `Combat.isActive`. The attacker is +the damage source's *causing* entity, which covers arrows, fangs, explosions and pets. + +| Hit | Worked out as | +|:--------------------------------------|:--------------------------------------------------------------------------------| +| player → player, or a pet → a player | **left alone** | +| the world → a player | left alone, so falls, lava and drowning take vanilla's share | +| player → mob, melee | `DamageMath.meleeHit`: `(lens + Damage) × (1 + Strength/100) × share × crit` | +| player → mob, arrow or trident | `DamageMath.shotHit`: vanilla's hit × the weapon's shot multiplier × Strength × crit | +| mob → player, mob → mob | `DamageMath.mobHit`: vanilla's hit × lens × `damage-growth^(level−1)` × `MobPower.damage`, then Defense | +| the world, or a player's potion or TNT → mob | `DamageMath.environmentHit`: vanilla × lens, i.e. level-1 units | + +- **The share** is vanilla's own melee hit divided by the player's attack damage (`DamageMath.vanillaShare`). It carries + everything *but* the weapon — attack cooldown, sweeping, Sharpness, Smite, a mace's fall — so those keep working as + vanilla meant them. Vanilla's jump-crit ×1.5 is divided back out; a jump attack adds `jump-crit-chance` instead. +- **A bow's Damage is only for what it shoots**: a player swinging with a bow or crossbow in hand hits with the bare + hand's worth. +- **Arrows and tridents** carry the shooter's Strength and crit stats in their own data (`ShotStats`), with what the + weapon makes each point of the shot worth (`DamageMath.shotMultiplier`: the weapon's Damage, with the lens's worth for + the bare hand, over the vanilla damage of a shot from a weapon of no Damage). `StatListener` writes it at + `EntityShootBowEvent` / `ProjectileLaunchEvent`, so a shot lands with what it was fired with. A vanilla bow comes out + at the lens, so vanilla stays vanilla. +- **Environment hits on mobs** are level-1 units whatever the mob's level, so a lava pit, fall trap or fire aspect is + only ever as strong as it is against a level-1 mob. That is what stops traps from farming the wild. + +The RPG hit becomes vanilla damage through the victim's own pool (`DamageMath.toVanilla`), which is written to the +event's `BASE`: + +- **A mob** keeps vanilla's reductions as they are — `event.damage = …` recalculates them. +- **A player's** worn armor is already their Defense, so vanilla's armor reduction would count it twice. + `applyWithoutArmor` zeroes the `ARMOR` modifier and carries every other one over with `VanillaReductions`: each takes + the same *share* of what reaches it as it took of vanilla's hit (Resistance, Protection and its kin, a shield, a + helmet under an anvil, invulnerability frames), and absorption soaks up what it can hold. The modifier API is + deprecated without a replacement; it is still the only way to take one reduction out and keep the rest. + +`afterDamage` (at `MONITOR`) floats the damage up from the mob (`DamageIndicators`) for hits a player landed, and asks +the mob's nameplate to redraw. + +### Stats + +`Stat` is Health, Defense, Damage, Strength, Crit Chance, Crit Damage, Speed, Vitality and Magic Find. **None of +them apply between players but Speed**, which is movement and cannot tell who is chasing whom, so `SpeedSync` caps it +at `combat.speed-cap` and applies it as a transient modifier twice a second (`tasks/combat/SpeedTask`). Vitality +scales natural regeneration and healing potions (`VitalityListener`), and Magic Find the rare loot rolls. `StatSheet` is +an immutable value per stat, and `StatSources` is the only place one is built: + +| Source | Gives | +|:----------|:-------------------------------------------------------------------------------------------------| +| `BASE` | `balance.yml`'s `player` block: Health, Crit Chance, Crit Damage | +| `ARMOR` | Each worn piece: its gear stats, or for vanilla armor, its armor × `armor-point` + its toughness × `toughness-point` as Defense | +| `WEAPON` | The held item: its gear stats, or for a vanilla item its attack damage × the lens as Damage; and Magic Find for Looting | +| `EFFECTS` | Strength for the Strength effect, less for Weakness, per level | + +Items are read through their `ATTRIBUTE_MODIFIERS` component, so a material's defaults and any custom modifiers both +count. Defense works as `100 / (100 + Defense)` of a mob's hit getting through. + +`PlayerStats` caches a sheet per player. `StatListener` drops it whenever equipment, the held item or effects may have +changed, and a sheet also expires after a second regardless, which catches what no event reports. `/tritown reload` +drops them all. + +### Levels + +`MobListener.onSpawn` gives a hostile mob (`Enemy`) a level as it spawns, stored in its own persistent data under +`tritown:mob` by `MobProfiles`. The game saves it with the entity, so the level survives unloads and restarts. + +- **Only wild spawns are levelled**: `NATURAL`, `REINFORCEMENTS`, `PATROL`, `JOCKEY`, `MOUNT`. Spawners, trial spawners, + eggs, raids, commands and built golems get no profile, and a mob with no profile is level 1. Vanilla farms keep + working. +- **Transformations keep the level**: `EntityTransformEvent` copies the profile to what a mob becomes — a drowned zombie, + a slime's children. +- **The level comes from where it spawned** (`MobZones`): distance from the world's spawn picks a ring of the world's + `LevelZone`, and night and depth add to it in the Overworld. `LevelZone` is plain Kotlin, tested in `LevelZoneTest`. +- **Town claims are always level 1**, checked with Towny on every spawn. Towns are safe, and a mob farm inside one works + as it does in vanilla. + +A level grows a mob's pool by `health-growth^(level−1)` and its hits by `damage-growth^(level−1)`, and its dropped +experience by `mobs.xp-per-level` per level. + +### Ranks and affixes + +A wild mob at or above a rank's `min-level` can spawn **elite** or **champion** instead (`MobRoll.rank`, the champion +roll first). A rank multiplies the mob's pool and hits (`DamageMath`'s `multiplier`) and gives it distinct random +affixes (`MobRoll.affixes`). Rank, chances and multipliers are the `ranks` block of `balance.yml`; `mobs.ranks.exempt` +keeps bosses such as the Warden unranked. `MobRoll` is plain Kotlin over a `Random` the caller hands in, tested in +`MobRollTest` with seeds. + +| Affix | Does | +|:-----------|:-------------------------------------------------------------------------| +| Armored | Has Defense | +| Frenzied | Moves faster (an attribute modifier saved with the mob) | +| Vampiric | Heals a share of what it deals to a player | +| Enraged | Hits harder below a share of its health | +| Molten, Frostbound, Venomous | Sets a player it hits on fire, slows them, poisons them | +| Volatile | A telegraphed blast where it died: a hit from the mob, not a real explosion, so no blocks or drops are lost | +| Summoner | Calls minions of its kind once below a share of its health, at its level but unranked | +| Blinking | Steps in behind a target that keeps its distance | +| Warded | Shrugs off projectiles until something strikes it in melee | + +**`AffixEffects` is the only place any of it happens**, and every effect is triggered by a mob, so none of it can reach +a hit between players. What an affix remembers about one mob — a broken ward, minions already called, the last blink — +is in memory and forgotten when the mob unloads, which only ever favours the mob. `ActiveMobs` holds every mob in +loaded chunks that has a rank, a custom kind or an affix (`MobProfile.isActive`), kept in step by `MobListener`'s spawn, +load and unload handlers, and `tasks/mobs/MobTurnTask` gives each one with a player within 32 blocks its turn twice a +second: affix particles and blinking, then a custom mob's abilities. + +A slime's children keep its level but not its rank, or one champion would split into a crowd of them. + +**`MobPower` is the one place a profile becomes numbers**: the multiplier on a mob's pool (rank × kind), on its hits +(rank × kind × Enraged) and its Defense (Armored + its kind's). `CombatHealth.max` and `DamageListener` ask it, so a +new source of toughness goes there and nowhere else. + +**Nameplates** (`MobNameplate`) give a levelled mob a name — `[Lv21] ★ Vampiric Zombie 1,820/2,400❤` — redrawn a +tick after it is hurt or healed. A ranked mob's is always visible, so nobody walks into a champion unwarned. A name is +shared by every viewer, so its frame is rendered in the configured language, and the kind of mob is a +`` tag that each client fills in itself. A normal mob's shows on the crosshair, the way a +named mob's does. Setting a name never makes a mob persistent (only a name tag does), and a mob a player names keeps +their name: `PlayerNameEntityEvent` drops the nameplate. A custom mob's nameplate carries its own name +(`mob.kind-name`) in place of its kind's, rendered in the configured language like the frame, and always shows. + +### Custom mobs + +The server's own mobs are defined in `bestiary.yml` (see [Content Files](#content-files)): a vanilla kind with a name, +a look, traits and loot of its own. `content/MobKindDef` is the model and `BestiaryCatalog` holds them, read by +`ContentParser.bestiary`; `mobs/MobKinds` finds a mob's kind and dresses it, and `mobs/MobSetup` settles any mob TriTown +has just decided about. + +- **A mob stores its kind, never its stats.** `MobProfile.kind` is the id alone. What the kind multiplies its pool and + hits by, and its Defense, are read from the catalog every time through `MobPower`, so `/tritown reload` retunes every + one already out there. Size, speed and footing are attribute modifiers (`tritown:kind-scale`, `-speed`, + `-knockback`), which `MobKinds.refresh` puts on again as a mob loads and on a reload. A mob whose kind has left the + file is an ordinary one at its level. +- **A variant takes the place of a wild spawn of its own kind.** `MobListener.onSpawn` asks `MobRoll.kind` — plain + Kotlin over a `SpawnPlace` (`MobZones.place`: kind of world, world, biome, height, night) and a seeded `Random`, + tested in `MobRollTest` — before the rank roll. Each variant of the kind whose `spawn` rule allows the level and place + rolls its `chance` in file order, and the first to succeed takes the spawn. So a custom mob is only ever a wild one: + never in a town (level 1, below every variant), from a spawner, an egg or a command. `rankable: false` keeps a variant + from also rolling a rank; its fixed `affixes` are written into its profile alongside any its rank rolls. +- **A costume is only a look.** `MobKinds.dress` puts on what `equipment` names — `Gear.costume` for a piece of gear + (its base with its model, trim and dye, and no `GearData`), or a vanilla item — with its `ATTRIBUTE_MODIFIERS` + emptied, so netherite on a mob is no hidden armor and a sword no hidden damage. Every piece has a drop chance of 0 and + a custom mob picks nothing up, so gear only leaves a mob through `MobLoot`. +- **It changes no blocks.** `MobListener` cancels `EntityChangeBlockEvent` for any custom mob, so a Voidstalker carries + nothing off. +- **Transformations keep the kind only if it still fits.** A drowned Gravewalker is an ordinary drowned at the same + level, and a split never keeps a kind. +- **Summoned minions share their summoner's kind**, through `MobSetup.spawn`, which is also what + `/tritown mob spawn [level]` uses. Neither is ever eligible for loot. `mob kinds` lists them all. + +### Abilities + +A custom mob's `abilities` are what it does in a fight, on cooldowns. `mobs/Ability` names them and +`mobs/AbilityEffects` is **the only place any of them happens**; their numbers are the `abilities` block of +`balance.yml` (`Balance.AbilityTuning`: a cooldown, a wind-up, damage, range, radius, seconds, a count and a power for +each, of which each ability reads only what it needs). + +| Ability | Does | +|:-----------|:-----------------------------------------------------------------------------------------| +| Leap | Pounces on a target 4 blocks or more away, and hurts players where it lands | +| Slam | Rears up, then a shockwave hurts and throws up players around it | +| Charge | Lowers its head, then dashes, hitting everyone it runs through once | +| Volley | Looses a fan of arrows that cannot be picked up | +| Fireball | Hurls small fireballs that set no blocks alight | +| Meteor, Storm | Marks spots on and around its target, then brings fire or lightning (an effect only) down on them | +| Ensnare | Spits a web that all but roots its target | +| Hook | Drags a target that keeps its distance in | +| Summon | Calls minions until its cap is alive: its `minions` kind, or its own | +| Bulwark | Once hurt, braces for far more Defense for a few seconds (`MobPower.defense` asks) | +| Frost Nova | A burst that hurts and slows everyone close by | +| Miasma | A poisonous cloud where its target stood, hurting twice a second | +| Drain | A tether that hurts its target twice a second and heals the mob by a share of it | + +- **It only ever hurts players, and only as the mob.** Every hit is `player.damage` with a `DamageSource` the mob + caused, so `DamageListener.hurtPlayer` grows it by the mob's level, rank and kind and takes the player's Defense off + it like any of its hits, and the mob's affixes answer it. Ability damage is in level-1 vanilla units — a zombie's hit + is 3. Nothing an ability does can reach a hit between players, and only players in survival or adventure are hit. +- **It changes no blocks.** Fireballs are not incendiary, lightning is `strikeLightningEffect`, and meteors are + particles. +- **It always warns first.** `windUp` shows particles and sound for `windup-ticks` (holding the mob in place for the + close-quarters ones) before it lands, and a mob winding one up starts no other. `MobTurnTask` offers a mob a turn + twice a second; it picks at random among its abilities that are off cooldown and can reach its target — a distance + window, line of sight, its minion cap — and waits `global-cooldown-ticks` before the next. +- **What it remembers is in memory**: cooldowns, a Bulwark's end, the minions it has called. `forget` clears it when + the mob dies or unloads. + +### Bosses + +A boss is a custom mob under `bosses:` in `bestiary.yml`: everything a variant has but `spawn`, plus a `BossDef` — a +fixed `level`, the `sigil` that summons it, the `place` it may be summoned (worlds, water, a highest Y), an `arena`, a +bar colour and `phases`. They live in `mobs/boss/`. + +- **Summoned on purpose.** A sigil is a content item; `listeners/mobs/BossListener` takes a right-click with one in the + main hand to `BossSummons.summon`. It keeps `ShopTrade`'s order: **everything that can refuse is asked first** — + `mobs.bosses.enabled`, combat on in the world, not a town claim, the boss's `place`, no other boss or ritual within + two arenas, and room for the boss's scaled height (measured on an entity made with `createEntity`, never added) — + and only then is one sigil taken from the hand. After `ritual-seconds` the boss rises a few blocks ahead; if it + cannot, the sigil is handed back. It is **eligible**, at its own level whatever the ring, glowing, and + `removeWhenFarAway = false`; its profile and home (`tritown:boss-home`) are set in the spawn consumer, before + anything sees it arrive. +- **`BossFights` runs every fight**, from `MobTurnTask` twice a second whether or not anyone is near: a boss bar per + viewer in their own language (`boss.bar`), a boss pulled past `arena + arena-margin` taken home, a boss with no one to + fight turned to the nearest player in its arena (which keeps a spider or an enderman hostile), phases entered, and a + boss nobody has been in the arena of for `idle-seconds` sent away. It takes a fight up again as the boss loads + (`adopt`), without announcing phases it has already passed, and lets it go as it unloads or dies (`release`). +- **Phases** are read from health: below a phase's `below` percent, its abilities join the boss's + (`AbilityEffects.abilities`), and the first time, its line (`mob.kind..phase-N`) is announced, its minions are + called (`MobSetup.minions`) and its affixes are written into the boss's profile. +- **Only TriTown moves a boss.** `BossListener` cancels a boss's own teleports (an enderman's) and portals; the moves + TriTown makes — taking it home, Blinking — go through `MobSetup.teleport`, which lets them through. +- **Loot is personal.** `MobListener.onDeath` hands a boss to `BossFights.defeated` instead of dropping anything. + `MobLoot.bossRewards` rolls its `loot` once for each player whose share of the `DamageLedger` is at least + `contributor-share`, with their own Magic Find, gear at `gear-odds` (mythic among them), plus a `gear-chance` of a + random craftable piece of its tier — as long as a player killed it and players dealt `player-share` between them, as + for any mob. A share is dropped where the boss fell as its owner's alone (`ItemOwnership.dropFor(player, stack, at)`) + for a contributor within `loot-range`, or handed over with `InventoryUtil.give`. Only players online are given + anything. Those who took part without earning a share are told so. +- **Signature gear** is a piece with no recipe: the Forge never crafts it and the random champion drop never picks it, + so only the loot that names it drops it. + +`/tritown mob spawn ` calls a boss up at its level, not eligible, for testing; `/tritown mob bosses` prints each +boss against one player in a kit of its tier, reading its kind's vanilla health and hit off an entity made with +`createEntity`. + +### The bestiary + +`mobs/BestiaryRecords` keeps what each player has found of the custom mobs in their player data, under `bestiary`: per +kind id, the kills and the ids of every drop it gave them (an item's id, or `gear:` and a piece's). `MobListener.onDeath` +credits the killer of an eligible custom mob with its drops; `BossFights.defeated` credits every contributor to a boss, +those short of a share with no drops. + +`guis/bestiary/BestiaryGUI` (`/tritown bestiary`, no permission node, or the main menu's **Bestiary** button, shown +where combat is on) lists every kind, variants and then bosses. One the viewer has not slain is a blank page that says +where it lives or how it is summoned; one they have shows its description, where it lives or its level and sigil, its +abilities (a boss's phases' too) and affixes, the kills — a star at 10, 100 and 1,000 — and which of its own `loot` +lines they have found. It reads and never acts. Abilities are named by `Ability.key` (`mob.ability.*`). + +### Loot + +A wild mob drops the materials and essence `mobs.yml` gives it (see [Content Files](#content-files)) on top of vanilla's +loot, through `MobLoot.dropsFor`, when all of these hold: + +1. it is **eligible**: its profile says it spawned in the wild. A summoner's minions, and anything from a spawner, are + not; +2. a **player killed it**; +3. **players dealt enough of it**: the `DamageLedger` has at least `player-share` percent of its pool credited to + players, a tamed pet's hits counting for its owner. `DamageListener.afterDamage` credits each hit, capped at the + health the mob had left. So a trap, a lava pit or another mob doing the work earns nothing; +4. it is at least `min-level`. + +`LootRoller` rolls the drops: the family's material at the rank's chance — a normal mob's multiplied by the killer's +Magic Find, up to `magic-find-cap` — and, for a ranked mob, essence of the grade its level falls in. A champion also has +a `gear-chance` of a finished piece of gear (`LootRoller.dropsGear`, Magic Find helping), of the tier its level is made +for and a rarity from `drop-odds` — but only a piece with a recipe, since one without is a custom mob's own. A custom +mob rolls its own `loot` lines on top (`LootRoller.rollEntries`), each on its own and each boosted by Magic Find: items +from `items.yml`, or a piece of gear at a rarity from `drop-odds`. A boss drops nothing here: its loot is personal (see +[Bosses](#bosses)). It is plain Kotlin over a seeded `Random`, tested in `LootRollerTest`; the ledger is tested in +`DamageLedgerTest`. + +The drops go into `EntityDeathEvent.getDrops()`, so item protection's death window hands them to the killer like the +rest of the mob's loot, and nothing new had to be taught to it. **Mobs never drop money**: materials are sold to the +server's shops, so every faucet stays one the owner prices. + +### Content items + +Materials, essence, trophies and sigils are *content items*, defined in `items.yml` and made by `ContentItems.create`. +Every one is an **echo shard** wearing another vanilla item's look through the `item_model` component, and carrying its +id under `PluginItem.ITEM_ID_KEY`. The one use TriTown gives any of them is a sigil's, right-clicked to summon its boss +(see [Bosses](#bosses)). An echo shard's only use is crafting a recovery compass, which +`listeners/items/ContentItemListener` refuses in a crafting grid and a crafter — so a content item can't be placed, +eaten, smelted, brewed, traded to a villager or used as what it looks like. Pick nothing else as the base without +re-checking every use it has. + +Its name and lore (`item..name`, `item..lore`) are written in the item language with `Lang.item`, and its name +takes its `Rarity`'s colour, so every copy is identical and stacks. It carries a stamp of its definition and the item +language under `tritown:revision`, and `ContentItems.refresh` redraws a copy drawn from older ones (see +[Item redraws](#item-redraws)). Text a single player reads about one — a price, a salvage line — names it in *their* +language through `GearText.item`. `ContentItems.idOf` recognises one even after its definition is gone, so it stays as +inert as the day it dropped. + +`/tritown item give [amount]` and `item list` (`tritown.item.admin`, `commands/items/ItemCommand`) hand +them out. + +### Gear + +Gear is defined in `gear.yml` (see [Content Files](#content-files)) and lives in `gear/`, outside the scan. A piece is a +stack of its definition's `base` item carrying a `GearData` under `tritown:gear` (`GearCodec`): its id, rarity, stars, +the quality each stat rolled, its reforge, and the content stamp it was drawn with. **Never its stats.** + +- **Stats are worked out, never stored.** `GearStats.of` turns a definition and its data into a `StatSheet` every + time: the slot's points (`gear.slot-points`), shared by the definition's weights, each through its stat's curve + (`per-100 × growth^(tier−1)`), then scaled by rarity, stars and roll, plus a reforge's share. So retuning + `gear.yml` or `balance.yml` reaches every piece already out there. +- **Every piece of a slot and tier is worth the same.** A definition lists weights, not numbers, and the budget does the + rest, so no author can make a piece stronger than its tier. `GearStatsTest` holds this, and holds a mythic piece of + a tier weaker than a rare one of the next: the tier is the progression, rarity the chase. +- **Drawn in the item language.** `GearRender` writes the name (reforge first), a line per stat, the flavour line, + what the piece is between players, and its stars, rarity and slot — every word from `Lang.item`, every number plain + text, and only the base item's name a vanilla translation key the client fills in. It also sets the look + (`item_model`, trim, dye), the glint from epic up, and unbreakable: gear never wears out, so the sinks are the + Forge's. +- **Redrawn when stale.** `GearData.revision` stamps the content a piece was drawn from (`GearCatalog.revision`, built + from text so it is the same from one start to the next) together with `Lang.itemRevision`. `Gear.refresh` redraws a + piece whose stamp is out of date, and costs one read when it is not (see [Item redraws](#item-redraws)). Drawing + works on the stack in place and leaves enchantments and anvil names alone. +- **Between players, a piece is its base item.** Its own vanilla armor and attack damage never count as Defense or + Damage — `StatSources` uses its gear stats instead — so they only matter in PvP, where the piece is exactly the + vanilla item it is made of. + +`Gear` is the one entry point: `create` (fresh rolls), `preview` (common, average rolls, for the Forge's list), +`read`, `stats`, `save` and `refresh`. Bows and crossbows are the `bow` slot: their Damage only counts for what they +shoot (`Gear.isRanged`), and an arrow's worth is `DamageMath.shotMultiplier` of the bow's Damage — a vanilla bow counts +as `vanilla.bow-attack`, so it shoots like an iron sword swings. A thrown trident is measured against its own melee +hit. + +`/tritown item give [rarity]` hands out a piece of gear as well as an item (`commands/items/ItemCommand`). + +### Item redraws + +Paper renders server-side translations for each player in chat, titles, boss bars and entity names, but **never inside +an item**: an item's network encoding switches it off. So gear and content items are written in one language — the +item language, `item-language` in `config.yml`, which follows `language` when `auto` and is English when that is `auto` +too — and read with `Lang.item(key)`. `Lang.itemRevision` stamps that language's text, and each item stamps it along +with its own content, so an edit to a content file, a language file or either setting leaves every copy out there +stale. + +`content/ItemRedraw` is the one place that brings them back into step: `refresh(stack)` asks `Gear.refresh`, then +`ContentItems.refresh`, and costs one read when nothing changed. It runs: + +- from `listeners/items/ItemRefreshListener` as a player joins, opens an inventory (the one they open and their own), + picks something up, takes a piece in hand or puts one on; +- on `/tritown reload`, over everyone online; +- from `ShopManager.redrawItems`, over every shop's goods and price items, on enable and on reload — otherwise a shop + would hand out stale copies that do not stack with fresh ones, and stop recognising the fresh ones it buys back. + +Anything new that keeps its own copy of a TriTown item, as the shops do, redraws it the same way. + +### The Forge + +`/tritown forge` (`commands/adventure/ForgeCommand`, no permission node) and the main menu's **Forge** button open +`guis/forge/ForgeGUI`. With nothing in hand it offers crafting; with a piece in the main hand it offers what that piece +can still take — an action it cannot, a sixth star, a rarity above the cap, is left out rather than greyed. + +| Action | What it does | Costs | +|:--------|:-----------------------------------------------------------|:--------------------------------------------------------| +| Craft | Makes a piece from its recipe, at a rarity from `craft-odds` | The recipe's items and money | +| Upgrade | Adds a star: `star-bonus` percent more base stats | Essence × the star's number, money × the star's number | +| Refine | Raises its rarity one step, no higher than `refine-cap` | Essence and money × the step's number | +| Reforge | Gives it a new reforge, never the one it has if another fits | Essence and money | +| Salvage | Breaks it down for good | Nothing; gives back essence for its rarity and stars | + +`gear/Forge` does all of it, and nothing else makes or changes gear. It keeps `ShopTrade`'s order: **everything that +can refuse is asked first** (the items are counted, the economy is there), then the money is charged, and only once it +has gone are the items taken and the piece made or changed — all in one tick, so nothing can vanish in between. The +piece being changed is re-read from the main hand at that moment; no TriTown menu lets the hand change underneath it. +Every action asks first through `ConfirmGUI` and reports through `ForgeRender.announce`. + +`ForgeCosts` works out every cost, plain Kotlin tested in `ForgeCostsTest`. Money grows by `money-growth` per tier. +Essence is the grade a mob of the piece's tier drops (`essenceFor(6 × tier)`), so upgrading gear means fighting at its +tier. **Mythic is never crafted or refined**: it only ever drops. + +Money moves as `EconomyContext.SOURCE_GEAR` with the reasons `money.reason.gear-craft`, `-upgrade`, `-refine` and +`-reforge`, all filed under `FlowCategory.GEAR`, so the admin panel shows the Forge as the sink it is. + +### Balance + +Every number is in `plugins/TriTown/content/balance.yml` (see [Content Files](#content-files)); nothing is tuned in +Kotlin. `DamageMath` holds every formula and is the only place one lives — a listener only turns an event into a call +and the result back into vanilla damage — so the whole model runs in tests: + +- `DamageMathTest` covers each formula. +- `BalanceSimulationTest` holds the bundled tuning to the design: **the vanilla anchor** (at level 1 with iron, diamond + or netherite, a zombie dies in vanilla's number of swings and hits for vanilla's share, ±15%), the default crits + adding no more than 15% to an average swing, every level being harder than the last, and vanilla diamond being + outclassed by the Overworld's cap. +- It holds gear to the same design through `BalanceSimulator.gearRow`, a rare three-star kit of the plainest shape: + **on level** (tier T against level 6T) a zombie dies in 3–5 swings and each of its hits takes 3–8% of health, an + elite takes 12–20 swings, **a tier behind** needs no more than 2.5 times the swings (a mob's health about doubles + every tier, so a tier of gear is worth about double), and **two tiers ahead** a zombie dies in two. + +- `BossBalanceTest` holds every bundled boss to a group fight (`BalanceSimulator.bossRow`): one player in a rare + three-star kit of the boss's tier needs 60–200 swings to bring it down, and each of its hits takes 10–30% of their + health. + +`BalanceSimulator` is the same model as a table, and `/tritown mob balance` prints the on-level gear table — and +`/tritown mob bosses` each boss — so an owner can see what an edit does before anyone fights. A change that breaks +`BalanceSimulationTest` or `BossBalanceTest` changes how the game feels, and has to be a decision. + +### The HUD + +`CombatHudTask` redraws each fighting player's health and Defense on the action bar twice a second +(`combat.hud.action-bar`). Towny's own notices can use the same line. + +`DamageIndicators` are text displays that live under a second. They are never persistent, so a crash or an unloading +chunk leaves nothing behind, `Main.onDisable` removes the rest, and `combat.hud.indicator-limit` caps how many exist at +once. + +The sidebar gets `%health%`, `%max_health%`, `%defense%`, `%mob_level%` and `%danger%` (`CombatPlaceholders`). +`%danger%` returns a whole translated fragment (`scoreboard.danger`), or nothing where combat is off, so the default +wilderness line can end with it and still read naturally without it. + +### Settings + +| Key | What it does | +|:-----------------------------|:---------------------------------------------------------------------| +| `combat.enabled` | Everything above | +| `combat.disabled-worlds` | Worlds, by name, where hits stay vanilla and mobs are not levelled | +| `combat.hud.*` | The action bar, the damage indicators and their limit | +| `combat.speed-cap` | The most Speed, in percent, that makes a player faster | +| `mobs.levels.*` | Night and depth bonuses, each kind of world's rings, and per-world ones | +| `mobs.xp-per-level` | Extra experience per level above 1 | +| `mobs.nameplates` | Whether levelled mobs wear a nameplate | +| `mobs.custom.enabled` | Whether the custom mobs of `bestiary.yml` take the place of wild spawns | +| `mobs.bosses.enabled` | Whether sigils summon their bosses | +| `mobs.bosses.announce-range` | How near a player must be to hear of a boss summoned, fallen or gone | + +`CombatSettings` and `MobSettings` are snapshots swapped in whole on a reload. + +## Content Files + +The combat layer is tuned in files of its own rather than in `config.yml`: `config.yml` says what runs where, and the +content files say what it is worth. They live in `src/main/resources/content/`, are copied to +`plugins/TriTown/content/` on first start the way the language files are, and `/tritown reload` reads them again. + +| File | Holds | +|:---------------|:------------------------------------------------------------------------------------------| +| `balance.yml` | The lens, player base stats, the jump crit, effect bonuses, mob growth, vanilla gear's worth, ranks, affixes and abilities, gear's budget and the Forge's costs | +| `items.yml` | Content items: each id's look (`model`), `rarity` and `glint` | +| `mobs.yml` | Mob families and their material, the loot rules per rank, and the essence grades | +| `gear.yml` | Gear: each piece's slot, tier, base, look, weights and recipe; and reforges | +| `bestiary.yml` | Custom mobs: each one's base kind, where it spawns, its traits, costume and loot | + +`ContentRegistry` loads them with SnakeYAML into immutable snapshots and swaps each in whole. Parsing is plain Kotlin +over the parsed map (`BalanceParser`, `ContentParser`, both reading through `YamlReader`), so the bundled files are +checked in tests: + +- `BalanceParserTest` requires `balance.yml` to parse to exactly `Balance.DEFAULT` with no warnings, so the defaults in + Kotlin and the file can never disagree. +- `ContentFilesTest` requires `items.yml`, `mobs.yml`, `gear.yml` and `bestiary.yml` to parse without a warning, + every item, piece of gear, reforge and custom mob to be named in every language, no language to translate one that + does not exist, every piece a tier from 1 to 10 and a recipe unless a custom mob drops it (signature gear, of its + boss's tier), every variant a chance to appear, and every boss phases and a sigil some variant drops. +- `BestiaryParserTest` covers what `bestiary.yml` can get wrong. + +What only the server can check — that a piece's `base` is worn where its slot says — leaves the piece out and says so. + +What only the server can check — that a `model` is a real item, that a family's mob is a real kind, that a custom +mob's base is a mob that can be spawned (or it is left out), that its biomes and costume items exist — is checked as +the files load, and warned about in the console. + +- A value that is missing takes the default quietly; one that is present but unusable takes it too, and the console + names it. +- A file that cannot be read at all keeps what was loaded before — the defaults, the first time — so a typo made while + the server runs never takes combat down with it. + ## Sidebar The sidebar lives in `net.trilleo.mc.plugins.tritown.scoreboard`, which — like `economy` — is **not** one of the @@ -3435,7 +3913,7 @@ IN_CAPITAL -> context.plotTown?.isCapital == true ### Placeholders A line is **translated first and substituted second**, so a translator can move a value to wherever it reads best. -`PlaceholderEngine` holds the `%marker%` resolvers; the four `placeholders/` objects register them when the service +`PlaceholderEngine` holds the `%marker%` resolvers; the five `placeholders/` objects register them when the service starts. An unknown marker is left on screen as written, so a typo shows up instead of silently blanking a value, and a resolver that throws falls back to `common.none` rather than taking the whole sidebar down. @@ -3447,6 +3925,7 @@ PlaceholderEngine.register("town_plot_price") { context -> Every value a resolver returns must already be escaped — the line it lands in is parsed as MiniMessage afterwards. Use `TownyUtil.name` / `TownyUtil.text` for anything player-written. +The one exception is `%danger%`, which returns a whole translated fragment of TriTown's own (see [Combat](#the-hud)). ### Rendering and cost diff --git a/docs/UTILITY_GUIDE.md b/docs/UTILITY_GUIDE.md index ec871c8..56e45e3 100644 --- a/docs/UTILITY_GUIDE.md +++ b/docs/UTILITY_GUIDE.md @@ -9,7 +9,7 @@ reduce boilerplate and provide commonly needed functionality out of the box. | `CountdownUtil` | Per-player countdown with configurable display and sound | | `TeamUtil` | Custom team management with server-data persistence | | `TagUtil` | Per-player string tag management with player-data persistence | -| `Lang` | Translations: per-player language files and the `tr()` helper | +| `Lang` | Translations: per-player language files, `tr()`, and the item language | | `MessageUtil` | Prefix-decorated message sender for any command sender | | `ChatPrompt` | Asks a player a question in chat and hands the answer back | | `EconomyUtil` | Economy access: balances, withdrawals, deposits, transfers, formatting | @@ -374,9 +374,11 @@ has to follow. | Method | Description | |:-------------------------------------|:-----------------------------------------------------------------------------------------------------| -| `Lang.load(plugin, language)` | Copies the bundled files to `plugins/TriTown/lang/` if missing and loads every language file. | +| `Lang.load(plugin, language, items)` | Copies the bundled files to `plugins/TriTown/lang/` if missing and loads every language file. | | `Lang.tr(sender, key, vararg args)` | The translation of `key` for `sender`, with `{name}` placeholders filled; the key itself if missing. | | `Lang.find(sender, key)` | The raw translation, or `null` when no language defines `key` (for runtime-built keys). | +| `Lang.item(key)` | The translation of `key` in the item language, for text drawn on an item; the key itself if missing. | +| `Lang.itemRevision` | A stamp of the item language's text, stable across restarts, that changes when item text could. | | `Lang.idFor(sender)` | The id of the language `sender` reads, as `tr` picks it, for text kept outside the language files. | | `Lang.ids` | Ids of every loaded language file (`en_US`, `zh_CN`, and any the server owner added). | | `CommandSender.tr(key, vararg args)` | Extension shorthand for `Lang.tr(this, key, *args)`. | @@ -398,6 +400,27 @@ with `MiniMessage.miniMessage().escapeTags(...)` before passing it. --- +### The item language + +An item is stored once but seen by everyone who holds it, trades for it or looks at it in a shop, and Paper never +translates anything inside an item for each player — its network encoding switches server-side translation off. So +text on an item is written in one language, the one `item-language` in `config.yml` names (`auto` follows `language`, +and English when that is `auto` too), through `Lang.item`: + +```kotlin +stack.setData(DataComponentTypes.ITEM_NAME, ComponentUtil.parse(Lang.item("item.grave-dust.name")).applyFallbackStyle(NamedTextColor.WHITE)) +``` + +- **Every copy is identical**, so they stack wherever they came from. Never write a single player's `tr` into a stack + that can reach another player. +- **Stamp what you drew with `Lang.itemRevision`**, and redraw when it changes: gear and content items do this through + `ItemRedraw` (see [Item redraws](DEVELOPER_GUIDE.md#item-redraws)). +- **Keep such text free of arguments.** Compose a line from components instead — a translated label followed by a + plain number — rather than filling `{placeholders}`. +- A vanilla key such as the base item's name can still be a `Component.translatable`: the client knows those itself. + +--- + ## MessageUtil `MessageUtil` sends prefix-decorated messages to any `CommandSender`. The prefix is read from `config.yml` under the diff --git a/gradle.properties b/gradle.properties index c35acf0..e8a0b50 100644 --- a/gradle.properties +++ b/gradle.properties @@ -1,7 +1,7 @@ kotlin.code.style=official # Plugin Properties -plugin_version=1.4.0 +plugin_version=1.5.0 # Dependency Versions towny_version=0.103.2.7 diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt index a66b908..f7c0dec 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/Main.kt @@ -2,7 +2,12 @@ package net.trilleo.mc.plugins.tritown import com.palmergames.bukkit.towny.TownyEconomyHandler import net.milkbowl.vault.economy.Economy +import net.trilleo.mc.plugins.tritown.combat.DamageIndicators +import net.trilleo.mc.plugins.tritown.combat.PlayerStats +import net.trilleo.mc.plugins.tritown.combat.SpeedSync import net.trilleo.mc.plugins.tritown.config.* +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.content.ItemRedraw import net.trilleo.mc.plugins.tritown.data.PlayerDataManager import net.trilleo.mc.plugins.tritown.data.ServerDataManager import net.trilleo.mc.plugins.tritown.economy.* @@ -13,6 +18,8 @@ import net.trilleo.mc.plugins.tritown.economy.vault.VaultRegistration import net.trilleo.mc.plugins.tritown.enums.ProviderMode import net.trilleo.mc.plugins.tritown.guis.storage.StorageGUI import net.trilleo.mc.plugins.tritown.menu.MenuItem +import net.trilleo.mc.plugins.tritown.mobs.ActiveMobs +import net.trilleo.mc.plugins.tritown.mobs.MobKinds import net.trilleo.mc.plugins.tritown.news.NewsManager import net.trilleo.mc.plugins.tritown.news.storage.JsonNewsStorage import net.trilleo.mc.plugins.tritown.registration.* @@ -50,7 +57,7 @@ class Main : JavaPlugin() { override fun onLoad() { instance = this pluginConfig = PluginConfig(this) - Lang.load(this, pluginConfig.language) + Lang.load(this, pluginConfig.language, pluginConfig.itemLanguage) val settings = EconomySettings.load(pluginConfig) if (!settings.enabled) { @@ -112,6 +119,12 @@ class Main : JavaPlugin() { ProtectionSettings.load(pluginConfig) MainMenuSettings.load(pluginConfig, logger) + ContentRegistry.load(this) + CombatSettings.load(pluginConfig) + MobSettings.load(pluginConfig) + // Once the content is loaded, since a shop's copy of an item is redrawn from it. + ShopManager.redrawItems() + // Before the registrars, like the shops: the main menu reads a player's storage as it is drawn. StorageSettings.load(pluginConfig) if (StorageSettings.snapshot.enabled) StorageManager.start(JsonStorageStore(dataFolder, logger), logger) @@ -142,7 +155,7 @@ class Main : JavaPlugin() { fun reload() { pluginConfig.reload() MessageUtil.init(pluginConfig.messagePrefix) - Lang.load(this, pluginConfig.language) + Lang.load(this, pluginConfig.language, pluginConfig.itemLanguage) val settings = EconomySettings.load(pluginConfig) if (settings.enabled) { @@ -164,6 +177,19 @@ class Main : JavaPlugin() { TradeSettings.load(pluginConfig) TownSettings.load(pluginConfig) ProtectionSettings.load(pluginConfig) + + ContentRegistry.load(this) + CombatSettings.load(pluginConfig) + MobSettings.load(pluginConfig) + // The balance, or what a stat is worth, may have changed under every cached sheet, and + // under every piece of gear anyone is carrying; the content or the item language, under + // every item TriTown drew. + PlayerStats.invalidateAll() + server.onlinePlayers.forEach { player -> ItemRedraw.refreshAll(player.inventory) } + ShopManager.redrawItems() + // A custom mob's size and speed are attribute modifiers, which only change when they are put on again. + ActiveMobs.all().filter { it.isValid }.forEach(MobKinds::refresh) + // Only the settings, as with the shops: the storage files are never re-read while players hold them open. StorageSettings.load(pluginConfig) // Only the settings, as with the shops: every change to a post is already on disk. @@ -194,6 +220,8 @@ class Main : JavaPlugin() { // So no menu item is saved into an inventory and left behind once TriTown is gone. MenuItem.stripAll() + DamageIndicators.clearAll() + server.onlinePlayers.forEach(SpeedSync::clear) ShopManager.shutdown() diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/BalanceSimulator.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/BalanceSimulator.kt new file mode 100644 index 0000000..49f4979 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/BalanceSimulator.kt @@ -0,0 +1,136 @@ +package net.trilleo.mc.plugins.tritown.combat + +import net.trilleo.mc.plugins.tritown.content.Balance +import net.trilleo.mc.plugins.tritown.content.GearDef +import net.trilleo.mc.plugins.tritown.content.GearSlot +import net.trilleo.mc.plugins.tritown.content.Rarity +import net.trilleo.mc.plugins.tritown.gear.GearData +import net.trilleo.mc.plugins.tritown.gear.GearStats +import kotlin.math.ceil + +/** + * What a fight at a given level looks like, worked out with [DamageMath] + * alone. + * + * `/tritown mob balance` prints it, so an owner can see what an edit to + * `balance.yml` does before anyone fights, and `BalanceSimulationTest` holds + * the bundled tuning to the design's targets with the same numbers. + */ +object BalanceSimulator { + + /** A full set of vanilla gear, by what its items add: a sword's attack damage, the set's armor and toughness. */ + data class Kit(val attackDamage: Double, val armor: Double, val toughness: Double) { + + companion object { + val IRON = Kit(attackDamage = 5.0, armor = 15.0, toughness = 0.0) + val DIAMOND = Kit(attackDamage = 6.0, armor = 20.0, toughness = 8.0) + val NETHERITE = Kit(attackDamage = 7.0, armor = 20.0, toughness = 12.0) + } + } + + /** A vanilla mob, by its health and the hit it lands on normal difficulty. */ + data class Foe(val health: Double, val hit: Double) { + + companion object { + val ZOMBIE = Foe(health = 20.0, hit = 3.0) + } + } + + /** + * One level of the table. + * + * @param hitsToKill uncritical swings the kit's sword needs to kill the foe + * @param shareTaken the part of the player's health one of the foe's hits takes through the kit's armor + */ + data class Row( + val level: Int, + val foeHealth: Double, + val foeHit: Double, + val hitsToKill: Int, + val shareTaken: Double + ) + + fun row(balance: Balance, level: Int, kit: Kit, foe: Foe = Foe.ZOMBIE): Row { + val sheet = StatSheet.of( + Stat.HEALTH to balance.player.health, + Stat.DAMAGE to kit.attackDamage * balance.lens, + Stat.DEFENSE to kit.armor * balance.vanilla.armorPoint + kit.toughness * balance.vanilla.toughnessPoint, + ) + return fight(balance, level, sheet, foe) + } + + /** + * The same table for a player in a full kit of TriTown gear of [tier]: a + * weapon spending its points on Damage and Strength, and armor spending + * its on Health and Defense — the plainest shape gear takes, at [rarity] + * and [stars], every stat at its average roll. + */ + fun gearRow( + balance: Balance, + level: Int, + tier: Int, + rarity: Rarity = Rarity.RARE, + stars: Int = 3, + foe: Foe = Foe.ZOMBIE, + ): Row = fight(balance, level, kit(balance, tier, rarity, stars), foe) + + /** A full kit of the plainest gear of [tier], with the base stats under it. */ + private fun kit(balance: Balance, tier: Int, rarity: Rarity, stars: Int): StatSheet { + val gear = listOf( + reference(GearSlot.WEAPON, tier, Stat.DAMAGE to 0.6, Stat.STRENGTH to 0.4), + reference(GearSlot.HELMET, tier, Stat.HEALTH to 0.5, Stat.DEFENSE to 0.5), + reference(GearSlot.CHESTPLATE, tier, Stat.HEALTH to 0.5, Stat.DEFENSE to 0.5), + reference(GearSlot.LEGGINGS, tier, Stat.HEALTH to 0.5, Stat.DEFENSE to 0.5), + reference(GearSlot.BOOTS, tier, Stat.HEALTH to 0.5, Stat.DEFENSE to 0.5), + ).map { def -> + GearStats.of(def, GearData(def.id, rarity, stars, emptyMap(), null, 0), balance.gear, null) + } + return gear.fold(StatSheet.of(Stat.HEALTH to balance.player.health), StatSheet::plus) + } + + /** + * A boss of [foe]'s kind at [level] — its pool and hits multiplied by + * [health] and [damage], and with [defense] against every swing — against + * one player in the same kit of [tier] as [gearRow]. + */ + fun bossRow( + balance: Balance, + level: Int, + tier: Int, + foe: Foe, + health: Double, + damage: Double, + defense: Double + ): Row = + fight(balance, level, kit(balance, tier, Rarity.RARE, 3), foe, health, damage, defense) + + private fun reference(slot: GearSlot, tier: Int, vararg weights: Pair): GearDef = + GearDef("reference", slot, tier, "", null, null, null, weights.toMap(), null) + + private fun fight( + balance: Balance, + level: Int, + sheet: StatSheet, + foe: Foe, + health: Double = 1.0, + damage: Double = 1.0, + defense: Double = 0.0, + ): Row { + val foeHealth = DamageMath.mobMaxHealth(foe.health, balance, level, health) + val foeHit = DamageMath.mobHit(foe.hit, balance, level, damage) + val swing = + DamageMath.afterDefense(DamageMath.meleeHit(sheet, balance, vanillaShare = 1.0, crit = false), defense) + val taken = DamageMath.afterDefense(foeHit, sheet[Stat.DEFENSE]) + + return Row( + level = level, + foeHealth = foeHealth, + foeHit = foeHit, + hitsToKill = ceil(foeHealth / swing - EPSILON).toInt(), + shareTaken = taken / sheet[Stat.HEALTH], + ) + } + + /** Guards `ceil` against a hit that divides a pool exactly landing a hair over. */ + private const val EPSILON = 1e-9 +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/Combat.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/Combat.kt new file mode 100644 index 0000000..5d2bc45 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/Combat.kt @@ -0,0 +1,14 @@ +package net.trilleo.mc.plugins.tritown.combat + +import net.trilleo.mc.plugins.tritown.config.CombatSettings +import org.bukkit.World + +object Combat { + + /** Whether hits in [world] are worked out in RPG numbers. Every combat handler asks this first. */ + fun isActive(world: World): Boolean { + if (!CombatSettings.isLoaded) return false + val settings = CombatSettings.snapshot + return settings.enabled && world.name.lowercase() !in settings.disabledWorlds + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/CombatFormat.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/CombatFormat.kt new file mode 100644 index 0000000..8b17405 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/CombatFormat.kt @@ -0,0 +1,16 @@ +package net.trilleo.mc.plugins.tritown.combat + +import java.util.* +import kotlin.math.ceil +import kotlin.math.roundToLong + +/** How combat numbers are written: whole, with thousands grouped, the way they read at a glance. */ +object CombatFormat { + + fun number(value: Double): String = String.format(Locale.ROOT, "%,d", value.roundToLong()) + + /** Health still left, rounded up, so anything alive never reads as 0. */ + fun health(value: Double): String = String.format(Locale.ROOT, "%,d", ceil(value).toLong()) + + fun stat(stat: Stat, value: Double): String = if (stat.percent) "${number(value)}%" else number(value) +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/CombatHealth.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/CombatHealth.kt new file mode 100644 index 0000000..073e8fb --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/CombatHealth.kt @@ -0,0 +1,38 @@ +package net.trilleo.mc.plugins.tritown.combat + +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.mobs.MobPower +import net.trilleo.mc.plugins.tritown.mobs.MobProfiles +import org.bukkit.attribute.Attribute +import org.bukkit.entity.LivingEntity +import org.bukkit.entity.Player + +/** + * The RPG health pool drawn over an entity's vanilla health. + * + * **Vanilla health is the truth.** Nothing here is stored: the pool is always + * as full as the entity's vanilla health is, so regeneration, potions, totems, + * `/kill` and death need nothing from TriTown, and the two can never disagree. + * Neither a player's nor a mob's vanilla maximum is ever changed. + */ +object CombatHealth { + + fun vanillaMax(entity: LivingEntity): Double = + entity.getAttribute(Attribute.MAX_HEALTH)?.value?.takeIf { it > 0.0 } ?: DamageMath.VANILLA_PLAYER_HEALTH + + fun max(entity: LivingEntity): Double { + if (entity is Player) return DamageMath.playerMaxHealth( + PlayerStats.sheet(entity)[Stat.HEALTH], + vanillaMax(entity) + ) + val balance = ContentRegistry.balance + val profile = MobProfiles.of(entity) + return DamageMath.mobMaxHealth(vanillaMax(entity), balance, profile.level, MobPower.health(profile, balance)) + } + + /** How full the pool is, from 0 to 1 — the same as the entity's hearts. */ + fun fraction(entity: LivingEntity): Double = (entity.health / vanillaMax(entity)).coerceIn(0.0, 1.0) + + fun current(entity: LivingEntity): Double = + DamageMath.toRpg(entity.health, max(entity), vanillaMax(entity)).coerceAtLeast(0.0) +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/CombatHud.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/CombatHud.kt new file mode 100644 index 0000000..9f97762 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/CombatHud.kt @@ -0,0 +1,19 @@ +package net.trilleo.mc.plugins.tritown.combat + +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.entity.Player + +/** A player's RPG health and Defense above their hotbar. */ +object CombatHud { + + fun show(player: Player) { + val text = player.tr( + "combat.hud.bar", + "health" to CombatFormat.health(CombatHealth.current(player)), + "max" to CombatFormat.number(CombatHealth.max(player)), + "defense" to CombatFormat.number(PlayerStats.sheet(player)[Stat.DEFENSE]), + ) + player.sendActionBar(ComponentUtil.parse(text)) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/DamageIndicators.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/DamageIndicators.kt new file mode 100644 index 0000000..9e3f66c --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/DamageIndicators.kt @@ -0,0 +1,71 @@ +package net.trilleo.mc.plugins.tritown.combat + +import net.trilleo.mc.plugins.tritown.Main +import net.trilleo.mc.plugins.tritown.config.CombatSettings +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.Lang +import org.bukkit.Bukkit +import org.bukkit.Color +import org.bukkit.entity.Display +import org.bukkit.entity.LivingEntity +import org.bukkit.entity.TextDisplay +import org.bukkit.util.Vector +import java.util.concurrent.ThreadLocalRandom + +/** + * The damage a hit did, floating up from the mob it hit. + * + * Each is a text display that lives for under a second. It is never saved, so + * one left behind by a crash or a chunk unloading mid-float is simply gone, + * and [clearAll] takes the rest down as TriTown stops. At most + * `combat.hud.indicator-limit` exist at once, so a crowd of players farming a + * crowd of mobs cannot flood the server with them. + */ +object DamageIndicators { + + private const val LIFETIME = 16L + private const val RISE_DELAY = 1L + private const val RISE_TICKS = 12 + private const val RISE = 0.7 + + private val live = HashSet() + + fun show(victim: LivingEntity, damage: Double, crit: Boolean) { + val settings = CombatSettings.snapshot + if (!settings.damageIndicators || live.size >= settings.indicatorLimit) return + + val random = ThreadLocalRandom.current() + val offset = Vector(random.nextDouble(-0.5, 0.5), random.nextDouble(0.0, 0.4), random.nextDouble(-0.5, 0.5)) + val location = victim.location.add(0.0, victim.height * 0.75, 0.0).add(offset) + val text = Lang.tr( + null, + if (crit) "combat.indicator.crit" else "combat.indicator.hit", + "amount" to CombatFormat.number(damage), + ) + + val display = victim.world.spawn(location, TextDisplay::class.java) { + it.text(ComponentUtil.parse(text)) + it.billboard = Display.Billboard.CENTER + it.backgroundColor = Color.fromARGB(0, 0, 0, 0) + it.isShadowed = true + it.isPersistent = false + it.teleportDuration = RISE_TICKS + } + live += display + + val scheduler = Bukkit.getScheduler() + scheduler.runTaskLater(Main.instance, Runnable { + if (display.isValid) display.teleport(display.location.add(0.0, RISE, 0.0)) + }, RISE_DELAY) + scheduler.runTaskLater(Main.instance, Runnable { remove(display) }, LIFETIME) + } + + fun clearAll() { + live.toList().forEach(::remove) + } + + private fun remove(display: TextDisplay) { + live -= display + display.remove() + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/DamageMath.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/DamageMath.kt new file mode 100644 index 0000000..0e363a5 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/DamageMath.kt @@ -0,0 +1,119 @@ +package net.trilleo.mc.plugins.tritown.combat + +import net.trilleo.mc.plugins.tritown.content.Balance + +/** + * Every formula of the combat layer, and the only place one lives. + * + * Plain Kotlin with no server behind it, so the whole balance model can be + * run in a test. The listeners only turn events into these calls and the + * results back into vanilla damage. + * + * Two units meet here. **RPG** numbers are the ones players see: health pools, + * hits, Defense. **Vanilla** numbers are what the server actually applies to an + * entity's health. [toVanilla] and [toRpg] convert between them through the + * entity's own pool, which is what keeps vanilla health the single truth. + */ +object DamageMath { + + /** The damage multiplier of vanilla's own critical hit, which the jump attack's crit chance replaces. */ + const val VANILLA_CRIT = 1.5 + + /** A player's vanilla maximum health, which their RPG pool is measured against. */ + const val VANILLA_PLAYER_HEALTH = 20.0 + + /** How much of a mob's hit gets through [defense]: 100 Defense halves it, 300 leaves a quarter. */ + fun defenseMultiplier(defense: Double): Double = 100.0 / (100.0 + defense.coerceAtLeast(0.0)) + + fun strengthMultiplier(sheet: StatSheet): Double = (1.0 + sheet[Stat.STRENGTH] / 100.0).coerceAtLeast(0.0) + + fun critMultiplier(sheet: StatSheet, crit: Boolean): Double = + if (crit) 1.0 + sheet[Stat.CRIT_DAMAGE] / 100.0 else 1.0 + + /** The chance, from 0 to 1, that a player's hit on a mob is critical. */ + fun critChance(sheet: StatSheet, jumpAttack: Boolean, balance: Balance): Double { + val percent = sheet[Stat.CRIT_CHANCE] + if (jumpAttack) balance.jumpCritChance else 0.0 + return (percent / 100.0).coerceIn(0.0, 1.0) + } + + /** + * How much of a full swing vanilla says a melee hit was worth: the attack + * cooldown, a sweep, Sharpness and its kin, a mace's fall. + * + * Vanilla's hit is measured against the player's attack damage, so what is + * left is everything *but* the weapon — which the RPG hit supplies instead. + * Vanilla's own crit is divided back out, since the jump attack's crit + * chance stands in for it. + */ + fun vanillaShare(vanillaHit: Double, attackDamage: Double, vanillaCrit: Boolean): Double { + val share = if (attackDamage > 0.0) vanillaHit / attackDamage else vanillaHit + return if (vanillaCrit) share / VANILLA_CRIT else share + } + + /** + * A player's melee hit on a mob, before the mob's Defense. [weaponDamage] is + * the held weapon's Damage: a bow's only counts for what it shoots. + */ + fun meleeHit( + sheet: StatSheet, + balance: Balance, + vanillaShare: Double, + crit: Boolean, + weaponDamage: Double = sheet[Stat.DAMAGE], + ): Double = (balance.lens + weaponDamage) * strengthMultiplier(sheet) * vanillaShare * critMultiplier(sheet, crit) + + /** + * What a shot is worth for each point of vanilla damage it lands: the + * weapon's Damage, with the lens's worth for the bare hand, measured + * against [reference] — the vanilla damage of the shot a weapon of no + * Damage makes. A vanilla bow comes out at the lens, so vanilla stays + * vanilla. + */ + fun shotMultiplier(weaponDamage: Double, reference: Double, balance: Balance): Double = + (balance.lens + weaponDamage) / reference.coerceAtLeast(0.1) + + /** + * A player's arrow or trident hitting a mob. Vanilla's hit already carries + * the draw, the arrow's speed and Power, so [multiplier] — what the weapon + * made each point of it worth — scales it as it is. + */ + fun shotHit(vanillaHit: Double, shooter: StatSheet, multiplier: Double, crit: Boolean): Double = + vanillaHit * multiplier * strengthMultiplier(shooter) * critMultiplier(shooter, crit) + + /** + * A mob's hit on a player or another mob, before the target's Defense. + * [multiplier] is what the mob's rank and affixes make of it. + */ + fun mobHit(vanillaHit: Double, balance: Balance, level: Int, multiplier: Double = 1.0): Double = + vanillaHit * balance.lens * balance.mobs.damage(level) * multiplier + + /** + * Whatever else hurts a mob — fire, lava, falling, cactus, a player's + * potion — in level-1 units, so a trap is only ever as strong as it is + * against a level-1 mob. + */ + fun environmentHit(vanillaHit: Double, balance: Balance): Double = vanillaHit * balance.lens + + fun afterDefense(hit: Double, defense: Double): Double = hit * defenseMultiplier(defense) + + /** A mob's RPG pool. [multiplier] is what its rank makes of it. */ + fun mobMaxHealth(vanillaMax: Double, balance: Balance, level: Int, multiplier: Double = 1.0): Double = + vanillaMax * balance.lens * balance.mobs.health(level) * multiplier + + /** + * A player's RPG pool. The Health stat is measured against vanilla's 20, so + * Health Boost, or anything else that adds vanilla hearts, adds to it in the + * same proportion. + */ + fun playerMaxHealth(health: Double, vanillaMax: Double): Double = health * vanillaMax / VANILLA_PLAYER_HEALTH + + fun toVanilla(rpg: Double, rpgMax: Double, vanillaMax: Double): Double = + if (rpgMax > 0.0) rpg / rpgMax * vanillaMax else rpg + + fun toRpg(vanilla: Double, rpgMax: Double, vanillaMax: Double): Double = + if (vanillaMax > 0.0) vanilla / vanillaMax * rpgMax else vanilla + + /** The Defense a worn piece of vanilla armor is worth. */ + fun armorDefense(armor: Double, toughness: Double, balance: Balance): Double = + armor * balance.vanilla.armorPoint + toughness * balance.vanilla.toughnessPoint +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/PlayerStats.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/PlayerStats.kt new file mode 100644 index 0000000..0ac72c2 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/PlayerStats.kt @@ -0,0 +1,40 @@ +package net.trilleo.mc.plugins.tritown.combat + +import net.trilleo.mc.plugins.tritown.combat.PlayerStats.MAX_AGE +import org.bukkit.Bukkit +import org.bukkit.entity.Player +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * Every online player's [StatSheet], built by [StatSources] and kept until + * something changes it. + * + * `StatListener` drops a sheet when the player's equipment or effects change. + * A sheet also expires after [MAX_AGE] ticks regardless, which catches what no + * event reports — a plugin swapping an item in, a stack landing in the held + * slot — at the price of one rebuild a second. + */ +object PlayerStats { + + private const val MAX_AGE = 20 + + private class Cached(val sheet: StatSheet, val tick: Int) + + private val sheets = ConcurrentHashMap() + + fun sheet(player: Player): StatSheet { + val now = Bukkit.getCurrentTick() + sheets[player.uniqueId]?.takeIf { now - it.tick < MAX_AGE }?.let { return it.sheet } + return StatSources.total(player).also { sheets[player.uniqueId] = Cached(it, now) } + } + + fun invalidate(player: Player) { + sheets.remove(player.uniqueId) + } + + /** Drops every sheet, after a reload may have changed what the sources are worth. */ + fun invalidateAll() { + sheets.clear() + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/ShotStats.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/ShotStats.kt new file mode 100644 index 0000000..f472350 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/ShotStats.kt @@ -0,0 +1,33 @@ +package net.trilleo.mc.plugins.tritown.combat + +import org.bukkit.NamespacedKey +import org.bukkit.entity.Projectile +import org.bukkit.persistence.PersistentDataType + +/** + * What a shot was fired with, written onto an arrow or trident as it leaves + * the bow or the hand: the shooter's stats, and what the weapon makes each + * point of the shot's damage worth ([DamageMath.shotMultiplier]). + * + * A shot lands with what it was fired with, so swapping to a different item + * while it flies changes nothing. Kept in the projectile's own data, it + * outlives the shooter logging off and survives a chunk unload. + */ +object ShotStats { + + class Shot(val stats: StatSheet, val multiplier: Double) + + private val KEY = NamespacedKey("tritown", "shot") + private val CARRIED = listOf(Stat.STRENGTH, Stat.CRIT_CHANCE, Stat.CRIT_DAMAGE) + + fun write(projectile: Projectile, shooter: StatSheet, multiplier: Double) { + val values = CARRIED.map { shooter[it] } + multiplier + projectile.persistentDataContainer.set(KEY, PersistentDataType.LIST.doubles(), values) + } + + fun read(projectile: Projectile): Shot? { + val values = projectile.persistentDataContainer.get(KEY, PersistentDataType.LIST.doubles()) ?: return null + if (values.size != CARRIED.size + 1) return null + return Shot(StatSheet.of(*CARRIED.zip(values).toTypedArray()), values.last()) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/SpeedSync.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/SpeedSync.kt new file mode 100644 index 0000000..e0514d7 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/SpeedSync.kt @@ -0,0 +1,40 @@ +package net.trilleo.mc.plugins.tritown.combat + +import net.trilleo.mc.plugins.tritown.config.CombatSettings +import org.bukkit.NamespacedKey +import org.bukkit.attribute.Attribute +import org.bukkit.attribute.AttributeModifier +import org.bukkit.entity.Player + +/** + * Turns a player's Speed stat into movement. + * + * Speed is the one stat that shows between players — movement cannot tell who + * is chasing whom — so it is capped at `combat.speed-cap`, short of a Speed II + * potion. It is a transient modifier, never saved with the player, so nothing + * of it outlives TriTown. + */ +object SpeedSync { + + private val KEY = NamespacedKey("tritown", "speed") + + fun sync(player: Player) { + val attribute = player.getAttribute(Attribute.MOVEMENT_SPEED) ?: return + val wanted = if (Combat.isActive(player.world)) { + PlayerStats.sheet(player)[Stat.SPEED].coerceIn(0.0, CombatSettings.snapshot.speedCap) / 100.0 + } else 0.0 + + val current = attribute.getModifier(KEY) + if (current?.amount == wanted || (current == null && wanted == 0.0)) return + current?.let(attribute::removeModifier) + if (wanted > 0.0) { + attribute.addTransientModifier(AttributeModifier(KEY, wanted, AttributeModifier.Operation.ADD_SCALAR)) + } + } + + fun clear(player: Player) { + player.getAttribute(Attribute.MOVEMENT_SPEED)?.getModifier(KEY)?.let { + player.getAttribute(Attribute.MOVEMENT_SPEED)?.removeModifier(it) + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/Stat.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/Stat.kt new file mode 100644 index 0000000..8e4b5e9 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/Stat.kt @@ -0,0 +1,56 @@ +package net.trilleo.mc.plugins.tritown.combat + +/** + * A number that shapes how a player fights mobs. + * + * None of them apply between players but [SPEED]: a hit from one player to + * another is left to vanilla, so what a player has here only ever changes how + * they fare against mobs. + * + * @property percent whether the value is read as a percentage + */ +enum class Stat(val percent: Boolean) { + + /** RPG health, drawn over the player's vanilla hearts. */ + HEALTH(false), + + /** Takes a share off every mob's hit: `100 / (100 + defense)` of it gets through. */ + DEFENSE(false), + + /** What the held weapon adds to a hit, on top of the lens's worth for the bare hand. */ + DAMAGE(false), + + /** Adds its value in percent to every hit on a mob. */ + STRENGTH(false), + + CRIT_CHANCE(true), + + CRIT_DAMAGE(true), + + /** + * Moves the player faster, by its value in percent. The one stat that + * shows between players, since movement cannot tell who is chasing whom, + * so it is capped at `combat.speed-cap`. + */ + SPEED(true), + + /** Adds its value in percent to the health natural regeneration and potions give back. */ + VITALITY(true), + + /** Makes a mob likelier to drop its material, by its value in percent. */ + MAGIC_FIND(false); + + /** The translation key naming the stat, spelled out so the language test can see it. */ + val key: String + get() = when (this) { + HEALTH -> "combat.stat.health" + DEFENSE -> "combat.stat.defense" + DAMAGE -> "combat.stat.damage" + STRENGTH -> "combat.stat.strength" + CRIT_CHANCE -> "combat.stat.crit-chance" + CRIT_DAMAGE -> "combat.stat.crit-damage" + SPEED -> "combat.stat.speed" + VITALITY -> "combat.stat.vitality" + MAGIC_FIND -> "combat.stat.magic-find" + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/StatSheet.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/StatSheet.kt new file mode 100644 index 0000000..0950fa5 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/StatSheet.kt @@ -0,0 +1,37 @@ +package net.trilleo.mc.plugins.tritown.combat + +/** + * A value for every [Stat], unset ones being zero. + * + * Immutable, so a player's sheet can be cached and read from anywhere without + * a copy. + */ +class StatSheet private constructor(private val values: DoubleArray) { + + operator fun get(stat: Stat): Double = values[stat.ordinal] + + operator fun plus(other: StatSheet): StatSheet = + StatSheet(DoubleArray(values.size) { values[it] + other.values[it] }) + + /** Whether every stat is zero, so a source with nothing to add can be left out of a breakdown. */ + val isEmpty: Boolean + get() = values.all { it == 0.0 } + + override fun equals(other: Any?): Boolean = other is StatSheet && values.contentEquals(other.values) + + override fun hashCode(): Int = values.contentHashCode() + + override fun toString(): String = + Stat.entries.filter { this[it] != 0.0 }.joinToString(prefix = "StatSheet(", postfix = ")") { "$it=${this[it]}" } + + companion object { + + val EMPTY = StatSheet(DoubleArray(Stat.entries.size)) + + fun of(vararg entries: Pair): StatSheet { + val values = DoubleArray(Stat.entries.size) + entries.forEach { (stat, value) -> values[stat.ordinal] += value } + return StatSheet(values) + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/StatSources.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/StatSources.kt new file mode 100644 index 0000000..533711f --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/StatSources.kt @@ -0,0 +1,116 @@ +package net.trilleo.mc.plugins.tritown.combat + +import io.papermc.paper.datacomponent.DataComponentTypes +import net.trilleo.mc.plugins.tritown.content.Balance +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.gear.Gear +import org.bukkit.attribute.Attribute +import org.bukkit.attribute.AttributeModifier +import org.bukkit.enchantments.Enchantment +import org.bukkit.entity.Player +import org.bukkit.inventory.EquipmentSlot +import org.bukkit.inventory.ItemStack +import org.bukkit.potion.PotionEffectType + +/** + * Where a player's stats come from, and the only place a [StatSheet] is built. + * + * A piece of TriTown gear gives the stats [Gear] works out for it. Vanilla + * items count too: a worn piece of armor is Defense by its armor and + * toughness, and the held weapon is Damage by its attack damage, both through + * `balance.yml`. That is what keeps level-1 play with vanilla gear vanilla. A + * piece of gear's own vanilla armor or attack damage never counts twice: it + * only matters between players. + */ +object StatSources { + + /** A place stats come from, as the stats menu breaks them down. */ + enum class Source { + BASE, ARMOR, WEAPON, EFFECTS; + + /** The translation key naming the source, spelled out so the language test can see it. */ + val key: String + get() = when (this) { + BASE -> "gui.stats.source.base" + ARMOR -> "gui.stats.source.armor" + WEAPON -> "gui.stats.source.weapon" + EFFECTS -> "gui.stats.source.effects" + } + } + + private val ARMOR_SLOTS = listOf(EquipmentSlot.HEAD, EquipmentSlot.CHEST, EquipmentSlot.LEGS, EquipmentSlot.FEET) + + fun total(player: Player): StatSheet = breakdown(player).values.fold(StatSheet.EMPTY, StatSheet::plus) + + /** What each source gives [player]; a source with nothing to give is left out. */ + fun breakdown(player: Player): Map { + val balance = ContentRegistry.balance + return linkedMapOf( + Source.BASE to base(balance), + Source.ARMOR to armor(player, balance), + Source.WEAPON to weapon(player, balance), + Source.EFFECTS to effects(player, balance), + ).filterValues { !it.isEmpty } + } + + private fun base(balance: Balance): StatSheet = StatSheet.of( + Stat.HEALTH to balance.player.health, + Stat.CRIT_CHANCE to balance.player.critChance, + Stat.CRIT_DAMAGE to balance.player.critDamage, + ) + + private fun armor(player: Player, balance: Balance): StatSheet { + val equipment = player.equipment + return ARMOR_SLOTS.map { slot -> + val item = equipment.getItem(slot) + Gear.read(item)?.takeIf { it.def.slot.isArmor }?.stats ?: StatSheet.of( + Stat.DEFENSE to DamageMath.armorDefense( + added(item, Attribute.ARMOR, slot), + added(item, Attribute.ARMOR_TOUGHNESS, slot), + balance, + ) + ) + }.fold(StatSheet.EMPTY, StatSheet::plus) + } + + /** + * The held weapon's stats if it is gear, or else the lens's worth for every + * point of attack damage it adds to the bare hand; and Magic Find for its + * Looting either way. + */ + private fun weapon(player: Player, balance: Balance): StatSheet { + val item = player.inventory.itemInMainHand + val looting = StatSheet.of( + Stat.MAGIC_FIND to item.getEnchantmentLevel(Enchantment.LOOTING) * balance.vanilla.lootingMagicFind, + ) + val gear = Gear.read(item)?.takeIf { !it.def.slot.isArmor }?.stats + return looting + (gear ?: StatSheet.of(Stat.DAMAGE to attackDamage(item) * balance.lens)) + } + + /** What [item] adds to the bare hand's attack damage, its material's default included. */ + fun attackDamage(item: ItemStack?): Double = added(item, Attribute.ATTACK_DAMAGE, EquipmentSlot.HAND) + + private fun effects(player: Player, balance: Balance): StatSheet { + val strength = player.getPotionEffect(PotionEffectType.STRENGTH)?.let { it.amplifier + 1 } ?: 0 + val weakness = player.getPotionEffect(PotionEffectType.WEAKNESS)?.let { it.amplifier + 1 } ?: 0 + return StatSheet.of( + Stat.STRENGTH to strength * balance.effects.strengthPerLevel - weakness * balance.effects.weaknessPerLevel + ) + } + + /** + * What [item] adds to [attribute] when it is in [slot]: the sum of its + * flat modifiers for that slot, its material's defaults included. + */ + private fun added(item: ItemStack?, attribute: Attribute, slot: EquipmentSlot): Double { + if (item == null || item.isEmpty) return 0.0 + val modifiers = item.getData(DataComponentTypes.ATTRIBUTE_MODIFIERS) ?: return 0.0 + return modifiers.modifiers() + .filter { entry -> + entry.attribute() == attribute && + entry.modifier().operation == AttributeModifier.Operation.ADD_NUMBER && + entry.modifier().slotGroup.test(slot) + } + .sumOf { it.modifier().amount } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/VanillaReductions.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/VanillaReductions.kt new file mode 100644 index 0000000..278dd3d --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/combat/VanillaReductions.kt @@ -0,0 +1,44 @@ +package net.trilleo.mc.plugins.tritown.combat + +/** + * Carries vanilla's damage reductions over to a hit whose size TriTown has + * decided, with armor taken out. + * + * A player's worn armor is already their Defense, which the RPG hit has been + * through, so vanilla's armor reduction would count it twice. Everything else + * vanilla reduces a hit by keeps its share: invulnerability frames, a helmet + * under a falling anvil, a raised shield, Resistance, and Protection and its + * kin. Each of those takes the same fraction of what reaches it as it took of + * vanilla's own hit, and absorption soaks up what it can hold. + * + * The event's own recalculation cannot do this: it would re-run the armor it + * is asked to leave out. + */ +object VanillaReductions { + + enum class Kind { SHARE, ARMOR, ABSORPTION } + + class Reduction(val kind: Kind, val value: Double) + + /** + * The values [reductions] should take for a hit of [base] instead of + * [originalBase]. Reductions are negative, and come in the order the + * server applies them, each one working on what the ones before left. + * + * @param absorption the absorption the target has to spend + */ + fun rescale(originalBase: Double, reductions: List, base: Double, absorption: Double): List { + var reachedBefore = originalBase + var reachesNow = base + return reductions.map { reduction -> + val value = when (reduction.kind) { + Kind.ARMOR -> 0.0 + Kind.ABSORPTION -> -minOf(absorption.coerceAtLeast(0.0), reachesNow.coerceAtLeast(0.0)) + Kind.SHARE -> if (reachedBefore > 0.0) reduction.value / reachedBefore * reachesNow else 0.0 + } + reachedBefore += reduction.value + reachesNow += value + value + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/adventure/BestiaryCommand.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/adventure/BestiaryCommand.kt new file mode 100644 index 0000000..860ef04 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/adventure/BestiaryCommand.kt @@ -0,0 +1,25 @@ +package net.trilleo.mc.plugins.tritown.commands.adventure + +import net.trilleo.mc.plugins.tritown.guis.bestiary.BestiaryGUI +import net.trilleo.mc.plugins.tritown.registration.PluginCommand +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player + +/** Opens the bestiary. Like `/tritown forge`, it declares no permission: everyone keeps one. */ +class BestiaryCommand : PluginCommand( + name = "bestiary", + description = "See the server's own mobs you have met", + usage = "/tritown bestiary", +) { + + override fun execute(sender: CommandSender, args: Array): Boolean { + val player = sender as? Player ?: run { + sender.sendPrefixed(sender.tr("command.bestiary.players-only")) + return true + } + BestiaryGUI.show(player) + return true + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/adventure/ForgeCommand.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/adventure/ForgeCommand.kt new file mode 100644 index 0000000..7ed5175 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/adventure/ForgeCommand.kt @@ -0,0 +1,33 @@ +package net.trilleo.mc.plugins.tritown.commands.adventure + +import net.trilleo.mc.plugins.tritown.combat.Combat +import net.trilleo.mc.plugins.tritown.guis.forge.ForgeGUI +import net.trilleo.mc.plugins.tritown.registration.PluginCommand +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player + +/** + * Opens the Forge. It declares no permission, the way `/tritown stats` does: + * everyone can craft, and whether combat runs at all is `combat.enabled`. + */ +class ForgeCommand : PluginCommand( + name = "forge", + description = "Craft and improve gear", + usage = "/tritown forge", +) { + + override fun execute(sender: CommandSender, args: Array): Boolean { + val player = sender as? Player ?: run { + sender.sendPrefixed(sender.tr("command.forge.players-only")) + return true + } + if (!Combat.isActive(player.world)) { + player.sendPrefixed(player.tr("command.forge.unavailable")) + return true + } + ForgeGUI.show(player) + return true + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/adventure/StatsCommand.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/adventure/StatsCommand.kt new file mode 100644 index 0000000..71250e2 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/adventure/StatsCommand.kt @@ -0,0 +1,53 @@ +package net.trilleo.mc.plugins.tritown.commands.adventure + +import net.trilleo.mc.plugins.tritown.combat.Combat +import net.trilleo.mc.plugins.tritown.guis.adventure.StatsGUI +import net.trilleo.mc.plugins.tritown.registration.PluginCommand +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Bukkit +import org.bukkit.command.CommandSender +import org.bukkit.entity.Player + +/** + * Opens a player's combat stats: your own, or anyone else's who is online. + * + * It declares no permission, the way `/tritown storage` does: everyone has + * stats, and whether combat runs at all is `combat.enabled`. + */ +class StatsCommand : PluginCommand( + name = "stats", + description = "See your combat stats", + usage = "/tritown stats [player]", +) { + + override fun execute(sender: CommandSender, args: Array): Boolean { + val player = sender as? Player ?: run { + sender.sendPrefixed(sender.tr("command.stats.players-only")) + return true + } + if (!Combat.isActive(player.world)) { + player.sendPrefixed(player.tr("command.stats.unavailable")) + return true + } + + val target = args.firstOrNull()?.let { name -> + Bukkit.getPlayerExact(name)?.takeIf(player::canSee) ?: run { + player.sendPrefixed(player.tr("command.stats.not-found", "name" to ComponentUtil.escape(name))) + return true + } + } ?: player + StatsGUI.show(player, target) + return true + } + + override fun tabComplete(sender: CommandSender, args: Array): List { + if (args.size != 1) return emptyList() + val viewer = sender as? Player + return Bukkit.getOnlinePlayers() + .filter { viewer == null || viewer.canSee(it) } + .map { it.name } + .filter { it.startsWith(args[0], ignoreCase = true) } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/items/ItemCommand.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/items/ItemCommand.kt new file mode 100644 index 0000000..0c03d25 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/items/ItemCommand.kt @@ -0,0 +1,99 @@ +package net.trilleo.mc.plugins.tritown.commands.items + +import net.trilleo.mc.plugins.tritown.content.ContentItems +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.content.Rarity +import net.trilleo.mc.plugins.tritown.gear.Gear +import net.trilleo.mc.plugins.tritown.registration.PluginCommand +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.InventoryUtil +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Bukkit +import org.bukkit.command.CommandSender + +/** + * Hands out the items `items.yml` defines and the gear `gear.yml` defines, for + * testing and for rewards the server gives by hand. What does not fit is + * dropped as the player's own. + */ +class ItemCommand : PluginCommand( + name = "item", + description = "Give out the server's own items", + usage = "/tritown item [amount|rarity]|list>", + permission = PERMISSION, +) { + + override fun execute(sender: CommandSender, args: Array): Boolean { + when (args.firstOrNull()?.lowercase()) { + GIVE -> give(sender, args) + LIST -> list(sender) + else -> sender.sendPrefixed(sender.tr("command.item.usage")) + } + return true + } + + override fun tabComplete(sender: CommandSender, args: Array): List { + val options = when (args.size) { + 1 -> listOf(GIVE, LIST) + 2 -> if (args[0].equals(GIVE, ignoreCase = true)) Bukkit.getOnlinePlayers().map { it.name } else emptyList() + 3 -> if (args[0].equals(GIVE, ignoreCase = true)) ids() else emptyList() + 4 -> if (args[2].lowercase() in ContentRegistry.gear.gear) Rarity.entries.map { it.name.lowercase() } else emptyList() + else -> emptyList() + } + return options.filter { it.startsWith(args.last(), ignoreCase = true) } + } + + /** + * An item takes an amount after its id; a piece of gear takes a rarity + * instead, and is always one piece. + */ + private fun give(sender: CommandSender, args: Array) { + val name = args.getOrNull(1) + val id = args.getOrNull(2)?.lowercase() + if (name == null || id == null) { + sender.sendPrefixed(sender.tr("command.item.usage")) + return + } + val player = Bukkit.getPlayerExact(name) ?: run { + sender.sendPrefixed(sender.tr("command.item.not-found", "name" to ComponentUtil.escape(name))) + return + } + + val gear = ContentRegistry.gear.gear[id] + val (stack, amount) = if (gear != null) { + val rarity = args.getOrNull(3)?.let(Rarity::of) ?: Rarity.COMMON + Gear.create(gear, rarity) to 1 + } else { + val amount = args.getOrNull(3)?.toIntOrNull() ?: 1 + if (amount !in 1..MAX_AMOUNT) { + sender.sendPrefixed(sender.tr("command.item.usage")) + return + } + ContentItems.create(id, amount) to amount + } + if (stack == null) { + sender.sendPrefixed(sender.tr("command.item.unknown", "id" to ComponentUtil.escape(id))) + return + } + InventoryUtil.give(player, listOf(stack)) + sender.sendPrefixed( + sender.tr("command.item.given", "amount" to amount, "id" to id, "name" to ComponentUtil.escape(player.name)) + ) + } + + private fun list(sender: CommandSender) { + val ids = ids() + sender.sendPrefixed(sender.tr("command.item.list", "amount" to ids.size, "ids" to ids.joinToString(", "))) + } + + private fun ids(): List = ContentRegistry.items.keys.toList() + ContentRegistry.gear.gear.keys + + companion object { + const val PERMISSION = "tritown.item.admin" + + private const val GIVE = "give" + private const val LIST = "list" + private const val MAX_AMOUNT = 64 * 36 + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/mobs/MobCommand.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/mobs/MobCommand.kt new file mode 100644 index 0000000..82d30e1 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/mobs/MobCommand.kt @@ -0,0 +1,201 @@ +package net.trilleo.mc.plugins.tritown.commands.mobs + +import net.trilleo.mc.plugins.tritown.combat.BalanceSimulator +import net.trilleo.mc.plugins.tritown.combat.Combat +import net.trilleo.mc.plugins.tritown.combat.CombatFormat +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.gear.ForgeCosts +import net.trilleo.mc.plugins.tritown.mobs.MobSetup +import net.trilleo.mc.plugins.tritown.mobs.MobZones +import net.trilleo.mc.plugins.tritown.mobs.boss.BossSummons +import net.trilleo.mc.plugins.tritown.registration.PluginCommand +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Bukkit +import org.bukkit.attribute.Attribute +import org.bukkit.command.CommandSender +import org.bukkit.entity.EntityType +import org.bukkit.entity.LivingEntity +import org.bukkit.entity.Player + +/** + * Looking into how dangerous the world is: the mob level where you stand, and + * what the balance in force makes of each level; and the custom mobs of + * `bestiary.yml`, which an administrator can call up to see. + */ +class MobCommand : PluginCommand( + name = "mob", + description = "Inspect mob levels and the combat balance", + usage = "/tritown mob [level]>", + permission = PERMISSION, +) { + + override fun execute(sender: CommandSender, args: Array): Boolean { + when (args.firstOrNull()?.lowercase()) { + LEVEL -> level(sender) + BALANCE -> balance(sender) + BOSSES -> bosses(sender) + KINDS -> kinds(sender) + SPAWN -> spawn(sender, args) + else -> sender.sendPrefixed(sender.tr("command.mob.usage")) + } + return true + } + + override fun tabComplete(sender: CommandSender, args: Array): List { + val options = when (args.size) { + 1 -> listOf(LEVEL, BALANCE, BOSSES, KINDS, SPAWN) + 2 -> if (args[0].equals( + SPAWN, + ignoreCase = true + ) + ) ContentRegistry.bestiary.kinds.keys.toList() else emptyList() + + else -> emptyList() + } + return options.filter { it.startsWith(args.last(), ignoreCase = true) } + } + + /** Why a mob spawning where the sender stands would be the level it would. */ + private fun level(sender: CommandSender) { + val player = sender as? Player ?: run { + sender.sendPrefixed(sender.tr("command.mob.players-only")) + return + } + if (!Combat.isActive(player.world)) { + player.sendPrefixed(player.tr("command.mob.inactive")) + return + } + + val reading = MobZones.read(player.location) + player.sendPrefixed(player.tr("command.mob.level", "level" to reading.level)) + val reason = when { + reading.town -> player.tr("command.mob.level-town") + reading.ring == 0 -> player.tr("command.mob.level-hub") + else -> player.tr("command.mob.level-ring", "ring" to reading.ring) + } + player.sendMessage(ComponentUtil.parse(reason)) + if (reading.night) player.sendMessage(ComponentUtil.parse(player.tr("command.mob.level-night"))) + if (reading.deep) player.sendMessage(ComponentUtil.parse(player.tr("command.mob.level-deep"))) + } + + private fun kinds(sender: CommandSender) { + val ids = ContentRegistry.bestiary.kinds.keys + sender.sendPrefixed(sender.tr("command.mob.kinds", "amount" to ids.size, "ids" to ids.joinToString(", "))) + } + + /** + * Calls up a custom mob a few blocks in front of the sender, at the level + * given or the level where they stand. It is not eligible, so it drops + * nothing beyond vanilla's loot: testing never becomes a way to farm. + */ + private fun spawn(sender: CommandSender, args: Array) { + val player = sender as? Player ?: run { + sender.sendPrefixed(sender.tr("command.mob.players-only")) + return + } + val id = args.getOrNull(1)?.lowercase() ?: run { + player.sendPrefixed(player.tr("command.mob.usage")) + return + } + val kind = ContentRegistry.bestiary.kinds[id] ?: run { + player.sendPrefixed(player.tr("command.mob.unknown-kind", "id" to ComponentUtil.escape(id))) + return + } + val given = args.getOrNull(2) + val level = if (given == null) kind.boss?.level ?: MobZones.levelAt(player.location) else { + given.toIntOrNull()?.takeIf { it in 1..MAX_LEVEL } ?: run { + player.sendPrefixed(player.tr("command.mob.usage")) + return + } + } + val type = EntityType.entries.firstOrNull { it.name == kind.base } ?: return + + val ahead = player.location.add(player.location.direction.setY(0).normalize().multiply(SPAWN_DISTANCE)) + val at = + if (ahead.block.isPassable && ahead.clone().add(0.0, 1.0, 0.0).block.isPassable) ahead else player.location + val spawned = if (kind.boss != null) BossSummons.spawn(kind, at, level, eligible = false) + else MobSetup.spawn(at, type, level, kind) + spawned ?: return + player.sendPrefixed( + player.tr("command.mob.spawned", "name" to player.tr(kind.nameKey), "level" to level) + ) + } + + /** + * Each boss at its level against a full kit of the tier made for it: how + * long one player would take to bring it down, and how hard it hits. A + * boss's kind is measured by making one that never enters the world. + */ + private fun bosses(sender: CommandSender) { + val balance = ContentRegistry.balance + val world = Bukkit.getWorlds().firstOrNull() ?: return + sender.sendPrefixed(sender.tr("command.mob.bosses-header")) + ContentRegistry.bestiary.kinds.values.forEach { kind -> + val boss = kind.boss ?: return@forEach + val type = EntityType.entries.firstOrNull { it.name == kind.base } ?: return@forEach + val body = type.entityClass?.let { world.createEntity(world.spawnLocation, it) } as? LivingEntity + ?: return@forEach + val foe = BalanceSimulator.Foe( + health = body.getAttribute(Attribute.MAX_HEALTH)?.baseValue ?: return@forEach, + hit = body.getAttribute(Attribute.ATTACK_DAMAGE)?.baseValue ?: 0.0, + ) + val tier = (boss.level + ForgeCosts.LEVELS_PER_TIER - 1) / ForgeCosts.LEVELS_PER_TIER + val row = BalanceSimulator.bossRow(balance, boss.level, tier, foe, kind.health, kind.damage, kind.defense) + sender.sendMessage( + ComponentUtil.parse( + sender.tr( + "command.mob.bosses-row", + "name" to sender.tr(kind.nameKey), + "level" to boss.level, + "tier" to tier, + "health" to CombatFormat.number(row.foeHealth), + "hit" to CombatFormat.number(row.foeHit), + "hits" to row.hitsToKill, + "percent" to CombatFormat.number(row.shareTaken * 100.0), + ) + ) + ) + } + } + + /** + * A zombie's health and hit at a spread of levels, and how a player fares + * against it in gear of the tier made for that level. + */ + private fun balance(sender: CommandSender) { + val balance = ContentRegistry.balance + sender.sendPrefixed(sender.tr("command.mob.balance-header")) + for (level in TABLE_LEVELS) { + val tier = (level + ForgeCosts.LEVELS_PER_TIER - 1) / ForgeCosts.LEVELS_PER_TIER + val row = BalanceSimulator.gearRow(balance, level, tier) + sender.sendMessage( + ComponentUtil.parse( + sender.tr( + "command.mob.balance-row", + "level" to level, + "tier" to tier, + "health" to CombatFormat.number(row.foeHealth), + "hit" to CombatFormat.number(row.foeHit), + "hits" to row.hitsToKill, + "percent" to CombatFormat.number(row.shareTaken * 100.0), + ) + ) + ) + } + } + + companion object { + const val PERMISSION = "tritown.mob.admin" + + private const val LEVEL = "level" + private const val BALANCE = "balance" + private const val BOSSES = "bosses" + private const val KINDS = "kinds" + private const val SPAWN = "spawn" + private const val MAX_LEVEL = 999 + private const val SPAWN_DISTANCE = 3.0 + private val TABLE_LEVELS = listOf(1, 5, 10, 15, 20, 25, 30, 40, 50, 60) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/protection/ProtectionCommand.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/protection/ProtectionCommand.kt index 5c47cd6..4edbd0a 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/protection/ProtectionCommand.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/protection/ProtectionCommand.kt @@ -57,7 +57,12 @@ class ProtectionCommand : PluginCommand( } override fun tabComplete(sender: CommandSender, args: Array): List = - if (args.size == 1) listOf(INSPECT, RELEASE).filter { it.startsWith(args[0], ignoreCase = true) } else emptyList() + if (args.size == 1) listOf(INSPECT, RELEASE).filter { + it.startsWith( + args[0], + ignoreCase = true + ) + } else emptyList() /** The entity in the crosshair if there is one, the block otherwise. */ private fun target(player: Player): ClaimHolder? = diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/storage/StorageCommand.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/storage/StorageCommand.kt index eb6494d..150aab7 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/storage/StorageCommand.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/commands/storage/StorageCommand.kt @@ -69,7 +69,13 @@ class StorageCommand : PluginCommand( return } val owner = name?.let(::resolve) ?: run { - player.sendPrefixed(error(player, "command.storage.not-found", "name" to ComponentUtil.escape(name.orEmpty()))) + player.sendPrefixed( + error( + player, + "command.storage.not-found", + "name" to ComponentUtil.escape(name.orEmpty()) + ) + ) return } StorageGUI.open(player, owner) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/CombatSettings.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/CombatSettings.kt new file mode 100644 index 0000000..ff01595 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/CombatSettings.kt @@ -0,0 +1,43 @@ +package net.trilleo.mc.plugins.tritown.config + +/** + * An immutable snapshot of the `combat` block of `config.yml`. + * + * @param disabledWorlds worlds, by lower-case name, where every hit stays vanilla and mobs are not levelled + * @param actionBar whether players see their health and Defense above the hotbar + * @param damageIndicators whether hits on mobs float their damage up from the mob + * @param indicatorLimit the most damage indicators that may exist at once, across the server + * @param speedCap the most Speed, in percent, that makes a player faster + */ +data class CombatSettings( + val enabled: Boolean, + val disabledWorlds: Set, + val actionBar: Boolean, + val damageIndicators: Boolean, + val indicatorLimit: Int, + val speedCap: Double, +) { + + companion object { + + @Volatile + private var current: CombatSettings? = null + + val snapshot: CombatSettings + get() = current ?: error("Combat settings have not been loaded yet") + + val isLoaded: Boolean + get() = current != null + + fun load(config: PluginConfig): CombatSettings = read(config).also { current = it } + + private fun read(config: PluginConfig): CombatSettings = CombatSettings( + enabled = config.getBoolean("combat.enabled", true), + disabledWorlds = config.getStringList("combat.disabled-worlds").map { it.lowercase() }.toSet(), + actionBar = config.getBoolean("combat.hud.action-bar", true), + damageIndicators = config.getBoolean("combat.hud.damage-indicators", true), + indicatorLimit = config.getInt("combat.hud.indicator-limit", 64).coerceAtLeast(0), + speedCap = config.getDouble("combat.speed-cap", 30.0).coerceAtLeast(0.0), + ) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/MobSettings.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/MobSettings.kt new file mode 100644 index 0000000..6ff7ba6 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/MobSettings.kt @@ -0,0 +1,88 @@ +package net.trilleo.mc.plugins.tritown.config + +import net.trilleo.mc.plugins.tritown.mobs.LevelZone +import org.bukkit.World +import org.bukkit.entity.EntityType + +/** + * An immutable snapshot of the `mobs` block of `config.yml`. + * + * @param nightBonus levels an Overworld mob gains for spawning at night + * @param depthBonus levels an Overworld mob gains for spawning below Y 0 + * @param xpPerLevel how much more experience a mob drops per level above 1, as a fraction + * @param nameplates whether levelled mobs carry their level and health as a name + * @param rankExempt kinds of mob that never spawn ranked + * @param customMobs whether the custom mobs of `bestiary.yml` take the place of wild spawns + * @param bosses whether sigils summon their bosses + * @param announceRange how near a player must be to hear of a boss summoned, fallen or gone + */ +data class MobSettings( + val nightBonus: Int, + val depthBonus: Int, + val overworld: LevelZone, + val nether: LevelZone, + val end: LevelZone, + val worlds: Map, + val xpPerLevel: Double, + val nameplates: Boolean, + val rankExempt: Set, + val customMobs: Boolean, + val bosses: Boolean, + val announceRange: Double, +) { + + /** The rings mobs in [world] follow: its own, if it has any, or those of its kind of world. */ + fun zone(world: World): LevelZone = worlds[world.name.lowercase()] ?: when (world.environment) { + World.Environment.NETHER -> nether + World.Environment.THE_END -> end + else -> overworld + } + + companion object { + + private const val LEVELS = "mobs.levels" + + @Volatile + private var current: MobSettings? = null + + val snapshot: MobSettings + get() = current ?: error("Mob settings have not been loaded yet") + + val isLoaded: Boolean + get() = current != null + + fun load(config: PluginConfig): MobSettings = read(config).also { current = it } + + private fun read(config: PluginConfig): MobSettings { + val overworld = zone(config, "$LEVELS.overworld", LevelZone(1, 300.0, 500.0, 2, 30)) + val worlds = config.getKeys("$LEVELS.worlds").associate { name -> + name.lowercase() to zone(config, "$LEVELS.worlds.$name", overworld) + } + return MobSettings( + nightBonus = config.getInt("mobs.levels.night-bonus", 2).coerceAtLeast(0), + depthBonus = config.getInt("mobs.levels.depth-bonus", 2).coerceAtLeast(0), + overworld = overworld, + nether = zone(config, "$LEVELS.nether", LevelZone(4, 64.0, 64.0, 2, 45)), + end = zone(config, "$LEVELS.end", LevelZone(8, 1000.0, 500.0, 3, 60)), + worlds = worlds, + xpPerLevel = config.getDouble("mobs.xp-per-level", 0.02).coerceAtLeast(0.0), + nameplates = config.getBoolean("mobs.nameplates", true), + rankExempt = config.getStringList("mobs.ranks.exempt") + .mapNotNull { name -> EntityType.entries.firstOrNull { it.name.equals(name, ignoreCase = true) } } + .toSet(), + customMobs = config.getBoolean("mobs.custom.enabled", true), + bosses = config.getBoolean("mobs.bosses.enabled", true), + announceRange = config.getDouble("mobs.bosses.announce-range", 128.0).coerceAtLeast(0.0), + ) + } + + /** A zone at [path], with anything it leaves out taken from [fallback]. */ + private fun zone(config: PluginConfig, path: String, fallback: LevelZone): LevelZone = LevelZone( + base = config.getInt("$path.base", fallback.base).coerceAtLeast(1), + spawnRadius = config.getDouble("$path.spawn-radius", fallback.spawnRadius).coerceAtLeast(0.0), + ringWidth = config.getDouble("$path.ring-width", fallback.ringWidth).coerceAtLeast(1.0), + perRing = config.getInt("$path.per-ring", fallback.perRing).coerceAtLeast(0), + cap = config.getInt("$path.cap", fallback.cap).coerceAtLeast(1), + ) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/PluginConfig.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/PluginConfig.kt index 32d954b..1f93cf3 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/PluginConfig.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/config/PluginConfig.kt @@ -53,6 +53,13 @@ class PluginConfig(private val plugin: JavaPlugin) { val language: String get() = getString("language", "auto") + /** + * The one language every item is written in, or `auto` to follow + * [language]. Taken from the `item-language` key in `config.yml`. + */ + val itemLanguage: String + get() = getString("item-language", "auto") + // ── Typed Getters ─────────────────────────────────────────────────── /** diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/Balance.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/Balance.kt new file mode 100644 index 0000000..1bce0a5 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/Balance.kt @@ -0,0 +1,246 @@ +package net.trilleo.mc.plugins.tritown.content + +import net.trilleo.mc.plugins.tritown.mobs.Ability +import kotlin.math.pow + +/** + * The numbers the combat layer is tuned with, read from `content/balance.yml`. + * + * Everything that decides how hard a hit lands or how much a mob can take is + * here rather than in Kotlin, so an owner can retune the curve without a + * rebuild. `BalanceSimulationTest` holds the bundled copy to the targets the + * design rests on. + * + * @param lens RPG health per vanilla health point at level 1. Level-1 play with + * vanilla gear stays vanilla whatever it is, because every hit and every pool + * is multiplied by it alike. + * @param jumpCritChance crit chance, in percent, that a vanilla jump attack adds + * in place of vanilla's flat bonus + */ +data class Balance( + val lens: Double, + val player: PlayerBase, + val jumpCritChance: Double, + val effects: EffectBonuses, + val mobs: MobCurves, + val vanilla: VanillaMapping, + val ranks: Ranks, + val affixes: AffixTuning, + val abilities: AbilityTuning, + val gear: GearTuning, + val forge: ForgeTuning, +) { + + /** What every player has before any gear. Crit values are percentages. */ + data class PlayerBase(val health: Double, val critChance: Double, val critDamage: Double) + + /** Strength a level of the Strength or Weakness effect is worth. */ + data class EffectBonuses(val strengthPerLevel: Double, val weaknessPerLevel: Double) + + /** How a mob's health and hits grow with its level; level 1 is always ×1. */ + data class MobCurves(val healthGrowth: Double, val damageGrowth: Double) { + + fun health(level: Int): Double = healthGrowth.pow(level.coerceAtLeast(1) - 1) + + fun damage(level: Int): Double = damageGrowth.pow(level.coerceAtLeast(1) - 1) + } + + /** + * What vanilla gear is worth: Defense for a point of armor or toughness on a + * worn piece, and Magic Find for a level of Looting on the held weapon. + * + * @param bowAttack the attack damage a vanilla bow or crossbow counts as for + * what it shoots, as though it were a sword + * @param arrowReference the vanilla damage of a fully drawn arrow, which a + * shot is measured against + */ + data class VanillaMapping( + val armorPoint: Double, + val toughnessPoint: Double, + val lootingMagicFind: Double, + val bowAttack: Double, + val arrowReference: Double, + ) + + data class Ranks(val elite: Rank, val champion: Rank) + + /** + * A rank a wild mob can spawn with. + * + * @param chance percent of the wild spawns at [minLevel] or above that take this rank + * @param health what the rank multiplies the mob's pool by + * @param damage what the rank multiplies the mob's hits by + */ + data class Rank( + val chance: Double, + val minLevel: Int, + val health: Double, + val damage: Double, + val minAffixes: Int, + val maxAffixes: Int, + ) + + /** + * What each affix does. Percentages are percentages, durations seconds + * unless they say ticks. + */ + data class AffixTuning( + val armoredDefense: Double, + val frenziedSpeed: Double, + val vampiricHeal: Double, + val enragedBelow: Double, + val enragedDamage: Double, + val moltenSeconds: Int, + val frostboundSeconds: Int, + val venomousSeconds: Int, + val volatilePower: Double, + val volatileDelayTicks: Int, + val summonerBelow: Double, + val summonerMinions: Int, + val blinkingRange: Double, + val blinkingCooldownTicks: Int, + ) + + /** + * What custom mobs' abilities are worth. + * + * @param globalCooldownTicks the least time between any two abilities of one mob + */ + data class AbilityTuning(val globalCooldownTicks: Int, val stats: Map) { + + operator fun get(ability: Ability): AbilityStats = stats[ability] ?: AbilityStats.NONE + } + + /** + * One ability's numbers. Each ability reads only those it needs; the rest + * stay 0. Damage is in the units of a level-1 mob's vanilla hit, so the + * mob's level, rank and kind grow it like any other hit of its own. + * + * @param cooldownTicks the least time between two of it + * @param windupTicks how long its warning lasts before it lands + * @param range how far off its target may be + * @param radius how far from where it lands it reaches + * @param seconds how long what it leaves behind lasts + * @param count how many of it: arrows, fireballs, marked spots, minions + * @param power what else it needs: a push, a heal, Defense + */ + data class AbilityStats( + val cooldownTicks: Int, + val windupTicks: Int, + val damage: Double, + val range: Double, + val radius: Double, + val seconds: Double, + val count: Int, + val power: Double, + ) { + companion object { + val NONE = AbilityStats(1, 0, 0.0, 0.0, 0.0, 0.0, 0, 0.0) + } + } + + companion object { + + /** The bundled tuning, used for anything `balance.yml` leaves out. */ + val DEFAULT = Balance( + lens = 5.0, + player = PlayerBase(health = 100.0, critChance = 20.0, critDamage = 50.0), + jumpCritChance = 25.0, + effects = EffectBonuses(strengthPerLevel = 40.0, weaknessPerLevel = 40.0), + mobs = MobCurves(healthGrowth = 1.13, damageGrowth = 1.10), + vanilla = VanillaMapping( + armorPoint = 8.0, + toughnessPoint = 20.0, + lootingMagicFind = 10.0, + bowAttack = 5.0, + arrowReference = 6.0, + ), + ranks = Ranks( + elite = Rank(chance = 3.0, minLevel = 5, health = 4.0, damage = 1.5, minAffixes = 1, maxAffixes = 2), + champion = Rank( + chance = 0.4, + minLevel = 15, + health = 12.0, + damage = 2.0, + minAffixes = 2, + maxAffixes = 3 + ), + ), + affixes = AffixTuning( + armoredDefense = 150.0, + frenziedSpeed = 30.0, + vampiricHeal = 25.0, + enragedBelow = 30.0, + enragedDamage = 50.0, + moltenSeconds = 2, + frostboundSeconds = 3, + venomousSeconds = 3, + volatilePower = 2.5, + volatileDelayTicks = 30, + summonerBelow = 50.0, + summonerMinions = 2, + blinkingRange = 6.0, + blinkingCooldownTicks = 100, + ), + abilities = AbilityTuning( + globalCooldownTicks = 40, + stats = mapOf( + Ability.LEAP to ability(cooldown = 160, windup = 8, range = 12.0, radius = 2.5, damage = 4.0), + Ability.SLAM to ability(cooldown = 200, windup = 20, radius = 5.0, damage = 6.0, power = 0.8), + Ability.CHARGE to ability(cooldown = 200, windup = 15, range = 16.0, damage = 6.0, power = 1.6), + Ability.VOLLEY to ability(cooldown = 140, windup = 10, range = 20.0, damage = 2.5, count = 5), + Ability.FIREBALL to ability(cooldown = 120, windup = 10, range = 24.0, count = 3), + Ability.METEOR to ability( + cooldown = 240, + windup = 30, + range = 20.0, + radius = 2.5, + damage = 6.0, + count = 3 + ), + Ability.STORM to ability( + cooldown = 240, + windup = 30, + range = 24.0, + radius = 2.0, + damage = 6.0, + count = 3 + ), + Ability.ENSNARE to ability(cooldown = 200, windup = 10, range = 14.0, seconds = 2.0), + Ability.HOOK to ability(cooldown = 200, windup = 10, range = 16.0, damage = 2.0, power = 1.4), + Ability.SUMMON to ability(cooldown = 400, windup = 20, count = 2), + Ability.BULWARK to ability(cooldown = 400, seconds = 6.0, power = 200.0), + Ability.FROST_NOVA to ability( + cooldown = 240, + windup = 20, + radius = 5.0, + damage = 5.0, + seconds = 3.0 + ), + Ability.MIASMA to ability( + cooldown = 260, + windup = 10, + range = 16.0, + radius = 3.0, + damage = 1.5, + seconds = 6.0 + ), + Ability.DRAIN to ability(cooldown = 240, range = 10.0, damage = 1.5, seconds = 3.0, power = 50.0), + ), + ), + gear = GearTuning.DEFAULT, + forge = ForgeTuning.DEFAULT, + ) + + private fun ability( + cooldown: Int, + windup: Int = 0, + damage: Double = 0.0, + range: Double = 0.0, + radius: Double = 0.0, + seconds: Double = 0.0, + count: Int = 0, + power: Double = 0.0, + ) = AbilityStats(cooldown, windup, damage, range, radius, seconds, count, power) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/BalanceParser.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/BalanceParser.kt new file mode 100644 index 0000000..e76d1a8 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/BalanceParser.kt @@ -0,0 +1,207 @@ +package net.trilleo.mc.plugins.tritown.content + +import net.trilleo.mc.plugins.tritown.combat.Stat +import net.trilleo.mc.plugins.tritown.mobs.Ability + +/** + * Reads [Balance] out of the parsed YAML of `balance.yml`. + * + * Plain Kotlin over the map SnakeYAML produces, so the bundled file can be + * checked in a test. A value that is missing takes the bundled default + * silently; one that is present but unusable takes it too, and is named in + * [Result.warnings] so the owner hears about the typo instead of wondering why + * the change did nothing. + */ +object BalanceParser { + + class Result(val balance: Balance, val warnings: List) + + fun parse(root: Map<*, *>): Result { + val reader = YamlReader(root) + val default = Balance.DEFAULT + + val balance = Balance( + lens = reader.number(listOf("lens"), default.lens, min = 1.0), + player = Balance.PlayerBase( + health = reader.number(listOf("player", "health"), default.player.health, min = 1.0), + critChance = reader.number(listOf("player", "crit-chance"), default.player.critChance, min = 0.0), + critDamage = reader.number(listOf("player", "crit-damage"), default.player.critDamage, min = 0.0), + ), + jumpCritChance = reader.number(listOf("jump-crit-chance"), default.jumpCritChance, min = 0.0), + effects = Balance.EffectBonuses( + strengthPerLevel = reader.number( + listOf("effects", "strength-per-level"), + default.effects.strengthPerLevel, + min = 0.0 + ), + weaknessPerLevel = reader.number( + listOf("effects", "weakness-per-level"), + default.effects.weaknessPerLevel, + min = 0.0 + ), + ), + mobs = Balance.MobCurves( + healthGrowth = reader.number(listOf("mobs", "health-growth"), default.mobs.healthGrowth, min = 1.0), + damageGrowth = reader.number(listOf("mobs", "damage-growth"), default.mobs.damageGrowth, min = 1.0), + ), + vanilla = Balance.VanillaMapping( + armorPoint = reader.number(listOf("vanilla", "armor-point"), default.vanilla.armorPoint, min = 0.0), + toughnessPoint = reader.number( + listOf("vanilla", "toughness-point"), + default.vanilla.toughnessPoint, + min = 0.0 + ), + lootingMagicFind = reader.number( + listOf("vanilla", "looting-magic-find"), + default.vanilla.lootingMagicFind, + min = 0.0 + ), + bowAttack = reader.number(listOf("vanilla", "bow-attack"), default.vanilla.bowAttack, min = 0.0), + arrowReference = reader.number( + listOf("vanilla", "arrow-reference"), + default.vanilla.arrowReference, + min = 0.1 + ), + ), + ranks = Balance.Ranks( + elite = rank(reader, "elite", default.ranks.elite), + champion = rank(reader, "champion", default.ranks.champion), + ), + affixes = affixes(reader, default.affixes), + abilities = abilities(reader, default.abilities), + gear = gear(reader, default.gear), + forge = forge(reader, default.forge), + ) + return Result(balance, reader.warnings) + } + + private fun gear(reader: YamlReader, default: GearTuning): GearTuning { + val root = listOf("gear") + val roll = reader.range(root + "roll", default.rollMin..default.rollMax, min = 1) + return GearTuning( + slotPoints = GearSlot.entries.associateWith { slot -> + reader.number(root + "slot-points" + slot.name.lowercase(), default.points(slot), min = 0.0) + }, + stats = Stat.entries.associateWith { stat -> + val path = root + "stats" + name(stat) + GearTuning.StatCurve( + per100 = reader.number(path + "per-100", default.curve(stat).per100, min = 0.0), + growth = reader.number(path + "growth", default.curve(stat).growth, min = 1.0), + ) + }, + rarity = Rarity.entries.associateWith { rarity -> + reader.number(root + "rarity" + rarity.name.lowercase(), default.rarityMultiplier(rarity), min = 0.0) + }, + starBonus = reader.number(root + "star-bonus", default.starBonus, min = 0.0), + maxStars = reader.integer(root + "max-stars", default.maxStars, min = 0), + rollMin = roll.first, + rollMax = roll.last, + reforgeShare = reader.number(root + "reforge-share", default.reforgeShare, min = 0.0), + craftOdds = odds(reader, root + "craft-odds", default.craftOdds), + dropOdds = odds(reader, root + "drop-odds", default.dropOdds), + ) + } + + /** Rarities with their weights; a section left out keeps the default, and a rarity it leaves out is never picked. */ + private fun odds(reader: YamlReader, path: List, default: Map): Map { + val names = reader.keys(path) + if (names.isEmpty()) return default + return names.mapNotNull { name -> + val rarity = Rarity.of(name) ?: run { + reader.warn(path + name, "no rarity is called '$name'; left out") + return@mapNotNull null + } + rarity to reader.number(path + name, 0.0, min = 0.0) + }.toMap() + } + + private fun forge(reader: YamlReader, default: ForgeTuning): ForgeTuning { + val root = listOf("forge") + val capName = reader.text(root + "refine-cap") + val cap = capName?.let { name -> + Rarity.of(name) ?: run { + reader.warn( + root + "refine-cap", + "no rarity is called '$name'; using ${default.refineCap.name.lowercase()}" + ) + null + } + } ?: default.refineCap + return ForgeTuning( + moneyGrowth = reader.number(root + "money-growth", default.moneyGrowth, min = 1.0), + upgradeEssence = reader.integer(root + "upgrade" + "essence", default.upgradeEssence, min = 0), + upgradeMoney = reader.number(root + "upgrade" + "money", default.upgradeMoney, min = 0.0), + refineEssence = reader.integer(root + "refine" + "essence", default.refineEssence, min = 0), + refineMoney = reader.number(root + "refine" + "money", default.refineMoney, min = 0.0), + refineCap = cap, + reforgeEssence = reader.integer(root + "reforge" + "essence", default.reforgeEssence, min = 0), + reforgeMoney = reader.number(root + "reforge" + "money", default.reforgeMoney, min = 0.0), + salvageEssence = reader.integer(root + "salvage-essence", default.salvageEssence, min = 0), + ) + } + + private const val GLOBAL_COOLDOWN = "global-cooldown-ticks" + + /** A stat's name as the content files write it: `crit-chance`. */ + fun name(stat: Stat): String = stat.name.lowercase().replace('_', '-') + + private fun rank(reader: YamlReader, name: String, default: Balance.Rank): Balance.Rank { + val path = listOf("ranks", name) + val minAffixes = reader.integer(path + "min-affixes", default.minAffixes, min = 0) + return Balance.Rank( + chance = reader.number(path + "chance", default.chance, min = 0.0), + minLevel = reader.integer(path + "min-level", default.minLevel, min = 1), + health = reader.number(path + "health", default.health, min = 1.0), + damage = reader.number(path + "damage", default.damage, min = 1.0), + minAffixes = minAffixes, + maxAffixes = reader.integer(path + "max-affixes", default.maxAffixes, min = minAffixes), + ) + } + + private fun abilities(reader: YamlReader, default: Balance.AbilityTuning): Balance.AbilityTuning { + val root = listOf("abilities") + reader.keys(root) + .filter { it != GLOBAL_COOLDOWN && Ability.of(it) == null } + .forEach { reader.warn(root + it, "no ability is called '$it'; left out") } + + return Balance.AbilityTuning( + globalCooldownTicks = reader.integer(root + GLOBAL_COOLDOWN, default.globalCooldownTicks, min = 0), + stats = Ability.entries.associateWith { ability -> + val path = root + ability.configName + val fallback = default[ability] + Balance.AbilityStats( + cooldownTicks = reader.integer(path + "cooldown-ticks", fallback.cooldownTicks, min = 1), + windupTicks = reader.integer(path + "windup-ticks", fallback.windupTicks, min = 0), + damage = reader.number(path + "damage", fallback.damage, min = 0.0), + range = reader.number(path + "range", fallback.range, min = 0.0), + radius = reader.number(path + "radius", fallback.radius, min = 0.0), + seconds = reader.number(path + "seconds", fallback.seconds, min = 0.0), + count = reader.integer(path + "count", fallback.count, min = 0), + power = reader.number(path + "power", fallback.power, min = 0.0), + ) + }, + ) + } + + private fun affixes(reader: YamlReader, default: Balance.AffixTuning): Balance.AffixTuning { + fun number(name: String, value: Double) = reader.number(listOf("affixes", name), value, min = 0.0) + fun integer(name: String, value: Int) = reader.integer(listOf("affixes", name), value, min = 0) + + return Balance.AffixTuning( + armoredDefense = number("armored-defense", default.armoredDefense), + frenziedSpeed = number("frenzied-speed", default.frenziedSpeed), + vampiricHeal = number("vampiric-heal", default.vampiricHeal), + enragedBelow = number("enraged-below", default.enragedBelow), + enragedDamage = number("enraged-damage", default.enragedDamage), + moltenSeconds = integer("molten-seconds", default.moltenSeconds), + frostboundSeconds = integer("frostbound-seconds", default.frostboundSeconds), + venomousSeconds = integer("venomous-seconds", default.venomousSeconds), + volatilePower = number("volatile-power", default.volatilePower), + volatileDelayTicks = integer("volatile-delay-ticks", default.volatileDelayTicks), + summonerBelow = number("summoner-below", default.summonerBelow), + summonerMinions = integer("summoner-minions", default.summonerMinions), + blinkingRange = number("blinking-range", default.blinkingRange), + blinkingCooldownTicks = integer("blinking-cooldown-ticks", default.blinkingCooldownTicks), + ) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/ContentItemDef.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/ContentItemDef.kt new file mode 100644 index 0000000..8025919 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/ContentItemDef.kt @@ -0,0 +1,28 @@ +package net.trilleo.mc.plugins.tritown.content + +/** + * An item defined in `items.yml`: a material mobs drop, an essence, and so on. + * + * Every one is the same inert base item wearing the look of the vanilla item + * [model] names, so it cannot be crafted with, placed, eaten or used as what it + * looks like. Its name and lore are `item..name` and `item..lore` in the + * language files. + * + * @param model a vanilla item's id, such as `gunpowder`, whose look it takes + */ +data class ContentItemDef(val id: String, val model: String, val rarity: Rarity, val glint: Boolean) { + + val nameKey: String + get() = "$KEY_PREFIX$id.name" + + val loreKey: String + get() = "$KEY_PREFIX$id.lore" + + companion object { + /** Where content items' names live in the language files, which the language tests know is built at runtime. */ + const val KEY_PREFIX = "item." + + /** What an id may look like: it is also a translation key segment and a command argument. */ + val ID = Regex("[a-z0-9][a-z0-9-]*") + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/ContentItems.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/ContentItems.kt new file mode 100644 index 0000000..10b3ed5 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/ContentItems.kt @@ -0,0 +1,93 @@ +package net.trilleo.mc.plugins.tritown.content + +import io.papermc.paper.datacomponent.DataComponentTypes +import io.papermc.paper.datacomponent.item.ItemLore +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.format.TextDecoration +import net.trilleo.mc.plugins.tritown.registration.PluginItem +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.Lang +import org.bukkit.Material +import org.bukkit.NamespacedKey +import org.bukkit.inventory.ItemStack +import org.bukkit.persistence.PersistentDataType + +/** + * Makes and recognises the items `items.yml` defines. + * + * Every one is an echo shard wearing the look of the vanilla item its + * definition names. An echo shard's only use is crafting a recovery compass, + * which `ContentItemListener` refuses, so a content item does nothing but be + * itself. It carries its id under [PluginItem.ITEM_ID_KEY] like every other + * custom item. + * + * Its name and lore are written in the item language ([Lang.item]), so every + * copy is identical and they stack wherever they came from. Each carries a + * stamp of what it was drawn from, and [refresh] redraws one drawn from an + * older definition or language (see [ItemRedraw]). + */ +object ContentItems { + + val BASE: Material = Material.ECHO_SHARD + + private val REVISION = NamespacedKey("tritown", "revision") + + /** [amount] of the item [id] names, or `null` if `items.yml` has no such item. */ + fun create(id: String, amount: Int = 1): ItemStack? { + val def = ContentRegistry.items[id] ?: return null + return ItemStack.of(BASE, amount).also { draw(it, def) } + } + + /** + * Redraws [stack] if it is a content item drawn from an older definition or + * item language. Cheap when it is not: one read of its data. An item whose + * definition has left `items.yml` is left exactly as it is. + * + * @return whether it was redrawn + */ + fun refresh(stack: ItemStack?): Boolean { + val def = idOf(stack)?.let { ContentRegistry.items[it] } ?: return false + if (stack!!.persistentDataContainer.get(REVISION, PersistentDataType.INTEGER) == revision(def)) return false + draw(stack, def) + return true + } + + /** + * The id of the content item [stack] is, or `null` if it is not one — even + * one whose definition has since been taken out of `items.yml`, so it stays + * as inert as the day it dropped. Read without cloning its meta. + */ + fun idOf(stack: ItemStack?): String? { + if (stack == null || stack.isEmpty || stack.type != BASE) return null + return stack.persistentDataContainer.get(PluginItem.ITEM_ID_KEY, PersistentDataType.STRING) + } + + private fun draw(stack: ItemStack, def: ContentItemDef) { + stack.setData(DataComponentTypes.ITEM_MODEL, Key.key(Key.MINECRAFT_NAMESPACE, def.model)) + stack.setData(DataComponentTypes.ITEM_NAME, text(def.nameKey).applyFallbackStyle(def.rarity.color)) + stack.setData( + DataComponentTypes.LORE, + ItemLore.lore( + listOf( + text(def.loreKey).applyFallbackStyle(NamedTextColor.GRAY), + Component.empty(), + text(def.rarity.key), + ).map { it.decoration(TextDecoration.ITALIC, false) } + ), + ) + if (def.glint) { + stack.setData(DataComponentTypes.ENCHANTMENT_GLINT_OVERRIDE, true) + } else stack.resetData(DataComponentTypes.ENCHANTMENT_GLINT_OVERRIDE) + stack.editPersistentDataContainer { + it.set(PluginItem.ITEM_ID_KEY, PersistentDataType.STRING, def.id) + it.set(REVISION, PersistentDataType.INTEGER, revision(def)) + } + } + + private fun text(key: String): Component = ComponentUtil.parse(Lang.item(key)) + + /** A stamp of what [def] is drawn from, built from text so it is the same from one start to the next. */ + private fun revision(def: ContentItemDef): Int = 31 * def.toString().hashCode() + Lang.itemRevision +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/ContentParser.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/ContentParser.kt new file mode 100644 index 0000000..f97a831 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/ContentParser.kt @@ -0,0 +1,443 @@ +package net.trilleo.mc.plugins.tritown.content + +import net.trilleo.mc.plugins.tritown.combat.Stat +import net.trilleo.mc.plugins.tritown.mobs.Ability +import net.trilleo.mc.plugins.tritown.mobs.Affix + +/** + * Reads `items.yml`, `mobs.yml`, `gear.yml` and `bestiary.yml` into their models. + * + * Plain Kotlin over what SnakeYAML parses, like [BalanceParser], so the bundled + * files are checked in tests. An entry that cannot be used is left out and + * named in the result's warnings, rather than taking the rest of the file down + * with it. + */ +object ContentParser { + + class Result(val value: T, val warnings: List) + + fun items(root: Map<*, *>): Result> { + val reader = YamlReader(root) + val items = linkedMapOf() + + for (id in reader.keys(listOf("items"))) { + val path = listOf("items", id) + if (!ContentItemDef.ID.matches(id)) { + reader.warn(path, "an id may only use lower-case letters, digits and dashes; left out") + continue + } + val model = reader.text(path + "model") ?: run { + reader.warn(path, "has no model; left out") + continue + } + val rarityName = reader.text(path + "rarity") ?: Rarity.COMMON.name + val rarity = Rarity.of(rarityName) ?: run { + reader.warn(path + "rarity", "no rarity is called '$rarityName'; using common") + Rarity.COMMON + } + items[id] = ContentItemDef(id, model.lowercase(), rarity, reader.boolean(path + "glint", false)) + } + return Result(items, reader.warnings) + } + + /** `mobs.yml`, checked against the ids of the [items] it may name. */ + fun loot(root: Map<*, *>, items: Set): Result { + val reader = YamlReader(root) + val default = LootTable.EMPTY + + val families = linkedMapOf() + for (id in reader.keys(listOf("families"))) { + val path = listOf("families", id) + val material = reader.text(path + "material") + if (material == null || material !in items) { + reader.warn(path + "material", "'$material' is not an item in items.yml; left out") + continue + } + for (mob in reader.texts(path + "mobs").map { it.uppercase() }) { + val taken = families[mob] + if (taken != null) { + reader.warn(path + "mobs", "$mob already drops ${taken.material} for ${taken.id}; left there") + continue + } + families[mob] = LootTable.Family(id, material) + } + } + + val essence = reader.sections(listOf("essence")).mapIndexedNotNull { index, section -> + val grade = YamlReader(section) + val item = grade.text(listOf("item")) + if (item == null || item !in items) { + reader.warn(listOf("essence", "$index"), "'$item' is not an item in items.yml; left out") + return@mapIndexedNotNull null + } + LootTable.EssenceGrade(item, grade.integer(listOf("max-level"), Int.MAX_VALUE, min = 1)) + }.sortedBy { it.maxLevel } + + val table = LootTable( + families = families, + rules = LootTable.Rules( + minLevel = reader.integer(listOf("loot", "min-level"), default.rules.minLevel, min = 1), + playerShare = reader.number(listOf("loot", "player-share"), default.rules.playerShare, min = 0.0), + magicFindCap = reader.number(listOf("loot", "magic-find-cap"), default.rules.magicFindCap, min = 0.0), + ), + normal = drop(reader, "normal"), + elite = drop(reader, "elite"), + champion = drop(reader, "champion"), + essence = essence, + ) + return Result(table, reader.warnings) + } + + /** `gear.yml`, checked against the ids of the [items] a recipe may name. */ + fun gear(root: Map<*, *>, items: Set): Result { + val reader = YamlReader(root) + + val reforges = linkedMapOf() + for (id in reader.keys(listOf("reforges"))) { + val path = listOf("reforges", id) + if (!ContentItemDef.ID.matches(id)) { + reader.warn(path, "an id may only use lower-case letters, digits and dashes; left out") + continue + } + val slots = reader.texts(path + "slots").mapNotNull { name -> + GearSlot.of(name) ?: run { + reader.warn(path + "slots", "no slot is called '$name'; left out") + null + } + }.toSet() + val weights = weights(reader, path + "weights") ?: continue + if (slots.isEmpty()) { + reader.warn(path, "fits no slot; left out") + continue + } + reforges[id] = ReforgeDef(id, slots, weights) + } + + val gear = linkedMapOf() + for (id in reader.keys(listOf("gear"))) { + val path = listOf("gear", id) + if (!ContentItemDef.ID.matches(id)) { + reader.warn(path, "an id may only use lower-case letters, digits and dashes; left out") + continue + } + val slotName = reader.text(path + "slot") + val slot = slotName?.let(GearSlot::of) ?: run { + reader.warn(path + "slot", "no slot is called '$slotName'; left out") + continue + } + val base = reader.text(path + "base") ?: run { + reader.warn(path, "has no base item; left out") + continue + } + val weights = weights(reader, path + "weights") ?: continue + + gear[id] = GearDef( + id = id, + slot = slot, + tier = reader.integer(path + "tier", 1, min = 1), + base = base.uppercase(), + model = reader.text(path + "model")?.lowercase(), + trim = trim(reader, path + "trim"), + dye = reader.text(path + "dye")?.let { dye(reader, path + "dye", it) }, + weights = weights, + recipe = recipe(reader, path + "recipe", items), + ) + } + return Result(GearCatalog(gear, reforges), reader.warnings) + } + + /** + * `bestiary.yml`, checked against the ids of the [items] and [gear] its + * costumes and loot may name. What only a server can check — that a base is + * a kind of mob, a biome exists, a costume's item is real — is left to + * `ContentRegistry`. + */ + fun bestiary(root: Map<*, *>, items: Set, gear: Set): Result { + val reader = YamlReader(root) + val kinds = linkedMapOf() + + for (id in reader.keys(listOf("variants"))) { + val path = listOf("variants", id) + if (!ContentItemDef.ID.matches(id)) { + reader.warn(path, "an id may only use lower-case letters, digits and dashes; left out") + continue + } + val base = reader.text(path + "base") ?: run { + reader.warn(path, "has no base kind of mob; left out") + continue + } + if (reader.keys(path + "spawn").isEmpty()) { + reader.warn(path, "has no spawn section, so it would never appear; left out") + continue + } + kinds[id] = kind(reader, path, id, base, items, gear).copy( + spawn = spawnRule(reader, path + "spawn"), + rankable = reader.boolean(path + "rankable", true), + ) + } + + for (id in reader.keys(listOf("bosses"))) { + val path = listOf("bosses", id) + if (!ContentItemDef.ID.matches(id) || id in kinds) { + reader.warn(path, "an id must be new and use only lower-case letters, digits and dashes; left out") + continue + } + val base = reader.text(path + "base") ?: run { + reader.warn(path, "has no base kind of mob; left out") + continue + } + val sigil = reader.text(path + "sigil") + if (sigil == null || sigil !in items) { + reader.warn( + path + "sigil", + "'$sigil' is not an item in items.yml, so nothing could summon it; left out" + ) + continue + } + if (kinds.values.any { it.boss?.sigil == sigil }) { + reader.warn(path + "sigil", "'$sigil' already summons another boss; left out") + continue + } + kinds[id] = kind(reader, path, id, base, items, gear).copy(boss = boss(reader, path, sigil)) + } + + val named = kinds.mapValues { (id, def) -> + if (def.minions == null || def.minions in kinds) return@mapValues def + val section = if (def.boss != null) "bosses" else "variants" + reader.warn( + listOf(section, id, "minions"), + "'${def.minions}' is not a custom mob here; it calls its own kind" + ) + def.copy(minions = null) + } + return Result(BestiaryCatalog(named, bossRules(reader)), reader.warnings) + } + + /** What every custom mob has, variant or boss, as neither: the caller says which it is. */ + private fun kind( + reader: YamlReader, + path: List, + id: String, + base: String, + items: Set, + gear: Set, + ) = MobKindDef( + id = id, + base = base.uppercase(), + spawn = null, + boss = null, + rankable = false, + health = reader.number(path + "health", 1.0, min = 0.1), + damage = reader.number(path + "damage", 1.0, min = 0.1), + defense = reader.number(path + "defense", 0.0, min = 0.0), + speed = reader.number(path + "speed", 0.0, min = -90.0), + scale = reader.number(path + "scale", 1.0, min = 0.1), + knockback = reader.number(path + "knockback", 0.0, min = 0.0).coerceAtMost(100.0), + affixes = affixes(reader, path + "affixes"), + abilities = abilities(reader, path + "abilities"), + minions = reader.text(path + "minions")?.lowercase(), + equipment = equipment(reader, path + "equipment", gear), + loot = kindLoot(reader, path + "loot", items, gear), + ) + + private fun boss(reader: YamlReader, path: List, sigil: String): BossDef { + val barName = reader.text(path + "bar")?.lowercase() + val bar = barName?.takeIf { it in BossDef.BAR_COLORS } ?: run { + if (barName != null) reader.warn(path + "bar", "no boss bar colour is called '$barName'; using red") + "red" + } + val phases = reader.sections(path + "phases").mapIndexed { index, section -> + val phase = YamlReader(section) + val at = path + "phases" + "$index" + BossPhase( + below = phase.number(listOf("below"), 0.0, min = 0.0).coerceAtMost(100.0), + affixes = affixes(phase, listOf("affixes")), + abilities = abilities(phase, listOf("abilities")), + summon = phase.integer(listOf("summon"), 0, min = 0), + ).also { phase.warnings.forEach { reader.warn(at, it) } } + } + val place = path + "place" + return BossDef( + level = reader.integer(path + "level", 1, min = 1), + sigil = sigil, + place = SummonPlace( + worlds = reader.texts(place + "worlds").map { it.lowercase() }.toSet(), + water = reader.boolean(place + "water", false), + maxY = height(reader, place + "max-y"), + ), + arena = reader.number(path + "arena", 24.0, min = 4.0), + bar = bar, + phases = phases.sortedByDescending { it.below }, + ) + } + + private fun bossRules(reader: YamlReader): BossRules { + val path = listOf("rules") + val default = BossRules.DEFAULT + val odds = reader.keys(path + "gear-odds").mapNotNull { name -> + val rarity = Rarity.of(name) ?: run { + reader.warn(path + "gear-odds" + name, "no rarity is called '$name'; left out") + return@mapNotNull null + } + rarity to reader.number(path + "gear-odds" + name, 0.0, min = 0.0) + }.toMap() + return BossRules( + contributorShare = reader.number(path + "contributor-share", default.contributorShare, min = 0.0), + lootRange = reader.number(path + "loot-range", default.lootRange, min = 0.0), + idleSeconds = reader.integer(path + "idle-seconds", default.idleSeconds, min = 1), + ritualSeconds = reader.integer(path + "ritual-seconds", default.ritualSeconds, min = 0), + arenaMargin = reader.number(path + "arena-margin", default.arenaMargin, min = 0.0), + gearChance = reader.number(path + "gear-chance", default.gearChance, min = 0.0), + gearOdds = odds.ifEmpty { default.gearOdds }, + ) + } + + private fun spawnRule(reader: YamlReader, path: List): SpawnRule { + val levels = reader.range(path + "levels", 1..Int.MAX_VALUE, min = 1) + val timeName = reader.text(path + "time") + val time = timeName?.let { name -> + SpawnTime.of(name) ?: run { + reader.warn(path + "time", "must be any, day or night, not '$name'; using any") + null + } + } ?: SpawnTime.ANY + return SpawnRule( + worlds = reader.texts(path + "worlds").map { it.lowercase() }.toSet(), + biomes = reader.texts(path + "biomes").map { biome -> + biome.lowercase().let { if (':' in it) it else "minecraft:$it" } + }.toSet(), + minLevel = levels.first, + maxLevel = levels.last, + chance = reader.number(path + "chance", 0.0, min = 0.0), + time = time, + minY = height(reader, path + "min-y"), + maxY = height(reader, path + "max-y"), + ) + } + + private fun height(reader: YamlReader, path: List): Int? = + if (reader.text(path) == null) null else reader.integer(path, 0, min = Int.MIN_VALUE) + + private fun affixes(reader: YamlReader, path: List): Set = + reader.texts(path).mapNotNull { name -> + Affix.entries.firstOrNull { it.name.equals(name, ignoreCase = true) } ?: run { + reader.warn(path, "no affix is called '$name'; left out") + null + } + }.toSet() + + private fun abilities(reader: YamlReader, path: List): List = + reader.texts(path).mapNotNull { name -> + Ability.of(name) ?: run { + reader.warn(path, "no ability is called '$name'; left out") + null + } + }.distinct() + + /** `gear:` for a piece of gear's look, or a vanilla item's name. */ + private fun equipment(reader: YamlReader, path: List, gear: Set): Map = + reader.keys(path).mapNotNull { name -> + val slot = CostumeSlot.of(name) ?: run { + reader.warn(path + name, "no slot is called '$name'; left out") + return@mapNotNull null + } + val text = reader.text(path + name) ?: return@mapNotNull null + val costume = if (text.startsWith(GEAR_PREFIX)) { + val id = text.removePrefix(GEAR_PREFIX) + if (id !in gear) { + reader.warn(path + name, "'$id' is not gear in gear.yml; left bare") + return@mapNotNull null + } + Costume.Gear(id) + } else Costume.Vanilla(text.uppercase()) + slot to costume + }.toMap() + + private fun kindLoot( + reader: YamlReader, + path: List, + items: Set, + gear: Set + ): List = + reader.sections(path).mapIndexedNotNull { index, section -> + val entry = YamlReader(section) + val at = path + "$index" + val chance = entry.number(listOf("chance"), 0.0, min = 0.0) + val item = entry.text(listOf("item")) + val piece = entry.text(listOf("gear")) + val result = when { + item != null && item in items -> { + val amount = entry.range(listOf("amount"), 1..1, min = 1) + LootEntry.Item(item, chance, amount.first, amount.last) + } + + piece != null && piece in gear -> LootEntry.Gear(piece, chance) + else -> { + reader.warn(at, "names neither an item in items.yml nor gear in gear.yml; left out") + null + } + } + entry.warnings.forEach { reader.warn(at, it) } + result + } + + /** Stat weights, scaled to sum to 1, or `null` (and a warning) when there are none to use. */ + private fun weights(reader: YamlReader, path: List): Map? { + val raw = reader.keys(path).mapNotNull { name -> + val stat = Stat.entries.firstOrNull { BalanceParser.name(it) == name } ?: run { + reader.warn(path + name, "no stat is called '$name'; left out") + return@mapNotNull null + } + stat to reader.number(path + name, 0.0, min = 0.0) + }.filter { it.second > 0.0 } + val total = raw.sumOf { it.second } + if (total <= 0.0) { + reader.warn(path, "gives no stat any weight; left out") + return null + } + return raw.associate { (stat, weight) -> stat to weight / total } + } + + private fun trim(reader: YamlReader, path: List): GearDef.Trim? { + val pattern = reader.text(path + "pattern") ?: return null + val material = reader.text(path + "material") ?: run { + reader.warn(path, "a trim needs both a pattern and a material; left off") + return null + } + return GearDef.Trim(pattern.lowercase(), material.lowercase()) + } + + private fun dye(reader: YamlReader, path: List, text: String): Int? { + val rgb = text.removePrefix("#").toIntOrNull(16)?.takeIf { text.removePrefix("#").length == 6 } + if (rgb == null) reader.warn(path, "'$text' is not a colour such as '#3a5f8c'; left undyed") + return rgb + } + + private fun recipe(reader: YamlReader, path: List, items: Set): GearDef.Recipe? { + if (reader.keys(path).isEmpty()) return null + val needs = reader.keys(path + "items").mapNotNull { id -> + if (id !in items) { + reader.warn(path + "items" + id, "'$id' is not an item in items.yml; the recipe is left out") + return null + } + id to reader.integer(path + "items" + id, 1, min = 1) + }.toMap() + return GearDef.Recipe(needs, reader.number(path + "money", 0.0, min = 0.0)) + } + + private fun drop(reader: YamlReader, rank: String): LootTable.Drop { + val path = listOf("loot", rank) + val amount = reader.range(path + "amount", 1..1, min = 0) + val essence = reader.range(path + "essence", 0..0, min = 0) + return LootTable.Drop( + chance = reader.number(path + "chance", 0.0, min = 0.0), + minAmount = amount.first, + maxAmount = amount.last, + minEssence = essence.first, + maxEssence = essence.last, + gearChance = reader.number(path + "gear-chance", 0.0, min = 0.0), + ) + } + + private const val GEAR_PREFIX = "gear:" +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/ContentRegistry.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/ContentRegistry.kt new file mode 100644 index 0000000..1be45e4 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/ContentRegistry.kt @@ -0,0 +1,167 @@ +package net.trilleo.mc.plugins.tritown.content + +import io.papermc.paper.registry.RegistryAccess +import io.papermc.paper.registry.RegistryKey +import org.bukkit.Material +import org.bukkit.NamespacedKey +import org.bukkit.entity.EntityType +import org.bukkit.entity.Mob +import org.bukkit.inventory.EquipmentSlot +import org.bukkit.plugin.java.JavaPlugin +import org.yaml.snakeyaml.Yaml +import java.io.File + +/** + * The content files: what the combat layer is made of and tuned with. + * + * | File | Holds | + * |:---------------|:--------------------------------------------------------| + * | `balance.yml` | [Balance]: the numbers every fight is worked out with | + * | `items.yml` | [ContentItemDef]s: materials, essence | + * | `mobs.yml` | [LootTable]: which mob drops what | + * | `gear.yml` | [GearCatalog]: gear, its recipes, and reforges | + * | `bestiary.yml` | [BestiaryCatalog]: custom mobs | + * + * They live in `plugins/TriTown/content/`, copied from the jar on first start + * the way the language files are, and `/tritown reload` reads them again. What + * each describes is swapped in whole, so nothing ever sees half of an edit. + * + * A file that cannot be read keeps what was loaded before it — nothing, or the + * bundled balance, the first time — and says so in the console, so a typo made + * while the server is running never takes combat down with it. + */ +object ContentRegistry { + + private const val FOLDER = "content" + private const val BALANCE = "balance.yml" + private const val ITEMS = "items.yml" + private const val MOBS = "mobs.yml" + private const val GEAR = "gear.yml" + private const val BESTIARY = "bestiary.yml" + + @Volatile + private var currentBalance: Balance? = null + + /** The combat tuning in force; the bundled defaults until [load] has run. */ + val balance: Balance + get() = currentBalance ?: Balance.DEFAULT + + @Volatile + var items: Map = emptyMap() + private set + + @Volatile + var loot: LootTable = LootTable.EMPTY + private set + + @Volatile + var gear: GearCatalog = GearCatalog.EMPTY + private set + + @Volatile + var bestiary: BestiaryCatalog = BestiaryCatalog.EMPTY + private set + + fun load(plugin: JavaPlugin) { + val folder = File(plugin.dataFolder, FOLDER) + listOf(BALANCE, ITEMS, MOBS, GEAR, BESTIARY).forEach { + if (!File(folder, it).exists()) plugin.saveResource("$FOLDER/$it", false) + } + + read(plugin, File(folder, BALANCE))?.let { root -> + val result = BalanceParser.parse(root) + report(plugin, BALANCE, result.warnings) + currentBalance = result.balance + } + + read(plugin, File(folder, ITEMS))?.let { root -> + val result = ContentParser.items(root) + val unknownModels = result.value.values + .filter { Material.matchMaterial(it.model) == null } + .map { "items.${it.id}.model: '${it.model}' is not a vanilla item, so it will look broken" } + report(plugin, ITEMS, result.warnings + unknownModels) + items = result.value + } + + read(plugin, File(folder, MOBS))?.let { root -> + val result = ContentParser.loot(root, items.keys) + val kinds = EntityType.entries.map { it.name }.toSet() + val unknownMobs = result.value.families.keys + .filter { it !in kinds } + .map { "families: '$it' is not a kind of mob" } + report(plugin, MOBS, result.warnings + unknownMobs) + loot = result.value + } + + read(plugin, File(folder, GEAR))?.let { root -> + val result = ContentParser.gear(root, items.keys) + val usable = result.value.gear.filterValues { def -> baseProblem(def) == null } + val problems = result.value.gear.values.mapNotNull { def -> + baseProblem(def)?.let { "gear.${def.id}.base: $it; left out" } + } + result.value.gear.values.mapNotNull { def -> + def.model?.takeIf { Material.matchMaterial(it) == null } + ?.let { "gear.${def.id}.model: '$it' is not a vanilla item, so it will look broken" } + } + report(plugin, GEAR, result.warnings + problems) + gear = result.value.copy(gear = usable) + } + + read(plugin, File(folder, BESTIARY))?.let { root -> + val result = ContentParser.bestiary(root, items.keys, gear.gear.keys) + val kinds = result.value.kinds.values + val usable = kinds.filter { baseProblem(it) == null }.associateBy { it.id } + val problems = kinds.mapNotNull { def -> baseProblem(def)?.let { "${def.id}.base: $it; left out" } } + + kinds.flatMap(::lookProblems) + report(plugin, BESTIARY, result.warnings + problems) + bestiary = BestiaryCatalog(usable) + } + } + + /** Why [def]'s base cannot be a custom mob, or `null` if it can. */ + private fun baseProblem(def: MobKindDef): String? { + val type = EntityType.entries.firstOrNull { it.name == def.base } ?: return "'${def.base}' is not a kind of mob" + val mob = type.entityClass?.let { Mob::class.java.isAssignableFrom(it) } == true + return if (!mob || !type.isSpawnable) "${type.name} is not a mob that can be spawned" else null + } + + /** Biomes that do not exist and costume items that are not items, which only look wrong. */ + private fun lookProblems(def: MobKindDef): List { + val biomes = RegistryAccess.registryAccess().getRegistry(RegistryKey.BIOME) + val unknownBiomes = def.spawn?.biomes.orEmpty() + .filter { key -> NamespacedKey.fromString(key)?.let(biomes::get) == null } + .map { "${def.id}.spawn.biomes: '$it' is not a biome, so it never matches" } + val unknownItems = def.equipment.values.filterIsInstance() + .filter { Material.matchMaterial(it.material)?.isItem != true } + .map { "${def.id}.equipment: '${it.material.lowercase()}' is not a vanilla item; left bare" } + return unknownBiomes + unknownItems + } + + /** Why [def]'s base item cannot be worn or held where its slot says, or `null` if it can. */ + private fun baseProblem(def: GearDef): String? { + val base = Material.matchMaterial(def.base)?.takeIf { it.isItem } + ?: return "'${def.base}' is not a vanilla item" + val wanted = when (def.slot) { + GearSlot.WEAPON, GearSlot.BOW -> EquipmentSlot.HAND + GearSlot.HELMET -> EquipmentSlot.HEAD + GearSlot.CHESTPLATE -> EquipmentSlot.CHEST + GearSlot.LEGGINGS -> EquipmentSlot.LEGS + GearSlot.BOOTS -> EquipmentSlot.FEET + } + if (base.equipmentSlot != wanted) return "${base.name} is not worn as ${def.slot.name.lowercase()}" + if (def.slot == GearSlot.BOW && base != Material.BOW && base != Material.CROSSBOW) { + return "${base.name} is not a bow or crossbow" + } + return null + } + + private fun report(plugin: JavaPlugin, file: String, warnings: List) { + warnings.forEach { plugin.logger.warning("$FOLDER/$file: $it") } + } + + private fun read(plugin: JavaPlugin, file: File): Map<*, *>? = try { + file.reader(Charsets.UTF_8).use { Yaml().load(it) } as? Map<*, *> ?: emptyMap() + } catch (e: Exception) { + plugin.logger.severe("$FOLDER/${file.name} could not be read, so what was loaded before stays in force: ${e.message}") + null + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/GearDef.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/GearDef.kt new file mode 100644 index 0000000..c179272 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/GearDef.kt @@ -0,0 +1,78 @@ +package net.trilleo.mc.plugins.tritown.content + +import net.trilleo.mc.plugins.tritown.combat.Stat + +/** + * A piece of gear defined in `gear.yml`. + * + * It never lists its stats: it lists [weights], and its tier's budget in + * `balance.yml` turns them into numbers. Its name and flavour line are + * `gear.item..name` and `gear.item..lore` in the language files. + * + * @param base the vanilla item it is made of, by its Minecraft name. Between players the piece is exactly that item + * @param model a vanilla item's id whose look it takes instead of its base's, if any + * @param weights how its points are shared between stats, summing to 1 + */ +data class GearDef( + val id: String, + val slot: GearSlot, + val tier: Int, + val base: String, + val model: String?, + val trim: Trim?, + val dye: Int?, + val weights: Map, + val recipe: Recipe?, +) { + + /** An armor trim, by the ids of its pattern and material, such as `coast` and `emerald`. */ + data class Trim(val pattern: String, val material: String) + + /** What the Forge takes to craft the piece: content items by id, and money. */ + data class Recipe(val items: Map, val money: Double) + + val nameKey: String + get() = "$KEY_PREFIX$id.name" + + val loreKey: String + get() = "$KEY_PREFIX$id.lore" + + companion object { + /** Where gear's names live in the language files, which the language tests know is built at runtime. */ + const val KEY_PREFIX = "gear.item." + } +} + +/** + * A reforge: a small, named extra a piece can be given at the Forge, worth + * `reforge-share` of its points and spent by its own [weights]. Its name is + * `gear.reforge.` and goes before the piece's. + */ +data class ReforgeDef(val id: String, val slots: Set, val weights: Map) { + + val key: String + get() = "$KEY_PREFIX$id" + + companion object { + const val KEY_PREFIX = "gear.reforge." + } +} + +/** Everything `gear.yml` defines. */ +data class GearCatalog(val gear: Map, val reforges: Map) { + + fun reforgesFor(slot: GearSlot): List = reforges.values.filter { slot in it.slots } + + /** + * A stamp of the content a piece's tooltip is drawn from — its words come + * from the item language, which `Gear` stamps alongside — so a piece drawn + * before an edit can tell it is stale. Built from text, which is the same + * from one start to the next, where an enum's hash is not. + */ + fun revision(id: String, tuning: GearTuning): Int = + listOf(gear[id], reforges, tuning).joinToString().hashCode() + + companion object { + val EMPTY = GearCatalog(emptyMap(), emptyMap()) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/GearSlot.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/GearSlot.kt new file mode 100644 index 0000000..bce4c26 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/GearSlot.kt @@ -0,0 +1,33 @@ +package net.trilleo.mc.plugins.tritown.content + +/** Where a piece of gear goes, which decides how many points it has to spend on stats. */ +enum class GearSlot { + /** Held in the main hand and swung, or thrown if it is a trident. */ + WEAPON, + + /** A bow or crossbow in the main hand. Its Damage only counts for what it shoots. */ + BOW, + + HELMET, + CHESTPLATE, + LEGGINGS, + BOOTS; + + val isArmor: Boolean + get() = this != WEAPON && this != BOW + + /** The translation key of the slot's label on a tooltip, spelled out so the language test can see it. */ + val key: String + get() = when (this) { + WEAPON -> "gear.slot.weapon" + BOW -> "gear.slot.bow" + HELMET -> "gear.slot.helmet" + CHESTPLATE -> "gear.slot.chestplate" + LEGGINGS -> "gear.slot.leggings" + BOOTS -> "gear.slot.boots" + } + + companion object { + fun of(name: String): GearSlot? = entries.firstOrNull { it.name.equals(name, ignoreCase = true) } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/GearTuning.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/GearTuning.kt new file mode 100644 index 0000000..db48899 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/GearTuning.kt @@ -0,0 +1,131 @@ +package net.trilleo.mc.plugins.tritown.content + +import net.trilleo.mc.plugins.tritown.combat.Stat +import kotlin.math.pow + +/** + * What gear is worth, the `gear` block of `balance.yml`. + * + * Every piece has the same number of points as every other piece of its slot + * and tier, and spends them on stats by the weights `gear.yml` gives it. So an + * item's shape is its author's choice, and its worth is the budget's: no piece + * can come out stronger than its tier allows. + * + * @param slotPoints points a piece has to spend, by slot + * @param stats what points buy of each stat, and how that grows with the tier + * @param rarity what each rarity multiplies a piece's stats by + * @param starBonus percent more base stats each star gives + * @param rollMin the lowest quality, in percent, a stat can roll at + * @param reforgeShare a reforge's worth, in percent of the piece's points + * @param craftOdds the rarities the Forge crafts, weighted + * @param dropOdds the rarities a champion's gear drops at, weighted + */ +data class GearTuning( + val slotPoints: Map, + val stats: Map, + val rarity: Map, + val starBonus: Double, + val maxStars: Int, + val rollMin: Int, + val rollMax: Int, + val reforgeShare: Double, + val craftOdds: Map, + val dropOdds: Map, +) { + + /** + * What 100 points buy of one stat on tier-1 gear, and how much more each + * tier above buys: a tier-T piece's points buy `growth^(T-1)` times as much. + */ + data class StatCurve(val per100: Double, val growth: Double) { + + fun value(points: Double, tier: Int): Double = points / 100.0 * per100 * growth.pow(tier.coerceAtLeast(1) - 1) + } + + fun points(slot: GearSlot): Double = slotPoints[slot] ?: 0.0 + + fun curve(stat: Stat): StatCurve = stats[stat] ?: StatCurve(0.0, 1.0) + + fun rarityMultiplier(rarity: Rarity): Double = this.rarity[rarity] ?: 1.0 + + fun starMultiplier(stars: Int): Double = 1.0 + starBonus / 100.0 * stars.coerceIn(0, maxStars) + + companion object { + val DEFAULT = GearTuning( + slotPoints = mapOf( + GearSlot.WEAPON to 100.0, + GearSlot.BOW to 100.0, + GearSlot.HELMET to 50.0, + GearSlot.CHESTPLATE to 80.0, + GearSlot.LEGGINGS to 70.0, + GearSlot.BOOTS to 40.0, + ), + stats = mapOf( + Stat.HEALTH to StatCurve(120.0, 1.5), + Stat.DEFENSE to StatCurve(100.0, 1.3), + Stat.DAMAGE to StatCurve(50.0, 1.7), + Stat.STRENGTH to StatCurve(85.0, 1.4), + Stat.CRIT_CHANCE to StatCurve(20.0, 1.0), + Stat.CRIT_DAMAGE to StatCurve(80.0, 1.25), + Stat.SPEED to StatCurve(10.0, 1.0), + Stat.VITALITY to StatCurve(40.0, 1.05), + Stat.MAGIC_FIND to StatCurve(30.0, 1.05), + ), + rarity = mapOf( + Rarity.COMMON to 1.0, + Rarity.UNCOMMON to 1.06, + Rarity.RARE to 1.12, + Rarity.EPIC to 1.2, + Rarity.LEGENDARY to 1.3, + Rarity.MYTHIC to 1.4, + ), + starBonus = 4.0, + maxStars = 5, + rollMin = 90, + rollMax = 100, + reforgeShare = 8.0, + craftOdds = mapOf(Rarity.COMMON to 60.0, Rarity.UNCOMMON to 25.0, Rarity.RARE to 11.0, Rarity.EPIC to 4.0), + dropOdds = mapOf( + Rarity.UNCOMMON to 45.0, + Rarity.RARE to 35.0, + Rarity.EPIC to 15.0, + Rarity.LEGENDARY to 5.0 + ), + ) + } +} + +/** + * What the Forge charges, the `forge` block of `balance.yml`. Money is scaled + * by `moneyGrowth^(tier-1)`, so a tier-10 piece costs what its tier is worth. + * + * @param refineCap the highest rarity the Forge refines to; anything above only drops + */ +data class ForgeTuning( + val moneyGrowth: Double, + val upgradeEssence: Int, + val upgradeMoney: Double, + val refineEssence: Int, + val refineMoney: Double, + val refineCap: Rarity, + val reforgeEssence: Int, + val reforgeMoney: Double, + val salvageEssence: Int, +) { + + fun money(base: Double, tier: Int): Double = base * moneyGrowth.pow(tier.coerceAtLeast(1) - 1) + + companion object { + val DEFAULT = ForgeTuning( + moneyGrowth = 1.5, + upgradeEssence = 2, + upgradeMoney = 100.0, + refineEssence = 6, + refineMoney = 400.0, + refineCap = Rarity.LEGENDARY, + reforgeEssence = 1, + reforgeMoney = 150.0, + salvageEssence = 2, + ) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/ItemRedraw.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/ItemRedraw.kt new file mode 100644 index 0000000..6434a15 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/ItemRedraw.kt @@ -0,0 +1,26 @@ +package net.trilleo.mc.plugins.tritown.content + +import net.trilleo.mc.plugins.tritown.gear.Gear +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack + +/** + * Keeps what TriTown draws on items — gear and content items — in step with + * the content files and the item language. + * + * The server cannot show an item's text to each player in their own language, + * so both are written in the one item language and carry a stamp of what they + * were drawn from. An edit to a content file, a language file or + * `item-language` leaves every copy out there stale until something asks + * here, which redraws it in place and costs one read when nothing changed. + */ +object ItemRedraw { + + /** Redraws [stack] if it is gear or a content item drawn from older content. @return whether it was redrawn */ + fun refresh(stack: ItemStack?): Boolean = Gear.refresh(stack) || ContentItems.refresh(stack) + + /** [refresh]es every stack in [inventory]. */ + fun refreshAll(inventory: Inventory) { + inventory.contents.forEach(::refresh) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/LootTable.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/LootTable.kt new file mode 100644 index 0000000..9845dbc --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/LootTable.kt @@ -0,0 +1,61 @@ +package net.trilleo.mc.plugins.tritown.content + +/** + * What wild mobs drop on top of vanilla's loot, read from `mobs.yml`. + * + * Mob kinds are held by their `EntityType` names, so this stays plain Kotlin + * that a test can build. + * + * @param families every mob kind that drops a material, by `EntityType` name + * @param essence the essence each level drops, lowest first: a mob drops the first grade its level is within + */ +data class LootTable( + val families: Map, + val rules: Rules, + val normal: Drop, + val elite: Drop, + val champion: Drop, + val essence: List, +) { + + /** Mob kinds that drop the same [material]. */ + data class Family(val id: String, val material: String) + + /** + * @param minLevel the lowest level a mob drops anything extra at + * @param playerShare percent of a mob's health players must have dealt for it to drop anything extra + * @param magicFindCap the most Magic Find that counts + */ + data class Rules(val minLevel: Int, val playerShare: Double, val magicFindCap: Double) + + /** + * What a mob of one rank drops: its family's material [chance] percent of + * the time, and essence; and a finished piece of gear [gearChance] percent + * of the time. Amounts are inclusive ranges; an essence range of zero drops + * none. + */ + data class Drop( + val chance: Double, + val minAmount: Int, + val maxAmount: Int, + val minEssence: Int, + val maxEssence: Int, + val gearChance: Double, + ) + + data class EssenceGrade(val item: String, val maxLevel: Int) + + /** The essence a mob of [level] drops: the first grade its level is within, or the last. */ + fun essenceFor(level: Int): String? = (essence.firstOrNull { level <= it.maxLevel } ?: essence.lastOrNull())?.item + + companion object { + val EMPTY = LootTable( + families = emptyMap(), + rules = Rules(minLevel = 1, playerShare = 50.0, magicFindCap = 100.0), + normal = Drop(0.0, 0, 0, 0, 0, 0.0), + elite = Drop(0.0, 0, 0, 0, 0, 0.0), + champion = Drop(0.0, 0, 0, 0, 0, 0.0), + essence = emptyList(), + ) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/MobKindDef.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/MobKindDef.kt new file mode 100644 index 0000000..bbcd189 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/MobKindDef.kt @@ -0,0 +1,239 @@ +package net.trilleo.mc.plugins.tritown.content + +import net.trilleo.mc.plugins.tritown.mobs.Ability +import net.trilleo.mc.plugins.tritown.mobs.Affix + +/** + * A custom mob defined in `bestiary.yml`: a vanilla kind of mob with a name, a + * look, traits and loot of its own. A variant takes the place of wild spawns + * ([spawn]); a boss is summoned with its sigil ([boss]). + * + * It never lists its health or its hits. It lists what it multiplies its + * kind's by, on top of what its level and rank make of them, and a mob only + * stores which kind it is — so a retune reaches every one already out there. + * Its name and description are `mob.kind..name` and `mob.kind..lore` in + * the language files. + * + * @param base the vanilla kind it is, by its `EntityType` name + * @param spawn when it takes the place of a wild spawn of its [base] kind; `null` for a boss + * @param boss what makes it a boss, or `null` for a variant + * @param rankable whether it may still spawn as an elite or a champion + * @param health what it multiplies its pool by + * @param damage what it multiplies its hits by + * @param defense Defense it has, the way a player's armor works against mobs + * @param speed percent faster it moves; less than 0 is slower + * @param scale how large it is, where 1 is its kind's own size + * @param knockback percent of knockback it shrugs off + * @param affixes affixes it always has, on top of any its rank rolls + * @param abilities what it does in a fight, on cooldowns + * @param minions the id of the kind its Summon calls, or `null` for its own + * @param equipment what it wears and holds, for the look alone + * @param loot what it drops on top of its family's material and its rank's essence; a + * boss's is all it drops, rolled once for each player who earned a share + */ +data class MobKindDef( + val id: String, + val base: String, + val spawn: SpawnRule?, + val boss: BossDef?, + val rankable: Boolean, + val health: Double, + val damage: Double, + val defense: Double, + val speed: Double, + val scale: Double, + val knockback: Double, + val affixes: Set, + val abilities: List, + val minions: String?, + val equipment: Map, + val loot: List, +) { + + val nameKey: String + get() = "$KEY_PREFIX$id.name" + + val loreKey: String + get() = "$KEY_PREFIX$id.lore" + + /** The line announced as a boss enters its phase at [index], counting from 0. */ + fun phaseKey(index: Int): String = "$KEY_PREFIX$id.phase-${index + 1}" + + companion object { + /** Where custom mobs' names live in the language files, which the language tests know is built at runtime. */ + const val KEY_PREFIX = "mob.kind." + } +} + +/** + * When a custom mob takes the place of a wild spawn of its kind: somewhere it + * may live, at a level it may be, and then [chance] percent of the time. + * + * @param worlds kinds of world (`normal`, `nether`, `the_end`) or world names it lives in; empty for any + * @param biomes biome keys such as `minecraft:desert` it lives in; empty for any + */ +data class SpawnRule( + val worlds: Set, + val biomes: Set, + val minLevel: Int, + val maxLevel: Int, + val chance: Double, + val time: SpawnTime, + val minY: Int?, + val maxY: Int?, +) { + + /** Whether a wild spawn at [level], at [place], may become this kind before its chance is rolled. */ + fun allows(level: Int, place: SpawnPlace): Boolean { + if (level !in minLevel..maxLevel) return false + if (worlds.isNotEmpty() && place.environment !in worlds && place.world !in worlds) return false + if (biomes.isNotEmpty() && place.biome !in biomes) return false + if (minY != null && place.y < minY) return false + if (maxY != null && place.y > maxY) return false + return when (time) { + SpawnTime.ANY -> true + SpawnTime.DAY -> !place.night + SpawnTime.NIGHT -> place.night + } + } +} + +enum class SpawnTime { + ANY, DAY, NIGHT; + + companion object { + fun of(name: String): SpawnTime? = entries.firstOrNull { it.name.equals(name, ignoreCase = true) } + } +} + +/** + * Where a wild mob is spawning, as plain values a [SpawnRule] is checked + * against. Names are lower-case, and the biome is a full key. + * + * @param night whether it is night in the Overworld; never in the Nether or the End + */ +data class SpawnPlace(val environment: String, val world: String, val biome: String, val y: Int, val night: Boolean) + +/** + * What makes a custom mob a boss: a fight a player starts on purpose, with its + * [sigil], at a fixed [level], held to an arena around where it was summoned. + * + * @param sigil the id of the item in `items.yml` that summons it + * @param arena how far from where it was summoned it fights, in blocks + * @param bar the colour of its boss bar: pink, blue, red, green, yellow, purple or white + * @param phases what changes as its health falls, highest threshold first + */ +data class BossDef( + val level: Int, + val sigil: String, + val place: SummonPlace, + val arena: Double, + val bar: String, + val phases: List, +) { + companion object { + val BAR_COLORS = setOf("pink", "blue", "red", "green", "yellow", "purple", "white") + } +} + +/** + * Where a boss may be summoned. + * + * @param worlds kinds of world (`normal`, `nether`, `the_end`) or world names; empty for any + * @param water whether it must be summoned from water + * @param maxY the highest it may be summoned at, or `null` for any height + */ +data class SummonPlace(val worlds: Set, val water: Boolean, val maxY: Int?) + +/** + * What a boss gains once its health falls below [below] percent: affixes and + * abilities it keeps from then on, and [summon] minions called at once. + */ +data class BossPhase(val below: Double, val affixes: Set, val abilities: List, val summon: Int) + +/** + * How every boss fight goes, from the `rules` block of `bestiary.yml`. + * + * @param contributorShare percent of a boss's health a player must have dealt to earn a share of its loot + * @param lootRange how near a contributor must be for their share to fall where the boss did; further off, it goes + * straight to them + * @param idleSeconds how long a boss waits with nobody in its arena before it leaves + * @param ritualSeconds how long a sigil takes to summon its boss + * @param arenaMargin how far past its arena a boss may be pulled before it is taken back + * @param gearChance percent chance each share has of a finished piece of the boss's tier, too + * @param gearOdds the rarities a boss's gear drops at, weighted + */ +data class BossRules( + val contributorShare: Double, + val lootRange: Double, + val idleSeconds: Int, + val ritualSeconds: Int, + val arenaMargin: Double, + val gearChance: Double, + val gearOdds: Map, +) { + companion object { + val DEFAULT = BossRules( + contributorShare = 10.0, + lootRange = 48.0, + idleSeconds = 120, + ritualSeconds = 3, + arenaMargin = 8.0, + gearChance = 25.0, + gearOdds = mapOf(Rarity.RARE to 40.0, Rarity.EPIC to 35.0, Rarity.LEGENDARY to 20.0, Rarity.MYTHIC to 5.0), + ) + } +} + +/** Where a custom mob wears or holds a piece of its costume. */ +enum class CostumeSlot { + HEAD, CHEST, LEGS, FEET, HAND, OFF_HAND; + + companion object { + fun of(name: String): CostumeSlot? = + entries.firstOrNull { it.name.equals(name.replace('-', '_'), ignoreCase = true) } + } +} + +/** + * What a custom mob wears in one slot. Only ever a look: it gives the mob no + * armor or attack damage, and never drops. + */ +sealed interface Costume { + + /** A piece of gear from `gear.yml`, by id, as it looks. */ + data class Gear(val id: String) : Costume + + /** A vanilla item, by its Minecraft name. */ + data class Vanilla(val material: String) : Costume +} + +/** One line of a custom mob's own loot, rolled on its own, [chance] percent of the time. */ +sealed interface LootEntry { + + val chance: Double + + /** Between [min] and [max] of the content item [id]. */ + data class Item(val id: String, override val chance: Double, val min: Int, val max: Int) : LootEntry + + /** A finished piece of the gear [id], at a rarity from `drop-odds`. */ + data class Gear(val id: String, override val chance: Double) : LootEntry +} + +/** Everything `bestiary.yml` defines: its custom mobs by id, and how boss fights go. */ +data class BestiaryCatalog(val kinds: Map, val rules: BossRules = BossRules.DEFAULT) { + + private val variants: Map> = kinds.values.filter { it.spawn != null }.groupBy { it.base } + private val bySigil: Map = + kinds.values.mapNotNull { def -> def.boss?.let { it.sigil to def } }.toMap() + + /** The kinds that may take the place of a wild spawn of [type] (an `EntityType` name), in the file's order. */ + fun variantsFor(type: String): List = variants[type].orEmpty() + + /** The boss the item [id] summons, if it is a sigil. */ + fun bossForSigil(id: String): MobKindDef? = bySigil[id] + + companion object { + val EMPTY = BestiaryCatalog(emptyMap()) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/Rarity.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/Rarity.kt new file mode 100644 index 0000000..8e62602 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/Rarity.kt @@ -0,0 +1,33 @@ +package net.trilleo.mc.plugins.tritown.content + +import net.kyori.adventure.text.format.NamedTextColor + +/** + * How rare a content item or a piece of gear is. + * + * An item's name takes its rarity's colour, so the name keys in the language + * files carry no colour of their own and every item of a rarity reads alike. + */ +enum class Rarity(val color: NamedTextColor) { + COMMON(NamedTextColor.WHITE), + UNCOMMON(NamedTextColor.GREEN), + RARE(NamedTextColor.BLUE), + EPIC(NamedTextColor.DARK_PURPLE), + LEGENDARY(NamedTextColor.GOLD), + MYTHIC(NamedTextColor.LIGHT_PURPLE); + + /** The translation key of the rarity's label, spelled out so the language test can see it. */ + val key: String + get() = when (this) { + COMMON -> "rarity.common" + UNCOMMON -> "rarity.uncommon" + RARE -> "rarity.rare" + EPIC -> "rarity.epic" + LEGENDARY -> "rarity.legendary" + MYTHIC -> "rarity.mythic" + } + + companion object { + fun of(name: String): Rarity? = entries.firstOrNull { it.name.equals(name, ignoreCase = true) } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/YamlReader.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/YamlReader.kt new file mode 100644 index 0000000..8ea5b87 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/content/YamlReader.kt @@ -0,0 +1,97 @@ +package net.trilleo.mc.plugins.tritown.content + +/** + * Typed reads out of the map SnakeYAML parses a content file into, collecting + * a warning for every value that is present but unusable. + * + * A value that is simply missing takes its default quietly: an owner who + * leaves a line out wants the default. One that is there but wrong takes the + * default too, and is named in [warnings], so a typo is heard about instead of + * silently doing nothing. + */ +class YamlReader(private val root: Map<*, *>) { + + val warnings = mutableListOf() + + fun number(path: List, default: Double, min: Double): Double { + val raw = find(path) ?: return default + val value = (raw as? Number)?.toDouble() + if (value == null || value.isNaN() || value.isInfinite() || value < min) { + warn(path, "a number of at least $min", raw, default) + return default + } + return value + } + + fun integer(path: List, default: Int, min: Int): Int { + val raw = find(path) ?: return default + val value = wholeNumber(raw) + if (value == null || value < min) { + warn(path, "a whole number of at least $min", raw, default) + return default + } + return value + } + + /** + * A `[low, high]` pair of whole numbers of at least [min], or a single one + * standing for both. + */ + fun range(path: List, default: IntRange, min: Int): IntRange { + val raw = find(path) ?: return default + val bounds = when (raw) { + is List<*> -> raw.map(::wholeNumber) + else -> listOf(wholeNumber(raw), wholeNumber(raw)) + } + val low = bounds.getOrNull(0) + val high = bounds.getOrNull(1) + if (bounds.size != 2 || low == null || high == null || low < min || high < low) { + warn(path, "[low, high] whole numbers of at least $min", raw, "[${default.first}, ${default.last}]") + return default + } + return low..high + } + + fun boolean(path: List, default: Boolean): Boolean { + val raw = find(path) ?: return default + if (raw !is Boolean) { + warn(path, "true or false", raw, default) + return default + } + return raw + } + + fun text(path: List): String? = find(path)?.toString()?.takeIf { it.isNotBlank() } + + fun texts(path: List): List = when (val raw = find(path)) { + null -> emptyList() + is List<*> -> raw.mapNotNull { it?.toString()?.takeIf(String::isNotBlank) } + else -> listOf(raw.toString()) + } + + /** The keys of the section at [path], in the order the file has them. */ + fun keys(path: List): List = + (find(path) as? Map<*, *>)?.keys?.map { it.toString() }.orEmpty() + + /** The entries of the list at [path] that are sections. */ + fun sections(path: List): List> = + (find(path) as? List<*>)?.filterIsInstance>().orEmpty() + + /** Records a problem this reader could not see for itself, such as an id that names nothing. */ + fun warn(path: List, message: String) { + warnings += "${path.joinToString(".")}: $message" + } + + private fun warn(path: List, expected: String, raw: Any, default: Any) { + warnings += "${path.joinToString(".")} must be $expected, not '$raw'; using $default" + } + + private fun wholeNumber(raw: Any?): Int? = + (raw as? Number)?.takeIf { it.toDouble() == it.toLong().toDouble() }?.toInt() + + private fun find(path: List): Any? { + var node: Any? = root + for (segment in path) node = (node as? Map<*, *>)?.get(segment) ?: return null + return node + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyContext.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyContext.kt index 832e03d..0bdc51b 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyContext.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/EconomyContext.kt @@ -45,4 +45,5 @@ object EconomyContext { const val SOURCE_SHOP = "shop" const val SOURCE_TRADE = "trade" const val SOURCE_STORAGE = "storage" + const val SOURCE_GEAR = "gear" } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionReason.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionReason.kt index 313e63e..d1e48c3 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionReason.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/economy/TransactionReason.kt @@ -29,6 +29,10 @@ object TransactionReason { const val SHOP_SELL = "money.reason.shop-sell" const val TRADE = "money.reason.trade" const val STORAGE_PAGE = "money.reason.storage-page" + const val GEAR_CRAFT = "money.reason.gear-craft" + const val GEAR_UPGRADE = "money.reason.gear-upgrade" + const val GEAR_REFINE = "money.reason.gear-refine" + const val GEAR_REFORGE = "money.reason.gear-reforge" /** Encodes [key] and its [args] into the single string that is stored with the transaction. */ fun of(key: String, vararg args: Pair): String = diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/FlowCategory.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/FlowCategory.kt index e0ac273..9a6ae01 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/FlowCategory.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/FlowCategory.kt @@ -25,6 +25,9 @@ enum class FlowCategory { /** Pages of a player's storage, bought from the server. Always a sink. */ STORAGE, + /** Crafting, upgrading, refining and reforging gear at the Forge. Always a sink. */ + GEAR, + /** Anything Towny moved: bank deposits and withdrawals, plot sales, upkeep, taxes. */ TOWNY, @@ -46,6 +49,7 @@ enum class FlowCategory { STARTING_BALANCE -> "money.flow.starting-balance" SHOP -> "money.flow.shop" STORAGE -> "money.flow.storage" + GEAR -> "money.flow.gear" TOWNY -> "money.flow.towny" ADMIN -> "money.flow.admin" PAYMENT -> "money.flow.payment" @@ -55,6 +59,13 @@ enum class FlowCategory { companion object { + private val GEAR_REASONS = setOf( + TransactionReason.GEAR_CRAFT, + TransactionReason.GEAR_UPGRADE, + TransactionReason.GEAR_REFINE, + TransactionReason.GEAR_REFORGE, + ) + /** * The category a transaction recorded with [source] and [reason] belongs * to. @@ -70,11 +81,13 @@ enum class FlowCategory { key == TransactionReason.STARTING_BALANCE -> STARTING_BALANCE key == TransactionReason.SHOP_BUY || key == TransactionReason.SHOP_SELL -> SHOP key == TransactionReason.STORAGE_PAGE -> STORAGE + key in GEAR_REASONS -> GEAR key == TransactionReason.TOWNY || key == TransactionReason.TOWN_DELETED -> TOWNY key == TransactionReason.ADMIN_SET || key == TransactionReason.ADMIN_RESET -> ADMIN key == TransactionReason.PAYMENT || key == TransactionReason.TRADE -> PAYMENT source == EconomyContext.SOURCE_SHOP -> SHOP source == EconomyContext.SOURCE_STORAGE -> STORAGE + source == EconomyContext.SOURCE_GEAR -> GEAR source == EconomyContext.SOURCE_TOWNY -> TOWNY key == TransactionReason.EXTERNAL -> EXTERNAL else -> OTHER diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/PagedLayout.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/PagedLayout.kt index 6e717ab..e0e1e14 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/PagedLayout.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/enums/PagedLayout.kt @@ -19,15 +19,4 @@ enum class PagedLayout { * grid of loose items. */ FRAMED, - - /** - * Framed, with each page's items centred inside the border rather than - * filled from its top-left corner. - * - * A full page looks exactly like [FRAMED]. A page holding a few — the last - * one, or a list that is short to begin with — keeps its items together in - * the middle of the menu, with the last row packed around the centre column. - * Meant for lists that are often short, such as the players online. - */ - CENTERED, } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/Forge.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/Forge.kt new file mode 100644 index 0000000..8f6c243 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/Forge.kt @@ -0,0 +1,145 @@ +package net.trilleo.mc.plugins.tritown.gear + +import net.trilleo.mc.plugins.tritown.content.ContentItems +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.content.GearDef +import net.trilleo.mc.plugins.tritown.economy.EconomyContext +import net.trilleo.mc.plugins.tritown.economy.TransactionReason +import net.trilleo.mc.plugins.tritown.gear.Forge.refusal +import net.trilleo.mc.plugins.tritown.utils.EconomyUtil +import net.trilleo.mc.plugins.tritown.utils.InventoryUtil +import org.bukkit.entity.Player +import org.bukkit.inventory.ItemStack +import kotlin.random.Random + +/** + * Everything the Forge does, and the only place gear is made or changed. + * + * Every action keeps the same order, the one `ShopTrade` keeps: **everything + * that can refuse is asked before anything is taken**. The items are counted, + * then the money is charged, and only once it has gone are the items taken and + * the piece made or changed — the items cannot vanish in between, since it all + * happens in one tick. A piece being changed is the one in the player's main + * hand, read afresh at the moment of the change. + * + * Money moves through [EconomyUtil] as `gear`, so the admin panel files it + * under the Forge. + */ +object Forge { + + enum class Outcome { DONE, NOT_GEAR, MAXED, MISSING_ITEMS, CANNOT_AFFORD, UNAVAILABLE } + + /** What a finished action produced, for the message: the piece as it is now. */ + class Result(val outcome: Outcome, val piece: Gear.Piece? = null, val returned: Map = emptyMap()) + + fun craft(player: Player, def: GearDef): Result { + val recipe = def.recipe ?: return Result(Outcome.UNAVAILABLE) + val cost = ForgeCosts.Cost(recipe.items, recipe.money) + val reason = TransactionReason.of(TransactionReason.GEAR_CRAFT, "item" to def.id) + refusal(player, cost)?.let { return Result(it) } + if (!charge(player, cost, reason)) return Result(Outcome.CANNOT_AFFORD) + + take(player, cost.items) + val rarity = GearStats.pick(ContentRegistry.balance.gear.craftOdds, Random) + val stack = Gear.create(def, rarity) ?: return Result(Outcome.UNAVAILABLE) + InventoryUtil.give(player, listOf(stack)) + return Result(Outcome.DONE, Gear.read(stack)) + } + + fun upgrade(player: Player): Result = change( + player, TransactionReason.GEAR_UPGRADE, + cost = { piece -> + ForgeCosts.upgrade(piece.def, piece.data, balance.gear, balance.forge, ContentRegistry.loot) + }, + apply = { piece -> piece.data.copy(stars = piece.data.stars + 1) }, + ) + + fun refine(player: Player): Result = change( + player, TransactionReason.GEAR_REFINE, + cost = { piece -> ForgeCosts.refine(piece.def, piece.data, balance.forge, ContentRegistry.loot) }, + apply = { piece -> piece.data.copy(rarity = ForgeCosts.nextRarity(piece.data.rarity)) }, + ) + + /** A new reforge, never the one the piece already has when another would fit. */ + fun reforge(player: Player): Result = change( + player, TransactionReason.GEAR_REFORGE, + cost = { piece -> + ForgeCosts.reforge(piece.def, balance.forge, ContentRegistry.loot) + .takeIf { ContentRegistry.gear.reforgesFor(piece.def.slot).isNotEmpty() } + }, + apply = { piece -> + val choices = ContentRegistry.gear.reforgesFor(piece.def.slot) + val fresh = choices.filter { it.id != piece.data.reforge }.ifEmpty { choices } + piece.data.copy(reforge = fresh.random().id) + }, + ) + + /** Breaks the held piece down into essence. Nothing is charged, so there is nothing to refuse but the piece. */ + fun salvage(player: Player): Result { + val held = player.inventory.itemInMainHand + val piece = Gear.read(held) ?: return Result(Outcome.NOT_GEAR) + val returned = ForgeCosts.salvage(piece.def, piece.data, balance.forge, ContentRegistry.loot) + + player.inventory.setItemInMainHand(null) + InventoryUtil.give(player, returned.mapNotNull { (id, amount) -> ContentItems.create(id, amount) }) + return Result(Outcome.DONE, piece, returned) + } + + /** How many of the content item [id] the player carries. */ + fun count(player: Player, id: String): Int = + player.inventory.storageContents.filter { ContentItems.idOf(it) == id }.sumOf { it!!.amount } + + /** + * Changes the piece in the player's main hand: [cost] says what it costs, + * or `null` when it cannot go further; [apply] says what it becomes. + */ + private fun change( + player: Player, + reason: String, + cost: (Gear.Piece) -> ForgeCosts.Cost?, + apply: (Gear.Piece) -> GearData, + ): Result { + val held = player.inventory.itemInMainHand + val piece = Gear.read(held) ?: return Result(Outcome.NOT_GEAR) + val price = cost(piece) ?: return Result(Outcome.MAXED) + refusal(player, price)?.let { return Result(it) } + if (!charge(player, price, TransactionReason.of(reason, "item" to piece.def.id))) { + return Result(Outcome.CANNOT_AFFORD) + } + + take(player, price.items) + val changed = apply(piece) + Gear.save(held, changed) + player.inventory.setItemInMainHand(held) + return Result(Outcome.DONE, Gear.read(held)) + } + + /** Why the player cannot pay [cost], or `null` if they can. */ + private fun refusal(player: Player, cost: ForgeCosts.Cost): Outcome? { + if (cost.items.any { (id, amount) -> count(player, id) < amount }) return Outcome.MISSING_ITEMS + if (cost.money > 0.0 && !EconomyUtil.isAvailable) return Outcome.UNAVAILABLE + return null + } + + private fun charge(player: Player, cost: ForgeCosts.Cost, reason: String): Boolean = + cost.money <= 0.0 || EconomyUtil.withdraw(player, cost.money, EconomyContext.SOURCE_GEAR, reason) + + /** Takes [items] out of the player's inventory, which [refusal] has already checked holds them. */ + private fun take(player: Player, items: Map) { + val inventory = player.inventory + for ((id, wanted) in items) { + var left = wanted + for (slot in 0 until inventory.storageContents.size) { + if (left <= 0) break + val stack: ItemStack = inventory.getItem(slot) ?: continue + if (ContentItems.idOf(stack) != id) continue + val used = minOf(stack.amount, left) + stack.amount -= used + inventory.setItem(slot, stack.takeIf { it.amount > 0 }) + left -= used + } + } + } + + private val balance get() = ContentRegistry.balance +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/ForgeCosts.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/ForgeCosts.kt new file mode 100644 index 0000000..5cce7ea --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/ForgeCosts.kt @@ -0,0 +1,55 @@ +package net.trilleo.mc.plugins.tritown.gear + +import net.trilleo.mc.plugins.tritown.content.* + +/** + * What each thing the Forge does costs, worked out from `balance.yml` and the + * essence grades in `mobs.yml`. Plain Kotlin, so `ForgeCostsTest` checks it. + * + * Essence is the grade a mob of the piece's tier drops — a mob of level + * `6 × tier` — so upgrading gear means fighting at its tier. + */ +object ForgeCosts { + + /** Content items by id, and money. */ + data class Cost(val items: Map, val money: Double) + + /** The levels one tier of gear is made for. */ + const val LEVELS_PER_TIER = 6 + + /** The essence a piece of [tier] is upgraded with, or `null` when `mobs.yml` has none. */ + fun essence(tier: Int, loot: LootTable): String? = loot.essenceFor(tier * LEVELS_PER_TIER) + + /** What the next star costs, or `null` when [data] already has every star. */ + fun upgrade(def: GearDef, data: GearData, gear: GearTuning, forge: ForgeTuning, loot: LootTable): Cost? { + if (data.stars >= gear.maxStars) return null + val step = data.stars + 1 + return cost(def, loot, forge.upgradeEssence * step, forge.money(forge.upgradeMoney * step, def.tier)) + } + + /** What the next step of rarity costs, or `null` when [data] is already as rare as the Forge refines. */ + fun refine(def: GearDef, data: GearData, forge: ForgeTuning, loot: LootTable): Cost? { + if (data.rarity >= forge.refineCap) return null + val step = data.rarity.ordinal + 1 + return cost(def, loot, forge.refineEssence * step, forge.money(forge.refineMoney * step, def.tier)) + } + + fun reforge(def: GearDef, forge: ForgeTuning, loot: LootTable): Cost = + cost(def, loot, forge.reforgeEssence, forge.money(forge.reforgeMoney, def.tier)) + + /** What salvaging [data] gives back: essence for each step of rarity, and one more for each star. */ + fun salvage(def: GearDef, data: GearData, forge: ForgeTuning, loot: LootTable): Map { + val essence = essence(def.tier, loot) ?: return emptyMap() + val amount = forge.salvageEssence * (data.rarity.ordinal + 1) + data.stars + return if (amount > 0) mapOf(essence to amount) else emptyMap() + } + + /** The rarity after [rarity] that the Forge refines to next. */ + fun nextRarity(rarity: Rarity): Rarity = Rarity.entries[(rarity.ordinal + 1).coerceAtMost(Rarity.entries.size - 1)] + + private fun cost(def: GearDef, loot: LootTable, essence: Int, money: Double): Cost { + val grade = essence(def.tier, loot) + val items = if (grade != null && essence > 0) mapOf(grade to essence) else emptyMap() + return Cost(items, money) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/Gear.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/Gear.kt new file mode 100644 index 0000000..be870e8 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/Gear.kt @@ -0,0 +1,105 @@ +package net.trilleo.mc.plugins.tritown.gear + +import net.trilleo.mc.plugins.tritown.combat.StatSheet +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.content.GearDef +import net.trilleo.mc.plugins.tritown.content.GearSlot +import net.trilleo.mc.plugins.tritown.content.Rarity +import net.trilleo.mc.plugins.tritown.utils.Lang +import org.bukkit.Material +import org.bukkit.inventory.ItemStack +import kotlin.random.Random + +/** + * Makes gear, keeps it drawn, and says what a piece is worth — the one entry + * point the rest of TriTown uses. + * + * A piece is a stack of its definition's base item with a [GearData] on it. + * Its stats are never stored: [stats] works them out from the content in + * force, and [refresh] redraws a piece whose tooltip was drawn from older + * content or another item language, so an edit to `gear.yml`, `balance.yml` + * or the language files reaches every piece. + */ +object Gear { + + /** A new piece of [def] at [rarity], with fresh rolls, or `null` if its base is not an item. */ + fun create(def: GearDef, rarity: Rarity, random: Random = Random): ItemStack? { + val base = Material.matchMaterial(def.base)?.takeIf { it.isItem } ?: return null + val stack = ItemStack.of(base) + val tuning = ContentRegistry.balance.gear + val data = GearData( + id = def.id, + rarity = rarity, + stars = 0, + rolls = GearStats.roll(def, tuning, random), + reforge = null, + revision = 0, + ) + return stack.also { save(it, data) } + } + + /** A piece of [def] as the Forge shows it before it is made: common, with every stat at its average roll. */ + fun preview(def: GearDef): ItemStack? { + val base = Material.matchMaterial(def.base)?.takeIf { it.isItem } ?: return null + val average = GearStats.averageRoll(ContentRegistry.balance.gear) + val data = GearData(def.id, Rarity.COMMON, 0, def.weights.keys.associateWith { average }, null, 0) + return ItemStack.of(base).also { save(it, data) } + } + + /** + * [def]'s look alone, for a custom mob to wear: its base item with its + * model, trim and dye, and no data, so it is not a piece of gear at all. + */ + fun costume(def: GearDef): ItemStack? { + val base = Material.matchMaterial(def.base)?.takeIf { it.isItem } ?: return null + return ItemStack.of(base).also { GearRender.look(it, def) } + } + + /** What [stack] is and what it is worth, or `null` if it is not gear the content files still define. */ + fun read(stack: ItemStack?): Piece? { + val data = GearCodec.read(stack) ?: return null + val def = ContentRegistry.gear.gear[data.id] ?: return null + return Piece(def, data) + } + + /** The stats [stack] gives, or `null` if it is not gear the content files still define. */ + fun stats(stack: ItemStack?): StatSheet? = read(stack)?.stats + + /** Whether [stack] is shot rather than swung: a bow or crossbow, gear or not. */ + fun isRanged(stack: ItemStack?): Boolean { + if (stack == null || stack.isEmpty) return false + return read(stack)?.def?.slot == GearSlot.BOW || stack.type == Material.BOW || stack.type == Material.CROSSBOW + } + + /** Writes [data] onto [stack] and redraws it. */ + fun save(stack: ItemStack, data: GearData) { + val def = ContentRegistry.gear.gear[data.id] ?: return + val stamped = data.copy(revision = revision(def)) + GearRender.draw(stack, def, stamped, Piece(def, stamped).stats, reforge(stamped)) + } + + /** + * Redraws [stack] if the content it was drawn from has changed since. + * Cheap when nothing has: one read of its data. + * + * @return whether it was redrawn + */ + fun refresh(stack: ItemStack?): Boolean { + val data = GearCodec.read(stack) ?: return false + val def = ContentRegistry.gear.gear[data.id] ?: return false + if (data.revision == revision(def)) return false + save(stack!!, data) + return true + } + + private fun revision(def: GearDef): Int = + 31 * ContentRegistry.gear.revision(def.id, ContentRegistry.balance.gear) + Lang.itemRevision + + private fun reforge(data: GearData) = data.reforge?.let { ContentRegistry.gear.reforges[it] } + + /** A piece of gear: its definition and its data, and what they are worth together. */ + class Piece(val def: GearDef, val data: GearData) { + + val stats: StatSheet by lazy { GearStats.of(def, data, ContentRegistry.balance.gear, reforge(data)) } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/GearCodec.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/GearCodec.kt new file mode 100644 index 0000000..67b8781 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/GearCodec.kt @@ -0,0 +1,65 @@ +package net.trilleo.mc.plugins.tritown.gear + +import net.trilleo.mc.plugins.tritown.combat.Stat +import net.trilleo.mc.plugins.tritown.content.BalanceParser +import net.trilleo.mc.plugins.tritown.content.Rarity +import org.bukkit.NamespacedKey +import org.bukkit.inventory.ItemStack +import org.bukkit.persistence.PersistentDataContainer +import org.bukkit.persistence.PersistentDataType + +/** + * Reads and writes a piece's [GearData], kept in the item's own persistent + * data under `tritown:gear`. Whatever holds the item — an inventory, a storage + * page, a trade, a shop's copy — carries it along untouched. + */ +object GearCodec { + + private val GEAR = NamespacedKey("tritown", "gear") + private val ID = NamespacedKey("tritown", "id") + private val RARITY = NamespacedKey("tritown", "rarity") + private val STARS = NamespacedKey("tritown", "stars") + private val ROLLS = NamespacedKey("tritown", "rolls") + private val REFORGE = NamespacedKey("tritown", "reforge") + private val REVISION = NamespacedKey("tritown", "revision") + + /** What [stack] is, or `null` if it is not a piece of gear. Read without cloning its meta. */ + fun read(stack: ItemStack?): GearData? { + if (stack == null || stack.isEmpty) return null + val data = stack.persistentDataContainer.get(GEAR, PersistentDataType.TAG_CONTAINER) ?: return null + val id = data.get(ID, PersistentDataType.STRING) ?: return null + return GearData( + id = id, + rarity = data.get(RARITY, PersistentDataType.STRING)?.let(Rarity::of) ?: Rarity.COMMON, + stars = data.get(STARS, PersistentDataType.INTEGER) ?: 0, + rolls = rolls(data), + reforge = data.get(REFORGE, PersistentDataType.STRING), + revision = data.get(REVISION, PersistentDataType.INTEGER) ?: 0, + ) + } + + fun write(stack: ItemStack, gear: GearData) { + stack.editPersistentDataContainer { container -> + val data = container.adapterContext.newPersistentDataContainer() + data.set(ID, PersistentDataType.STRING, gear.id) + data.set(RARITY, PersistentDataType.STRING, gear.rarity.name) + data.set(STARS, PersistentDataType.INTEGER, gear.stars) + data.set( + ROLLS, + PersistentDataType.LIST.strings(), + gear.rolls.map { (stat, roll) -> "${BalanceParser.name(stat)}=$roll" }, + ) + gear.reforge?.let { data.set(REFORGE, PersistentDataType.STRING, it) } + data.set(REVISION, PersistentDataType.INTEGER, gear.revision) + container.set(GEAR, PersistentDataType.TAG_CONTAINER, data) + } + } + + private fun rolls(data: PersistentDataContainer): Map = + data.get(ROLLS, PersistentDataType.LIST.strings()).orEmpty().mapNotNull { entry -> + val name = entry.substringBefore('=') + val stat = Stat.entries.firstOrNull { BalanceParser.name(it) == name } ?: return@mapNotNull null + val roll = entry.substringAfter('=').toIntOrNull() ?: return@mapNotNull null + stat to roll + }.toMap() +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/GearData.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/GearData.kt new file mode 100644 index 0000000..cec2714 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/GearData.kt @@ -0,0 +1,23 @@ +package net.trilleo.mc.plugins.tritown.gear + +import net.trilleo.mc.plugins.tritown.combat.Stat +import net.trilleo.mc.plugins.tritown.content.Rarity + +/** + * What a piece of gear is: which piece, and what happened to it since it was + * made. **Never its stats**, which [GearStats] works out from this and the + * content files every time, so retuning `gear.yml` or `balance.yml` reaches + * every piece already out there. + * + * @param rolls the quality, in percent, each stat rolled when the piece was made + * @param reforge the id of its reforge, if it has one + * @param revision the content stamp its tooltip was last drawn with + */ +data class GearData( + val id: String, + val rarity: Rarity, + val stars: Int, + val rolls: Map, + val reforge: String?, + val revision: Int, +) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/GearRender.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/GearRender.kt new file mode 100644 index 0000000..c6bdc2b --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/GearRender.kt @@ -0,0 +1,129 @@ +package net.trilleo.mc.plugins.tritown.gear + +import io.papermc.paper.datacomponent.DataComponentTypes +import io.papermc.paper.datacomponent.item.DyedItemColor +import io.papermc.paper.datacomponent.item.ItemArmorTrim +import io.papermc.paper.datacomponent.item.ItemLore +import io.papermc.paper.datacomponent.item.TooltipDisplay +import net.kyori.adventure.key.Key +import net.kyori.adventure.text.Component +import net.kyori.adventure.text.format.NamedTextColor +import net.kyori.adventure.text.format.TextDecoration +import net.trilleo.mc.plugins.tritown.combat.CombatFormat +import net.trilleo.mc.plugins.tritown.combat.Stat +import net.trilleo.mc.plugins.tritown.combat.StatSheet +import net.trilleo.mc.plugins.tritown.content.GearDef +import net.trilleo.mc.plugins.tritown.content.Rarity +import net.trilleo.mc.plugins.tritown.content.ReforgeDef +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.Lang +import org.bukkit.Color +import org.bukkit.Material +import org.bukkit.NamespacedKey +import org.bukkit.Registry +import org.bukkit.inventory.ItemStack +import org.bukkit.inventory.meta.trim.ArmorTrim + +/** + * Draws a piece of gear: its name, its tooltip and its look. + * + * Every word is written in the item language ([Lang.item]), the one language + * all items share, and numbers are plain text beside them; only the base + * item's name is the game's own key, which each client fills in itself. + * Drawing works on the stack in place and leaves alone what a player gave it — + * enchantments, an anvil name — so a redraw after a content or language edit + * loses nothing. + */ +object GearRender { + + private val RARE_ENOUGH_TO_GLOW = Rarity.EPIC + + fun draw(stack: ItemStack, def: GearDef, data: GearData, stats: StatSheet, reforge: ReforgeDef?) { + stack.setData(DataComponentTypes.ITEM_NAME, name(def, data, reforge)) + stack.setData(DataComponentTypes.LORE, ItemLore.lore(lore(def, data, stats).map(::plain))) + stack.setData(DataComponentTypes.UNBREAKABLE) + stack.setData( + DataComponentTypes.TOOLTIP_DISPLAY, + TooltipDisplay.tooltipDisplay() + .addHiddenComponents(DataComponentTypes.UNBREAKABLE, DataComponentTypes.ATTRIBUTE_MODIFIERS) + .build(), + ) + + look(stack, def) + + if (data.rarity >= RARE_ENOUGH_TO_GLOW) { + stack.setData(DataComponentTypes.ENCHANTMENT_GLINT_OVERRIDE, true) + } else stack.resetData(DataComponentTypes.ENCHANTMENT_GLINT_OVERRIDE) + + GearCodec.write(stack, data) + } + + /** + * Gives [stack] the look [def] names — its model, trim and dye — and + * nothing else. A custom mob wears a piece's look this way (see + * [Gear.costume]). + */ + fun look(stack: ItemStack, def: GearDef) { + if (def.model != null) { + stack.setData(DataComponentTypes.ITEM_MODEL, Key.key(Key.MINECRAFT_NAMESPACE, def.model)) + } else stack.resetData(DataComponentTypes.ITEM_MODEL) + + val trim = def.trim?.let(::trim) + if (trim != null) { + stack.setData(DataComponentTypes.TRIM, ItemArmorTrim.itemArmorTrim(trim).build()) + } else stack.resetData(DataComponentTypes.TRIM) + + if (def.dye != null) { + stack.setData(DataComponentTypes.DYED_COLOR, DyedItemColor.dyedItemColor(Color.fromRGB(def.dye))) + } else stack.resetData(DataComponentTypes.DYED_COLOR) + } + + /** The piece's name, after its reforge's if it has one, in its rarity's colour. */ + private fun name(def: GearDef, data: GearData, reforge: ReforgeDef?): Component { + val name = text(def.nameKey) + val full = if (reforge == null) name else text(reforge.key).append(Component.space()).append(name) + return full.applyFallbackStyle(data.rarity.color).decoration(TextDecoration.ITALIC, false) + } + + private fun lore(def: GearDef, data: GearData, stats: StatSheet): List = buildList { + Stat.entries.filter { stats[it] != 0.0 }.forEach { stat -> + add( + text(stat.key) + .append(Component.text(" +" + CombatFormat.stat(stat, stats[stat]), NamedTextColor.WHITE)) + ) + } + add(Component.empty()) + add(text(def.loreKey).applyFallbackStyle(NamedTextColor.GRAY)) + add(Component.empty()) + add( + text("gear.pvp").applyFallbackStyle(NamedTextColor.DARK_GRAY) + .append(Component.space()) + .append(Component.translatable(baseTranslationKey(def), NamedTextColor.DARK_GRAY)) + ) + add(footer(def, data)) + } + + /** `★★★ RARE WEAPON`: the piece's stars, then its rarity and slot in the rarity's colour. */ + private fun footer(def: GearDef, data: GearData): Component { + val stars = + if (data.stars > 0) Component.text("★".repeat(data.stars) + " ", NamedTextColor.GOLD) else Component.empty() + return stars + .append(text(data.rarity.key)) + .append(Component.space()) + .append(text(def.slot.key).applyFallbackStyle(data.rarity.color, TextDecoration.BOLD)) + } + + /** The game's own key for the base item's name, which each client fills in itself. */ + private fun baseTranslationKey(def: GearDef): String = + Material.matchMaterial(def.base)?.translationKey() ?: def.base.lowercase() + + private fun trim(trim: GearDef.Trim): ArmorTrim? { + val material = Registry.TRIM_MATERIAL.get(NamespacedKey.minecraft(trim.material)) ?: return null + val pattern = Registry.TRIM_PATTERN.get(NamespacedKey.minecraft(trim.pattern)) ?: return null + return ArmorTrim(material, pattern) + } + + private fun text(key: String): Component = ComponentUtil.parse(Lang.item(key)) + + private fun plain(line: Component): Component = line.decoration(TextDecoration.ITALIC, false) +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/GearStats.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/GearStats.kt new file mode 100644 index 0000000..dfa93ac --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/GearStats.kt @@ -0,0 +1,63 @@ +package net.trilleo.mc.plugins.tritown.gear + +import net.trilleo.mc.plugins.tritown.combat.Stat +import net.trilleo.mc.plugins.tritown.combat.StatSheet +import net.trilleo.mc.plugins.tritown.content.GearDef +import net.trilleo.mc.plugins.tritown.content.GearTuning +import net.trilleo.mc.plugins.tritown.content.Rarity +import net.trilleo.mc.plugins.tritown.content.ReforgeDef +import kotlin.random.Random + +/** + * What a piece of gear is worth, and the only place that is worked out. + * + * A piece's slot and tier give it points (`balance.yml`), its definition's + * weights share them between stats, and each stat's curve turns its share into + * a number for the tier. Rarity, stars and the quality each stat rolled then + * scale that; a reforge adds a small share of its own on top. Plain Kotlin, so + * `GearStatsTest` and `BalanceSimulationTest` run it as the server does. + */ +object GearStats { + + /** What [data], a piece of [def], gives whoever wears or holds it. */ + fun of(def: GearDef, data: GearData, tuning: GearTuning, reforge: ReforgeDef?): StatSheet = + base(def, data, tuning) + reforged(def, data.rarity, tuning, reforge) + + /** The piece's own stats: its weights, at its rarity, stars and rolls. */ + private fun base(def: GearDef, data: GearData, tuning: GearTuning): StatSheet { + val points = tuning.points(def.slot) + val scale = tuning.rarityMultiplier(data.rarity) * tuning.starMultiplier(data.stars) + return StatSheet.of(*def.weights.map { (stat, weight) -> + val roll = data.rolls[stat] ?: averageRoll(tuning) + stat to tuning.curve(stat).value(points * weight, def.tier) * scale * roll / 100.0 + }.toTypedArray()) + } + + /** What [reforge] adds to a piece of [def] at [rarity]: a share of its points, spent by the reforge's weights. */ + private fun reforged(def: GearDef, rarity: Rarity, tuning: GearTuning, reforge: ReforgeDef?): StatSheet { + if (reforge == null || def.slot !in reforge.slots) return StatSheet.EMPTY + val points = tuning.points(def.slot) * tuning.reforgeShare / 100.0 + val scale = tuning.rarityMultiplier(rarity) + return StatSheet.of(*reforge.weights.map { (stat, weight) -> + stat to tuning.curve(stat).value(points * weight, def.tier) * scale + }.toTypedArray()) + } + + /** A fresh roll for every stat [def] has. */ + fun roll(def: GearDef, tuning: GearTuning, random: Random): Map = + def.weights.keys.associateWith { random.nextInt(tuning.rollMin, tuning.rollMax + 1) } + + /** A rarity drawn from [odds], weighted; the lowest listed if the odds are all zero. */ + fun pick(odds: Map, random: Random): Rarity { + val total = odds.values.sum() + if (total <= 0.0) return odds.keys.minOrNull() ?: Rarity.COMMON + var roll = random.nextDouble() * total + for ((rarity, weight) in odds.entries.sortedBy { it.key.ordinal }) { + roll -= weight + if (roll < 0.0) return rarity + } + return odds.keys.max() + } + + fun averageRoll(tuning: GearTuning): Int = (tuning.rollMin + tuning.rollMax) / 2 +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/GearText.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/GearText.kt new file mode 100644 index 0000000..769c459 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/gear/GearText.kt @@ -0,0 +1,50 @@ +package net.trilleo.mc.plugins.tritown.gear + +import net.kyori.adventure.text.format.NamedTextColor +import net.trilleo.mc.plugins.tritown.content.ContentItems +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.content.Rarity +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.command.CommandSender +import org.bukkit.inventory.ItemStack + +/** + * Gear and content items named in one reader's language, for chat and menus. + * An item itself is drawn by [GearRender] for every viewer at once; these are + * for text that only one player reads. + */ +object GearText { + + /** A piece's full name — reforge and all — in its rarity's colour, ready to embed in MiniMessage. */ + fun name(reader: CommandSender, piece: Gear.Piece): String { + val name = reader.tr(piece.def.nameKey) + val reforge = piece.data.reforge?.let { ContentRegistry.gear.reforges[it] }?.let { reader.tr(it.key) } + return colored(piece.data.rarity, if (reforge == null) name else "$reforge $name") + } + + /** A content item's name in its rarity's colour, or its id if `items.yml` no longer has it. */ + fun item(reader: CommandSender, id: String): String { + val def = ContentRegistry.items[id] ?: return id + return colored(def.rarity, reader.tr(def.nameKey)) + } + + fun rarity(reader: CommandSender, rarity: Rarity): String = reader.tr(rarity.key) + + /** What [stack] is called — a piece of gear by its full name, a content item by its own — or `null` if it is neither. */ + fun label(reader: CommandSender, stack: ItemStack): String? { + Gear.read(stack)?.let { return name(reader, it) } + return ContentItems.idOf(stack)?.let { item(reader, it) } + } + + /** What [stack] is — `3x Grave Dust`, or a piece of gear by its name — or `null` if it is neither. */ + fun stack(reader: CommandSender, stack: ItemStack): String? { + Gear.read(stack)?.let { return name(reader, it) } + val id = ContentItems.idOf(stack) ?: return null + return "${stack.amount}x ${item(reader, id)}" + } + + private fun colored(rarity: Rarity, text: String): String { + val color = NamedTextColor.NAMES.key(rarity.color) ?: return text + return "<$color>$text" + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/.gitkeep b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/.gitkeep deleted file mode 100644 index e69de29..0000000 diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminStorageGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminStorageGUI.kt index dfb40fe..9e8c695 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminStorageGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/AdminStorageGUI.kt @@ -45,7 +45,11 @@ class AdminStorageGUI : PagedPluginGUI( override fun getItems(player: Player): List { if (!StorageManager.isAvailable) { return listOf( - PanelRender.card(Material.BARRIER, player.tr("gui.admin-storage.disabled"), listOf(player.tr("gui.admin-storage.disabled-lore"))) + PanelRender.card( + Material.BARRIER, + player.tr("gui.admin-storage.disabled"), + listOf(player.tr("gui.admin-storage.disabled-lore")) + ) ) } val summaries = snapshots[player.uniqueId].orEmpty() @@ -121,7 +125,11 @@ class AdminStorageGUI : PagedPluginGUI( ) if (player.hasPermission(StorageCommand.ADMIN_PERMISSION)) lines += player.tr("gui.admin-storage.click-edit") - return MenuRender.head(Bukkit.getOfflinePlayer(summary.owner), player.tr("gui.admin-storage.owner", "name" to StorageRender.ownerName(summary.owner)), lines) + return MenuRender.head( + Bukkit.getOfflinePlayer(summary.owner), + player.tr("gui.admin-storage.owner", "name" to StorageRender.ownerName(summary.owner)), + lines + ) } companion object { diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyFlowGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyFlowGUI.kt index 6bda3cd..c1f44f2 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyFlowGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/admin/EconomyFlowGUI.kt @@ -174,6 +174,7 @@ class EconomyFlowGUI : PagedPluginGUI( FlowCategory.STARTING_BALANCE -> Material.EGG FlowCategory.SHOP -> Material.EMERALD FlowCategory.STORAGE -> Material.ENDER_CHEST + FlowCategory.GEAR -> Material.SMITHING_TABLE FlowCategory.TOWNY -> Material.BELL FlowCategory.ADMIN -> Material.COMMAND_BLOCK FlowCategory.PAYMENT -> Material.ENDER_PEARL @@ -195,6 +196,7 @@ class EconomyFlowGUI : PagedPluginGUI( FlowCategory.STARTING_BALANCE -> "gui.admin-flow.about-starting-balance" FlowCategory.SHOP -> "gui.admin-flow.about-shop" FlowCategory.STORAGE -> "gui.admin-flow.about-storage" + FlowCategory.GEAR -> "gui.admin-flow.about-gear" FlowCategory.TOWNY -> "gui.admin-flow.about-towny" FlowCategory.ADMIN -> "gui.admin-flow.about-admin" FlowCategory.PAYMENT -> "gui.admin-flow.about-payment" diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/adventure/StatsGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/adventure/StatsGUI.kt new file mode 100644 index 0000000..6a4be70 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/adventure/StatsGUI.kt @@ -0,0 +1,188 @@ +package net.trilleo.mc.plugins.tritown.guis.adventure + +import net.kyori.adventure.text.Component +import net.trilleo.mc.plugins.tritown.combat.* +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.guis.menu.MainMenuGUI +import net.trilleo.mc.plugins.tritown.guis.menu.MenuRender +import net.trilleo.mc.plugins.tritown.registration.GUIFrame +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginGUI +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.itemStack +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Bukkit +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemFlag +import org.bukkit.inventory.ItemStack +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * A player's combat stats, each broken down by where it comes from. + * + * Opened with `/tritown stats [player]` or by clicking the profile in the main + * menu. It only reads: nothing here changes a stat. + */ +class StatsGUI : PluginGUI( + id = ID, + titleKey = "gui.stats.title", + rows = 6, + fillMode = FillMode.NONE, +) { + + /** Whose stats each viewer is looking at. */ + private val targets = ConcurrentHashMap() + + override fun title(player: Player): Component { + val target = target(player) + if (target == null || target == player) return super.title(player) + return ComponentUtil.parse(player.tr("gui.stats.title-other", "name" to ComponentUtil.escape(target.name))) + } + + override fun setup(player: Player, inventory: Inventory) { + val target = target(player) ?: player + val breakdown = StatSources.breakdown(target) + val sheet = breakdown.values.fold(StatSheet.EMPTY, StatSheet::plus) + + val slots = mutableMapOf(PROFILE_SLOT to profile(player, target), BACK_SLOT to MenuRender.back(player)) + ROWS.forEachIndexed { index, stats -> + GUIFrame.spacedColumns(stats.size).zip(stats).forEach { (column, stat) -> + slots[(FIRST_STAT_ROW + index) * ROW_SIZE + column] = stat(player, stat, sheet, breakdown) + } + } + + GUIFrame.draw(inventory, slots.keys) + slots.forEach { (slot, item) -> inventory.setItem(slot, item) } + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + if (event.clickedInventory !== event.view.topInventory || event.rawSlot != BACK_SLOT) return + val player = event.whoClicked as? Player ?: return + MenuRender.later(player) { MainMenuGUI.show(player) } + } + + override fun onClose(event: InventoryCloseEvent) { + targets.remove(event.player.uniqueId) + } + + private fun target(viewer: Player): Player? = targets[viewer.uniqueId]?.let(Bukkit::getPlayer) + + private fun profile(viewer: Player, target: Player): ItemStack = MenuRender.head( + target, + viewer.tr( + if (viewer == target) "gui.stats.profile" else "gui.stats.profile-other", + "name" to ComponentUtil.escape(target.name), + ), + listOf( + viewer.tr( + "gui.stats.profile-health", + "health" to CombatFormat.health(CombatHealth.current(target)), + "max" to CombatFormat.number(CombatHealth.max(target)), + ), + "", + viewer.tr("gui.stats.profile-pvp"), + ), + ) + + private fun stat( + viewer: Player, + stat: Stat, + sheet: StatSheet, + breakdown: Map + ): ItemStack { + val lines = mutableListOf(viewer.tr(description(stat))) + if (stat == Stat.DEFENSE) { + val reduction = (1.0 - DamageMath.defenseMultiplier(sheet[stat])) * 100.0 + lines += viewer.tr("gui.stats.defense-reduction", "percent" to CombatFormat.number(reduction)) + } + + val sources = breakdown.filterValues { it[stat] != 0.0 } + if (sources.isNotEmpty()) { + lines += "" + sources.forEach { (source, values) -> + lines += viewer.tr( + "gui.stats.source-line", + "source" to viewer.tr(source.key), + "value" to signed(stat, values[stat], first = source == StatSources.Source.BASE), + ) + } + } + + return itemStack(icon(stat)) { + name( + viewer.tr( + "gui.stats.stat-name", + "stat" to viewer.tr(stat.key), + "value" to CombatFormat.stat(stat, sheet[stat]) + ) + ) + flag(ItemFlag.HIDE_ATTRIBUTES) + meta { lore(LoreUtil.wrapLore(lines.joinToString(""))) } + } + } + + /** A source's share, with a sign unless it is the base the others add to. */ + private fun signed(stat: Stat, value: Double, first: Boolean): String { + val text = CombatFormat.stat(stat, value) + return if (first || value < 0.0) text else "+$text" + } + + private fun description(stat: Stat): String = when (stat) { + Stat.HEALTH -> "gui.stats.about.health" + Stat.DEFENSE -> "gui.stats.about.defense" + Stat.DAMAGE -> "gui.stats.about.damage" + Stat.STRENGTH -> "gui.stats.about.strength" + Stat.CRIT_CHANCE -> "gui.stats.about.crit-chance" + Stat.CRIT_DAMAGE -> "gui.stats.about.crit-damage" + Stat.SPEED -> "gui.stats.about.speed" + Stat.VITALITY -> "gui.stats.about.vitality" + Stat.MAGIC_FIND -> "gui.stats.about.magic-find" + } + + private fun icon(stat: Stat): Material = when (stat) { + Stat.HEALTH -> Material.GOLDEN_APPLE + Stat.DEFENSE -> Material.IRON_CHESTPLATE + Stat.DAMAGE -> Material.IRON_SWORD + Stat.STRENGTH -> Material.BLAZE_POWDER + Stat.CRIT_CHANCE -> Material.SPECTRAL_ARROW + Stat.CRIT_DAMAGE -> Material.FIRE_CHARGE + Stat.SPEED -> Material.SUGAR + Stat.VITALITY -> Material.GLISTERING_MELON_SLICE + Stat.MAGIC_FIND -> Material.RABBIT_FOOT + } + + private fun open(viewer: Player, target: Player) { + targets[viewer.uniqueId] = target.uniqueId + GUIManager.open(viewer, ID) + } + + companion object { + const val ID = "stats" + + private const val ROW_SIZE = 9 + private const val PROFILE_SLOT = 13 + private const val FIRST_STAT_ROW = 2 + private const val BACK_SLOT = 49 + + /** Staying alive, then hitting hard, then everything else. */ + private val ROWS = listOf( + listOf(Stat.HEALTH, Stat.DEFENSE), + listOf(Stat.DAMAGE, Stat.STRENGTH, Stat.CRIT_CHANCE, Stat.CRIT_DAMAGE), + listOf(Stat.SPEED, Stat.VITALITY, Stat.MAGIC_FIND), + ) + + /** Opens [target]'s stats for [viewer]. */ + fun show(viewer: Player, target: Player = viewer): Boolean { + val gui = GUIManager.getGUI(ID) as? StatsGUI ?: return false + gui.open(viewer, target) + return true + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/bestiary/BestiaryGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/bestiary/BestiaryGUI.kt new file mode 100644 index 0000000..4457605 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/bestiary/BestiaryGUI.kt @@ -0,0 +1,175 @@ +package net.trilleo.mc.plugins.tritown.guis.bestiary + +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.content.MobKindDef +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.PagedLayout +import net.trilleo.mc.plugins.tritown.gear.GearText +import net.trilleo.mc.plugins.tritown.guis.menu.MainMenuGUI +import net.trilleo.mc.plugins.tritown.guis.menu.MenuRender +import net.trilleo.mc.plugins.tritown.mobs.BestiaryRecords +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI +import net.trilleo.mc.plugins.tritown.utils.LoreUtil +import net.trilleo.mc.plugins.tritown.utils.itemStack +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.inventory.ItemStack + +/** + * Every custom mob of `bestiary.yml`, variants first and then bosses, as the + * viewer knows it. + * + * One they have not slain yet is a blank page that only says where to look. + * One they have shows everything: what it is, where it lives or how it is + * summoned, its abilities and affixes, how many they have slain, and which of + * its own loot they have found. It reads, it never acts. + */ +class BestiaryGUI : PagedPluginGUI( + id = ID, + titleKey = "gui.bestiary.title", + rows = 6, + fillMode = FillMode.NONE, + layout = PagedLayout.FRAMED, +) { + + override fun getItems(player: Player): List = + kinds().map { kind -> + val record = BestiaryRecords.of(player, kind.id) + if (record.kills > 0) page(player, kind, record) else blank(player, kind) + } + + override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + } + + override fun navButtons(player: Player): Map = + mapOf(MenuRender.BACK_OFFSET to MenuRender.back(player), MenuRender.EXTRA_OFFSET to progress(player)) + + override fun onNavClick(event: InventoryClickEvent, offset: Int) { + val player = event.whoClicked as? Player ?: return + if (offset == MenuRender.BACK_OFFSET) MenuRender.later(player) { MainMenuGUI.show(player) } + } + + private fun page(viewer: Player, kind: MobKindDef, record: BestiaryRecords.Record): ItemStack { + val boss = kind.boss + val lines = buildList { + add(viewer.tr("gui.bestiary.lore", "lore" to viewer.tr(kind.loreKey))) + add("") + add(where(viewer, kind)) + if (kind.abilities.isNotEmpty() || boss?.phases.orEmpty().any { it.abilities.isNotEmpty() }) { + val abilities = (kind.abilities + boss?.phases.orEmpty().flatMap { it.abilities }).distinct() + add( + viewer.tr( + "gui.bestiary.abilities", + "abilities" to abilities.joinToString(", ") { viewer.tr(it.key) }) + ) + } + if (kind.affixes.isNotEmpty()) { + add( + viewer.tr( + "gui.bestiary.affixes", + "affixes" to kind.affixes.sortedBy { it.ordinal }.joinToString(", ") { viewer.tr(it.key) }) + ) + } + add("") + add(viewer.tr("gui.bestiary.kills", "amount" to record.kills, "stars" to "★".repeat(stars(record.kills)))) + + val own = kind.loot.map(BestiaryRecords::idOf).distinct() + val found = own.filter { it in record.drops } + if (own.isNotEmpty()) add(viewer.tr("gui.bestiary.drops", "found" to found.size, "total" to own.size)) + found.forEach { id -> add(viewer.tr("gui.bestiary.drop", "name" to dropName(viewer, id))) } + } + return itemStack(icon(kind)) { + name( + viewer.tr( + if (boss != null) "gui.bestiary.boss" else "gui.bestiary.variant", + "name" to viewer.tr(kind.nameKey) + ) + ) + meta { + lore(LoreUtil.wrapLore(lines.joinToString(""))) + if (boss != null) setEnchantmentGlintOverride(true) + } + } + } + + private fun blank(viewer: Player, kind: MobKindDef): ItemStack = itemStack(Material.INK_SAC) { + name(viewer.tr("gui.bestiary.unknown")) + meta { + lore( + LoreUtil.wrapLore( + listOf( + viewer.tr("gui.bestiary.unknown-lore"), + "", + where(viewer, kind) + ).joinToString("") + ) + ) + } + } + + /** Where a variant lives, or where a boss answers its sigil and at what level. */ + private fun where(viewer: Player, kind: MobKindDef): String { + val boss = kind.boss + if (boss != null) { + return viewer.tr( + "gui.bestiary.summoned", + "level" to boss.level, + "sigil" to GearText.item(viewer, boss.sigil), + ) + } + val spawn = kind.spawn ?: return "" + return viewer.tr( + "gui.bestiary.lives", + "worlds" to worlds(viewer, spawn.worlds), + "min" to spawn.minLevel, + "max" to spawn.maxLevel, + ) + } + + private fun worlds(viewer: Player, names: Set): String { + if (names.isEmpty()) return viewer.tr("gui.bestiary.world.any") + return names.joinToString(", ") { name -> + when (name) { + "normal" -> viewer.tr("gui.bestiary.world.normal") + "nether" -> viewer.tr("gui.bestiary.world.nether") + "the_end" -> viewer.tr("gui.bestiary.world.end") + else -> name + } + } + } + + private fun dropName(viewer: Player, id: String): String { + val gear = BestiaryRecords.gearId(id) ?: return GearText.item(viewer, id) + return ContentRegistry.gear.gear[gear]?.let { viewer.tr(it.nameKey) } ?: gear + } + + /** How many of the viewer's pages are filled in, on every page of the menu. */ + private fun progress(viewer: Player): ItemStack { + val kinds = kinds() + val found = kinds.count { BestiaryRecords.of(viewer, it.id).kills > 0 } + return itemStack(Material.WRITABLE_BOOK) { + name(viewer.tr("gui.bestiary.progress", "found" to found, "total" to kinds.size)) + meta { lore(LoreUtil.wrapLore(viewer.tr("gui.bestiary.progress-lore"))) } + } + } + + private fun kinds(): List = + ContentRegistry.bestiary.kinds.values.sortedBy { if (it.boss != null) 1 else 0 } + + private fun icon(kind: MobKindDef): Material = Material.matchMaterial("${kind.base}_SPAWN_EGG") ?: Material.BOOK + + /** A star for every tenfold of kills: 10, 100, 1,000. */ + private fun stars(kills: Int): Int = STAR_KILLS.count { kills >= it } + + companion object { + const val ID = "bestiary" + + private val STAR_KILLS = listOf(10, 100, 1000) + + fun show(player: Player): Boolean = GUIManager.open(player, ID) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/forge/ForgeGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/forge/ForgeGUI.kt new file mode 100644 index 0000000..1036ad9 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/forge/ForgeGUI.kt @@ -0,0 +1,226 @@ +package net.trilleo.mc.plugins.tritown.guis.forge + +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.gear.Forge +import net.trilleo.mc.plugins.tritown.gear.ForgeCosts +import net.trilleo.mc.plugins.tritown.gear.Gear +import net.trilleo.mc.plugins.tritown.gear.GearText +import net.trilleo.mc.plugins.tritown.guis.ConfirmGUI +import net.trilleo.mc.plugins.tritown.guis.admin.PanelRender +import net.trilleo.mc.plugins.tritown.guis.menu.MainMenuGUI +import net.trilleo.mc.plugins.tritown.guis.menu.MenuRender +import net.trilleo.mc.plugins.tritown.registration.GUIFrame +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PluginGUI +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.Inventory +import org.bukkit.inventory.ItemStack +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * The Forge: crafting new gear, and upgrading, refining, reforging or + * salvaging the piece in the viewer's main hand. + * + * The held piece sits at the top and the actions below it. An action the + * piece cannot take — a sixth star, a rarity above the cap — is left out + * rather than greyed, and with nothing in hand only crafting is offered. Every + * action that costs something asks first ([ConfirmGUI]) and is carried out by + * [Forge], which checks everything again at that moment. + */ +class ForgeGUI : PluginGUI( + id = ID, + titleKey = "gui.forge.title", + rows = 6, + fillMode = FillMode.NONE, +) { + + private enum class Action { CRAFT, UPGRADE, REFINE, REFORGE, SALVAGE } + + /** Which action each slot holds, per viewer, since the actions offered depend on what they hold. */ + private val layouts = ConcurrentHashMap>() + + override fun setup(player: Player, inventory: Inventory) { + val piece = Gear.read(player.inventory.itemInMainHand) + val actions = listOfNotNull( + Action.CRAFT, + Action.UPGRADE.takeIf { piece != null && upgradeCost(piece) != null }, + Action.REFINE.takeIf { piece != null && refineCost(piece) != null }, + Action.REFORGE.takeIf { piece != null && ContentRegistry.gear.reforgesFor(piece.def.slot).isNotEmpty() }, + Action.SALVAGE.takeIf { piece != null }, + ) + val layout = GUIFrame.packedColumns(actions.size).zip(actions) + .associate { (column, action) -> ACTION_ROW * ROW_SIZE + column to action } + layouts[player.uniqueId] = layout + + val slots = layout.mapValues { (_, action) -> render(player, action, piece) } + + mapOf(HELD_SLOT to held(player, piece), BACK_SLOT to MenuRender.back(player)) + GUIFrame.draw(inventory, slots.keys) + slots.forEach { (slot, item) -> inventory.setItem(slot, item) } + } + + override fun onClick(event: InventoryClickEvent) { + event.isCancelled = true + if (event.clickedInventory !== event.view.topInventory) return + val player = event.whoClicked as? Player ?: return + + if (event.rawSlot == BACK_SLOT) { + MenuRender.later(player) { MainMenuGUI.show(player) } + return + } + when (layouts[player.uniqueId]?.get(event.rawSlot) ?: return) { + Action.CRAFT -> MenuRender.later(player) { ForgeRecipesGUI.show(player) } + Action.UPGRADE -> confirm( + player, + "gui.forge.upgrade-confirm", + "forge.upgraded", + ::upgradeCost, + Forge::upgrade + ) + + Action.REFINE -> confirm(player, "gui.forge.refine-confirm", "forge.refined", ::refineCost, Forge::refine) + Action.REFORGE -> confirm( + player, + "gui.forge.reforge-confirm", + "forge.reforged", + ::reforgeCost, + Forge::reforge + ) + + Action.SALVAGE -> confirm(player, "gui.forge.salvage-confirm", "forge.salvaged", { null }, Forge::salvage) + } + } + + override fun onClose(event: InventoryCloseEvent) { + layouts.remove(event.player.uniqueId) + } + + /** + * Asks before [act] runs on the held piece, showing it and what it costs, + * then says how it went and comes back here. + */ + private fun confirm( + player: Player, + titleKey: String, + doneKey: String, + cost: (Gear.Piece) -> ForgeCosts.Cost?, + act: (Player) -> Forge.Result, + ) { + val held = player.inventory.itemInMainHand + val piece = Gear.read(held) ?: return + val lines = cost(piece)?.let { ForgeRender.costLines(player, it) }.orEmpty() + MenuRender.later(player) { + ConfirmGUI.show( + player, + title = player.tr(titleKey), + subject = held.clone(), + choices = listOf(ConfirmGUI.Choice(ForgeRender.confirm(player, lines)) { viewer -> + ForgeRender.announce(viewer, act(viewer), doneKey) + show(viewer) + }), + onCancel = ::show, + ) + } + } + + private fun render(viewer: Player, action: Action, piece: Gear.Piece?): ItemStack = when (action) { + Action.CRAFT -> PanelRender.card( + Material.CRAFTING_TABLE, + viewer.tr("gui.forge.craft"), + listOf(viewer.tr("gui.forge.craft-lore"), "", viewer.tr("gui.admin.click-open")), + ) + + Action.UPGRADE -> card( + viewer, Material.NETHER_STAR, "gui.forge.upgrade", + viewer.tr( + "gui.forge.upgrade-lore", + "bonus" to ContentRegistry.balance.gear.starBonus.toInt(), + "stars" to piece!!.data.stars, + "max" to ContentRegistry.balance.gear.maxStars, + ), + upgradeCost(piece), + ) + + Action.REFINE -> card( + viewer, Material.DIAMOND, "gui.forge.refine", + viewer.tr( + "gui.forge.refine-lore", + "from" to GearText.rarity(viewer, piece!!.data.rarity), + "to" to GearText.rarity(viewer, ForgeCosts.nextRarity(piece.data.rarity)), + ), + refineCost(piece), + ) + + Action.REFORGE -> card( + viewer, Material.ANVIL, "gui.forge.reforge", + viewer.tr( + "gui.forge.reforge-lore", + "reforge" to (piece!!.data.reforge?.let { ContentRegistry.gear.reforges[it] }?.let { viewer.tr(it.key) } + ?: viewer.tr("common.none")), + ), + reforgeCost(piece), + ) + + Action.SALVAGE -> { + val returned = + ForgeCosts.salvage(piece!!.def, piece.data, ContentRegistry.balance.forge, ContentRegistry.loot) + val lines = listOf(viewer.tr("gui.forge.salvage-lore")) + returned.map { (id, amount) -> + viewer.tr("gui.forge.salvage-item", "amount" to amount, "name" to GearText.item(viewer, id)) + } + PanelRender.card( + Material.GRINDSTONE, + viewer.tr("gui.forge.salvage"), + lines + "" + viewer.tr("gui.forge.click-act"), + ) + } + } + + /** An action on the held piece: what it does, what it costs, and the click to do it. */ + private fun card( + viewer: Player, + material: Material, + nameKey: String, + about: String, + cost: ForgeCosts.Cost? + ): ItemStack { + val lines = listOf(about, "") + cost?.let { ForgeRender.costLines(viewer, it) }.orEmpty() + + "" + viewer.tr("gui.forge.click-act") + return PanelRender.card(material, viewer.tr(nameKey), lines) + } + + /** The piece in the viewer's hand, or how to put one there. */ + private fun held(viewer: Player, piece: Gear.Piece?): ItemStack = + if (piece != null) viewer.inventory.itemInMainHand.clone() + else PanelRender.card( + Material.ITEM_FRAME, + viewer.tr("gui.forge.empty"), + listOf(viewer.tr("gui.forge.empty-lore")), + ) + + private fun upgradeCost(piece: Gear.Piece): ForgeCosts.Cost? { + val balance = ContentRegistry.balance + return ForgeCosts.upgrade(piece.def, piece.data, balance.gear, balance.forge, ContentRegistry.loot) + } + + private fun refineCost(piece: Gear.Piece): ForgeCosts.Cost? = + ForgeCosts.refine(piece.def, piece.data, ContentRegistry.balance.forge, ContentRegistry.loot) + + private fun reforgeCost(piece: Gear.Piece): ForgeCosts.Cost = + ForgeCosts.reforge(piece.def, ContentRegistry.balance.forge, ContentRegistry.loot) + + companion object { + const val ID = "forge" + + private const val ROW_SIZE = 9 + private const val HELD_SLOT = 13 + private const val ACTION_ROW = 3 + private const val BACK_SLOT = 49 + + fun show(player: Player): Boolean = GUIManager.open(player, ID) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/forge/ForgeRecipesGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/forge/ForgeRecipesGUI.kt new file mode 100644 index 0000000..7cedfb6 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/forge/ForgeRecipesGUI.kt @@ -0,0 +1,101 @@ +package net.trilleo.mc.plugins.tritown.guis.forge + +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.content.GearDef +import net.trilleo.mc.plugins.tritown.enums.FillMode +import net.trilleo.mc.plugins.tritown.enums.PagedLayout +import net.trilleo.mc.plugins.tritown.gear.Forge +import net.trilleo.mc.plugins.tritown.gear.ForgeCosts +import net.trilleo.mc.plugins.tritown.gear.Gear +import net.trilleo.mc.plugins.tritown.guis.ConfirmGUI +import net.trilleo.mc.plugins.tritown.guis.menu.MenuRender +import net.trilleo.mc.plugins.tritown.registration.GUIManager +import net.trilleo.mc.plugins.tritown.registration.PagedPluginGUI +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.entity.Player +import org.bukkit.event.inventory.InventoryClickEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.inventory.ItemStack +import java.util.* +import java.util.concurrent.ConcurrentHashMap + +/** + * Every piece the Forge can craft, lowest tier first, each shown as it would + * come out at its most common with what it takes beneath it. Clicking one asks + * first, then crafts it through [Forge.craft], which rolls its rarity. + */ +class ForgeRecipesGUI : PagedPluginGUI( + id = ID, + titleKey = "gui.forge-recipes.title", + rows = 6, + fillMode = FillMode.NONE, + layout = PagedLayout.FRAMED, +) { + + /** What each viewer's list holds, in the order it shows them. */ + private val recipes = ConcurrentHashMap>() + + override fun getItems(player: Player): List = + recipes.getOrPut(player.uniqueId) { craftable() }.mapNotNull { def -> render(player, def) } + + override fun navButtons(player: Player): Map = + mapOf(MenuRender.BACK_OFFSET to MenuRender.back(player)) + + override fun onNavClick(event: InventoryClickEvent, offset: Int) { + val player = event.whoClicked as? Player ?: return + if (offset == MenuRender.BACK_OFFSET) MenuRender.later(player) { ForgeGUI.show(player) } + } + + override fun onContentClick(event: InventoryClickEvent, page: Int) { + event.isCancelled = true + val player = event.whoClicked as? Player ?: return + val index = contentIndex(event, page) ?: return + val def = recipes[player.uniqueId]?.getOrNull(index) ?: return + val preview = Gear.preview(def) ?: return + + MenuRender.later(player) { + ConfirmGUI.show( + player, + title = player.tr("gui.forge-recipes.confirm"), + subject = preview, + choices = listOf(ConfirmGUI.Choice(ForgeRender.confirm(player, costLines(player, def))) { viewer -> + ForgeRender.announce(viewer, Forge.craft(viewer, def), "forge.crafted") + show(viewer) + }), + onCancel = ::show, + ) + } + } + + override fun onClose(event: InventoryCloseEvent) { + super.onClose(event) + recipes.remove(event.player.uniqueId) + } + + private fun render(viewer: Player, def: GearDef): ItemStack? { + val preview = Gear.preview(def) ?: return null + val lines = listOf("", viewer.tr("gui.forge-recipes.tier", "tier" to def.tier)) + + costLines(viewer, def) + "" + viewer.tr("gui.forge-recipes.click") + return ForgeRender.withLines(preview, lines) + } + + private fun costLines(viewer: Player, def: GearDef): List { + val recipe = def.recipe ?: return emptyList() + return ForgeRender.costLines(viewer, ForgeCosts.Cost(recipe.items, recipe.money)) + } + + /** Every piece with a recipe, by tier and then by slot, so a tier's set reads together. */ + private fun craftable(): List = + ContentRegistry.gear.gear.values + .filter { it.recipe != null } + .sortedWith(compareBy({ it.tier }, { it.slot.ordinal }, { it.id })) + + companion object { + const val ID = "forge-recipes" + + fun show(player: Player): Boolean { + (GUIManager.getGUI(ID) as? ForgeRecipesGUI)?.recipes?.remove(player.uniqueId) + return GUIManager.open(player, ID) + } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/forge/ForgeRender.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/forge/ForgeRender.kt new file mode 100644 index 0000000..c97b9e2 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/forge/ForgeRender.kt @@ -0,0 +1,83 @@ +package net.trilleo.mc.plugins.tritown.guis.forge + +import io.papermc.paper.datacomponent.DataComponentTypes +import io.papermc.paper.datacomponent.item.ItemLore +import net.kyori.adventure.text.format.TextDecoration +import net.trilleo.mc.plugins.tritown.gear.Forge +import net.trilleo.mc.plugins.tritown.gear.ForgeCosts +import net.trilleo.mc.plugins.tritown.gear.GearText +import net.trilleo.mc.plugins.tritown.utils.* +import org.bukkit.Material +import org.bukkit.entity.Player +import org.bukkit.inventory.ItemStack + +/** The pieces the Forge's menus share: what a thing costs, the confirm button, and what happened. */ +object ForgeRender { + + /** One line per item a [cost] asks for, and one for its money, each saying whether [viewer] has enough. */ + fun costLines(viewer: Player, cost: ForgeCosts.Cost): List = buildList { + cost.items.forEach { (id, need) -> + val have = Forge.count(viewer, id) + add( + viewer.tr( + if (have >= need) "gui.forge.cost-item" else "gui.forge.cost-item-missing", + "name" to GearText.item(viewer, id), + "have" to have, + "need" to need, + ) + ) + } + if (cost.money > 0.0) { + val enough = EconomyUtil.isAvailable && EconomyUtil.balance(viewer) >= cost.money + val amount = if (EconomyUtil.isAvailable) EconomyUtil.format(cost.money) else cost.money.toString() + add( + viewer.tr( + if (enough) "gui.forge.cost-money" else "gui.forge.cost-money-missing", + "amount" to ComponentUtil.escape(amount), + ) + ) + } + } + + fun confirm(viewer: Player, lines: List): ItemStack = itemStack(Material.LIME_CONCRETE) { + name(viewer.tr("gui.forge.confirm")) + meta { lore(LoreUtil.wrapLore(lines.joinToString(""))) } + } + + /** [stack] with [lines] of [viewer]'s language added under its own tooltip. Only ever for a menu's copy. */ + fun withLines(stack: ItemStack, lines: List): ItemStack = stack.clone().apply { + val existing = getData(DataComponentTypes.LORE)?.lines().orEmpty() + val added = lines.map { ComponentUtil.parse(it).decoration(TextDecoration.ITALIC, false) } + setData(DataComponentTypes.LORE, ItemLore.lore(existing + added)) + } + + /** + * Tells [player] how an action went: [doneKey] with the piece's name when + * it went through, or why it did not. + */ + fun announce(player: Player, result: Forge.Result, doneKey: String) { + val message = when (result.outcome) { + Forge.Outcome.DONE -> { + val piece = result.piece ?: return + player.tr( + doneKey, + "name" to GearText.name(player, piece), + "rarity" to GearText.rarity(player, piece.data.rarity), + "stars" to piece.data.stars, + "items" to result.returned.entries.joinToString(", ") { (id, amount) -> + "${amount}x ${GearText.item(player, id)}" + }, + ) + } + + Forge.Outcome.NOT_GEAR -> error(player, "forge.error.not-gear") + Forge.Outcome.MAXED -> error(player, "forge.error.maxed") + Forge.Outcome.MISSING_ITEMS -> error(player, "forge.error.missing-items") + Forge.Outcome.CANNOT_AFFORD -> error(player, "forge.error.cannot-afford") + Forge.Outcome.UNAVAILABLE -> error(player, "forge.error.unavailable") + } + player.sendPrefixed(message) + } + + private fun error(player: Player, key: String): String = player.tr("common.error", "message" to player.tr(key)) +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/menu/LeaderboardGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/menu/LeaderboardGUI.kt index 66ebd83..bd34c44 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/menu/LeaderboardGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/menu/LeaderboardGUI.kt @@ -34,7 +34,7 @@ class LeaderboardGUI : PagedPluginGUI( titleKey = "gui.menu-leaderboard.title", rows = 6, fillMode = FillMode.NONE, - layout = PagedLayout.CENTERED, + layout = PagedLayout.FRAMED, ) { private val snapshots = ConcurrentHashMap>() diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/menu/MainMenuGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/menu/MainMenuGUI.kt index 66b2349..900d1ed 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/menu/MainMenuGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/menu/MainMenuGUI.kt @@ -1,8 +1,10 @@ package net.trilleo.mc.plugins.tritown.guis.menu import com.palmergames.bukkit.towny.TownyAPI +import net.trilleo.mc.plugins.tritown.combat.Combat import net.trilleo.mc.plugins.tritown.config.ShopSettings import net.trilleo.mc.plugins.tritown.config.StorageSettings +import net.trilleo.mc.plugins.tritown.content.ContentRegistry import net.trilleo.mc.plugins.tritown.economy.BaltopCache import net.trilleo.mc.plugins.tritown.economy.CurrencyRegistry import net.trilleo.mc.plugins.tritown.economy.EconomyService @@ -10,6 +12,7 @@ import net.trilleo.mc.plugins.tritown.enums.FillMode import net.trilleo.mc.plugins.tritown.guis.admin.AdminPanelGUI import net.trilleo.mc.plugins.tritown.guis.admin.PanelRender import net.trilleo.mc.plugins.tritown.guis.storage.StorageRender +import net.trilleo.mc.plugins.tritown.mobs.BestiaryRecords import net.trilleo.mc.plugins.tritown.news.NewsManager import net.trilleo.mc.plugins.tritown.news.NewsReadState import net.trilleo.mc.plugins.tritown.registration.CommandRegistrar @@ -53,7 +56,9 @@ class MainMenuGUI : PluginGUI( fillMode = FillMode.NONE, ) { - private enum class Button { PROFILE, TOWN, STORAGE, SHOP, TRADE, PAY, LEADERBOARD, SERVER, NEWS, SIDEBAR, ADMIN, CLOSE } + private enum class Button { + PROFILE, TOWN, STORAGE, SHOP, TRADE, PAY, LEADERBOARD, SERVER, FORGE, NEWS, BESTIARY, SIDEBAR, ADMIN, CLOSE + } /** Which button each slot holds, per viewer, since the buttons shown depend on who is looking. */ private val layouts = ConcurrentHashMap>() @@ -79,6 +84,8 @@ class MainMenuGUI : PluginGUI( Button.PAY -> MenuRender.later(player) { PlayerPickerGUI.show(player, PlayerPickerGUI.Mode.PAY) } Button.LEADERBOARD -> MenuRender.later(player) { LeaderboardGUI.show(player) } Button.NEWS -> MenuRender.later(player) { CommandRegistrar.run(player, "news") } + Button.FORGE -> MenuRender.later(player) { CommandRegistrar.run(player, "forge") } + Button.BESTIARY -> MenuRender.later(player) { CommandRegistrar.run(player, "bestiary") } Button.ADMIN -> MenuRender.later(player) { GUIManager.open(player, AdminPanelGUI.ID) } Button.CLOSE -> MenuRender.later(player) { player.closeInventory() } Button.SIDEBAR -> { @@ -87,7 +94,11 @@ class MainMenuGUI : PluginGUI( GUIManager.refresh(player) } - Button.PROFILE, Button.SERVER, null -> Unit + Button.PROFILE -> if (Combat.isActive(player.world)) { + MenuRender.later(player) { CommandRegistrar.run(player, "stats") } + } + + Button.SERVER, null -> Unit } } @@ -115,9 +126,11 @@ class MainMenuGUI : PluginGUI( Button.PAY.takeIf { hasEconomy() && CommandRegistrar.canRun(player, "pay") }, Button.LEADERBOARD.takeIf { hasEconomy() && CommandRegistrar.canRun(player, "baltop") }, Button.SERVER, + Button.FORGE.takeIf { Combat.isActive(player.world) }, ), listOfNotNull( Button.NEWS.takeIf { NewsManager.isAvailable }, + Button.BESTIARY.takeIf { Combat.isActive(player.world) && ContentRegistry.bestiary.kinds.isNotEmpty() }, Button.SIDEBAR.takeIf { ScoreboardService.isRunning }, Button.ADMIN.takeIf { player.hasPermission(AdminPanelGUI.PERMISSION) }, ), @@ -157,6 +170,8 @@ class MainMenuGUI : PluginGUI( Button.NEWS -> news(player) Button.SIDEBAR -> sidebar(player) Button.ADMIN -> card(player, Material.COMMAND_BLOCK, "gui.menu.admin", "gui.menu.admin-lore") + Button.FORGE -> card(player, Material.SMITHING_TABLE, "gui.menu.forge", "gui.menu.forge-lore") + Button.BESTIARY -> bestiary(player) Button.CLOSE -> itemStack(Material.BARRIER) { name(player.tr("gui.menu.close")) } } @@ -191,6 +206,10 @@ class MainMenuGUI : PluginGUI( "gui.menu.profile-nation", "nation" to (resident?.nationOrNull?.let { TownyUtil.name(it.name) } ?: none), ) + if (Combat.isActive(player.world)) { + lines += "" + lines += player.tr("gui.menu.profile-stats") + } return MenuRender.head( player, @@ -315,6 +334,20 @@ class MainMenuGUI : PluginGUI( } } + /** How much of the bestiary the viewer has filled in. */ + private fun bestiary(player: Player): ItemStack { + val kinds = ContentRegistry.bestiary.kinds.keys + val found = kinds.count { BestiaryRecords.of(player, it).kills > 0 } + val lines = listOf( + player.tr("gui.menu.bestiary-lore"), + "", + player.tr("gui.menu.bestiary-found", "found" to found, "total" to kinds.size), + "", + player.tr("gui.admin.click-open"), + ) + return PanelRender.card(Material.WRITABLE_BOOK, player.tr("gui.menu.bestiary"), lines) + } + private fun sidebar(player: Player): ItemStack { val shown = ScoreboardService.isEnabledFor(player) val lines = listOf( diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/menu/PlayerPickerGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/menu/PlayerPickerGUI.kt index a867508..07997b4 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/menu/PlayerPickerGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/menu/PlayerPickerGUI.kt @@ -36,7 +36,7 @@ class PlayerPickerGUI : PagedPluginGUI( titleKey = "gui.menu-players.title-trade", rows = 6, fillMode = FillMode.NONE, - layout = PagedLayout.CENTERED, + layout = PagedLayout.FRAMED, ) { /** What the picker is picking someone for. */ diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt index 0540a42..214451f 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopAmountGUI.kt @@ -159,7 +159,7 @@ class ShopAmountGUI : PluginGUI( val name = player.tr( if (refusal == null) "gui.shop-amount.option" else "gui.shop-amount.option-locked", "amount" to amount, - "item" to ShopRender.itemName(entry.item), + "item" to ShopRender.itemName(player, entry.item), ) val closing = refusal diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEditorGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEditorGUI.kt index 742ca80..97015d4 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEditorGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopEditorGUI.kt @@ -255,7 +255,7 @@ class ShopEditorGUI : PagedPluginGUI( shop.entries += entry ShopManager.save() - player.sendPrefixed(player.tr("shop.editor.entry-added", "item" to ShopRender.itemName(entry.item))) + player.sendPrefixed(player.tr("shop.editor.entry-added", "item" to ShopRender.itemName(player, entry.item))) ShopRender.navigate { ShopEntryGUI.show(player, shop, entry) } } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt index 568d108..f6de620 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/shop/ShopRender.kt @@ -8,6 +8,7 @@ import net.trilleo.mc.plugins.tritown.Main import net.trilleo.mc.plugins.tritown.enums.LimitPeriod import net.trilleo.mc.plugins.tritown.enums.MatchMode import net.trilleo.mc.plugins.tritown.enums.TownyRequirement +import net.trilleo.mc.plugins.tritown.gear.GearText import net.trilleo.mc.plugins.tritown.shops.ShopCost import net.trilleo.mc.plugins.tritown.shops.ShopEntry import net.trilleo.mc.plugins.tritown.shops.ShopTrade @@ -39,17 +40,20 @@ object ShopRender { } /** - * What to call [item] in a line of text. + * What to call [item] in a line of text [viewer] reads. * * A renamed item is called by the name it was given, escaped because an - * administrator wrote it. Anything else uses the client's own translation, - * so a Chinese player reads Chinese item names without TriTown shipping a - * copy of Minecraft's dictionary. + * administrator wrote it. Gear and content items go by their own names in + * the viewer's language, not the vanilla item they are made of. Anything + * else uses the client's own translation, so a Chinese player reads Chinese + * item names without TriTown shipping a copy of Minecraft's dictionary. */ - fun itemName(item: ItemStack): String { + fun itemName(viewer: Player, item: ItemStack): String { val meta = item.itemMeta val custom: Component? = if (meta != null && meta.hasDisplayName()) meta.displayName() else null - return custom?.let { ComponentUtil.escape(plain.serialize(it)) } ?: "" + return custom?.let { ComponentUtil.escape(plain.serialize(it)) } + ?: GearText.label(viewer, item) + ?: "" } /** @@ -59,7 +63,7 @@ object ShopRender { * the player hands over or as something they are given. */ fun itemLine(player: Player, item: ItemStack, multiplier: Int = 1, key: String = "gui.shop.cost-item"): String = - player.tr(key, "amount" to item.amount * multiplier, "item" to itemName(item)) + player.tr(key, "amount" to item.amount * multiplier, "item" to itemName(player, item)) /** * What [amount] of [entry] costs, money first, coloured as something the @@ -164,7 +168,7 @@ object ShopRender { player.tr( key, "amount" to result.amount, - "item" to itemName(entry.item), + "item" to itemName(player, entry.item), "price" to money(result.money), ) ) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StorageGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StorageGUI.kt index 4e97e46..b08dd9d 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StorageGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StorageGUI.kt @@ -20,11 +20,7 @@ import net.trilleo.mc.plugins.tritown.utils.tr import org.bukkit.Bukkit import org.bukkit.Material import org.bukkit.entity.Player -import org.bukkit.event.inventory.ClickType -import org.bukkit.event.inventory.InventoryAction -import org.bukkit.event.inventory.InventoryClickEvent -import org.bukkit.event.inventory.InventoryCloseEvent -import org.bukkit.event.inventory.InventoryDragEvent +import org.bukkit.event.inventory.* import org.bukkit.inventory.Inventory import org.bukkit.inventory.ItemStack import java.util.* @@ -209,7 +205,15 @@ class StorageGUI : PluginGUI( when (slot) { SLOT_PREVIOUS -> if (view.page > 0) switchPage(player, view, view.page - 1) - SLOT_OVERVIEW -> leave(player) { StoragePagesGUI.show(player, view.owner, view.page, readOnly = !view.canEdit) } + SLOT_OVERVIEW -> leave(player) { + StoragePagesGUI.show( + player, + view.owner, + view.page, + readOnly = !view.canEdit + ) + } + SLOT_DEPOSIT -> if (view.canEdit) quickDeposit(player, view, storage) SLOT_SORT -> if (view.canEdit) sort(player, view, storage, everyPage = click.isShiftClick) SLOT_INFO -> leave(player) { back(player, view) } @@ -346,7 +350,8 @@ class StorageGUI : PluginGUI( buttons[SLOT_UNPACK] = card(player, Material.SHULKER_BOX, "gui.storage.unpack", "gui.storage.unpack-lore") } if (canRename(player, view)) { - buttons[SLOT_SETTINGS] = card(player, Material.NAME_TAG, "gui.storage.settings", "gui.storage.settings-lore") + buttons[SLOT_SETTINGS] = + card(player, Material.NAME_TAG, "gui.storage.settings", "gui.storage.settings-lore") } val buyable = view.owner == player.uniqueId && view.canEdit diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StoragePageSettingsGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StoragePageSettingsGUI.kt index c23ddaa..6fe1bd7 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StoragePageSettingsGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StoragePageSettingsGUI.kt @@ -45,22 +45,43 @@ class StoragePageSettingsGUI : PluginGUI( val page = storage.pageOrNull(target.page) GUIFrame.draw(inventory, BUTTONS + PREVIEW_SLOT + BACK_SLOT) - inventory.setItem(PREVIEW_SLOT, StorageRender.pageCard(player, page, target.page, current = false).also { item -> - item.editMeta { it.lore(null) } - }) + inventory.setItem( + PREVIEW_SLOT, + StorageRender.pageCard(player, page, target.page, current = false).also { item -> + item.editMeta { it.lore(null) } + }) inventory.setItem( SLOT_RENAME, - PanelRender.card(Material.NAME_TAG, player.tr("gui.storage-settings.rename"), listOf(player.tr("gui.storage-settings.rename-lore"))) + PanelRender.card( + Material.NAME_TAG, + player.tr("gui.storage-settings.rename"), + listOf(player.tr("gui.storage-settings.rename-lore")) + ) ) inventory.setItem( SLOT_ICON, - PanelRender.card(Material.ITEM_FRAME, player.tr("gui.storage-settings.icon"), listOf(player.tr("gui.storage-settings.icon-lore"))) + PanelRender.card( + Material.ITEM_FRAME, + player.tr("gui.storage-settings.icon"), + listOf(player.tr("gui.storage-settings.icon-lore")) + ) ) inventory.setItem( SLOT_RESET, - PanelRender.card(Material.BARRIER, player.tr("gui.storage-settings.reset"), listOf(player.tr("gui.storage-settings.reset-lore"))) + PanelRender.card( + Material.BARRIER, + player.tr("gui.storage-settings.reset"), + listOf(player.tr("gui.storage-settings.reset-lore")) + ) + ) + inventory.setItem( + BACK_SLOT, + PanelRender.card( + Material.ARROW, + player.tr("gui.storage-settings.back"), + listOf(player.tr("gui.storage-settings.back-lore")) + ) ) - inventory.setItem(BACK_SLOT, PanelRender.card(Material.ARROW, player.tr("gui.storage-settings.back"), listOf(player.tr("gui.storage-settings.back-lore")))) } override fun onClick(event: InventoryClickEvent) { diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StoragePagesGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StoragePagesGUI.kt index 2f4d5bc..bdad99d 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StoragePagesGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StoragePagesGUI.kt @@ -95,7 +95,8 @@ class StoragePagesGUI : PagedPluginGUI( contexts.remove(event.player.uniqueId)?.let { StorageManager.unloadIfIdle(it.owner) } } - private fun canBuy(player: Player, context: Context): Boolean = context.owner == player.uniqueId && !context.readOnly + private fun canBuy(player: Player, context: Context): Boolean = + context.owner == player.uniqueId && !context.readOnly companion object { const val ID = "storage-pages" diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StorageRender.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StorageRender.kt index 668af22..367779a 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StorageRender.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/guis/storage/StorageRender.kt @@ -3,6 +3,7 @@ package net.trilleo.mc.plugins.tritown.guis.storage import net.kyori.adventure.key.Key import net.kyori.adventure.sound.Sound import net.trilleo.mc.plugins.tritown.economy.Money +import net.trilleo.mc.plugins.tritown.gear.GearText import net.trilleo.mc.plugins.tritown.guis.admin.PanelRender import net.trilleo.mc.plugins.tritown.storage.PlayerStorage import net.trilleo.mc.plugins.tritown.storage.StorageManager @@ -43,8 +44,13 @@ object StorageRender { fun icon(page: StoragePage?): Material = page?.icon?.let(Material::matchMaterial)?.takeIf { it.isItem && !it.isAir } ?: DEFAULT_ICON - /** An item's name in the viewer's own language, whatever language the server runs in. */ - fun itemName(material: Material): String = "" + /** + * What [stack] is called in [viewer]'s own language, whatever language the + * server runs in: gear and content items by their own names, anything else + * by its material's. + */ + fun itemName(viewer: Player, stack: ItemStack): String = + GearText.label(viewer, stack) ?: "" /** * [page] as a card for the overview: its name, how full it is, and what @@ -57,22 +63,26 @@ object StorageRender { ) val top = page?.slots?.filterNotNull() - ?.groupBy { it.type } + ?.groupBy { itemName(viewer, it) } ?.mapValues { (_, stacks) -> stacks.sumOf { it.amount } } ?.entries?.sortedByDescending { it.value } ?.take(PREVIEW_SIZE) .orEmpty() if (top.isNotEmpty()) { lines += "" - top.forEach { (material, amount) -> - lines += viewer.tr("gui.storage.overview-item", "amount" to amount, "name" to itemName(material)) + top.forEach { (name, amount) -> + lines += viewer.tr("gui.storage.overview-item", "amount" to amount, "name" to name) } } lines += "" lines += viewer.tr(if (current) "gui.storage.overview-current" else "gui.storage.overview-open") - return PanelRender.card(icon(page), viewer.tr("gui.storage.overview-name", "name" to pageName(viewer, page, index)), lines) + return PanelRender.card( + icon(page), + viewer.tr("gui.storage.overview-name", "name" to pageName(viewer, page, index)), + lines + ) .also { item -> if (current) item.editMeta { it.setEnchantmentGlintOverride(true) } } } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/combat/DamageListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/combat/DamageListener.kt new file mode 100644 index 0000000..088155b --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/combat/DamageListener.kt @@ -0,0 +1,177 @@ +package net.trilleo.mc.plugins.tritown.listeners.combat + +import net.trilleo.mc.plugins.tritown.combat.* +import net.trilleo.mc.plugins.tritown.content.Balance +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.gear.Gear +import net.trilleo.mc.plugins.tritown.mobs.* +import org.bukkit.attribute.Attribute +import org.bukkit.entity.* +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.entity.EntityDamageByEntityEvent +import org.bukkit.event.entity.EntityDamageEvent +import org.bukkit.event.entity.EntityDamageEvent.DamageCause +import java.util.* +import java.util.concurrent.ThreadLocalRandom + +/** + * Works out every hit between a player and a mob, or between two mobs, in RPG + * numbers, and hands vanilla the result. + * + * | Hit | Worked out as | + * |:---------------------------|:-----------------------------------------------------------| + * | player → player | **left alone**, and so is a player's pet biting a player | + * | anything the world does to a player | left alone | + * | player → mob (melee) | the player's stats, scaled by vanilla's share of a full swing | + * | player → mob (arrow, trident) | vanilla's hit, scaled by the stats the shot was fired with | + * | mob → player or mob | vanilla's hit, grown by the attacker's level, rank and kind, through Defense | + * | anything else → mob | level-1 units, so no trap or fire outgrows level 1 | + * + * The result is converted to vanilla damage through the target's own pool + * ([DamageMath.toVanilla]). A mob keeps vanilla's reductions as they are. A + * player's worn armor is already their Defense, so vanilla's armor reduction + * is taken out and the rest carried over ([VanillaReductions]). + */ +class DamageListener : Listener { + + /** Whether each hit a player landed on a mob was critical, until its indicator is drawn. */ + private val crits = WeakHashMap() + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + fun onDamage(event: EntityDamageEvent) { + val victim = event.entity as? LivingEntity ?: return + if (victim is ArmorStand || !Combat.isActive(victim.world)) return + + val balance = ContentRegistry.balance + val attacker = event.damageSource.causingEntity as? LivingEntity + if (victim is Player) hurtPlayer(event, victim, attacker, balance) else hurtMob( + event, + victim, + attacker, + balance + ) + } + + /** + * Everything that follows a hit that landed: a mob's affixes answering it, + * its nameplate catching up, the hit counted towards its loot, and the + * damage a player dealt floating up. + */ + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + fun afterDamage(event: EntityDamageEvent) { + val victim = event.entity as? LivingEntity ?: return + if (!Combat.isActive(victim.world)) return + val attacker = event.damageSource.causingEntity as? LivingEntity + val dealt = DamageMath.toRpg(event.finalDamage, CombatHealth.max(victim), CombatHealth.vanillaMax(victim)) + + if (victim is Player) { + if (attacker == null || attacker is Player) return + val profile = MobProfiles.of(attacker) + if (profile.affixes.isNotEmpty()) AffixEffects.afterHittingPlayer(attacker, profile, victim, dealt) + return + } + + MobNameplate.updateLater(victim) + MobLoot.credit(victim, attacker, dealt) + val left = (victim.health - event.finalDamage) / CombatHealth.vanillaMax(victim) + AffixEffects.afterHurt(victim, MobProfiles.of(victim), left) + + val crit = crits.remove(event) ?: return + if (dealt > 0.0) DamageIndicators.show(victim, dealt, crit) + } + + private fun hurtPlayer(event: EntityDamageEvent, victim: Player, attacker: LivingEntity?, balance: Balance) { + if (attacker == null || attacker is Player || (attacker as? Tameable)?.isTamed == true) return + + val profile = MobProfiles.of(attacker) + val multiplier = MobPower.damage(attacker, profile, balance) + val hit = DamageMath.afterDefense( + DamageMath.mobHit(event.damage, balance, profile.level, multiplier), + PlayerStats.sheet(victim)[Stat.DEFENSE], + ) + applyWithoutArmor( + event, + victim, + DamageMath.toVanilla(hit, CombatHealth.max(victim), CombatHealth.vanillaMax(victim)) + ) + } + + private fun hurtMob(event: EntityDamageEvent, victim: LivingEntity, attacker: LivingEntity?, balance: Balance) { + val direct = event.damageSource.directEntity + val profile = MobProfiles.of(victim) + if (AffixEffects.wards(victim, profile, projectile = direct is Projectile, melee = event.cause in MELEE)) { + event.isCancelled = true + return + } + var crit: Boolean? = null + + val hit = when { + attacker is Player && direct === attacker && event.cause in MELEE -> { + val sheet = PlayerStats.sheet(attacker) + val jumpAttack = (event as? EntityDamageByEntityEvent)?.isCritical == true + crit = roll(DamageMath.critChance(sheet, jumpAttack, balance)) + val share = DamageMath.vanillaShare(event.damage, attackDamage(attacker), jumpAttack) + val weaponDamage = if (Gear.isRanged(attacker.inventory.itemInMainHand)) 0.0 else sheet[Stat.DAMAGE] + DamageMath.meleeHit(sheet, balance, share, crit, weaponDamage) + } + + attacker is Player && direct is AbstractArrow -> { + val shot = ShotStats.read(direct) + val sheet = shot?.stats ?: PlayerStats.sheet(attacker) + crit = roll(DamageMath.critChance(sheet, false, balance)) + DamageMath.shotHit(event.damage, sheet, shot?.multiplier ?: balance.lens, crit) + } + + attacker != null && attacker !is Player -> { + val attackerProfile = MobProfiles.of(attacker) + val multiplier = MobPower.damage(attacker, attackerProfile, balance) + DamageMath.mobHit(event.damage, balance, attackerProfile.level, multiplier) + } + + else -> DamageMath.environmentHit(event.damage, balance) + } + + val defended = DamageMath.afterDefense(hit, MobPower.defense(victim, profile)) + event.damage = DamageMath.toVanilla(defended, CombatHealth.max(victim), CombatHealth.vanillaMax(victim)) + crit?.let { crits[event] = it } + } + + /** + * Sets the hit to [base] and carries vanilla's reductions over, all but + * armor. The modifier API is deprecated without a replacement, and it is + * the only way to take one reduction out and keep the rest. + */ + @Suppress("DEPRECATION") + private fun applyWithoutArmor(event: EntityDamageEvent, victim: Player, base: Double) { + val modifiers = REDUCTIONS.filter(event::isApplicable) + val rescaled = VanillaReductions.rescale( + originalBase = event.getDamage(EntityDamageEvent.DamageModifier.BASE), + reductions = modifiers.map { VanillaReductions.Reduction(kind(it), event.getDamage(it)) }, + base = base, + absorption = victim.absorptionAmount, + ) + event.setDamage(EntityDamageEvent.DamageModifier.BASE, base) + modifiers.zip(rescaled).forEach { (modifier, value) -> event.setDamage(modifier, value) } + } + + @Suppress("DEPRECATION") + private fun kind(modifier: EntityDamageEvent.DamageModifier): VanillaReductions.Kind = when (modifier) { + EntityDamageEvent.DamageModifier.ARMOR -> VanillaReductions.Kind.ARMOR + EntityDamageEvent.DamageModifier.ABSORPTION -> VanillaReductions.Kind.ABSORPTION + else -> VanillaReductions.Kind.SHARE + } + + private fun attackDamage(player: Player): Double = player.getAttribute(Attribute.ATTACK_DAMAGE)?.value ?: 1.0 + + private fun roll(chance: Double): Boolean = ThreadLocalRandom.current().nextDouble() < chance + + private companion object { + val MELEE = setOf(DamageCause.ENTITY_ATTACK, DamageCause.ENTITY_SWEEP_ATTACK) + + /** Every reduction, in the order the server applies them: the enum is kept in that order. */ + @Suppress("DEPRECATION") + val REDUCTIONS = EntityDamageEvent.DamageModifier.entries.filter { it != EntityDamageEvent.DamageModifier.BASE } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/combat/StatListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/combat/StatListener.kt new file mode 100644 index 0000000..b8feb14 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/combat/StatListener.kt @@ -0,0 +1,94 @@ +package net.trilleo.mc.plugins.tritown.listeners.combat + +import com.destroystokyo.paper.event.player.PlayerArmorChangeEvent +import net.trilleo.mc.plugins.tritown.combat.* +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.gear.Gear +import org.bukkit.entity.Player +import org.bukkit.entity.Projectile +import org.bukkit.entity.Trident +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.entity.EntityPickupItemEvent +import org.bukkit.event.entity.EntityPotionEffectEvent +import org.bukkit.event.entity.EntityShootBowEvent +import org.bukkit.event.entity.ProjectileLaunchEvent +import org.bukkit.event.inventory.InventoryCloseEvent +import org.bukkit.event.player.PlayerDropItemEvent +import org.bukkit.event.player.PlayerItemHeldEvent +import org.bukkit.event.player.PlayerQuitEvent +import org.bukkit.event.player.PlayerSwapHandItemsEvent + +/** + * Keeps [PlayerStats] honest: a player's sheet is dropped whenever what they + * wear, hold or are affected by may have changed, and rebuilt the next time it + * is read. It also writes a shooter's stats onto what they fire. + */ +class StatListener : Listener { + + @EventHandler(priority = EventPriority.MONITOR) + fun onArmor(event: PlayerArmorChangeEvent) = PlayerStats.invalidate(event.player) + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + fun onHeld(event: PlayerItemHeldEvent) = PlayerStats.invalidate(event.player) + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + fun onSwapHands(event: PlayerSwapHandItemsEvent) = PlayerStats.invalidate(event.player) + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + fun onDrop(event: PlayerDropItemEvent) = PlayerStats.invalidate(event.player) + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + fun onPickup(event: EntityPickupItemEvent) { + (event.entity as? Player)?.let(PlayerStats::invalidate) + } + + @EventHandler(priority = EventPriority.MONITOR) + fun onClose(event: InventoryCloseEvent) { + (event.player as? Player)?.let(PlayerStats::invalidate) + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + fun onEffect(event: EntityPotionEffectEvent) { + (event.entity as? Player)?.let(PlayerStats::invalidate) + } + + @EventHandler(priority = EventPriority.MONITOR) + fun onQuit(event: PlayerQuitEvent) = PlayerStats.invalidate(event.player) + + /** + * An arrow is worth what its bow makes it: the bow's Damage if it is gear, + * or what `vanilla.bow-attack` says a vanilla bow counts as. + */ + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + fun onShoot(event: EntityShootBowEvent) { + val shooter = event.entity as? Player ?: return + val projectile = event.projectile as? Projectile ?: return + if (!Combat.isActive(shooter.world)) return + + val balance = ContentRegistry.balance + val damage = Gear.stats(event.bow)?.get(Stat.DAMAGE) ?: (balance.vanilla.bowAttack * balance.lens) + val multiplier = DamageMath.shotMultiplier(damage, balance.vanilla.arrowReference, balance) + ShotStats.write(projectile, PlayerStats.sheet(shooter), multiplier) + } + + /** + * Tridents are thrown, not shot, so they come through here instead. A + * thrown trident is measured against its own melee hit, so it lands for + * what it would have struck for. + */ + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + fun onThrow(event: ProjectileLaunchEvent) { + val trident = event.entity as? Trident ?: return + val thrower = trident.shooter as? Player ?: return + if (!Combat.isActive(thrower.world)) return + + val balance = ContentRegistry.balance + val item = trident.itemStack + val vanilla = StatSources.attackDamage(item) + val damage = Gear.stats(item)?.get(Stat.DAMAGE) ?: (vanilla * balance.lens) + val multiplier = DamageMath.shotMultiplier(damage, 1.0 + vanilla, balance) + ShotStats.write(trident, PlayerStats.sheet(thrower), multiplier) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/combat/VitalityListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/combat/VitalityListener.kt new file mode 100644 index 0000000..9f61055 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/combat/VitalityListener.kt @@ -0,0 +1,31 @@ +package net.trilleo.mc.plugins.tritown.listeners.combat + +import net.trilleo.mc.plugins.tritown.combat.Combat +import net.trilleo.mc.plugins.tritown.combat.PlayerStats +import net.trilleo.mc.plugins.tritown.combat.Stat +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.entity.EntityRegainHealthEvent +import org.bukkit.event.entity.EntityRegainHealthEvent.RegainReason + +/** + * Vitality: a player's natural regeneration and healing potions give back + * more, by its value in percent. Health is a share of the pool, so this is a + * share too, and grows with the pool rather than falling behind it. + */ +class VitalityListener : Listener { + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + fun onRegain(event: EntityRegainHealthEvent) { + val player = event.entity as? Player ?: return + if (event.regainReason !in HEALING || !Combat.isActive(player.world)) return + val vitality = PlayerStats.sheet(player)[Stat.VITALITY] + if (vitality > 0.0) event.amount *= 1.0 + vitality / 100.0 + } + + private companion object { + val HEALING = setOf(RegainReason.SATIATED, RegainReason.REGEN, RegainReason.MAGIC, RegainReason.MAGIC_REGEN) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/items/ContentItemListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/items/ContentItemListener.kt new file mode 100644 index 0000000..7349661 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/items/ContentItemListener.kt @@ -0,0 +1,30 @@ +package net.trilleo.mc.plugins.tritown.listeners.items + +import net.trilleo.mc.plugins.tritown.content.ContentItems +import org.bukkit.block.Container +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.block.CrafterCraftEvent +import org.bukkit.event.inventory.PrepareItemCraftEvent + +/** + * Keeps content items inert. + * + * Every one is an echo shard underneath (see [ContentItems]), and the only + * thing an echo shard does is craft a recovery compass. Refusing that, in a + * crafting grid or a crafter, leaves a content item nothing to be but itself. + */ +class ContentItemListener : Listener { + + @EventHandler(priority = EventPriority.HIGHEST) + fun onPrepareCraft(event: PrepareItemCraftEvent) { + if (event.inventory.matrix.any { ContentItems.idOf(it) != null }) event.inventory.result = null + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGHEST) + fun onCrafter(event: CrafterCraftEvent) { + val crafter = event.block.getState(false) as? Container ?: return + if (crafter.inventory.contents.any { ContentItems.idOf(it) != null }) event.isCancelled = true + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/items/ItemRefreshListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/items/ItemRefreshListener.kt new file mode 100644 index 0000000..fac1c8e --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/items/ItemRefreshListener.kt @@ -0,0 +1,48 @@ +package net.trilleo.mc.plugins.tritown.listeners.items + +import com.destroystokyo.paper.event.player.PlayerArmorChangeEvent +import net.trilleo.mc.plugins.tritown.content.ItemRedraw +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.entity.EntityPickupItemEvent +import org.bukkit.event.inventory.InventoryOpenEvent +import org.bukkit.event.player.PlayerItemHeldEvent +import org.bukkit.event.player.PlayerJoinEvent + +/** + * Redraws gear and content items drawn from older content or another item + * language wherever a player is about to look at them: as they join, open an + * inventory, pick something up, or take a piece in hand or put one on. + * [ItemRedraw.refresh] is one read when nothing has changed, so this costs next + * to nothing after the first time. + */ +class ItemRefreshListener : Listener { + + @EventHandler(priority = EventPriority.MONITOR) + fun onJoin(event: PlayerJoinEvent) = ItemRedraw.refreshAll(event.player.inventory) + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + fun onOpen(event: InventoryOpenEvent) { + ItemRedraw.refreshAll(event.inventory) + ItemRedraw.refreshAll(event.player.inventory) + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + fun onHeld(event: PlayerItemHeldEvent) { + ItemRedraw.refresh(event.player.inventory.getItem(event.newSlot)) + } + + @EventHandler(priority = EventPriority.MONITOR) + fun onArmor(event: PlayerArmorChangeEvent) { + ItemRedraw.refresh(event.player.inventory.getItem(event.slot)) + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + fun onPickup(event: EntityPickupItemEvent) { + if (event.entity !is Player) return + val stack = event.item.itemStack + if (ItemRedraw.refresh(stack)) event.item.itemStack = stack + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/mobs/BossListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/mobs/BossListener.kt new file mode 100644 index 0000000..73fe654 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/mobs/BossListener.kt @@ -0,0 +1,52 @@ +package net.trilleo.mc.plugins.tritown.listeners.mobs + +import net.trilleo.mc.plugins.tritown.content.ContentItems +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.mobs.MobSetup +import net.trilleo.mc.plugins.tritown.mobs.boss.BossFights +import net.trilleo.mc.plugins.tritown.mobs.boss.BossSummons +import org.bukkit.entity.LivingEntity +import org.bukkit.event.Event +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.block.Action +import org.bukkit.event.entity.EntityPortalEvent +import org.bukkit.event.entity.EntityTeleportEvent +import org.bukkit.event.player.PlayerInteractEvent +import org.bukkit.inventory.EquipmentSlot + +/** + * Sigils and the bosses they summon. + * + * A sigil is a content item, so it does nothing on its own; right-clicked in + * the main hand, it summons its boss through [BossSummons]. A boss keeps to its + * arena: it never teleports on its own, as an enderman would, or goes through + * a portal. Only TriTown moves it (see [MobSetup.teleport]). + */ +class BossListener : Listener { + + @EventHandler(priority = EventPriority.HIGH) + fun onUse(event: PlayerInteractEvent) { + if (event.hand != EquipmentSlot.HAND) return + if (event.action != Action.RIGHT_CLICK_AIR && event.action != Action.RIGHT_CLICK_BLOCK) return + val id = ContentItems.idOf(event.item) ?: return + val boss = ContentRegistry.bestiary.bossForSigil(id) ?: return + + event.setUseInteractedBlock(Event.Result.DENY) + event.setUseItemInHand(Event.Result.DENY) + BossSummons.summon(event.player, boss) + } + + @EventHandler(ignoreCancelled = true) + fun onTeleport(event: EntityTeleportEvent) { + val mob = event.entity as? LivingEntity ?: return + if (BossFights.isBoss(mob) && !MobSetup.isMovingOnPurpose(mob.uniqueId)) event.isCancelled = true + } + + @EventHandler(ignoreCancelled = true) + fun onPortal(event: EntityPortalEvent) { + val mob = event.entity as? LivingEntity ?: return + if (BossFights.isBoss(mob)) event.isCancelled = true + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/mobs/MobListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/mobs/MobListener.kt new file mode 100644 index 0000000..0c0a9ba --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/mobs/MobListener.kt @@ -0,0 +1,150 @@ +package net.trilleo.mc.plugins.tritown.listeners.mobs + +import com.destroystokyo.paper.event.entity.EntityAddToWorldEvent +import com.destroystokyo.paper.event.entity.EntityRemoveFromWorldEvent +import io.papermc.paper.event.player.PlayerNameEntityEvent +import net.trilleo.mc.plugins.tritown.combat.Combat +import net.trilleo.mc.plugins.tritown.config.MobSettings +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.mobs.* +import net.trilleo.mc.plugins.tritown.mobs.boss.BossFights +import org.bukkit.entity.Enemy +import org.bukkit.entity.LivingEntity +import org.bukkit.entity.Player +import org.bukkit.event.EventHandler +import org.bukkit.event.EventPriority +import org.bukkit.event.Listener +import org.bukkit.event.entity.* +import org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason +import kotlin.math.roundToInt +import kotlin.random.Random + +/** + * Gives a hostile mob its level, and perhaps a custom kind and a rank, as it + * spawns, and keeps them with the mob. + * + * Only mobs that spawn the way wild ones do are levelled — naturally, as + * reinforcements, on patrol, or riding or being ridden. Everything else stays + * level 1: spawners, trial spawners, eggs, commands, raids, and anything a + * player built. So vanilla farms keep working, and only the wild gets harder + * the further out it is. What a mob turns into takes the mob's own level, + * whatever it was. + */ +class MobListener : Listener { + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + fun onSpawn(event: CreatureSpawnEvent) { + val entity = event.entity + if (entity !is Enemy || event.spawnReason !in WILD || !Combat.isActive(entity.world)) return + + val balance = ContentRegistry.balance + val settings = MobSettings.snapshot + val level = MobZones.levelAt(event.location) + val kind = if (settings.customMobs) { + MobRoll.kind(ContentRegistry.bestiary, entity.type.name, level, MobZones.place(event.location), Random) + } else null + val rank = if (entity.type in settings.rankExempt || kind?.rankable == false) MobRank.NORMAL + else MobRoll.rank(level, balance, Random) + val affixes = kind?.affixes.orEmpty() + MobRoll.affixes(rank, balance, Random) + val profile = MobProfile(level, rank, affixes, settings.nameplates, eligible = true, kind = kind?.id) + + MobProfiles.assign(entity, profile) + MobSetup.settle(entity, profile) + } + + /** + * A zombie that drowns, a skeleton that freezes: what it becomes is the + * same mob, at the same level and rank. A slime's children keep its level + * but not its rank, or one champion would split into a crowd of them. A + * custom mob only stays one if it is still its kind of mob: a drowned + * Gravewalker is an ordinary drowned. + */ + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + fun onTransform(event: EntityTransformEvent) { + val from = event.entity as? LivingEntity ?: return + val profile = MobProfiles.of(from).takeIf { it != MobProfile.VANILLA } ?: return + val split = event.transformReason == EntityTransformEvent.TransformReason.SPLIT + + event.transformedEntities.filterIsInstance().forEach { into -> + val inherited = when { + split -> profile.copy(rank = MobRank.NORMAL, affixes = emptySet(), kind = null) + MobKinds.def(profile)?.base != into.type.name -> profile.copy(kind = null) + else -> profile + } + MobProfiles.assign(into, inherited) + MobSetup.settle(into, inherited) + } + } + + /** An active mob coming back with its chunk rejoins the mob task, as its kind now is. */ + @EventHandler + fun onLoad(event: EntityAddToWorldEvent) { + val entity = event.entity as? LivingEntity ?: return + if (entity is Player) return + val profile = MobProfiles.of(entity) + if (!profile.isActive) return + ActiveMobs.track(entity) + if (profile.kind != null) MobKinds.refresh(entity) + if (BossFights.isBoss(entity)) BossFights.adopt(entity) + } + + @EventHandler + fun onUnload(event: EntityRemoveFromWorldEvent) { + ActiveMobs.untrack(event.entity.uniqueId) + AffixEffects.forget(event.entity.uniqueId) + AbilityEffects.forget(event.entity.uniqueId) + MobLoot.forget(event.entity.uniqueId) + BossFights.release(event.entity.uniqueId) + } + + /** A custom mob changes no blocks: an enderman of the bestiary carries nothing off. */ + @EventHandler(ignoreCancelled = true, priority = EventPriority.HIGH) + fun onChangeBlock(event: EntityChangeBlockEvent) { + val entity = event.entity as? LivingEntity ?: return + if (entity !is Player && MobProfiles.of(entity).kind != null) event.isCancelled = true + } + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + fun onName(event: PlayerNameEntityEvent) = MobProfiles.dropNameplate(event.entity) + + @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) + fun onHeal(event: EntityRegainHealthEvent) { + val entity = event.entity as? LivingEntity ?: return + if (entity !is Player) MobNameplate.updateLater(entity) + } + + @EventHandler(ignoreCancelled = true) + fun onDeath(event: EntityDeathEvent) { + val entity = event.entity + if (entity is Player) return + val profile = MobProfiles.of(entity) + AffixEffects.onDeath(entity, profile) + AbilityEffects.forget(entity.uniqueId) + if (BossFights.isBoss(entity)) { + BossFights.defeated(entity) + } else { + val drops = MobLoot.dropsFor(entity) + event.drops += drops + val killer = entity.killer + if (killer != null && profile.kind != null && profile.eligible) BestiaryRecords.credit( + killer, + profile.kind, + drops + ) + } + if (profile.level > 1) { + event.droppedExp = + (event.droppedExp * (1.0 + MobSettings.snapshot.xpPerLevel * (profile.level - 1))).roundToInt() + } + } + + private companion object { + val WILD = setOf( + SpawnReason.NATURAL, + SpawnReason.REINFORCEMENTS, + SpawnReason.PATROL, + SpawnReason.JOCKEY, + SpawnReason.MOUNT, + ) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/protection/ActionDropListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/protection/ActionDropListener.kt index df5b70c..f354975 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/protection/ActionDropListener.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/protection/ActionDropListener.kt @@ -6,9 +6,9 @@ import net.trilleo.mc.plugins.tritown.protection.Protection import org.bukkit.block.TileState import org.bukkit.entity.Item import org.bukkit.entity.Player +import org.bukkit.event.Event import org.bukkit.event.EventHandler import org.bukkit.event.EventPriority -import org.bukkit.event.Event import org.bukkit.event.Listener import org.bukkit.event.block.Action import org.bukkit.event.block.BlockDispenseLootEvent diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/protection/ContainerProtectionListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/protection/ContainerProtectionListener.kt index ad3ae86..04ec423 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/protection/ContainerProtectionListener.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/protection/ContainerProtectionListener.kt @@ -257,7 +257,10 @@ class ContainerProtectionListener : Listener { if (event.action != org.bukkit.event.block.Action.RIGHT_CLICK_BLOCK) return null val block = event.clickedBlock ?: return null if (Protection.settings(block.world)?.containers != true) return null - return block.takeIf { block.getState(false).let { it is DecoratedPot || it is ChiseledBookshelf || it is Shelf || it is Jukebox || it is Campfire } } + return block.takeIf { + block.getState(false) + .let { it is DecoratedPot || it is ChiseledBookshelf || it is Shelf || it is Jukebox || it is Campfire } + } } @EventHandler(ignoreCancelled = true, priority = EventPriority.MONITOR) @@ -278,6 +281,7 @@ class ContainerProtectionListener : Listener { } private companion object { - val FACES = listOf(BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST) + val FACES = + listOf(BlockFace.UP, BlockFace.DOWN, BlockFace.NORTH, BlockFace.SOUTH, BlockFace.EAST, BlockFace.WEST) } } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/storage/ContainerLockListener.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/storage/ContainerLockListener.kt index e121bbc..11f5d42 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/storage/ContainerLockListener.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/listeners/storage/ContainerLockListener.kt @@ -86,7 +86,11 @@ class ContainerLockListener : Listener { val inTop = event.rawSlot in 0 until top.size val fills = when (event.action) { InventoryAction.MOVE_TO_OTHER_INVENTORY -> !inTop - InventoryAction.HOTBAR_SWAP, InventoryAction.HOTBAR_MOVE_AND_READD -> inTop && swapsSomethingIn(player, event) + InventoryAction.HOTBAR_SWAP, InventoryAction.HOTBAR_MOVE_AND_READD -> inTop && swapsSomethingIn( + player, + event + ) + in TAKING -> false else -> inTop } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/Ability.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/Ability.kt new file mode 100644 index 0000000..a37b7a4 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/Ability.kt @@ -0,0 +1,77 @@ +package net.trilleo.mc.plugins.tritown.mobs + +/** + * Something a custom mob does in a fight, on a cooldown, with a warning first. + * What each one does is in `AbilityEffects`, and how strongly in the + * `abilities` block of `balance.yml`. + */ +enum class Ability { + /** Pounces on a target keeping its distance, and lands hard. */ + LEAP, + + /** Rears up, then strikes the ground: a shockwave that throws players up. */ + SLAM, + + /** Lowers its head, then dashes at its target, bowling over whoever is in the way. */ + CHARGE, + + /** Looses a fan of arrows. */ + VOLLEY, + + /** Hurls fireballs that set no blocks alight. */ + FIREBALL, + + /** Marks the ground around its target, then brings fire down on it. */ + METEOR, + + /** Marks the ground around its target, then brings lightning down on it. */ + STORM, + + /** Spits a web that all but roots its target. */ + ENSNARE, + + /** Drags its target in. */ + HOOK, + + /** Calls minions to its side. */ + SUMMON, + + /** Braces itself: far more Defense for a few seconds. */ + BULWARK, + + /** A burst of frost that hurts and slows everyone close by. */ + FROST_NOVA, + + /** Leaves a poisonous cloud where its target stands. */ + MIASMA, + + /** Latches on to its target and drinks its health back. */ + DRAIN; + + /** The translation key naming the ability, spelled out so the language test can see it. */ + val key: String + get() = when (this) { + LEAP -> "mob.ability.leap" + SLAM -> "mob.ability.slam" + CHARGE -> "mob.ability.charge" + VOLLEY -> "mob.ability.volley" + FIREBALL -> "mob.ability.fireball" + METEOR -> "mob.ability.meteor" + STORM -> "mob.ability.storm" + ENSNARE -> "mob.ability.ensnare" + HOOK -> "mob.ability.hook" + SUMMON -> "mob.ability.summon" + BULWARK -> "mob.ability.bulwark" + FROST_NOVA -> "mob.ability.frost-nova" + MIASMA -> "mob.ability.miasma" + DRAIN -> "mob.ability.drain" + } + + /** Its name in the content files: `frost-nova`. */ + val configName: String + get() = name.lowercase().replace('_', '-') + + companion object { + fun of(name: String): Ability? = entries.firstOrNull { it.configName == name.lowercase().replace('_', '-') } + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/AbilityEffects.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/AbilityEffects.kt new file mode 100644 index 0000000..205995c --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/AbilityEffects.kt @@ -0,0 +1,543 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import net.trilleo.mc.plugins.tritown.Main +import net.trilleo.mc.plugins.tritown.combat.CombatHealth +import net.trilleo.mc.plugins.tritown.combat.DamageMath +import net.trilleo.mc.plugins.tritown.content.Balance +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import org.bukkit.* +import org.bukkit.damage.DamageSource +import org.bukkit.damage.DamageType +import org.bukkit.entity.* +import org.bukkit.potion.PotionEffect +import org.bukkit.potion.PotionEffectType +import org.bukkit.scheduler.BukkitRunnable +import org.bukkit.util.Vector +import java.util.* +import kotlin.math.PI +import kotlin.math.cos +import kotlin.math.sin +import kotlin.random.Random + +/** + * What every [Ability] does, and the only place any of it is done. + * + * A custom mob's turn in the mob task may start one: its target must be a + * player the ability can reach, the ability off its cooldown, and the mob off + * the global one. Every ability warns first — particles and sound for its + * `windup-ticks` — so it can be dodged, and a mob winding one up starts no + * other. Numbers come from the `abilities` block of `balance.yml`. + * + * **An ability only ever hurts players, and only as the mob.** Every hit is a + * [DamageSource] the mob caused, so `DamageListener` grows it by the mob's + * level, rank and kind and takes the player's Defense off it like any other of + * its hits, and no hit between players is ever touched. **No ability changes a + * block**: fire and lightning are only what they look like. + * + * What it remembers about one mob — cooldowns, a Bulwark, its minions — is kept + * in memory and forgotten when the mob unloads, which only ever favours the + * players. + */ +object AbilityEffects { + + private val readyAt = HashMap>() + private val nextCast = HashMap() + private val busy = HashSet() + private val bracedUntil = HashMap() + private val minions = HashMap>() + + /** + * What [mob] can do now: its kind's abilities, and a boss's from every + * phase its health has fallen into. + */ + fun abilities(mob: LivingEntity, profile: MobProfile): List { + val def = MobKinds.def(profile) ?: return emptyList() + val phases = def.boss?.phases.orEmpty().filter { CombatHealth.fraction(mob) * 100.0 < it.below } + return (def.abilities + phases.flatMap { it.abilities }).distinct() + } + + /** [mob]'s twice-a-second turn: showing a Bulwark it has up, and perhaps starting an ability. */ + fun tick(mob: LivingEntity, profile: MobProfile) { + if (braced(mob)) showBulwark(mob) + val abilities = abilities(mob, profile) + if (abilities.isEmpty() || mob.uniqueId in busy) return + val target = (mob as? Mob)?.target as? Player ?: return + if (!hittable(target) || target.world != mob.world) return + + val now = Bukkit.getCurrentTick() + val tuning = ContentRegistry.balance.abilities + if (now < nextCast.getOrPut(mob.uniqueId) { now + tuning.globalCooldownTicks }) return + + val ready = readyAt.getOrPut(mob.uniqueId, ::HashMap) + val distance = target.location.distance(mob.location) + val ability = abilities + .filter { now >= (ready[it] ?: 0) && usable(it, mob, target, distance, tuning[it]) } + .randomOrNull() ?: return + + ready[ability] = now + tuning[ability].cooldownTicks + nextCast[mob.uniqueId] = now + tuning.globalCooldownTicks + cast(ability, mob, profile, target, tuning[ability]) + } + + /** The Defense a Bulwark [mob] has up adds, on top of what its profile gives it. */ + fun defense(mob: LivingEntity): Double = + if (braced(mob)) ContentRegistry.balance.abilities[Ability.BULWARK].power else 0.0 + + fun forget(mob: UUID) { + readyAt -= mob + nextCast -= mob + busy -= mob + bracedUntil -= mob + minions -= mob + } + + private fun usable( + ability: Ability, + mob: LivingEntity, + target: Player, + distance: Double, + stats: Balance.AbilityStats + ) = + when (ability) { + Ability.LEAP -> mob.isOnGround && distance in LEAP_MIN..stats.range + Ability.SLAM -> mob.isOnGround && distance <= stats.radius * CLOSE_ENOUGH + Ability.CHARGE -> mob.isOnGround && distance in CHARGE_MIN..stats.range && mob.hasLineOfSight(target) + Ability.VOLLEY, Ability.FIREBALL, Ability.ENSNARE, Ability.DRAIN -> + distance <= stats.range && mob.hasLineOfSight(target) + + Ability.HOOK -> distance in HOOK_MIN..stats.range && mob.hasLineOfSight(target) + Ability.METEOR, Ability.STORM, Ability.MIASMA -> distance <= stats.range + Ability.SUMMON -> livingMinions(mob) < stats.count + Ability.BULWARK -> !braced(mob) && CombatHealth.fraction(mob) < BULWARK_BELOW + Ability.FROST_NOVA -> distance <= stats.radius * CLOSE_ENOUGH + } + + private fun cast( + ability: Ability, + mob: LivingEntity, + profile: MobProfile, + target: Player, + stats: Balance.AbilityStats + ) = + when (ability) { + Ability.LEAP -> leap(mob, target, stats) + Ability.SLAM -> slam(mob, stats) + Ability.CHARGE -> charge(mob, target, stats) + Ability.VOLLEY -> volley(mob, target, stats) + Ability.FIREBALL -> fireball(mob, target, stats) + Ability.METEOR -> strike(mob, target, stats, lightning = false) + Ability.STORM -> strike(mob, target, stats, lightning = true) + Ability.ENSNARE -> ensnare(mob, target, stats) + Ability.HOOK -> hook(mob, target, stats) + Ability.SUMMON -> summon(mob, profile, target, stats) + Ability.BULWARK -> bulwark(mob, stats) + Ability.FROST_NOVA -> frostNova(mob, stats) + Ability.MIASMA -> miasma(mob, target, stats) + Ability.DRAIN -> drain(mob, target, stats) + } + + // ── The abilities ─────────────────────────────────────────────────── + + private fun leap(mob: LivingEntity, target: Player, stats: Balance.AbilityStats) { + windUp(mob, stats.windupTicks, root = true, show = { tick -> + if (tick == 0) sound(mob.location, Sound.ENTITY_RAVAGER_STEP, 0.6f) + mob.world.spawnParticle(Particle.CLOUD, mob.location, 2, 0.3, 0.05, 0.3, 0.01) + }) { + val flat = target.location.toVector().subtract(mob.location.toVector()).setY(0.0) + val reach = flat.length().coerceAtMost(stats.range) + mob.velocity = flat.normalizeOrZero().multiply(reach * LEAP_REACH).setY(LEAP_LIFT) + sound(mob.location, Sound.ENTITY_GOAT_LONG_JUMP, 0.8f) + + repeatFor(mob, LEAP_AIRTIME) { tick -> + if (tick < LEAP_TAKEOFF || !mob.isOnGround) return@repeatFor true + val at = mob.location + mob.world.spawnParticle(Particle.EXPLOSION, at, 1) + mob.world.spawnParticle(Particle.CLOUD, at, 20, stats.radius / 2, 0.1, stats.radius / 2, 0.05) + sound(at, Sound.BLOCK_ANVIL_LAND, 0.6f) + playersNear(at, stats.radius).forEach { hurt(it, mob, stats.damage, DamageType.MOB_ATTACK) } + false + } + } + } + + private fun slam(mob: LivingEntity, stats: Balance.AbilityStats) { + windUp(mob, stats.windupTicks, root = true, show = { tick -> + if (tick == 0) sound(mob.location, Sound.ENTITY_RAVAGER_ROAR, 0.7f) + ring(mob.location, stats.radius * (tick + 1) / stats.windupTicks.coerceAtLeast(1), Particle.CRIT) + }) { + val at = mob.location + sound(at, Sound.ENTITY_GENERIC_EXPLODE, 0.7f) + mob.world.spawnParticle(Particle.EXPLOSION, at, 3, stats.radius / 3, 0.2, stats.radius / 3, 0.0) + ring(at, stats.radius, Particle.SWEEP_ATTACK) + playersNear(at, stats.radius).forEach { player -> + hurt(player, mob, stats.damage, DamageType.MOB_ATTACK) + player.velocity = away(at, player.location, 0.4).setY(stats.power) + } + } + } + + private fun charge(mob: LivingEntity, target: Player, stats: Balance.AbilityStats) { + windUp(mob, stats.windupTicks, root = true, show = { tick -> + if (tick == 0) sound(mob.location, Sound.ENTITY_RAVAGER_ROAR, 1.2f) + face(mob, target.location) + mob.world.spawnParticle(Particle.LARGE_SMOKE, mob.location, 2, 0.3, 0.1, 0.3, 0.01) + }) { + val direction = target.location.toVector().subtract(mob.location.toVector()).setY(0.0).normalizeOrZero() + mob.velocity = direction.clone().multiply(stats.power).setY(CHARGE_LIFT) + sound(mob.location, Sound.ENTITY_BREEZE_CHARGE, 0.8f) + val struck = HashSet() + + repeatFor(mob, CHARGE_TICKS) { + mob.world.spawnParticle(Particle.CLOUD, mob.location, 3, 0.2, 0.05, 0.2, 0.01) + playersNear(mob.location, CHARGE_REACH).filter { struck.add(it.uniqueId) }.forEach { player -> + hurt(player, mob, stats.damage, DamageType.MOB_ATTACK) + player.velocity = direction.clone().multiply(CHARGE_PUSH).setY(CHARGE_PUSH / 2) + } + true + } + } + } + + private fun volley(mob: LivingEntity, target: Player, stats: Balance.AbilityStats) { + windUp(mob, stats.windupTicks, root = false, show = { tick -> + if (tick == 0) sound(mob.location, Sound.ITEM_CROSSBOW_LOADING_START, 1f) + face(mob, target.location) + }) { + val aim = aimAt(mob, target) + val spread = VOLLEY_SPREAD_DEGREES * PI / 180.0 + repeat(stats.count) { index -> + val angle = (index - (stats.count - 1) / 2.0) * spread + val arrow = + mob.launchProjectile(Arrow::class.java, aim.clone().rotateAroundY(angle).multiply(VOLLEY_SPEED)) + arrow.damage = stats.damage / VOLLEY_SPEED + arrow.pickupStatus = AbstractArrow.PickupStatus.DISALLOWED + } + sound(mob.location, Sound.ENTITY_WITHER_SHOOT, 1.4f) + } + } + + private fun fireball(mob: LivingEntity, target: Player, stats: Balance.AbilityStats) { + windUp(mob, stats.windupTicks, root = false, show = { tick -> + if (tick == 0) sound(mob.location, Sound.ENTITY_BLAZE_BURN, 1f) + mob.world.spawnParticle(Particle.FLAME, mob.eyeLocation, 3, 0.3, 0.3, 0.3, 0.01) + }) { + repeatFor(mob, stats.count * FIREBALL_GAP, FIREBALL_GAP.toLong()) { + if (!target.isValid || target.world != mob.world) return@repeatFor false + val fireball = + mob.launchProjectile(SmallFireball::class.java, aimAt(mob, target).multiply(FIREBALL_SPEED)) + fireball.setIsIncendiary(false) + sound(mob.location, Sound.ENTITY_BLAZE_SHOOT, 1f) + true + } + } + } + + /** Meteor and Storm: spots marked on and around the target, and then fire or lightning on each. */ + private fun strike(mob: LivingEntity, target: Player, stats: Balance.AbilityStats, lightning: Boolean) { + val spots = List(stats.count.coerceAtLeast(1)) { index -> + if (index == 0) target.location else target.location.add( + Random.nextDouble(-STRIKE_SCATTER, STRIKE_SCATTER), + 0.0, + Random.nextDouble(-STRIKE_SCATTER, STRIKE_SCATTER), + ) + } + val mark = if (lightning) Particle.ELECTRIC_SPARK else Particle.FLAME + windUp(mob, stats.windupTicks, root = false, show = { tick -> + if (tick == 0) sound( + mob.location, + if (lightning) Sound.ENTITY_EVOKER_CAST_SPELL else Sound.ITEM_FIRECHARGE_USE, + 0.8f + ) + if (tick % 2 == 0) spots.forEach { ring(it, stats.radius, mark) } + }) { + spots.forEach { spot -> + if (lightning) { + spot.world.strikeLightningEffect(spot) + } else { + spot.world.spawnParticle(Particle.EXPLOSION, spot, 2, 0.5, 0.2, 0.5, 0.0) + spot.world.spawnParticle(Particle.LAVA, spot, 12, 0.6, 0.2, 0.6, 0.0) + sound(spot, Sound.ENTITY_GENERIC_EXPLODE, 1.2f) + } + playersNear(spot, stats.radius).forEach { player -> + hurt(player, mob, stats.damage, if (lightning) DamageType.LIGHTNING_BOLT else DamageType.EXPLOSION) + } + } + } + } + + private fun ensnare(mob: LivingEntity, target: Player, stats: Balance.AbilityStats) { + windUp(mob, stats.windupTicks, root = false, show = { tick -> + if (tick == 0) sound(mob.location, Sound.ENTITY_SPIDER_AMBIENT, 0.6f) + line(mob.eyeLocation, target.location.add(0.0, 1.0, 0.0), Particle.ITEM_SNOWBALL) + }) { + if (!reaches(mob, target, stats.range + REACH_GRACE)) return@windUp + target.addPotionEffect(PotionEffect(PotionEffectType.SLOWNESS, ticks(stats.seconds), ENSNARED)) + target.world.spawnParticle( + Particle.BLOCK, + target.location.add(0.0, 0.5, 0.0), + 30, + 0.4, + 0.5, + 0.4, + 0.0, + Material.COBWEB.createBlockData(), + ) + sound(target.location, Sound.BLOCK_POWDER_SNOW_STEP, 0.6f) + } + } + + private fun hook(mob: LivingEntity, target: Player, stats: Balance.AbilityStats) { + windUp(mob, stats.windupTicks, root = true, show = { tick -> + if (tick == 0) sound(mob.location, Sound.ENTITY_FISHING_BOBBER_THROW, 0.6f) + line(mob.eyeLocation, target.location.add(0.0, 1.0, 0.0), Particle.CRIT) + }) { + if (!reaches(mob, target, stats.range + REACH_GRACE)) return@windUp + val pull = mob.location.toVector().subtract(target.location.toVector()).setY(0.0) + val speed = (pull.length() * LEAP_REACH).coerceAtMost(stats.power) + hurt(target, mob, stats.damage, DamageType.MOB_ATTACK) + target.velocity = pull.normalizeOrZero().multiply(speed).setY(HOOK_LIFT) + sound(target.location, Sound.ENTITY_FISHING_BOBBER_RETRIEVE, 0.8f) + } + } + + private fun summon(mob: LivingEntity, profile: MobProfile, target: Player, stats: Balance.AbilityStats) { + windUp(mob, stats.windupTicks, root = true, show = { tick -> + if (tick == 0) sound(mob.location, Sound.ENTITY_EVOKER_PREPARE_SUMMON, 1f) + mob.world.spawnParticle(Particle.SOUL, mob.location.add(0.0, 1.0, 0.0), 3, 0.6, 0.6, 0.6, 0.02) + }) { + val called = MobSetup.minions(mob, profile, stats.count - livingMinions(mob), target) + minions.getOrPut(mob.uniqueId, ::HashSet) += called.map { it.uniqueId } + } + } + + private fun bulwark(mob: LivingEntity, stats: Balance.AbilityStats) { + bracedUntil[mob.uniqueId] = Bukkit.getCurrentTick() + ticks(stats.seconds) + sound(mob.location, Sound.ITEM_SHIELD_BLOCK, 0.7f) + sound(mob.location, Sound.ENTITY_IRON_GOLEM_REPAIR, 0.8f) + showBulwark(mob) + } + + private fun frostNova(mob: LivingEntity, stats: Balance.AbilityStats) { + windUp(mob, stats.windupTicks, root = true, show = { tick -> + if (tick == 0) sound(mob.location, Sound.BLOCK_POWDER_SNOW_STEP, 0.5f) + val left = 1.0 - tick.toDouble() / stats.windupTicks.coerceAtLeast(1) + ring(mob.location.add(0.0, 0.5, 0.0), stats.radius * left, Particle.SNOWFLAKE) + }) { + val at = mob.location + sound(at, Sound.BLOCK_GLASS_BREAK, 0.6f) + sound(at, Sound.ENTITY_PLAYER_HURT_FREEZE, 0.8f) + mob.world.spawnParticle( + Particle.SNOWFLAKE, + at.clone().add(0.0, 1.0, 0.0), + 80, + stats.radius / 2, + 0.5, + stats.radius / 2, + 0.1 + ) + playersNear(at, stats.radius).forEach { player -> + hurt(player, mob, stats.damage, DamageType.FREEZE) + player.addPotionEffect(PotionEffect(PotionEffectType.SLOWNESS, ticks(stats.seconds), CHILLED)) + player.freezeTicks = maxOf(player.freezeTicks, player.maxFreezeTicks - FROST_MARGIN) + } + } + } + + private fun miasma(mob: LivingEntity, target: Player, stats: Balance.AbilityStats) { + val center = target.location + windUp(mob, stats.windupTicks, root = false, show = { tick -> + if (tick == 0) sound(mob.location, Sound.BLOCK_BREWING_STAND_BREW, 0.7f) + ring(center, stats.radius, Particle.WITCH) + }) { + repeatFor(mob, ticks(stats.seconds), PULSE.toLong()) { + center.world.spawnParticle( + Particle.DUST, center.clone().add(0.0, 0.6, 0.0), 30, stats.radius / 2, 0.4, stats.radius / 2, 0.0, + Particle.DustOptions(MIASMA_COLOR, 1.6f), + ) + playersNear(center, stats.radius).forEach { hurt(it, mob, stats.damage, DamageType.MAGIC) } + true + } + } + } + + private fun drain(mob: LivingEntity, target: Player, stats: Balance.AbilityStats) { + sound(mob.location, Sound.ENTITY_PHANTOM_BITE, 0.6f) + repeatFor(mob, ticks(stats.seconds), PULSE.toLong()) { + if (!reaches(mob, target, stats.range + REACH_GRACE) || !mob.hasLineOfSight(target)) return@repeatFor false + line( + target.location.add(0.0, 1.0, 0.0), + mob.eyeLocation, + Particle.DUST, + Particle.DustOptions(DRAIN_COLOR, 1.2f) + ) + val before = target.health + hurt(target, mob, stats.damage, DamageType.MAGIC) + val taken = + DamageMath.toRpg(before - target.health, CombatHealth.max(target), CombatHealth.vanillaMax(target)) + if (taken > 0.0) heal(mob, taken * stats.power / 100.0) + true + } + } + + // ── Helpers ───────────────────────────────────────────────────────── + + /** + * [mob]'s warning: [show] every tick for [ticks] ticks, held in place if + * [root], and then [land] — unless it died or unloaded first. It starts + * nothing else while it winds up. + */ + private fun windUp(mob: LivingEntity, ticks: Int, root: Boolean, show: (Int) -> Unit, land: () -> Unit) { + busy += mob.uniqueId + if (root && ticks > 0) mob.addPotionEffect(PotionEffect(PotionEffectType.SLOWNESS, ticks, ROOTED, false, false)) + object : BukkitRunnable() { + var elapsed = 0 + + override fun run() { + if (!mob.isValid || elapsed >= ticks) { + busy -= mob.uniqueId + cancel() + if (mob.isValid) land() + return + } + show(elapsed++) + } + }.runTaskTimer(Main.instance, 0L, 1L) + } + + /** Runs [step] every [period] ticks for up to [ticks] ticks while [mob] lives, until it answers `false`. */ + private fun repeatFor(mob: LivingEntity, ticks: Int, period: Long = 1L, step: (Int) -> Boolean) { + object : BukkitRunnable() { + var elapsed = 0 + + override fun run() { + if (!mob.isValid || elapsed >= ticks || !step(elapsed)) { + cancel() + return + } + elapsed += period.toInt() + } + }.runTaskTimer(Main.instance, 0L, period) + } + + private fun hurt(player: Player, mob: LivingEntity, amount: Double, type: DamageType) { + if (amount <= 0.0 || !hittable(player)) return + player.damage(amount, DamageSource.builder(type).withCausingEntity(mob).withDirectEntity(mob).build()) + } + + /** Heals [mob] by [rpg] of its own health, no further than full. */ + private fun heal(mob: LivingEntity, rpg: Double) { + val vanilla = DamageMath.toVanilla(rpg, CombatHealth.max(mob), CombatHealth.vanillaMax(mob)) + mob.heal(vanilla.coerceAtMost(CombatHealth.vanillaMax(mob) - mob.health).coerceAtLeast(0.0)) + } + + private fun hittable(player: Player): Boolean = + player.isValid && !player.isDead && (player.gameMode == GameMode.SURVIVAL || player.gameMode == GameMode.ADVENTURE) + + private fun playersNear(center: Location, radius: Double): List = + center.world.getNearbyPlayers(center, radius) + .filter { hittable(it) && it.location.distanceSquared(center) <= radius * radius } + + private fun reaches(mob: LivingEntity, target: Player, range: Double): Boolean = + hittable(target) && target.world == mob.world && target.location.distance(mob.location) <= range + + private fun braced(mob: LivingEntity): Boolean = Bukkit.getCurrentTick() < (bracedUntil[mob.uniqueId] ?: 0) + + private fun showBulwark(mob: LivingEntity) = + ring(mob.location.add(0.0, mob.height / 2, 0.0), mob.width + 0.4, Particle.ENCHANTED_HIT) + + private fun livingMinions(mob: LivingEntity): Int { + val called = minions[mob.uniqueId] ?: return 0 + called.removeIf { Bukkit.getEntity(it)?.isValid != true } + return called.size + } + + /** A unit vector from [mob]'s eyes to [target]'s chest, aimed a little high for the drop over distance. */ + private fun aimAt(mob: LivingEntity, target: Player): Vector { + val to = target.location.add(0.0, target.height * 0.6, 0.0).toVector() + val from = mob.eyeLocation.toVector() + val aim = to.subtract(from) + return aim.setY(aim.y + aim.length() * DROP_ALLOWANCE).normalizeOrZero() + } + + private fun away(from: Location, to: Location, strength: Double): Vector = + to.toVector().subtract(from.toVector()).setY(0.0).normalizeOrZero().multiply(strength) + + private fun face(mob: LivingEntity, at: Location) { + mob.setRotation( + mob.location.setDirection(at.toVector().subtract(mob.location.toVector())).yaw, + mob.location.pitch + ) + } + + private fun ring(center: Location, radius: Double, particle: Particle) { + val points = (radius * RING_DENSITY).toInt().coerceAtLeast(8) + repeat(points) { index -> + val angle = 2 * PI * index / points + center.world.spawnParticle( + particle, + center.clone().add(radius * cos(angle), 0.1, radius * sin(angle)), + 1, + 0.0, + 0.0, + 0.0, + 0.0 + ) + } + } + + private fun line(from: Location, to: Location, particle: Particle, data: Any? = null) { + val step = to.toVector().subtract(from.toVector()) + val points = (step.length() * LINE_DENSITY).toInt().coerceAtLeast(2) + step.multiply(1.0 / points) + val at = from.clone() + repeat(points) { + at.add(step) + at.world.spawnParticle(particle, at, 1, 0.0, 0.0, 0.0, 0.0, data) + } + } + + private fun sound(at: Location, sound: Sound, pitch: Float) = at.world.playSound(at, sound, 1f, pitch) + + private fun ticks(seconds: Double): Int = (seconds * 20).toInt() + + private fun Vector.normalizeOrZero(): Vector = if (lengthSquared() > 0.0) normalize() else this + + private const val CLOSE_ENOUGH = 0.8 + private const val REACH_GRACE = 2.0 + private const val BULWARK_BELOW = 0.8 + private const val PULSE = 10 + + private const val LEAP_MIN = 4.0 + private const val LEAP_REACH = 0.08 + private const val LEAP_LIFT = 0.6 + private const val LEAP_TAKEOFF = 4 + private const val LEAP_AIRTIME = 40 + + private const val CHARGE_MIN = 5.0 + private const val CHARGE_TICKS = 15 + private const val CHARGE_LIFT = 0.15 + private const val CHARGE_REACH = 1.8 + private const val CHARGE_PUSH = 0.9 + + private const val HOOK_MIN = 5.0 + private const val HOOK_LIFT = 0.45 + + private const val VOLLEY_SPEED = 1.6 + private const val VOLLEY_SPREAD_DEGREES = 8.0 + private const val FIREBALL_SPEED = 1.2 + private const val FIREBALL_GAP = 5 + private const val DROP_ALLOWANCE = 0.02 + private const val STRIKE_SCATTER = 3.0 + + private const val FROST_MARGIN = 10 + private const val RING_DENSITY = 6.0 + private const val LINE_DENSITY = 3.0 + + /** Slowness amplifiers: rooted in place, all but rooted, and chilled. */ + private const val ROOTED = 9 + private const val ENSNARED = 4 + private const val CHILLED = 1 + + private val MIASMA_COLOR = Color.fromRGB(0x6a8f2a) + private val DRAIN_COLOR = Color.fromRGB(0x8a0f1f) +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/ActiveMobs.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/ActiveMobs.kt new file mode 100644 index 0000000..ee7af3c --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/ActiveMobs.kt @@ -0,0 +1,27 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import org.bukkit.entity.LivingEntity +import java.util.* + +/** + * Every mob in a loaded chunk that takes a turn in the mob task — one with a + * rank, a custom kind or an affix ([MobProfile.isActive]) — so `MobTurnTask` can + * give each its turn without walking every entity in every world. + * + * Filled as such mobs spawn or load back in, emptied as they unload or die; + * `MobListener` keeps it in step. Main thread only. + */ +object ActiveMobs { + + private val mobs = HashMap() + + fun track(mob: LivingEntity) { + mobs[mob.uniqueId] = mob + } + + fun untrack(mob: UUID) { + mobs -= mob + } + + fun all(): List = mobs.values.toList() +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/Affix.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/Affix.kt new file mode 100644 index 0000000..43aa421 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/Affix.kt @@ -0,0 +1,56 @@ +package net.trilleo.mc.plugins.tritown.mobs + +/** + * Something that makes a ranked mob fight differently. What each one does is + * in `AffixEffects`, and how strongly in the `affixes` block of `balance.yml`. + */ +enum class Affix { + /** Has Defense, like a player's armor. */ + ARMORED, + + /** Moves faster. */ + FRENZIED, + + /** Heals a share of the damage it deals. */ + VAMPIRIC, + + /** Hits harder once badly hurt. */ + ENRAGED, + + /** Sets whoever it hits on fire. */ + MOLTEN, + + /** Slows whoever it hits. */ + FROSTBOUND, + + /** Poisons whoever it hits. */ + VENOMOUS, + + /** Explodes a moment after it dies. */ + VOLATILE, + + /** Calls more of its kind once badly hurt. */ + SUMMONER, + + /** Steps in behind a target that keeps its distance. */ + BLINKING, + + /** Shrugs off projectiles until something strikes it in melee. */ + WARDED; + + /** The translation key naming the affix, spelled out so the language test can see it. */ + val key: String + get() = when (this) { + ARMORED -> "mob.affix.armored" + FRENZIED -> "mob.affix.frenzied" + VAMPIRIC -> "mob.affix.vampiric" + ENRAGED -> "mob.affix.enraged" + MOLTEN -> "mob.affix.molten" + FROSTBOUND -> "mob.affix.frostbound" + VENOMOUS -> "mob.affix.venomous" + VOLATILE -> "mob.affix.volatile" + SUMMONER -> "mob.affix.summoner" + BLINKING -> "mob.affix.blinking" + WARDED -> "mob.affix.warded" + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/AffixEffects.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/AffixEffects.kt new file mode 100644 index 0000000..8df314d --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/AffixEffects.kt @@ -0,0 +1,209 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import net.trilleo.mc.plugins.tritown.Main +import net.trilleo.mc.plugins.tritown.combat.CombatHealth +import net.trilleo.mc.plugins.tritown.combat.DamageMath +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import org.bukkit.Bukkit +import org.bukkit.NamespacedKey +import org.bukkit.Particle +import org.bukkit.Sound +import org.bukkit.attribute.Attribute +import org.bukkit.attribute.AttributeModifier +import org.bukkit.damage.DamageSource +import org.bukkit.damage.DamageType +import org.bukkit.entity.LivingEntity +import org.bukkit.entity.Mob +import org.bukkit.entity.Player +import org.bukkit.potion.PotionEffect +import org.bukkit.potion.PotionEffectType +import org.bukkit.util.Vector +import java.util.* + +/** + * What every [Affix] does, and the only place any of it is done. + * + * Numbers come from the `affixes` block of `balance.yml`; what Armored and + * Enraged add to a mob's Defense and hits is `MobPower`'s. What an affix + * remembers about one mob — a ward already broken, minions already called, the + * last blink — is kept in memory: a mob that unloads mid-fight gets its ward + * back, which only ever favours the mob. + * + * **Nothing here touches a hit between players.** Every effect is triggered by + * a mob, and lands on a player only through a mob's hit. + */ +object AffixEffects { + + private val FRENZY = NamespacedKey("tritown", "frenzied") + + private val brokenWards = HashSet() + private val summoned = HashSet() + private val lastBlink = HashMap() + + /** Sets up what an affix changes about the mob itself, as it spawns or gains one. Safe to run again. */ + fun onSpawn(mob: LivingEntity, profile: MobProfile) { + val tuning = ContentRegistry.balance.affixes + if (profile.has(Affix.FRENZIED)) { + mob.getAttribute(Attribute.MOVEMENT_SPEED)?.let { speed -> + speed.removeModifier(FRENZY) + speed.addModifier( + AttributeModifier( + FRENZY, + tuning.frenziedSpeed / 100.0, + AttributeModifier.Operation.ADD_SCALAR + ) + ) + } + } + if (profile.rank == MobRank.CHAMPION) mob.isGlowing = true + } + + /** + * Whether a warded [mob] shrugs this hit off. A projectile is shrugged off + * until anything strikes the mob in melee, which breaks the ward for good. + */ + fun wards(mob: LivingEntity, profile: MobProfile, projectile: Boolean, melee: Boolean): Boolean { + if (!profile.has(Affix.WARDED) || mob.uniqueId in brokenWards) return false + if (melee) { + brokenWards += mob.uniqueId + mob.world.playSound(mob.location, Sound.BLOCK_GLASS_BREAK, 1f, 0.8f) + return false + } + if (!projectile) return false + mob.world.playSound(mob.location, Sound.ITEM_SHIELD_BLOCK, 1f, 1f) + mob.world.spawnParticle(Particle.ENCHANT, mob.location.add(0.0, mob.height / 2, 0.0), 20, 0.4, 0.5, 0.4, 0.5) + return true + } + + /** What happens to [player] once [mob]'s hit has landed for [dealt] RPG damage. */ + fun afterHittingPlayer(mob: LivingEntity, profile: MobProfile, player: Player, dealt: Double) { + val tuning = ContentRegistry.balance.affixes + if (profile.has(Affix.VAMPIRIC) && !mob.isDead) { + val heal = DamageMath.toVanilla( + dealt * tuning.vampiricHeal / 100.0, + CombatHealth.max(mob), + CombatHealth.vanillaMax(mob) + ) + mob.heal(heal.coerceAtMost(CombatHealth.vanillaMax(mob) - mob.health).coerceAtLeast(0.0)) + } + if (profile.has(Affix.MOLTEN)) player.fireTicks = maxOf(player.fireTicks, tuning.moltenSeconds * TICKS) + if (profile.has(Affix.FROSTBOUND)) { + player.addPotionEffect(PotionEffect(PotionEffectType.SLOWNESS, tuning.frostboundSeconds * TICKS, 1)) + } + if (profile.has(Affix.VENOMOUS)) { + player.addPotionEffect(PotionEffect(PotionEffectType.POISON, tuning.venomousSeconds * TICKS, 0)) + } + } + + /** + * What happens once [mob] has been hit down to [left] of its health, from + * 0 to 1: a summoner that has fallen far enough calls for help, once. + */ + fun afterHurt(mob: LivingEntity, profile: MobProfile, left: Double) { + val tuning = ContentRegistry.balance.affixes + if (!profile.has(Affix.SUMMONER) || left <= 0.0 || mob.uniqueId in summoned) return + if (left * 100.0 >= tuning.summonerBelow) return + + summoned += mob.uniqueId + mob.world.playSound(mob.location, Sound.ENTITY_EVOKER_PREPARE_SUMMON, 1f, 1f) + MobSetup.minions(mob, profile, tuning.summonerMinions, (mob as? Mob)?.target) + } + + /** + * A volatile mob's parting shot: after a hiss, a blast that hurts every + * player near where it died, as an explosion of the mob's, so its level + * scales it and Blast Protection guards against it. It is not a real + * explosion, so it breaks no blocks and leaves the mob's drops alone. + */ + fun onDeath(mob: LivingEntity, profile: MobProfile) { + forget(mob.uniqueId) + if (!profile.has(Affix.VOLATILE)) return + + val tuning = ContentRegistry.balance.affixes + val center = mob.location.add(0.0, mob.height / 2, 0.0) + mob.world.playSound(center, Sound.ENTITY_CREEPER_PRIMED, 1f, 0.8f) + mob.world.spawnParticle(Particle.SMOKE, center, 30, 0.4, 0.4, 0.4, 0.02) + + Bukkit.getScheduler().runTaskLater(Main.instance, Runnable { + val world = center.world ?: return@Runnable + val radius = tuning.volatilePower * 2.0 + world.playSound(center, Sound.ENTITY_GENERIC_EXPLODE, 1f, 1f) + world.spawnParticle(Particle.EXPLOSION_EMITTER, center, 1) + world.getNearbyPlayers(center, radius).forEach { player -> + val falloff = 1.0 - player.location.distance(center) / radius + if (falloff <= 0.0) return@forEach + val blast = + DamageSource.builder(DamageType.EXPLOSION).withCausingEntity(mob).withDirectEntity(mob).build() + player.damage(tuning.volatilePower * VOLATILE_HIT * falloff, blast) + player.velocity = + player.location.toVector().subtract(center.toVector()).normalizeOrZero().multiply(falloff) + } + }, tuning.volatileDelayTicks.toLong()) + } + + /** A ranked mob's twice-a-second turn: what it shows, and a blinker closing the distance. */ + fun tick(mob: LivingEntity, profile: MobProfile) { + val center = mob.location.add(0.0, mob.height / 2, 0.0) + profile.affixes.forEach { affix -> + particle(affix, mob)?.let { + mob.world.spawnParticle( + it, + center, + 1, + 0.3, + 0.4, + 0.3, + 0.0 + ) + } + } + if (profile.has(Affix.BLINKING)) blink(mob) + } + + fun forget(mob: UUID) { + brokenWards -= mob + summoned -= mob + lastBlink -= mob + } + + private fun blink(mob: LivingEntity) { + val tuning = ContentRegistry.balance.affixes + val target = (mob as? Mob)?.target as? Player ?: return + if (target.world != mob.world || target.location.distance(mob.location) <= tuning.blinkingRange) return + val now = Bukkit.getCurrentTick() + if (now - (lastBlink[mob.uniqueId] ?: Int.MIN_VALUE / 2) < tuning.blinkingCooldownTicks) return + + val behind = + target.location.clone().subtract(target.location.direction.setY(0).normalizeOrZero().multiply(BLINK_BEHIND)) + if (!MobSetup.standable(behind)) return + lastBlink[mob.uniqueId] = now + mob.world.spawnParticle(Particle.PORTAL, mob.location.add(0.0, 1.0, 0.0), 30, 0.3, 0.6, 0.3, 0.5) + MobSetup.teleport(mob, behind.setDirection(target.location.toVector().subtract(behind.toVector()))) + mob.world.playSound(behind, Sound.ENTITY_ENDERMAN_TELEPORT, 1f, 1f) + } + + private fun particle(affix: Affix, mob: LivingEntity): Particle? = when (affix) { + Affix.ARMORED -> Particle.CRIT + Affix.FRENZIED -> Particle.CLOUD + Affix.VAMPIRIC -> Particle.DAMAGE_INDICATOR + Affix.ENRAGED -> Particle.ANGRY_VILLAGER.takeIf { + CombatHealth.fraction(mob) * 100.0 < ContentRegistry.balance.affixes.enragedBelow + } + + Affix.MOLTEN -> Particle.FLAME + Affix.FROSTBOUND -> Particle.SNOWFLAKE + Affix.VENOMOUS -> Particle.ITEM_SLIME + Affix.VOLATILE -> Particle.SMOKE + Affix.SUMMONER -> Particle.SOUL + Affix.BLINKING -> Particle.PORTAL + Affix.WARDED -> Particle.ENCHANT.takeIf { mob.uniqueId !in brokenWards } + } + + private fun Vector.normalizeOrZero(): Vector = if (lengthSquared() > 0.0) normalize() else this + + private const val TICKS = 20 + private const val BLINK_BEHIND = 1.5 + + /** Vanilla damage a Volatile blast of power 1 lands point-blank, before the mob's level scales it. */ + private const val VOLATILE_HIT = 4.0 +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/BestiaryRecords.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/BestiaryRecords.kt new file mode 100644 index 0000000..fb32420 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/BestiaryRecords.kt @@ -0,0 +1,63 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import com.google.gson.JsonArray +import com.google.gson.JsonObject +import net.trilleo.mc.plugins.tritown.content.ContentItems +import net.trilleo.mc.plugins.tritown.content.LootEntry +import net.trilleo.mc.plugins.tritown.data.PlayerDataManager +import net.trilleo.mc.plugins.tritown.gear.Gear +import org.bukkit.entity.Player +import org.bukkit.inventory.ItemStack + +/** + * What each player has found of the custom mobs: how many of each they have + * slain, and which drops each has given them. Kept in their player data under + * `bestiary`, by kind id, and read by the bestiary menu. + * + * A drop is remembered by its id — an item's, or `gear:` and a piece's — so it + * can be matched against a kind's loot lines ([idOf]). + */ +object BestiaryRecords { + + private const val ROOT = "bestiary" + private const val KILLS = "kills" + private const val DROPS = "drops" + private const val GEAR_PREFIX = "gear:" + + class Record(val kills: Int, val drops: Set) + + fun of(player: Player, kind: String): Record { + val entry = root(player).getAsJsonObject(kind) ?: return Record(0, emptySet()) + val kills = entry.get(KILLS)?.asInt ?: 0 + val drops = + entry.getAsJsonArray(DROPS)?.mapNotNull { runCatching { it.asString }.getOrNull() }?.toSet().orEmpty() + return Record(kills, drops) + } + + /** Counts one more of [kind] slain by [player], and remembers anything new among the [drops] it gave them. */ + fun credit(player: Player, kind: String, drops: List) { + val root = root(player) + val entry = root.getAsJsonObject(kind) ?: JsonObject().also { root.add(kind, it) } + entry.addProperty(KILLS, (entry.get(KILLS)?.asInt ?: 0) + 1) + + val known = entry.getAsJsonArray(DROPS) ?: JsonArray().also { entry.add(DROPS, it) } + val seen = known.mapNotNull { runCatching { it.asString }.getOrNull() }.toMutableSet() + drops.mapNotNull(::idOf).filter(seen::add).forEach(known::add) + PlayerDataManager.get(player).set(ROOT, root) + } + + /** The id a drop is remembered by, or `null` if it is neither a content item nor gear. */ + fun idOf(stack: ItemStack): String? = + Gear.read(stack)?.let { GEAR_PREFIX + it.def.id } ?: ContentItems.idOf(stack) + + /** The id a loot line's drop is remembered by. */ + fun idOf(entry: LootEntry): String = when (entry) { + is LootEntry.Item -> entry.id + is LootEntry.Gear -> GEAR_PREFIX + entry.id + } + + /** The piece of gear [id] names, if it names one rather than an item. */ + fun gearId(id: String): String? = id.takeIf { it.startsWith(GEAR_PREFIX) }?.removePrefix(GEAR_PREFIX) + + private fun root(player: Player): JsonObject = PlayerDataManager.get(player).getJsonObject(ROOT) +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/DamageLedger.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/DamageLedger.kt new file mode 100644 index 0000000..3ad4766 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/DamageLedger.kt @@ -0,0 +1,39 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import java.util.* + +/** + * How much of each wild mob's health players have taken, and who took it. + * + * A mob only drops more than vanilla does when players dealt enough of its + * damage, so a trap, a lava pit or another mob doing the work earns nothing. + * A tamed pet's damage counts for its owner. Kept in memory: a mob that + * unloads mid-fight starts over, and forgetting it on death or unload is the + * listener's job. Main thread only. + */ +class DamageLedger { + + private val credit = HashMap>() + + /** Records that [player] took [amount] RPG health off [mob]. */ + fun credit(mob: UUID, player: UUID, amount: Double) { + if (amount <= 0.0) return + credit.getOrPut(mob, ::HashMap).merge(player, amount, Double::plus) + } + + /** How much of a pool of [max] each player has dealt to [mob], from 0 to 1. */ + fun shares(mob: UUID, max: Double): Map { + if (max <= 0.0) return emptyMap() + return credit[mob].orEmpty().mapValues { (_, dealt) -> dealt / max } + } + + /** How much of a pool of [max] players have dealt to [mob], from 0 to 1. */ + fun share(mob: UUID, max: Double): Double { + if (max <= 0.0) return 0.0 + return (credit[mob]?.values?.sum() ?: 0.0) / max + } + + fun forget(mob: UUID) { + credit -= mob + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/LevelZone.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/LevelZone.kt new file mode 100644 index 0000000..9d67ac5 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/LevelZone.kt @@ -0,0 +1,31 @@ +package net.trilleo.mc.plugins.tritown.mobs + +/** + * How mob levels climb across one world. + * + * Around the world's spawn is a hub where mobs are [base]. Beyond it the world + * is cut into rings [ringWidth] blocks wide, each [perRing] levels harder than + * the one inside it, up to [cap]. Plain Kotlin, so the rings can be tested. + */ +data class LevelZone( + val base: Int, + val spawnRadius: Double, + val ringWidth: Double, + val perRing: Int, + val cap: Int, +) { + + /** 0 inside the hub, then 1 for the first ring out and so on. */ + fun ring(distance: Double): Int = + if (distance <= spawnRadius) 0 else 1 + ((distance - spawnRadius) / ringWidth).toInt() + + /** + * The level of a mob spawning [distance] blocks from spawn. [bonus] — night, + * depth — only applies outside the hub, which stays as tame as its base. + */ + fun level(distance: Double, bonus: Int): Int { + val ring = ring(distance) + val level = base + ring * perRing + if (ring > 0) bonus else 0 + return level.coerceIn(1, cap.coerceAtLeast(base).coerceAtLeast(1)) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/LootRoller.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/LootRoller.kt new file mode 100644 index 0000000..99fb1a7 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/LootRoller.kt @@ -0,0 +1,93 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import net.trilleo.mc.plugins.tritown.content.LootEntry +import net.trilleo.mc.plugins.tritown.content.LootTable +import kotlin.random.Random + +/** + * What a dead mob drops on top of vanilla's loot. Plain Kotlin over a [Random] + * the caller hands in, so the odds can be tested with a seed. + * + * Whether it drops anything at all — a wild mob, killed by a player, who with + * the others dealt enough of the damage — is the caller's to decide; this only + * rolls the dice. + */ +object LootRoller { + + data class Drop(val item: String, val amount: Int) + + /** A line of a custom mob's own loot that came up, and how many of it: always one piece of gear. */ + data class Won(val entry: LootEntry, val amount: Int) + + /** + * The drops of a mob of [type] (its `EntityType` name), [rank] and + * [level], killed by someone with [magicFind]. Magic Find only multiplies + * a chance that is short of certain, up to the table's cap. + */ + fun roll(table: LootTable, type: String, rank: MobRank, level: Int, magicFind: Double, random: Random): List { + if (level < table.rules.minLevel) return emptyList() + val drop = dropFor(table, rank) + val drops = mutableListOf() + + table.families[type]?.let { family -> + if (random.nextDouble() * 100.0 < drop.chance * boost(table, magicFind)) { + val amount = random.nextInt(drop.minAmount, drop.maxAmount + 1) + if (amount > 0) drops += Drop(family.material, amount) + } + } + + table.essenceFor(level)?.let { essence -> + val amount = random.nextInt(drop.minEssence, drop.maxEssence + 1) + if (amount > 0) drops += Drop(essence, amount) + } + return drops + } + + /** + * Whether a mob of [rank] and [level] drops a finished piece of gear, its + * chance multiplied by Magic Find like any other rare drop. + */ + fun dropsGear(table: LootTable, rank: MobRank, level: Int, magicFind: Double, random: Random): Boolean { + if (level < table.rules.minLevel) return false + return chance(table, dropFor(table, rank).gearChance, magicFind, random) + } + + /** Whether something with a [percent] chance comes up, that chance multiplied by Magic Find. */ + fun chance(table: LootTable, percent: Double, magicFind: Double, random: Random): Boolean { + if (percent <= 0.0) return false + return random.nextDouble() * 100.0 < percent * boost(table, magicFind) + } + + /** + * What a custom mob's own [entries] give, each rolled on its own and each + * chance multiplied by Magic Find like any other. Nothing below the table's + * minimum level. + */ + fun rollEntries( + table: LootTable, + entries: List, + level: Int, + magicFind: Double, + random: Random + ): List { + if (level < table.rules.minLevel) return emptyList() + val boost = boost(table, magicFind) + return entries.mapNotNull { entry -> + if (random.nextDouble() * 100.0 >= entry.chance * boost) return@mapNotNull null + val amount = when (entry) { + is LootEntry.Item -> random.nextInt(entry.min, entry.max + 1) + is LootEntry.Gear -> 1 + } + Won(entry, amount) + } + } + + private fun dropFor(table: LootTable, rank: MobRank): LootTable.Drop = when (rank) { + MobRank.NORMAL -> table.normal + MobRank.ELITE -> table.elite + MobRank.CHAMPION -> table.champion + } + + private fun boost(table: LootTable, magicFind: Double): Double = + 1.0 + magicFind.coerceIn(0.0, table.rules.magicFindCap) / 100.0 +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobKinds.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobKinds.kt new file mode 100644 index 0000000..1b85b84 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobKinds.kt @@ -0,0 +1,106 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import io.papermc.paper.datacomponent.DataComponentTypes +import io.papermc.paper.datacomponent.item.ItemAttributeModifiers +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.content.Costume +import net.trilleo.mc.plugins.tritown.content.CostumeSlot +import net.trilleo.mc.plugins.tritown.content.MobKindDef +import net.trilleo.mc.plugins.tritown.gear.Gear +import org.bukkit.Material +import org.bukkit.NamespacedKey +import org.bukkit.attribute.Attribute +import org.bukkit.attribute.AttributeModifier +import org.bukkit.entity.LivingEntity +import org.bukkit.inventory.EquipmentSlot +import org.bukkit.inventory.ItemStack + +/** + * The custom mobs of `bestiary.yml` as they are in the world: which one a mob + * is, and dressing it as one. + * + * **A costume is only a look.** Every piece a custom mob wears is stripped of + * its attribute modifiers, so a netherite costume gives it no vanilla armor + * and a sword no attack damage — its [MobKindDef.defense] and + * [MobKindDef.damage] are what it has. Every piece has no chance to drop, and a + * custom mob picks nothing up, so gear only ever leaves a mob through + * `MobLoot`. + */ +object MobKinds { + + private val SCALE = NamespacedKey("tritown", "kind-scale") + private val SPEED = NamespacedKey("tritown", "kind-speed") + private val KNOCKBACK = NamespacedKey("tritown", "kind-knockback") + + /** What [profile]'s kind is, or `null` for an ordinary mob, or one whose kind has left `bestiary.yml`. */ + fun def(profile: MobProfile): MobKindDef? = profile.kind?.let { ContentRegistry.bestiary.kinds[it] } + + /** Dresses a mob that has just become [def]: its costume, and the traits [refresh] keeps. */ + fun dress(mob: LivingEntity, def: MobKindDef) { + mob.canPickupItems = false + val equipment = mob.equipment + if (equipment != null) { + def.equipment.forEach { (slot, costume) -> + val item = costume(costume) ?: return@forEach + equipment.setItem(slot(slot), item) + equipment.setDropChance(slot(slot), 0f) + } + } + refresh(mob) + } + + /** + * Brings the traits [mob]'s kind gives it — size, speed, footing — up to + * what `bestiary.yml` says now. Run as it is dressed and each time it + * loads, so an edit reaches a mob the next time it is seen. A mob with no + * kind loses any it had. + */ + fun refresh(mob: LivingEntity) { + val def = def(MobProfiles.of(mob)) + trait(mob, Attribute.SCALE, SCALE, def?.let { it.scale - 1.0 }, AttributeModifier.Operation.ADD_SCALAR) + trait( + mob, + Attribute.MOVEMENT_SPEED, + SPEED, + def?.let { it.speed / 100.0 }, + AttributeModifier.Operation.ADD_SCALAR + ) + trait( + mob, + Attribute.KNOCKBACK_RESISTANCE, + KNOCKBACK, + def?.let { it.knockback / 100.0 }, + AttributeModifier.Operation.ADD_NUMBER + ) + } + + private fun trait( + mob: LivingEntity, + attribute: Attribute, + key: NamespacedKey, + amount: Double?, + operation: AttributeModifier.Operation, + ) { + val instance = mob.getAttribute(attribute) ?: return + instance.removeModifier(key) + if (amount != null && amount != 0.0) instance.addModifier(AttributeModifier(key, amount, operation)) + } + + private fun costume(costume: Costume): ItemStack? { + val stack = when (costume) { + is Costume.Gear -> ContentRegistry.gear.gear[costume.id]?.let(Gear::costume) + is Costume.Vanilla -> Material.matchMaterial(costume.material)?.takeIf { it.isItem }?.let(ItemStack::of) + } ?: return null + stack.setData(DataComponentTypes.ATTRIBUTE_MODIFIERS, ItemAttributeModifiers.itemAttributes().build()) + return stack + } + + private fun slot(slot: CostumeSlot): EquipmentSlot = when (slot) { + CostumeSlot.HEAD -> EquipmentSlot.HEAD + CostumeSlot.CHEST -> EquipmentSlot.CHEST + CostumeSlot.LEGS -> EquipmentSlot.LEGS + CostumeSlot.FEET -> EquipmentSlot.FEET + CostumeSlot.HAND -> EquipmentSlot.HAND + CostumeSlot.OFF_HAND -> EquipmentSlot.OFF_HAND + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobLoot.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobLoot.kt new file mode 100644 index 0000000..5704dde --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobLoot.kt @@ -0,0 +1,153 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import net.trilleo.mc.plugins.tritown.combat.CombatHealth +import net.trilleo.mc.plugins.tritown.combat.PlayerStats +import net.trilleo.mc.plugins.tritown.combat.Stat +import net.trilleo.mc.plugins.tritown.content.ContentItems +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.content.LootEntry +import net.trilleo.mc.plugins.tritown.content.Rarity +import net.trilleo.mc.plugins.tritown.gear.ForgeCosts +import net.trilleo.mc.plugins.tritown.gear.Gear +import net.trilleo.mc.plugins.tritown.gear.GearStats +import org.bukkit.Bukkit +import org.bukkit.entity.LivingEntity +import org.bukkit.entity.Player +import org.bukkit.entity.Tameable +import org.bukkit.inventory.ItemStack +import java.util.* +import kotlin.random.Random + +/** + * What a wild mob drops beyond vanilla's loot, and who earned it. + * + * A mob only drops anything extra when all of these hold: + * + * 1. it spawned in the wild ([MobProfile.eligible]); + * 2. a player killed it; + * 3. players, their pets included, dealt at least `player-share` of its health + * between them, as the [DamageLedger] recorded it; + * 4. it is at least the loot table's `min-level`. + * + * What it drops is rolled by [LootRoller] against `mobs.yml`, with the + * killer's Magic Find: materials and essence, and now and then — a champion, + * mostly — a finished piece of gear of the tier its level is made for. A + * custom mob drops its own loot from `bestiary.yml` besides. A boss drops + * nothing here: [bossRewards] rolls its loot once for each player who earned a + * share of it. The + * drops go into the death event, so item protection hands them to the killer + * like the rest of the mob's loot. + */ +object MobLoot { + + private val ledger = DamageLedger() + + /** Credits whoever is behind [attacker] with the [dealt] RPG damage it took off [victim]. */ + fun credit(victim: LivingEntity, attacker: LivingEntity?, dealt: Double) { + val player = creditor(attacker) ?: return + if (!MobProfiles.of(victim).eligible) return + ledger.credit(victim.uniqueId, player, dealt.coerceAtMost(CombatHealth.current(victim))) + } + + /** What [mob], which has just died, drops on top of vanilla's loot. Its ledger is settled either way. */ + fun dropsFor(mob: LivingEntity): List { + val share = ledger.share(mob.uniqueId, CombatHealth.max(mob)) + ledger.forget(mob.uniqueId) + + val profile = MobProfiles.of(mob) + val killer = mob.killer ?: return emptyList() + val table = ContentRegistry.loot + if (!profile.eligible || share * 100.0 < table.rules.playerShare) return emptyList() + + val kind = MobKinds.def(profile) + if (kind?.boss != null) return emptyList() + val odds = ContentRegistry.balance.gear.dropOdds + val magicFind = PlayerStats.sheet(killer)[Stat.MAGIC_FIND] + val drops = LootRoller.roll(table, mob.type.name, profile.rank, profile.level, magicFind, Random) + .mapNotNull { drop -> ContentItems.create(drop.item, drop.amount) } + val gear = if (LootRoller.dropsGear(table, profile.rank, profile.level, magicFind, Random)) gearFor( + profile.level, + odds + ) else null + val own = kind?.let { + LootRoller.rollEntries(table, it.loot, profile.level, magicFind, Random) + .mapNotNull { won -> stack(won, odds) } + }.orEmpty() + return drops + listOfNotNull(gear) + own + } + + /** + * What each player who earned a share of [boss], which has just died, is + * given: its loot rolled for them alone, with their own Magic Find, and + * now and then a finished piece of its tier. A share takes + * `contributor-share` percent of its health, and players between them must + * have dealt `player-share` of it, as for any mob. Its ledger is settled + * either way. Only players still online are given anything. + */ + fun bossRewards(boss: LivingEntity): Spoils { + val shares = ledger.shares(boss.uniqueId, CombatHealth.max(boss)) + ledger.forget(boss.uniqueId) + + val profile = MobProfiles.of(boss) + val kind = MobKinds.def(profile) + val table = ContentRegistry.loot + val rules = ContentRegistry.bestiary.rules + val (earned, short) = shares.keys.partition { shares.getValue(it) * 100.0 >= rules.contributorShare } + if (kind == null || !profile.eligible || boss.killer == null || shares.values.sum() * 100.0 < table.rules.playerShare) { + return Spoils(emptyMap(), emptySet()) + } + + val rewards = earned.mapNotNull { id -> + val player = Bukkit.getPlayer(id) ?: return@mapNotNull null + val magicFind = PlayerStats.sheet(player)[Stat.MAGIC_FIND] + val own = LootRoller.rollEntries(table, kind.loot, profile.level, magicFind, Random) + .mapNotNull { stack(it, rules.gearOdds) } + val gear = if (LootRoller.chance(table, rules.gearChance, magicFind, Random)) gearFor( + profile.level, + rules.gearOdds + ) else null + id to own + listOfNotNull(gear) + }.toMap() + return Spoils(rewards, short.toSet()) + } + + /** + * A boss's loot: what each player who earned a share is given, and who + * took part without earning one. + */ + class Spoils(val rewards: Map>, val shortOfShare: Set) + + /** + * A random piece of the tier made for [level], at a rarity drawn from + * [odds] — or of the highest tier below it, where the content has no piece + * of that tier. Only a piece the Forge can craft drops this way: one with + * no recipe is a custom mob's own, and only its loot drops it. + */ + private fun gearFor(level: Int, odds: Map): ItemStack? { + val tier = (level + ForgeCosts.LEVELS_PER_TIER - 1) / ForgeCosts.LEVELS_PER_TIER + val pieces = ContentRegistry.gear.gear.values.filter { it.tier <= tier && it.recipe != null } + val best = pieces.maxOfOrNull { it.tier } ?: return null + val def = pieces.filter { it.tier == best }.random() + return Gear.create(def, GearStats.pick(odds, Random)) + } + + /** What a line of a custom mob's own loot that came up is, as an item: gear at a rarity from [odds]. */ + private fun stack(won: LootRoller.Won, odds: Map): ItemStack? = when (val entry = won.entry) { + is LootEntry.Item -> ContentItems.create(entry.id, won.amount) + is LootEntry.Gear -> ContentRegistry.gear.gear[entry.id]?.let { def -> + Gear.create( + def, + GearStats.pick(odds, Random) + ) + } + } + + fun forget(mob: UUID) = ledger.forget(mob) + + /** The player a hit counts for: the attacker, or the owner of a tamed pet. */ + private fun creditor(attacker: LivingEntity?): UUID? = when (attacker) { + is Player -> attacker.uniqueId + is Tameable -> attacker.ownerUniqueId?.takeIf { attacker.isTamed } + else -> null + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobNameplate.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobNameplate.kt new file mode 100644 index 0000000..92a862e --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobNameplate.kt @@ -0,0 +1,73 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import net.trilleo.mc.plugins.tritown.Main +import net.trilleo.mc.plugins.tritown.combat.CombatFormat +import net.trilleo.mc.plugins.tritown.combat.CombatHealth +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.Lang +import org.bukkit.Bukkit +import org.bukkit.entity.LivingEntity +import java.util.* + +/** + * The name a levelled mob wears: its level, its rank and affixes, its kind — or + * its own name, if it is a custom mob — and its RPG health. + * + * A name is shared by everyone who sees the mob, so its frame is in the + * server's configured language, and so is a custom mob's name. An ordinary + * mob's kind is written as the game's own translation key, which each client + * fills in in its own language. + * + * A normal mob's shows when a player looks straight at it, the way a named + * mob's does; a ranked or custom mob's always shows, so nobody walks into a + * champion unwarned. Setting it does not make the mob persistent: only a name + * tag does that, and a mob a player names keeps their name and loses the + * nameplate. + */ +object MobNameplate { + + private val pending = HashSet() + + fun update(entity: LivingEntity) { + if (!entity.isValid) return + val profile = MobProfiles.of(entity) + if (!profile.nameplate) return + + val text = Lang.tr( + null, + "mob.nameplate", + "level" to profile.level, + "badge" to (badgeKey(profile)?.let { Lang.tr(null, it) } ?: ""), + "affixes" to profile.affixes.sortedBy { it.ordinal }.joinToString("") { affix -> + Lang.tr(null, "mob.affix-name", "name" to Lang.tr(null, affix.key)) + }, + "name" to name(entity, profile), + "health" to CombatFormat.health(CombatHealth.current(entity)), + "max" to CombatFormat.number(CombatHealth.max(entity)), + ) + entity.customName(ComponentUtil.parse(text)) + entity.isCustomNameVisible = profile.rank != MobRank.NORMAL || profile.kind != null + } + + /** A boss's badge, or its rank's: a normal mob wears none. */ + private fun badgeKey(profile: MobProfile): String? = + if (MobKinds.def(profile)?.boss != null) "mob.rank.boss" else profile.rank.badgeKey + + /** + * A custom mob's own name, in the server's language like the rest of the + * frame; an ordinary one's kind, which each client writes in its own. + */ + private fun name(entity: LivingEntity, profile: MobProfile): String { + val kind = MobKinds.def(profile) ?: return "" + return Lang.tr(null, "mob.kind-name", "name" to Lang.tr(null, kind.nameKey)) + } + + /** [update] on the next tick, once the hit or heal in progress has landed. Asked for twice, it runs once. */ + fun updateLater(entity: LivingEntity) { + if (!MobProfiles.of(entity).nameplate || !pending.add(entity.uniqueId)) return + Bukkit.getScheduler().runTask(Main.instance, Runnable { + pending.remove(entity.uniqueId) + update(entity) + }) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobPower.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobPower.kt new file mode 100644 index 0000000..6909e3e --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobPower.kt @@ -0,0 +1,32 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import net.trilleo.mc.plugins.tritown.combat.CombatHealth +import net.trilleo.mc.plugins.tritown.content.Balance +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import org.bukkit.entity.LivingEntity + +/** + * What a mob's profile makes of it: its rank, its kind and its affixes, put + * together in one place. `CombatHealth` asks for its pool and `DamageListener` + * for its hits and its Defense, and each is `DamageMath`'s `multiplier` or + * Defense — this only says how large they are. + */ +object MobPower { + + /** What [profile]'s mob multiplies its pool by: its rank's, and its kind's. */ + fun health(profile: MobProfile, balance: Balance): Double = + profile.rank.health(balance) * (MobKinds.def(profile)?.health ?: 1.0) + + /** What [mob]'s hits are multiplied by: its rank, its kind, and its rage once it is badly hurt. */ + fun damage(mob: LivingEntity, profile: MobProfile, balance: Balance): Double { + val enraged = profile.has(Affix.ENRAGED) && CombatHealth.fraction(mob) * 100.0 < balance.affixes.enragedBelow + val rage = if (enraged) 1.0 + balance.affixes.enragedDamage / 100.0 else 1.0 + return profile.rank.damage(balance) * (MobKinds.def(profile)?.damage ?: 1.0) * rage + } + + /** The Defense [mob] has against every hit: its kind's, Armored's, and a Bulwark it has up. */ + fun defense(mob: LivingEntity, profile: MobProfile): Double { + val armored = if (profile.has(Affix.ARMORED)) ContentRegistry.balance.affixes.armoredDefense else 0.0 + return armored + (MobKinds.def(profile)?.defense ?: 0.0) + AbilityEffects.defense(mob) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobProfiles.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobProfiles.kt new file mode 100644 index 0000000..99836bf --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobProfiles.kt @@ -0,0 +1,97 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import org.bukkit.NamespacedKey +import org.bukkit.entity.LivingEntity +import org.bukkit.entity.Player +import org.bukkit.persistence.PersistentDataContainer +import org.bukkit.persistence.PersistentDataType + +/** + * What TriTown decided about a mob when it spawned. + * + * @param eligible whether the mob spawned the way a wild one does, so it may + * drop more than vanilla gives: a summoner's minions, for one, may not + * @param kind the id of the custom mob it is in `bestiary.yml`, or `null` for + * an ordinary one. Only the id is kept: what the kind is worth is read from + * the file every time, so a retune reaches every one already out there + */ +data class MobProfile( + val level: Int, + val rank: MobRank, + val affixes: Set, + val nameplate: Boolean, + val eligible: Boolean, + val kind: String? = null, +) { + + fun has(affix: Affix): Boolean = affix in affixes + + /** Whether the mob takes a turn in the mob task: it has a rank, a kind, or an affix to show. */ + val isActive: Boolean + get() = rank != MobRank.NORMAL || kind != null || affixes.isNotEmpty() + + companion object { + /** A mob TriTown never touched: level 1, which is to say vanilla. */ + val VANILLA = + MobProfile(level = 1, rank = MobRank.NORMAL, affixes = emptySet(), nameplate = false, eligible = false) + } +} + +/** + * Reads and writes a mob's [MobProfile], kept in its own persistent data under + * `tritown:mob`. + * + * The game saves it with the entity, so a mob keeps its level and rank through + * chunk unloads and restarts without any file of TriTown's. Only a mob that + * spawned the way a wild one does gets a profile. One with none — from a + * spawner, an egg, a command, or from before TriTown was installed — is + * [MobProfile.VANILLA]. + */ +object MobProfiles { + + private val PROFILE = NamespacedKey("tritown", "mob") + private val LEVEL = NamespacedKey("tritown", "level") + private val RANK = NamespacedKey("tritown", "rank") + private val AFFIXES = NamespacedKey("tritown", "affixes") + private val NAMEPLATE = NamespacedKey("tritown", "nameplate") + private val ELIGIBLE = NamespacedKey("tritown", "eligible") + private val KIND = NamespacedKey("tritown", "kind") + + fun of(entity: LivingEntity): MobProfile { + if (entity is Player) return MobProfile.VANILLA + val data = container(entity) ?: return MobProfile.VANILLA + return MobProfile( + level = data.get(LEVEL, PersistentDataType.INTEGER)?.coerceAtLeast(1) ?: 1, + rank = data.get(RANK, PersistentDataType.STRING) + ?.let { name -> MobRank.entries.firstOrNull { it.name == name } } ?: MobRank.NORMAL, + affixes = data.get(AFFIXES, PersistentDataType.LIST.strings()).orEmpty() + .mapNotNull { name -> Affix.entries.firstOrNull { it.name == name } } + .toSet(), + nameplate = data.get(NAMEPLATE, PersistentDataType.BOOLEAN) == true, + eligible = data.get(ELIGIBLE, PersistentDataType.BOOLEAN) == true, + kind = data.get(KIND, PersistentDataType.STRING), + ) + } + + fun level(entity: LivingEntity): Int = of(entity).level + + fun assign(entity: LivingEntity, profile: MobProfile) { + val data = entity.persistentDataContainer.adapterContext.newPersistentDataContainer() + data.set(LEVEL, PersistentDataType.INTEGER, profile.level) + data.set(RANK, PersistentDataType.STRING, profile.rank.name) + data.set(AFFIXES, PersistentDataType.LIST.strings(), profile.affixes.map { it.name }) + data.set(NAMEPLATE, PersistentDataType.BOOLEAN, profile.nameplate) + data.set(ELIGIBLE, PersistentDataType.BOOLEAN, profile.eligible) + profile.kind?.let { data.set(KIND, PersistentDataType.STRING, it) } + entity.persistentDataContainer.set(PROFILE, PersistentDataType.TAG_CONTAINER, data) + } + + /** Stops drawing a nameplate on the mob, because a player has named it. */ + fun dropNameplate(entity: LivingEntity) { + if (container(entity) == null) return + assign(entity, of(entity).copy(nameplate = false)) + } + + private fun container(entity: LivingEntity): PersistentDataContainer? = + entity.persistentDataContainer.get(PROFILE, PersistentDataType.TAG_CONTAINER) +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobRank.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobRank.kt new file mode 100644 index 0000000..4103527 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobRank.kt @@ -0,0 +1,30 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import net.trilleo.mc.plugins.tritown.content.Balance + +/** How much tougher than its kind a mob spawned: most are [NORMAL]. */ +enum class MobRank { + NORMAL, ELITE, CHAMPION; + + /** + * The translation key of the rank's badge on a nameplate, spelled out so the + * language test can see it. A normal mob wears none. + */ + val badgeKey: String? + get() = when (this) { + NORMAL -> null + ELITE -> "mob.rank.elite" + CHAMPION -> "mob.rank.champion" + } + + /** What `balance.yml` says this rank is worth, or `null` for [NORMAL], which is worth nothing extra. */ + fun tuning(balance: Balance): Balance.Rank? = when (this) { + NORMAL -> null + ELITE -> balance.ranks.elite + CHAMPION -> balance.ranks.champion + } + + fun health(balance: Balance): Double = tuning(balance)?.health ?: 1.0 + + fun damage(balance: Balance): Double = tuning(balance)?.damage ?: 1.0 +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobRoll.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobRoll.kt new file mode 100644 index 0000000..8c4bab8 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobRoll.kt @@ -0,0 +1,42 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import net.trilleo.mc.plugins.tritown.content.Balance +import net.trilleo.mc.plugins.tritown.content.BestiaryCatalog +import net.trilleo.mc.plugins.tritown.content.MobKindDef +import net.trilleo.mc.plugins.tritown.content.SpawnPlace +import kotlin.random.Random + +/** + * The dice a wild mob's kind, rank and affixes are decided by. Plain Kotlin + * over a [Random] the caller hands in, so the odds can be tested with a seed. + */ +object MobRoll { + + /** + * The custom mob a wild spawn of [type] (its `EntityType` name) at [level] + * and [place] becomes, if any. Each kind that may live there rolls its + * chance in the order `bestiary.yml` lists them, and the first to succeed + * takes it. + */ + fun kind(catalog: BestiaryCatalog, type: String, level: Int, place: SpawnPlace, random: Random): MobKindDef? = + catalog.variantsFor(type).firstOrNull { def -> + val rule = def.spawn ?: return@firstOrNull false + rule.allows(level, place) && random.nextDouble() * 100.0 < rule.chance + } + + /** The rank of a wild mob spawning at [level]. The champion roll comes first. */ + fun rank(level: Int, balance: Balance, random: Random): MobRank { + val champion = balance.ranks.champion + if (level >= champion.minLevel && random.nextDouble() * 100.0 < champion.chance) return MobRank.CHAMPION + val elite = balance.ranks.elite + if (level >= elite.minLevel && random.nextDouble() * 100.0 < elite.chance) return MobRank.ELITE + return MobRank.NORMAL + } + + /** Distinct affixes for a mob of [rank], as many as its tuning allows. */ + fun affixes(rank: MobRank, balance: Balance, random: Random): List { + val tuning = rank.tuning(balance) ?: return emptyList() + val count = random.nextInt(tuning.minAffixes, tuning.maxAffixes + 1).coerceAtMost(Affix.entries.size) + return Affix.entries.shuffled(random).take(count) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobSetup.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobSetup.kt new file mode 100644 index 0000000..c8226eb --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobSetup.kt @@ -0,0 +1,107 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import net.trilleo.mc.plugins.tritown.config.MobSettings +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.content.MobKindDef +import org.bukkit.Location +import org.bukkit.Particle +import org.bukkit.entity.EntityType +import org.bukkit.entity.LivingEntity +import org.bukkit.entity.Mob +import org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason +import java.util.* +import kotlin.random.Random + +/** Setting a mob up once TriTown has decided what it is. */ +object MobSetup { + + private val moving = HashSet() + + /** Everything a mob with a fresh [profile] needs besides the profile itself. */ + fun settle(mob: LivingEntity, profile: MobProfile) { + MobKinds.def(profile)?.let { MobKinds.dress(mob, it) } + if (profile.isActive) { + AffixEffects.onSpawn(mob, profile) + ActiveMobs.track(mob) + } + if (profile.nameplate) MobNameplate.updateLater(mob) + } + + /** + * Spawns a mob of [type] at [level] that TriTown calls up itself — a + * summoner's minion, or one an administrator asks for — as [kind] if one is + * given, with the affixes the kind always has. It is unranked, and never + * eligible: it drops nothing beyond vanilla's loot. + */ + fun spawn(at: Location, type: EntityType, level: Int, kind: MobKindDef?): LivingEntity? { + val profile = MobProfile( + level = level, + rank = MobRank.NORMAL, + affixes = kind?.affixes.orEmpty(), + nameplate = MobSettings.snapshot.nameplates, + eligible = false, + kind = kind?.id, + ) + val entity = at.world.spawnEntity(at, type, SpawnReason.CUSTOM) { spawned -> + (spawned as? LivingEntity)?.let { MobProfiles.assign(it, profile) } + } + val mob = entity as? LivingEntity ?: run { + entity.remove() + return null + } + settle(mob, profile) + return mob + } + + /** + * Calls [count] minions to [summoner]'s side, at its level, aimed at + * [target]: the kind its `minions` names, or its own — but never a copy of + * a boss, which calls its base kind instead. How many turn up depends on + * the room around it. + */ + fun minions(summoner: LivingEntity, profile: MobProfile, count: Int, target: LivingEntity?): List { + val own = MobKinds.def(profile) + val kind = own?.minions?.let { ContentRegistry.bestiary.kinds[it] } ?: own?.takeIf { it.boss == null } + val type = kind?.base?.let { base -> EntityType.entries.firstOrNull { it.name == base } } ?: summoner.type + return (0 until count).mapNotNull { + val spot = besideOf(summoner.location) ?: return@mapNotNull null + spawn(spot, type, profile.level, kind)?.also { minion -> + if (target != null) (minion as? Mob)?.target = target + summoner.world.spawnParticle(Particle.SOUL, spot.clone().add(0.0, 1.0, 0.0), 12, 0.3, 0.5, 0.3, 0.02) + } + } + } + + /** + * Moves [mob] to [to] on TriTown's own account — a blink, a boss taken + * back to its arena — which the teleports a boss is otherwise refused + * (see [isMovingOnPurpose]) must let through. + */ + fun teleport(mob: LivingEntity, to: Location) { + moving += mob.uniqueId + try { + mob.teleport(to) + } finally { + moving -= mob.uniqueId + } + } + + fun isMovingOnPurpose(mob: UUID): Boolean = mob in moving + + /** A spot within a couple of blocks of [origin] that a mob can stand in, if one turns up in a few tries. */ + fun besideOf(origin: Location): Location? { + repeat(SPOT_TRIES) { + val spot = origin.clone().add(Random.nextDouble(-2.0, 2.0), 0.0, Random.nextDouble(-2.0, 2.0)) + if (standable(spot)) return spot + } + return null + } + + /** Whether a mob can stand at [spot]: room for its feet and head, and ground beneath. */ + fun standable(spot: Location): Boolean { + val feet = spot.block + return feet.isPassable && feet.getRelative(0, 1, 0).isPassable && !feet.getRelative(0, -1, 0).isPassable + } + + private const val SPOT_TRIES = 6 +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobZones.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobZones.kt new file mode 100644 index 0000000..58dccd3 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobZones.kt @@ -0,0 +1,53 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import com.palmergames.bukkit.towny.TownyAPI +import net.trilleo.mc.plugins.tritown.config.MobSettings +import net.trilleo.mc.plugins.tritown.content.SpawnPlace +import org.bukkit.Location +import org.bukkit.World +import kotlin.math.hypot + +/** + * The level a mob spawning somewhere would be. + * + * Distance from the world's spawn picks the ring (see [LevelZone]); night and + * depth add to it in the Overworld. **Town claims are always level 1**, so a + * town is safe, and a mob farm built inside one works exactly as it does in + * vanilla. Towny is asked afresh every time. + */ +object MobZones { + + /** Everything that went into a level, so `/tritown mob level` can say why. */ + class Reading(val level: Int, val ring: Int, val night: Boolean, val deep: Boolean, val town: Boolean) + + fun levelAt(location: Location): Int = read(location).level + + fun read(location: Location): Reading { + if (!TownyAPI.getInstance().isWilderness(location)) return Reading(1, 0, false, false, true) + + val world = location.world + val settings = MobSettings.snapshot + val zone = settings.zone(world) + val spawn = world.spawnLocation + val distance = hypot(location.x - spawn.x, location.z - spawn.z) + + val overworld = world.environment == World.Environment.NORMAL + val night = overworld && !world.isDayTime + val deep = overworld && location.y < 0 + val bonus = (if (night) settings.nightBonus else 0) + (if (deep) settings.depthBonus else 0) + + return Reading(zone.level(distance, bonus), zone.ring(distance), night, deep, false) + } + + /** What a custom mob's spawn rule is checked against at [location]. */ + fun place(location: Location): SpawnPlace { + val world = location.world + return SpawnPlace( + environment = world.environment.name.lowercase(), + world = world.name.lowercase(), + biome = location.block.biome.key.asString(), + y = location.blockY, + night = world.environment == World.Environment.NORMAL && !world.isDayTime, + ) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/boss/BossFights.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/boss/BossFights.kt new file mode 100644 index 0000000..9787f71 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/boss/BossFights.kt @@ -0,0 +1,270 @@ +package net.trilleo.mc.plugins.tritown.mobs.boss + +import net.kyori.adventure.bossbar.BossBar +import net.trilleo.mc.plugins.tritown.combat.CombatHealth +import net.trilleo.mc.plugins.tritown.config.MobSettings +import net.trilleo.mc.plugins.tritown.content.BossDef +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.content.MobKindDef +import net.trilleo.mc.plugins.tritown.gear.GearText +import net.trilleo.mc.plugins.tritown.mobs.* +import net.trilleo.mc.plugins.tritown.protection.ItemOwnership +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.InventoryUtil +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.* +import org.bukkit.entity.LivingEntity +import org.bukkit.entity.Mob +import org.bukkit.entity.Player +import org.bukkit.inventory.ItemStack +import org.bukkit.persistence.PersistentDataType +import java.util.* + +/** + * Every boss fight going on in a loaded chunk, and the only thing that runs + * one. + * + * A boss is held to an **arena**: `arena` blocks around where it was summoned, + * its home, which it keeps in its own persistent data. Twice a second, from + * `MobTurnTask`, each fight: + * + * - shows a boss bar to every player near the arena, in their own language; + * - takes a boss pulled more than `arena-margin` past its arena back home; + * - turns a boss to the nearest player in its arena when it has no one to + * fight there, which keeps even a spider or an enderman on the attack; + * - enters any phase its health has fallen into: announced once, its minions + * called at once, and its affixes written into the boss's profile; + * - sends a boss that has had nobody in its arena for `idle-seconds` away. + * + * What a fight remembers — which phases it has announced, who sees its bar — + * is kept in memory, and taken up again as the boss loads back in: phases it + * has already passed are not announced again. + */ +object BossFights { + + private val HOME = NamespacedKey("tritown", "boss-home") + + private class Fight(val boss: LivingEntity) { + val bars = HashMap() + val entered = HashSet() + var idleSince: Int? = null + } + + private val fights = HashMap() + + fun isBoss(mob: LivingEntity): Boolean = boss(mob) != null + + /** Where a boss fights: [at] from now on. Set before it is added to the world. */ + fun setHome(boss: LivingEntity, at: Location) { + boss.persistentDataContainer.set(HOME, PersistentDataType.LIST.doubles(), listOf(at.x, at.y, at.z)) + } + + /** Takes up the fight around [boss] as it comes into the world: summoned, spawned by a command, or loaded back in. */ + fun adopt(boss: LivingEntity) { + val def = boss(boss) ?: return + if (home(boss) == null) setHome(boss, boss.location) + val fight = Fight(boss) + def.phases.forEachIndexed { index, phase -> if (CombatHealth.fraction(boss) * 100.0 < phase.below) fight.entered += index } + fights.put(boss.uniqueId, fight)?.let(::hideBars) + } + + /** Lets go of a boss leaving the world, unloaded or dead: nobody is shown its bar any more. */ + fun release(boss: UUID) { + fights.remove(boss)?.let(::hideBars) + } + + /** Whether a boss is fighting within [radius] of [at]. */ + fun near(at: Location, radius: Double): Boolean = fights.values.any { fight -> + fight.boss.isValid && fight.boss.world == at.world && fight.boss.location.distanceSquared(at) <= radius * radius + } + + /** Every fight's twice-a-second turn. */ + fun tick() { + val now = Bukkit.getCurrentTick() + val rules = ContentRegistry.bestiary.rules + for (fight in fights.values.toList()) { + val boss = fight.boss + val profile = MobProfiles.of(boss) + val kind = MobKinds.def(profile) + val def = kind?.boss + if (!boss.isValid || kind == null || def == null) { + release(boss.uniqueId) + continue + } + val home = home(boss) ?: boss.location + val arena = fighters(home, def.arena) + + showBars(fight, kind, def, home) + if (boss.world != home.world || boss.location.distance(home) > def.arena + rules.arenaMargin) takeHome( + boss, + home + ) + retarget(boss, arena) + enterPhases(fight, kind, def) + + if (arena.isNotEmpty()) { + fight.idleSince = null + } else if (now - (fight.idleSince ?: now.also { fight.idleSince = it }) >= rules.idleSeconds * TICKS) { + depart(fight, kind, home) + } + } + } + + /** + * [boss] has died: its bar goes, its fall is announced, and every player + * who earned a share is given theirs — where it fell if they are near, or + * straight into their inventory if not. Nothing drops for anyone else. + * Everyone who took part counts it slain in their bestiary. + */ + fun defeated(boss: LivingEntity) { + release(boss.uniqueId) + val kind = MobKinds.def(MobProfiles.of(boss)) ?: return + val spoils = MobLoot.bossRewards(boss) + val at = boss.location + val range = ContentRegistry.bestiary.rules.lootRange + + val victors = spoils.rewards.keys.mapNotNull { Bukkit.getPlayer(it)?.name }.map(ComponentUtil::escape) + at.world.playSound(at, Sound.UI_TOAST_CHALLENGE_COMPLETE, 1f, 1f) + announce(at) { viewer -> + if (victors.isEmpty()) viewer.tr("boss.defeated-alone", "name" to name(viewer, kind)) + else viewer.tr("boss.defeated", "name" to name(viewer, kind), "players" to victors.joinToString(", ")) + } + + spoils.rewards.forEach { (id, items) -> + val player = Bukkit.getPlayer(id) ?: return@forEach + if (player.world == at.world && player.location.distance(at) <= range) { + items.forEach { ItemOwnership.dropFor(player, it, at) } + } else InventoryUtil.give(player, items) + player.sendPrefixed(reward(player, items)) + BestiaryRecords.credit(player, kind.id, items) + } + spoils.shortOfShare.forEach { id -> + val player = Bukkit.getPlayer(id) ?: return@forEach + player.sendPrefixed(player.tr("boss.no-share")) + BestiaryRecords.credit(player, kind.id, emptyList()) + } + } + + /** Tells everyone within `announce-range` of [at] what [line] says to them. */ + fun announce(at: Location, line: (Player) -> String) { + val range = MobSettings.snapshot.announceRange + at.world.players + .filter { it.location.distanceSquared(at) <= range * range } + .forEach { it.sendPrefixed(line(it)) } + } + + /** A boss's name as chat shows it to [viewer]. */ + fun name(viewer: Player, kind: MobKindDef): String = viewer.tr("boss.name", "name" to viewer.tr(kind.nameKey)) + + private fun boss(mob: LivingEntity): BossDef? = MobKinds.def(MobProfiles.of(mob))?.boss + + private fun home(boss: LivingEntity): Location? { + val point = boss.persistentDataContainer.get(HOME, PersistentDataType.LIST.doubles()) ?: return null + if (point.size != 3) return null + return Location(boss.world, point[0], point[1], point[2]) + } + + /** The players in the arena around [home] who can fight: in survival or adventure. */ + private fun fighters(home: Location, radius: Double): List = home.world.players.filter { player -> + (player.gameMode == GameMode.SURVIVAL || player.gameMode == GameMode.ADVENTURE) && !player.isDead && + player.location.distanceSquared(home) <= radius * radius + } + + /** The players near enough to [home] to see its boss's bar and hear its phases. */ + private fun watchers(home: Location, def: BossDef): List { + val reach = def.arena + ContentRegistry.bestiary.rules.arenaMargin + BAR_REACH + return home.world.players.filter { it.location.distanceSquared(home) <= reach * reach } + } + + private fun showBars(fight: Fight, kind: MobKindDef, def: BossDef, home: Location) { + val viewers = watchers(home, def) + val progress = CombatHealth.fraction(fight.boss).toFloat() + val color = BossBar.Color.NAMES.value(def.bar) ?: BossBar.Color.RED + + viewers.forEach { viewer -> + val bar = fight.bars.getOrPut(viewer.uniqueId) { + BossBar.bossBar( + ComponentUtil.parse(viewer.tr("boss.bar", "name" to viewer.tr(kind.nameKey), "level" to def.level)), + progress, + color, + BossBar.Overlay.NOTCHED_10, + ) + } + bar.progress(progress) + viewer.showBossBar(bar) + } + val seen = viewers.map { it.uniqueId }.toSet() + fight.bars.keys.filter { it !in seen }.forEach { id -> + val bar = fight.bars.remove(id) ?: return@forEach + Bukkit.getPlayer(id)?.hideBossBar(bar) + } + } + + private fun hideBars(fight: Fight) { + fight.bars.forEach { (id, bar) -> Bukkit.getPlayer(id)?.hideBossBar(bar) } + fight.bars.clear() + } + + private fun takeHome(boss: LivingEntity, home: Location) { + boss.world.spawnParticle(Particle.REVERSE_PORTAL, boss.location.add(0.0, 1.0, 0.0), 40, 0.5, 1.0, 0.5, 0.05) + MobSetup.teleport(boss, home) + boss.world.playSound(home, Sound.ENTITY_ENDERMAN_TELEPORT, 1f, 0.6f) + } + + private fun retarget(boss: LivingEntity, arena: List) { + val mob = boss as? Mob ?: return + if ((mob.target as? Player)?.let { it in arena } == true) return + mob.target = arena.minByOrNull { it.location.distanceSquared(boss.location) } ?: return + } + + private fun enterPhases(fight: Fight, kind: MobKindDef, def: BossDef) { + val boss = fight.boss + val health = CombatHealth.fraction(boss) * 100.0 + def.phases.forEachIndexed { index, phase -> + if (health >= phase.below || !fight.entered.add(index)) return@forEachIndexed + + watchers(home(boss) ?: boss.location, def).forEach { + it.sendPrefixed( + it.tr( + kind.phaseKey(index), + "name" to name(it, kind) + ) + ) + } + boss.world.playSound(boss.location, Sound.ENTITY_WITHER_SPAWN, 0.6f, 1.2f) + + val profile = MobProfiles.of(boss) + if (phase.affixes.isNotEmpty()) { + val stronger = profile.copy(affixes = profile.affixes + phase.affixes) + MobProfiles.assign(boss, stronger) + AffixEffects.onSpawn(boss, stronger) + MobNameplate.updateLater(boss) + } + if (phase.summon > 0) MobSetup.minions(boss, profile, phase.summon, (boss as? Mob)?.target) + } + } + + private fun depart(fight: Fight, kind: MobKindDef, home: Location) { + val boss = fight.boss + release(boss.uniqueId) + announce(home) { viewer -> viewer.tr("boss.departed", "name" to name(viewer, kind)) } + boss.world.spawnParticle(Particle.SOUL, boss.location.add(0.0, 1.0, 0.0), 60, 0.6, 1.2, 0.6, 0.05) + boss.world.playSound(boss.location, Sound.ENTITY_ENDERMAN_TELEPORT, 1f, 0.5f) + MobLoot.forget(boss.uniqueId) + boss.remove() + } + + private fun reward(player: Player, items: List): String { + val names = items.mapNotNull { GearText.stack(player, it) } + return if (names.isEmpty()) player.tr("boss.reward-empty") else player.tr( + "boss.reward", + "items" to names.joinToString(", ") + ) + } + + private const val TICKS = 20 + + /** How far past the arena's edge a player still sees its bar and hears its phases. */ + private const val BAR_REACH = 16.0 +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/boss/BossSummons.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/boss/BossSummons.kt new file mode 100644 index 0000000..4d5f134 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/mobs/boss/BossSummons.kt @@ -0,0 +1,212 @@ +package net.trilleo.mc.plugins.tritown.mobs.boss + +import net.kyori.adventure.title.Title +import net.trilleo.mc.plugins.tritown.Main +import net.trilleo.mc.plugins.tritown.combat.Combat +import net.trilleo.mc.plugins.tritown.config.MobSettings +import net.trilleo.mc.plugins.tritown.content.BossDef +import net.trilleo.mc.plugins.tritown.content.ContentItems +import net.trilleo.mc.plugins.tritown.content.ContentRegistry +import net.trilleo.mc.plugins.tritown.content.MobKindDef +import net.trilleo.mc.plugins.tritown.mobs.* +import net.trilleo.mc.plugins.tritown.utils.ComponentUtil +import net.trilleo.mc.plugins.tritown.utils.InventoryUtil +import net.trilleo.mc.plugins.tritown.utils.sendPrefixed +import net.trilleo.mc.plugins.tritown.utils.tr +import org.bukkit.Location +import org.bukkit.Material +import org.bukkit.Particle +import org.bukkit.Sound +import org.bukkit.entity.EntityType +import org.bukkit.entity.LivingEntity +import org.bukkit.entity.Mob +import org.bukkit.entity.Player +import org.bukkit.event.entity.CreatureSpawnEvent.SpawnReason +import org.bukkit.scheduler.BukkitRunnable +import kotlin.math.ceil + +/** + * Summoning a boss with its sigil. + * + * Everything that can refuse is asked first — bosses switched on, combat on in + * the world, the wilds and not a town, the place the boss asks for, room for + * it, no other boss or summoning close by — and only then is one sigil taken + * from the player's hand. A short ritual follows, and the boss rises a few + * blocks in front of them, at its own level, eligible for loot like any wild + * mob. If it cannot rise after all, the sigil is handed back. + */ +object BossSummons { + + private val rituals = mutableListOf() + + fun summon(player: Player, kind: MobKindDef) { + val boss = kind.boss ?: return + val spot = spotFor(player) + refusal(kind, boss, spot)?.let { key -> + player.sendPrefixed( + player.tr( + "common.error", + "message" to player.tr(key, "name" to BossFights.name(player, kind)) + ) + ) + return + } + if (!takeSigil(player, boss.sigil)) return + ritual(player, kind, boss, spot) + } + + /** Why [kind] cannot be summoned at [spot], as a translation key, or `null` if it can. */ + private fun refusal(kind: MobKindDef, boss: BossDef, spot: Location): String? { + val world = spot.world + val crowd = boss.arena * CROWD + return when { + !MobSettings.snapshot.bosses -> "boss.refused.disabled" + !Combat.isActive(world) -> "boss.refused.inactive" + MobZones.read(spot).town -> "boss.refused.town" + !placeFits(boss, spot) -> "boss.refused.place" + BossFights.near( + spot, + crowd + ) || rituals.any { it.world == world && it.distanceSquared(spot) <= crowd * crowd } -> + "boss.refused.crowded" + + !roomFor(kind, spot, boss.place.water) -> "boss.refused.room" + else -> null + } + } + + private fun placeFits(boss: BossDef, spot: Location): Boolean { + val place = boss.place + val world = spot.world + if (place.worlds.isNotEmpty() && world.environment.name.lowercase() !in place.worlds && world.name.lowercase() !in place.worlds) { + return false + } + if (place.water && spot.block.type != Material.WATER) return false + return place.maxY == null || spot.blockY <= place.maxY + } + + /** Whether a boss of [kind] would fit at [spot]: tall enough a space, on the ground unless it rises from water. */ + private fun roomFor(kind: MobKindDef, spot: Location, water: Boolean): Boolean { + val type = type(kind) ?: return false + val body = type.entityClass?.let { spot.world.createEntity(spot, it) } ?: return false + val height = ceil(body.height * kind.scale).toInt().coerceAtLeast(1) + val feet = spot.block + if ((0 until height).any { !feet.getRelative(0, it, 0).isPassable }) return false + return water || !feet.getRelative(0, -1, 0).isPassable + } + + /** A few blocks in front of [player] if there is space there, or where they stand. */ + private fun spotFor(player: Player): Location { + val facing = player.location.direction.setY(0) + if (facing.lengthSquared() < MIN_FACING) return player.location + val ahead = player.location.add(facing.normalize().multiply(AHEAD)).toBlockLocation() + return if (ahead.block.isPassable && ahead.clone().add(0.0, 1.0, 0.0).block.isPassable) ahead.add(0.5, 0.0, 0.5) + else player.location + } + + private fun takeSigil(player: Player, sigil: String): Boolean { + val hand = player.inventory.itemInMainHand + if (ContentItems.idOf(hand) != sigil) return false + val rest = hand.clone().apply { amount -= 1 } + player.inventory.setItemInMainHand(if (rest.amount > 0) rest else null) + return true + } + + private fun ritual(player: Player, kind: MobKindDef, boss: BossDef, spot: Location) { + rituals += spot + spot.world.playSound(spot, Sound.BLOCK_END_PORTAL_SPAWN, 0.8f, 1.2f) + player.sendPrefixed(player.tr("boss.ritual", "name" to BossFights.name(player, kind))) + + val ticks = ContentRegistry.bestiary.rules.ritualSeconds * TICKS + object : BukkitRunnable() { + var elapsed = 0 + + override fun run() { + if (elapsed < ticks) { + val rise = elapsed.toDouble() / ticks.coerceAtLeast(1) * RITUAL_HEIGHT + spot.world.spawnParticle( + Particle.SOUL_FIRE_FLAME, + spot.clone().add(0.0, rise, 0.0), + 8, + 0.4, + 0.1, + 0.4, + 0.01 + ) + spot.world.spawnParticle(Particle.SMOKE, spot, 4, 0.8, 0.05, 0.8, 0.01) + elapsed++ + return + } + cancel() + rituals -= spot + rise(player, kind, boss, spot) + } + }.runTaskTimer(Main.instance, 0L, 1L) + } + + private fun rise(player: Player, kind: MobKindDef, boss: BossDef, spot: Location) { + val mob = spawn(kind, spot, boss.level, eligible = true) + if (mob == null) { + if (player.isOnline) InventoryUtil.give(player, listOfNotNull(ContentItems.create(boss.sigil))) + return + } + (mob as? Mob)?.target = player.takeIf { it.isOnline && it.world == spot.world } + + spot.world.strikeLightningEffect(spot) + spot.world.playSound(spot, Sound.ENTITY_WITHER_SPAWN, 1f, 1f) + val summoner = ComponentUtil.escape(player.name) + BossFights.announce(spot) { viewer -> + viewer.tr("boss.summoned", "player" to summoner, "name" to BossFights.name(viewer, kind)) + } + val arena = boss.arena * boss.arena + spot.world.players.filter { it.location.distanceSquared(spot) <= arena }.forEach { viewer -> + viewer.showTitle( + Title.title( + ComponentUtil.parse(BossFights.name(viewer, kind)), + ComponentUtil.parse(viewer.tr("boss.subtitle", "level" to boss.level)), + ) + ) + } + } + + /** + * The boss itself, glowing and never despawning while its fight lasts. + * Its profile and home are set before it is added to the world, so + * everything that sees it arrive already knows it for a boss. One an + * administrator calls up is not [eligible], and drops nothing. + */ + fun spawn(kind: MobKindDef, at: Location, level: Int, eligible: Boolean): LivingEntity? { + val type = type(kind) ?: return null + val profile = MobProfile( + level = level, + rank = MobRank.NORMAL, + affixes = kind.affixes, + nameplate = MobSettings.snapshot.nameplates, + eligible = eligible, + kind = kind.id, + ) + val entity = at.world.spawnEntity(at, type, SpawnReason.CUSTOM) { spawned -> + val living = spawned as? LivingEntity ?: return@spawnEntity + MobProfiles.assign(living, profile) + BossFights.setHome(living, at) + living.removeWhenFarAway = false + living.isGlowing = true + } + val mob = entity as? LivingEntity ?: run { + entity.remove() + return null + } + MobSetup.settle(mob, profile) + return mob + } + + private fun type(kind: MobKindDef): EntityType? = EntityType.entries.firstOrNull { it.name == kind.base } + + private const val TICKS = 20 + private const val AHEAD = 4.0 + private const val RITUAL_HEIGHT = 3.0 + private const val MIN_FACING = 1e-6 + + /** How many arenas' widths apart two bosses must be. */ + private const val CROWD = 2.0 +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/protection/Claims.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/protection/Claims.kt index 0ff508c..dbb4985 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/protection/Claims.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/protection/Claims.kt @@ -3,11 +3,7 @@ package net.trilleo.mc.plugins.tritown.protection import io.papermc.paper.block.TileStateInventoryHolder import org.bukkit.Location import org.bukkit.NamespacedKey -import org.bukkit.block.Block -import org.bukkit.block.Campfire -import org.bukkit.block.Chest -import org.bukkit.block.DoubleChest -import org.bukkit.block.TileState +import org.bukkit.block.* import org.bukkit.entity.* import org.bukkit.inventory.EquipmentSlot import org.bukkit.inventory.Inventory @@ -86,8 +82,14 @@ object Claims { } val vacancy: () -> Boolean = when (state) { - is TileStateInventoryHolder -> { { vacant(state.inventory) } } - is Campfire -> { { (0 until state.size).all { state.getItem(it)?.isEmpty ?: true } } } + is TileStateInventoryHolder -> { + { vacant(state.inventory) } + } + + is Campfire -> { + { (0 until state.size).all { state.getItem(it)?.isEmpty ?: true } } + } + else -> return null } return ClaimHolder(state.persistentDataContainer, location, vacancy) @@ -95,9 +97,18 @@ object Claims { fun of(entity: Entity): ClaimHolder? { val vacancy: () -> Boolean = when { - entity is ItemFrame -> { { entity.item.isEmpty } } - entity is ArmorStand -> { { EquipmentSlot.entries.all { slot -> isBare(entity, slot) } } } - entity is Allay -> { { entity.equipment.itemInMainHand.isEmpty && vacant(entity.inventory) } } + entity is ItemFrame -> { + { entity.item.isEmpty } + } + + entity is ArmorStand -> { + { EquipmentSlot.entries.all { slot -> isBare(entity, slot) } } + } + + entity is Allay -> { + { entity.equipment.itemInMainHand.isEmpty && vacant(entity.inventory) } + } + entity is Mob && entity !is Enemy && MOUNT_SLOTS.any(entity::canUseEquipmentSlot) -> { { MOUNT_SLOTS.all { slot -> isBare(entity, slot) } && @@ -105,7 +116,10 @@ object Claims { } } - entity is Vehicle && entity is InventoryHolder -> { { vacant(entity.inventory) } } + entity is Vehicle && entity is InventoryHolder -> { + { vacant(entity.inventory) } + } + else -> return null } return ClaimHolder(entity.persistentDataContainer, entity.location, vacancy) diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/protection/DropWindows.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/protection/DropWindows.kt index d0539d3..4b94d53 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/protection/DropWindows.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/protection/DropWindows.kt @@ -31,7 +31,14 @@ class DropWindows(private val clock: () -> Int, radius: Double = 2.0) { data class Owned(val owner: UUID) : Match } - private data class Window(val world: UUID, val x: Double, val y: Double, val z: Double, val owner: UUID?, val tick: Int) + private data class Window( + val world: UUID, + val x: Double, + val y: Double, + val z: Double, + val owner: UUID?, + val tick: Int + ) private val radiusSquared = radius * radius private val windows = ArrayList() diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/protection/ItemOwnership.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/protection/ItemOwnership.kt index 10fe7c8..0abf977 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/protection/ItemOwnership.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/protection/ItemOwnership.kt @@ -37,9 +37,12 @@ object ItemOwnership { * Never drop an item for a player with `dropItemNaturally` alone: anyone * standing nearby could take it. */ - fun dropFor(player: Player, stack: ItemStack): Item = - player.world.dropItemNaturally(player.location, stack) { item -> - if (Protection.settings(player.world) != null) bind(item, player.uniqueId) + fun dropFor(player: Player, stack: ItemStack): Item = dropFor(player, stack, player.location) + + /** Drops [stack] at [at] as [player]'s: a boss's loot, which falls where the boss did. */ + fun dropFor(player: Player, stack: ItemStack, at: Location): Item = + at.world.dropItemNaturally(at, stack) { item -> + if (Protection.settings(at.world) != null) bind(item, player.uniqueId) } /** Drops spawning near [location] this tick or the next belong to [owner]. */ diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrame.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrame.kt index 290a2fd..6035673 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrame.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrame.kt @@ -74,32 +74,6 @@ object GUIFrame { } } - /** - * The slots of a [rows]-row framed menu that show [count] items, centred - * both ways. - * - * Rows are filled seven at a time and the last one is [packedColumns], and - * the block of rows they take sits in the middle of the space inside the - * border — so two players in a list read as a pair in the middle of the - * menu rather than as two items lost in its top-left corner. Filling every - * slot gives exactly [contentSlots]. - */ - fun centeredSlots(rows: Int, count: Int): List { - val innerRows = (rows - 2).coerceAtLeast(0) - require(count in 0..innerRows * INNER_COLUMNS) { "$count items do not fit inside a $rows-row menu" } - if (count == 0) return emptyList() - - val used = (count + INNER_COLUMNS - 1) / INNER_COLUMNS - val firstRow = 1 + (innerRows - used) / 2 - - return buildList { - for (index in 0 until used) { - val inRow = if (index == used - 1) count - INNER_COLUMNS * index else INNER_COLUMNS - packedColumns(inRow).forEach { add((firstRow + index) * ROW_SIZE + it) } - } - } - } - /** * Fills every slot of [inventory] that is not in [contentSlots] with the border. * diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt index 59226a4..3c6cc19 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/registration/PagedPluginGUI.kt @@ -27,8 +27,8 @@ import java.util.* * [layout] decides what the rest of it does. [PagedLayout.FULL] fills every slot * above that row with content — a 6-row GUI gives 45 content slots per page. * [PagedLayout.FRAMED] insets the content by one slot on every side, giving 28 - * instead and a border around them. [PagedLayout.CENTERED] frames it the same - * way and centres a page that is not full. + * instead and a border around them. Either way a page fills in reading order + * from its top-left slot. * * The class must have either: * - A no-arg constructor, **or** @@ -100,15 +100,6 @@ abstract class PagedPluginGUI( /** Tracks the current page for each player viewing this GUI. */ private val playerPages = mutableMapOf() - /** - * Which position on the page each drawn slot shows, per viewer. - * - * Remembered as drawn rather than worked out again from the layout, because - * a centred page puts its items in different slots depending on how many - * of them there are. - */ - private val shownPositions = mutableMapOf>() - /** * Returns all items that should be distributed across pages for the * given player. The list may be of any size; items are automatically @@ -192,11 +183,14 @@ abstract class PagedPluginGUI( private val contentSlots: List by lazy { when (layout) { PagedLayout.FULL -> (0 until (rows - 1) * ROW_SIZE).toList() - PagedLayout.FRAMED, PagedLayout.CENTERED -> GUIFrame.contentSlots(rows) + PagedLayout.FRAMED -> GUIFrame.contentSlots(rows) } } - private val contentSlotSet: Set by lazy { contentSlots.toSet() } + /** Each content slot's position on a page. */ + private val contentPositions: Map by lazy { + contentSlots.withIndex().associate { (position, slot) -> slot to position } + } /** How many items fit on one page. */ protected val pageSize: Int @@ -211,14 +205,13 @@ abstract class PagedPluginGUI( * [page], or `null` when that slot is not part of the content area. * * An empty slot inside the content area still has a position, just one past - * the end of the list — except on a [PagedLayout.CENTERED] page, where only - * the slots holding an item are content at all. + * the end of the list, so look it up with `getOrNull`. * * In [PagedGUIMode.SET] this is `page * pageSize` plus the position the * item was given in [getSetItems]. */ protected fun contentIndex(event: InventoryClickEvent, page: Int): Int? = - shownPositions[event.whoClicked.uniqueId]?.get(event.rawSlot)?.let { position -> page * pageSize + position } + contentPositions[event.rawSlot]?.let { position -> page * pageSize + position } /** * Redraws the page [player] is looking at, into the inventory they have open. @@ -271,14 +264,13 @@ abstract class PagedPluginGUI( navOffset == PAGE_INDICATOR_OFFSET -> return navOffset in 0 until ROW_SIZE -> onNavClick(event, navOffset) - slot in contentSlotSet -> onContentClick(event, page) + slot in contentPositions -> onContentClick(event, page) } } override fun onClose(event: InventoryCloseEvent) { val player = event.player as? Player ?: return playerPages.remove(player.uniqueId) - shownPositions.remove(player.uniqueId) } // ----- Internal helpers --------------------------------------------------- @@ -318,15 +310,13 @@ abstract class PagedPluginGUI( } val totalPages = totalPages(player) - var slots = contentSlots when (mode) { PagedGUIMode.LIST -> { val items = getItems(player) val start = page * pageSize val onPage = items.subList(minOf(start, items.size), minOf(start + pageSize, items.size)) - if (layout == PagedLayout.CENTERED) slots = GUIFrame.centeredSlots(rows, onPage.size) - onPage.forEachIndexed { position, item -> inventory.setItem(slots[position], item) } + onPage.forEachIndexed { position, item -> inventory.setItem(contentSlots[position], item) } } PagedGUIMode.SET -> { @@ -336,7 +326,6 @@ abstract class PagedPluginGUI( } } } - shownPositions[player.uniqueId] = slots.withIndex().associate { (position, slot) -> slot to position } renderNavRow(player, inventory, page, totalPages) } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/scoreboard/ScoreboardService.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/scoreboard/ScoreboardService.kt index 560d578..3d61dab 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/scoreboard/ScoreboardService.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/scoreboard/ScoreboardService.kt @@ -8,10 +8,7 @@ import com.palmergames.bukkit.towny.huds.providers.ServerHUD import net.kyori.adventure.text.Component import net.trilleo.mc.plugins.tritown.config.ScoreboardSettings import net.trilleo.mc.plugins.tritown.data.PlayerDataManager -import net.trilleo.mc.plugins.tritown.scoreboard.placeholders.EconomyPlaceholders -import net.trilleo.mc.plugins.tritown.scoreboard.placeholders.NationPlaceholders -import net.trilleo.mc.plugins.tritown.scoreboard.placeholders.PlotPlaceholders -import net.trilleo.mc.plugins.tritown.scoreboard.placeholders.TownPlaceholders +import net.trilleo.mc.plugins.tritown.scoreboard.placeholders.* import net.trilleo.mc.plugins.tritown.utils.ComponentUtil import org.bukkit.Bukkit import org.bukkit.entity.Player @@ -281,5 +278,6 @@ object ScoreboardService { NationPlaceholders.register() PlotPlaceholders.register() EconomyPlaceholders.register() + CombatPlaceholders.register() } } diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/scoreboard/placeholders/CombatPlaceholders.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/scoreboard/placeholders/CombatPlaceholders.kt new file mode 100644 index 0000000..f5dd4e3 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/scoreboard/placeholders/CombatPlaceholders.kt @@ -0,0 +1,40 @@ +package net.trilleo.mc.plugins.tritown.scoreboard.placeholders + +import net.trilleo.mc.plugins.tritown.combat.* +import net.trilleo.mc.plugins.tritown.mobs.MobZones +import net.trilleo.mc.plugins.tritown.scoreboard.PlaceholderEngine +import net.trilleo.mc.plugins.tritown.scoreboard.PlayerContext +import net.trilleo.mc.plugins.tritown.utils.tr + +/** The player's RPG numbers, and how dangerous the ground they stand on is. */ +object CombatPlaceholders { + + fun register() { + PlaceholderEngine.register("health") { context -> + active(context) { CombatFormat.health(CombatHealth.current(context.player)) } + } + + PlaceholderEngine.register("max_health") { context -> + active(context) { CombatFormat.number(CombatHealth.max(context.player)) } + } + + PlaceholderEngine.register("defense") { context -> + active(context) { CombatFormat.number(PlayerStats.sheet(context.player)[Stat.DEFENSE]) } + } + + PlaceholderEngine.register("mob_level") { context -> + active(context) { MobZones.levelAt(context.player.location).toString() } + } + + // A whole translated fragment rather than a bare value, so a line can + // carry it and read naturally when combat is off and it is empty. + PlaceholderEngine.register("danger") { context -> + if (Combat.isActive(context.player.world)) { + context.player.tr("scoreboard.danger", "level" to MobZones.levelAt(context.player.location)) + } else "" + } + } + + private fun active(context: PlayerContext, value: () -> String): String = + if (Combat.isActive(context.player.world)) value() else context.none() +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt index e03f890..6a603be 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/shops/ShopManager.kt @@ -1,6 +1,7 @@ package net.trilleo.mc.plugins.tritown.shops import net.trilleo.mc.plugins.tritown.config.ShopSettings +import net.trilleo.mc.plugins.tritown.content.ItemRedraw import net.trilleo.mc.plugins.tritown.enums.LimitPeriod import net.trilleo.mc.plugins.tritown.enums.MatchMode import net.trilleo.mc.plugins.tritown.enums.TownyRequirement @@ -72,6 +73,24 @@ object ShopManager { shops.clear() } + /** + * Redraws every item a shop keeps a copy of — its goods and the items its + * prices ask for — that was drawn from older content or another item + * language, and saves if any was. + * + * Otherwise a shop would hand out stale copies that do not stack with fresh + * ones, and stop recognising the fresh ones it buys back. + */ + fun redrawItems() { + if (!isReady) return + var redrawn = false + for (entry in shops.values.flatMap { it.entries }) { + val items = listOf(entry.item) + listOfNotNull(entry.buy, entry.sell).flatMap { it.items } + items.forEach { if (ItemRedraw.refresh(it)) redrawn = true } + } + if (redrawn) save() + } + // ── Reading ───────────────────────────────────────────────────────── /** The shop with [id], or `null` when there is none. */ diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/storage/StorageItems.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/storage/StorageItems.kt index ccd15c3..150461b 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/storage/StorageItems.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/storage/StorageItems.kt @@ -23,7 +23,9 @@ object StorageItems { /** By material, then by name, so like sits beside like and a renamed stack keeps its place. */ val order: Comparator = compareBy { it.type.name } - .thenBy { stack -> stack.itemMeta?.displayName()?.let(PlainTextComponentSerializer.plainText()::serialize) ?: "" } + .thenBy { stack -> + stack.itemMeta?.displayName()?.let(PlainTextComponentSerializer.plainText()::serialize) ?: "" + } .thenByDescending { it.amount } /** diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/storage/StorageManager.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/storage/StorageManager.kt index e80e324..593d7cd 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/storage/StorageManager.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/storage/StorageManager.kt @@ -60,7 +60,11 @@ object StorageManager { fun start(storageStore: StorageStore, pluginLogger: Logger) { store = storageStore logger = pluginLogger - writer = Executors.newSingleThreadExecutor { runnable -> Thread(runnable, "TriTown-Storage").apply { isDaemon = true } } + writer = Executors.newSingleThreadExecutor { runnable -> + Thread(runnable, "TriTown-Storage").apply { + isDaemon = true + } + } isReady = true } @@ -227,7 +231,8 @@ object StorageManager { fun toDouble(money: Money): Double = if (CurrencyRegistry.isLoaded) CurrencyRegistry.primary.toDouble(money) else money.toDouble(DEFAULT_SCALE) - private fun scale(): Int = if (CurrencyRegistry.isLoaded) CurrencyRegistry.primary.fractionalDigits else DEFAULT_SCALE + private fun scale(): Int = + if (CurrencyRegistry.isLoaded) CurrencyRegistry.primary.fractionalDigits else DEFAULT_SCALE // ── Items ─────────────────────────────────────────────────────────── diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/combat/CombatHudTask.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/combat/CombatHudTask.kt new file mode 100644 index 0000000..d516d19 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/combat/CombatHudTask.kt @@ -0,0 +1,27 @@ +package net.trilleo.mc.plugins.tritown.tasks.combat + +import net.trilleo.mc.plugins.tritown.combat.Combat +import net.trilleo.mc.plugins.tritown.combat.CombatHud +import net.trilleo.mc.plugins.tritown.config.CombatSettings +import net.trilleo.mc.plugins.tritown.registration.PluginTask +import org.bukkit.Bukkit +import org.bukkit.GameMode + +/** + * Redraws every fighting player's health and Defense above their hotbar twice + * a second, often enough that the line never fades between draws. + */ +class CombatHudTask : PluginTask(delay = 20L, period = 10L) { + + override fun run() { + if (!CombatSettings.isLoaded || !CombatSettings.snapshot.actionBar) return + for (player in Bukkit.getOnlinePlayers()) { + if (player.isDead || player.gameMode !in FIGHTING || !Combat.isActive(player.world)) continue + CombatHud.show(player) + } + } + + private companion object { + val FIGHTING = setOf(GameMode.SURVIVAL, GameMode.ADVENTURE) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/combat/SpeedTask.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/combat/SpeedTask.kt new file mode 100644 index 0000000..dc41300 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/combat/SpeedTask.kt @@ -0,0 +1,15 @@ +package net.trilleo.mc.plugins.tritown.tasks.combat + +import net.trilleo.mc.plugins.tritown.combat.SpeedSync +import net.trilleo.mc.plugins.tritown.config.CombatSettings +import net.trilleo.mc.plugins.tritown.registration.PluginTask +import org.bukkit.Bukkit + +/** Keeps every player's movement in step with their Speed stat, twice a second. */ +class SpeedTask : PluginTask(delay = 20L, period = 10L) { + + override fun run() { + if (!CombatSettings.isLoaded) return + Bukkit.getOnlinePlayers().forEach(SpeedSync::sync) + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/mobs/MobTurnTask.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/mobs/MobTurnTask.kt new file mode 100644 index 0000000..53093a0 --- /dev/null +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/tasks/mobs/MobTurnTask.kt @@ -0,0 +1,36 @@ +package net.trilleo.mc.plugins.tritown.tasks.mobs + +import net.trilleo.mc.plugins.tritown.mobs.AbilityEffects +import net.trilleo.mc.plugins.tritown.mobs.ActiveMobs +import net.trilleo.mc.plugins.tritown.mobs.AffixEffects +import net.trilleo.mc.plugins.tritown.mobs.MobProfiles +import net.trilleo.mc.plugins.tritown.mobs.boss.BossFights +import net.trilleo.mc.plugins.tritown.registration.PluginTask + +/** + * Gives every active mob — ranked, custom or with an affix — that has a + * player nearby its turn twice a second: the particles that show its affixes, + * a blinker closing the distance, and a custom mob's abilities. One out of + * everyone's sight does nothing, so a far-off champion costs nothing. Every + * boss fight takes its turn first, whoever is near. + */ +class MobTurnTask : PluginTask(delay = 20L, period = 10L) { + + override fun run() { + BossFights.tick() + for (mob in ActiveMobs.all()) { + if (!mob.isValid) { + ActiveMobs.untrack(mob.uniqueId) + continue + } + if (mob.world.getNearbyPlayers(mob.location, AWAKE_RANGE).isEmpty()) continue + val profile = MobProfiles.of(mob) + AffixEffects.tick(mob, profile) + AbilityEffects.tick(mob, profile) + } + } + + private companion object { + const val AWAKE_RANGE = 32.0 + } +} diff --git a/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/Lang.kt b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/Lang.kt index bb8ee5a..b9a5571 100644 --- a/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/Lang.kt +++ b/src/main/kotlin/net/trilleo/mc/plugins/tritown/utils/Lang.kt @@ -22,6 +22,10 @@ import java.io.File * sender.sendPrefixed(sender.tr("command.reload.done")) * ``` * + * Items are the exception to reading in your own language: the server cannot + * show an item's text to each player differently, so everything drawn on an + * item is in the one item language ([item]). + * * Loaded translations are read from Towny's threads through the Vault economy, * so [load] publishes an immutable snapshot rather than mutating one in place. */ @@ -42,8 +46,23 @@ object Lang { @Volatile private var configured = AUTO - /** Loads every language file; [language] is `auto` (client locale) or a language id that everyone sees. */ - fun load(plugin: JavaPlugin, language: String) { + @Volatile + private var itemLanguage = fallback + + /** + * A stamp of the item language's text, the same from one start to the next, + * which changes whenever anything drawn on an item could read differently. + */ + @Volatile + var itemRevision: Int = 0 + private set + + /** + * Loads every language file. [language] is `auto` (client locale) or a + * language id that everyone sees; [items] is the language items are written + * in, or `auto` for [language]'s, and English when that is `auto` too. + */ + fun load(plugin: JavaPlugin, language: String, items: String) { val folder = File(plugin.dataFolder, "lang") BUNDLED.forEach { if (!File(folder, "$it.yml").exists()) plugin.saveResource("lang/$it.yml", false) } @@ -60,6 +79,14 @@ object Lang { if (!language.equals(AUTO, ignoreCase = true) && match(language) == null) { plugin.logger.warning("Unknown language '$language' in config.yml; using $DEFAULT.") } + + if (!items.equals(AUTO, ignoreCase = true) && match(items) == null) { + plugin.logger.warning("Unknown item-language '$items' in config.yml; items follow language instead.") + } + itemLanguage = listOf(items, language).firstNotNullOfOrNull { id -> + id.takeUnless { it.equals(AUTO, ignoreCase = true) }?.let(::match) + } ?: fallback + itemRevision = 31 * itemLanguage.id.hashCode() + itemLanguage.values.hashCode() } /** Ids of every loaded language file, such as `en_US`, sorted by file name. */ @@ -75,15 +102,24 @@ object Lang { /** The translation of [key] for [sender], or `null` when no language defines it. */ fun find(sender: CommandSender?, key: String): String? = language(sender).values[key] + /** + * The translation of [key] in the item language, for text drawn on an item. + * An item is one object seen by everyone, so it is written once, in one + * language, and every copy stays identical. + */ + fun item(key: String): String = itemLanguage.values[key] ?: key + /** * The id of the language [sender] reads, such as `zh_CN` — the same one [tr] * picks, so text kept outside the language files can follow it. */ fun idFor(sender: CommandSender?): String = language(sender).id - private fun language(sender: CommandSender?): Language { - val id = - if (configured.equals(AUTO, ignoreCase = true)) (sender as? Player)?.locale()?.toString() else configured + private fun language(sender: CommandSender?): Language = language((sender as? Player)?.locale()?.toString()) + + /** The language for a client asking for [clientLocale], whatever `language` in `config.yml` makes of it. */ + private fun language(clientLocale: String?): Language { + val id = if (configured.equals(AUTO, ignoreCase = true)) clientLocale else configured return id?.let(::match) ?: fallback } diff --git a/src/main/resources/config.yml b/src/main/resources/config.yml index 0fad59e..c4e8759 100644 --- a/src/main/resources/config.yml +++ b/src/main/resources/config.yml @@ -8,6 +8,12 @@ message-prefix: "[TriTown # Translations live in plugins/TriTown/lang/ and can be edited or extended with new .yml files. language: auto +# The one language every TriTown item is written in: gear, materials, essence and sigils. The server cannot show an +# item's text to each player in their own language, so items share one, which also keeps every copy identical so they +# stack. "auto" follows `language`, or en_US when that is "auto" too. Items already out there are redrawn in the new +# language the next time a player looks at them. +item-language: auto + # ── Main Menu ─────────────────────────────────────────────────────────────── # The menu players reach everything TriTown offers from: their balance, town and # storage, the global shop, trading and paying other players, the leaderboard, @@ -226,7 +232,7 @@ storage: item-protection: enabled: true # Worlds where nothing is protected, such as a minigame or creative world. - disabled-worlds: [] + disabled-worlds: [ ] # What a player drops can only be picked up by them. drops: true # What a player mines, harvests, shears or fishes up drops as theirs. @@ -248,6 +254,105 @@ item-protection: # Explosions leave a claimed container standing, so nobody can blow one open. explosion-guard: true +# ── Combat ────────────────────────────────────────────────────────────────── +# Fights with mobs in RPG numbers: every player and mob has a health pool drawn +# over their vanilla hearts, and hits are worked out from stats — Health, +# Defense, Damage, Strength, Crit Chance and Crit Damage — that /tritown stats +# shows. What those are worth, and how mobs grow, is in +# plugins/TriTown/content/balance.yml. +# +# Players fighting each other are left to vanilla, and so is everything the +# world does to a player: falls, lava, drowning. At level 1 with vanilla gear +# the game plays exactly as vanilla does. +combat: + # Turn the whole combat layer off: hits stay vanilla and mobs are not levelled. + enabled: true + # Worlds where hits stay vanilla and mobs are not levelled, such as a minigame + # world. + disabled-worlds: [ ] + hud: + # Show each player's health and Defense above the hotbar. Towny's own + # notices can use the same line; if yours do, turn one of them off. + action-bar: true + # Float the damage of every hit on a mob up from the mob. + damage-indicators: true + # The most damage indicators that may exist at once, across the server. + indicator-limit: 64 + # The most Speed, in percent, that makes a player faster. Speed is the one + # stat that shows between players, so it stays short of a Speed II potion. + speed-cap: 30 + +# ── Mobs ──────────────────────────────────────────────────────────────────── +# Wild hostile mobs are levelled by where they spawn. The further from a world's +# spawn, the higher the level: more health, harder hits, more experience. Mobs +# in town claims are always level 1, so towns are safe and a mob farm inside one +# works as it does in vanilla. So do mobs from spawners, eggs and commands. +# /tritown mob level tells you the level where you stand. +mobs: + levels: + # Levels an Overworld mob gains for spawning at night, and for spawning + # below Y 0. Neither applies inside the spawn area. + night-bonus: 2 + depth-bonus: 2 + # Each kind of world is a spawn area of spawn-radius blocks where mobs are + # the base level, and rings of ring-width blocks around it, each per-ring + # levels harder than the one inside it, up to cap. + overworld: + base: 1 + spawn-radius: 300 + ring-width: 500 + per-ring: 2 + cap: 30 + # The Nether is an eighth of the Overworld's size, so its rings are too. + nether: + base: 4 + spawn-radius: 64 + ring-width: 64 + per-ring: 2 + cap: 45 + # The spawn area is the main island; the outer islands are the rings. + end: + base: 8 + spawn-radius: 1000 + ring-width: 500 + per-ring: 3 + cap: 60 + # Rings of its own for a world, by name. Anything left out is the + # Overworld's. For example: + # resources: + # base: 10 + # cap: 40 + worlds: { } + # How much more experience a mob drops for each level above 1, as a fraction. + xp-per-level: 0.02 + # Show a levelled mob's level and health when a player looks at it. + nameplates: true + # A wild mob can spawn as an elite or a champion: tougher, harder-hitting, and + # with affixes such as Vampiric or Blinking. How often, from which level, and + # what the affixes do is in plugins/TriTown/content/balance.yml. + ranks: + # Kinds of mob that never spawn ranked, because they are bosses already. + exempt: + - WARDEN + - ELDER_GUARDIAN + - ENDER_DRAGON + - WITHER + # Custom mobs, such as the Gravewalker or the Widow Stalker, now and then take + # the place of a wild mob of their kind: named, dressed for the part, tougher, + # and with loot of their own. Who they are, where they live and what they drop + # is in plugins/TriTown/content/bestiary.yml. + custom: + enabled: true + # Bosses: summoned by a player with a sigil, which the custom mobs of the + # boss's region drop. Each fights in an arena around where it was summoned, + # and every player who dealt enough of its damage gets loot of their own. + # What each boss is and does is in plugins/TriTown/content/bestiary.yml. + bosses: + enabled: true + # Players this close hear when a boss is summoned, falls, or gives up and + # leaves. + announce-range: 128 + # ── Towns ─────────────────────────────────────────────────────────────────── towns: # Money set aside for a player's first town, on top of their starting balance. @@ -310,6 +415,9 @@ news: # Plot %plot_town% %plot_nation% %plot_type% %plot_owner% %plot_price% # %plot_coords% %plot_pvp% %plot_mobs% # Money %balance% %balance_raw% %baltop_rank% +# Combat %health% %max_health% %defense% %mob_level% +# %danger% (a whole fragment: " · Lv 21" in the wilderness, empty +# where combat is off) # # Conditions: always, no-town, has-town, no-nation, has-nation, in-wilderness, # in-own-town, in-own-plot, in-other-town, in-ally-town, in-enemy-town, diff --git a/src/main/resources/content/balance.yml b/src/main/resources/content/balance.yml new file mode 100644 index 0000000..2fcb380 --- /dev/null +++ b/src/main/resources/content/balance.yml @@ -0,0 +1,219 @@ +# TriTown - Combat balance +# +# The numbers the combat layer is tuned with. /tritown reload reads this file again. A value that is missing or unusable +# falls back to the bundled default, and the console says which. +# +# How it fits together: every living thing has an RPG health pool, drawn over its vanilla hearts. Vanilla health stays +# the truth — the pool is the same fraction full as the hearts — so potions, regeneration, totems and deaths all work as +# they always have. Only hits between a player and a mob, or between mobs, are worked out in RPG numbers. Hits between +# players, and everything the world does (falling, lava, drowning), are left to vanilla. + +# RPG health per vanilla half-heart at level 1. A player's 20 vanilla health is 100 RPG health at the default, a vanilla +# iron sword hits for 30, and a zombie has 100. At level 1 with vanilla gear the game plays exactly like vanilla, +# whatever this is. +lens: 5 + +# What every player has before any gear. +player: + health: 100 + # Percent chance for a hit on a mob to be critical. + crit-chance: 20 + # Percent more damage a critical hit does. + crit-damage: 50 + +# A vanilla jump attack adds this much crit chance, instead of vanilla's flat 50% more damage. +jump-crit-chance: 25 + +# Strength that one level of a potion effect is worth. Strength adds to the damage a player does to mobs by this many +# percent; Weakness takes it away. +effects: + strength-per-level: 40 + weakness-per-level: 40 + +# How mobs grow with their level. A level-L mob has growth^(L-1) times the health and damage of a level-1 one: with +# these, a level-30 zombie has about 34x the health and hits about 16x as hard. +mobs: + health-growth: 1.13 + damage-growth: 1.10 + +# How vanilla armor becomes Defense. Each worn piece gives this much Defense per point of armor and of toughness. Every +# point of Defense takes a share off a mob's hit: 100 Defense halves it, 300 leaves a quarter. At the defaults a full +# iron set is 120 Defense and a full diamond set 320, which is what vanilla's armor gives against a zombie. +vanilla: + armor-point: 8 + toughness-point: 20 + # Magic Find each level of Looting on the held weapon gives. + looting-magic-find: 10 + # What a vanilla bow or crossbow counts as for what it shoots: an attack damage, as a sword's is, so a vanilla bow + # shoots like an iron sword swings. A shot is measured against a fully drawn arrow's vanilla damage. + bow-attack: 5 + arrow-reference: 6 + +# Wild mobs that spawn at or above min-level can spawn ranked instead, chance percent of the time. A rank multiplies a +# mob's health and hits, and gives it affixes: between min-affixes and max-affixes of them, picked at random. The +# champion roll comes first. +ranks: + elite: + chance: 3 + min-level: 5 + health: 4 + damage: 1.5 + min-affixes: 1 + max-affixes: 2 + champion: + chance: 0.4 + min-level: 15 + health: 12 + damage: 2 + min-affixes: 2 + max-affixes: 3 + +# What each affix does. Percentages are percentages and durations seconds, unless they say ticks. +affixes: + # Armored: Defense the mob has, the way a player's armor works against mobs. + armored-defense: 150 + # Frenzied: how much faster it moves. + frenzied-speed: 30 + # Vampiric: how much of the damage it deals it heals. + vampiric-heal: 25 + # Enraged: below this share of its health, its hits land harder by enraged-damage. + enraged-below: 30 + enraged-damage: 50 + # Molten sets you on fire, Frostbound slows you and Venomous poisons you, for this long. + molten-seconds: 2 + frostbound-seconds: 3 + venomous-seconds: 3 + # Volatile: explodes where it died this long after dying, as hard as this. A creeper is 3. Blocks are never broken. + volatile-power: 2.5 + volatile-delay-ticks: 30 + # Summoner: once below this share of its health, calls this many of its kind to its side. + summoner-below: 50 + summoner-minions: 2 + # Blinking: steps in behind its target when it is further away than this, at most once per cooldown. + blinking-range: 6 + blinking-cooldown-ticks: 100 + +# What the abilities custom mobs have (see bestiary.yml) are worth. A mob uses one when its target is where the ability +# can reach, no sooner than its cooldown-ticks after the last time, and always with a warning first: windup-ticks of +# particles and sound. Damage is in the units of a level-1 mob's vanilla hit — a zombie's is 3 — so the mob's level, +# rank and kind grow it like any other hit of its own, and Defense takes its share. Only players are ever hit. +abilities: + # The least time between any two abilities of one mob. + global-cooldown-ticks: 40 + # Pounces on a target further than 4 blocks and within range, hurting players within radius where it lands. + leap: { cooldown-ticks: 160, windup-ticks: 8, range: 12, radius: 2.5, damage: 4 } + # Rears up, then a shockwave hurts players within radius and throws them up by power. + slam: { cooldown-ticks: 200, windup-ticks: 20, radius: 5, damage: 6, power: 0.8 } + # Lowers its head, then dashes at a target within range at power, hitting everyone it runs through once. + charge: { cooldown-ticks: 200, windup-ticks: 15, range: 16, damage: 6, power: 1.6 } + # Looses count arrows in a fan at a target within range, each hitting for damage. + volley: { cooldown-ticks: 140, windup-ticks: 10, range: 20, damage: 2.5, count: 5 } + # Hurls count fireballs at a target within range. They hit as a blaze's do, and set no blocks alight. + fireball: { cooldown-ticks: 120, windup-ticks: 10, range: 24, count: 3 } + # Marks count spots on and around a target within range, then fire lands on each, hurting players within radius. + meteor: { cooldown-ticks: 240, windup-ticks: 30, range: 20, radius: 2.5, damage: 6, count: 3 } + # As meteor, with lightning, which sets nothing alight. + storm: { cooldown-ticks: 240, windup-ticks: 30, range: 24, radius: 2, damage: 6, count: 3 } + # Spits a web at a target within range, slowing it almost to a stop for seconds. + ensnare: { cooldown-ticks: 200, windup-ticks: 10, range: 14, seconds: 2 } + # Drags a target further than 5 blocks and within range towards itself at up to power, hurting it a little. + hook: { cooldown-ticks: 200, windup-ticks: 10, range: 16, damage: 2, power: 1.4 } + # Calls minions to its side until count of them are alive. They are its own kind unless bestiary.yml names another. + summon: { cooldown-ticks: 400, windup-ticks: 20, count: 2 } + # Once hurt, braces itself: power more Defense for seconds. + bulwark: { cooldown-ticks: 400, seconds: 6, power: 200 } + # A burst that hurts players within radius and slows them for seconds. + frost-nova: { cooldown-ticks: 240, windup-ticks: 20, radius: 5, damage: 5, seconds: 3 } + # Leaves a cloud of radius where a target within range stands, hurting players in it twice a second for seconds. + miasma: { cooldown-ticks: 260, windup-ticks: 10, range: 16, radius: 3, damage: 1.5, seconds: 6 } + # Latches on to a target within range for seconds, hurting it twice a second and healing power percent of it. + drain: { cooldown-ticks: 240, range: 10, damage: 1.5, seconds: 3, power: 50 } + +# What gear is worth. Every piece of a slot and tier has the same points, spent on stats by the weights gear.yml gives +# it, so no piece is stronger than its tier allows however it is shaped. +gear: + # Points a piece has to spend on its stats, by where it goes. + slot-points: + weapon: 100 + bow: 100 + helmet: 50 + chestplate: 80 + leggings: 70 + boots: 40 + # What 100 points buy of each stat on tier-1 gear, and how many times more each tier above buys. A stat that is a + # percentage, such as crit chance, does not grow: it is capped anyway. + stats: + health: + per-100: 120 + growth: 1.5 + defense: + per-100: 100 + growth: 1.3 + damage: + per-100: 50 + growth: 1.7 + strength: + per-100: 85 + growth: 1.4 + crit-chance: + per-100: 20 + growth: 1 + crit-damage: + per-100: 80 + growth: 1.25 + speed: + per-100: 10 + growth: 1 + vitality: + per-100: 40 + growth: 1.05 + magic-find: + per-100: 30 + growth: 1.05 + # What each rarity multiplies a piece's stats by. A step up in rarity is worth far less than a tier. + rarity: + common: 1 + uncommon: 1.06 + rare: 1.12 + epic: 1.2 + legendary: 1.3 + mythic: 1.4 + # Percent more base stats each star of an upgrade gives, and the most stars a piece can have. + star-bonus: 4 + max-stars: 5 + # Each stat of a new piece rolls a quality between these, in percent, shown on it. + roll: [ 90, 100 ] + # A reforge is worth this percent of the piece's points, spent on its own stats. + reforge-share: 8 + # The rarities the Forge crafts at, and a champion's gear drops at, weighted. + craft-odds: + common: 60 + uncommon: 25 + rare: 11 + epic: 4 + drop-odds: + uncommon: 45 + rare: 35 + epic: 15 + legendary: 5 + +# What the Forge charges. Money is multiplied by money-growth for every tier above the first. Essence is the grade a +# mob of the piece's tier drops (see mobs.yml). +forge: + money-growth: 1.5 + # Each star costs this, times the star's number: the third star costs three times the first. + upgrade: + essence: 2 + money: 100 + # Each step of rarity costs this, times the step's number. The Forge refines no higher than refine-cap; anything + # rarer only ever drops. + refine: + essence: 6 + money: 400 + refine-cap: legendary + # A new reforge, rolled at random from those the piece can take. + reforge: + essence: 1 + money: 150 + # Salvaging gives back this much essence for each step of rarity the piece has, and one more for each star. + salvage-essence: 2 diff --git a/src/main/resources/content/bestiary.yml b/src/main/resources/content/bestiary.yml new file mode 100644 index 0000000..3ac26c2 --- /dev/null +++ b/src/main/resources/content/bestiary.yml @@ -0,0 +1,428 @@ +# TriTown - Bestiary +# +# The server's own mobs. /tritown reload reads this file again, and a change reaches every one already out there: a +# custom mob only remembers which one it is. Turn them off with mobs.custom.enabled in config.yml. +# +# A variant is a vanilla kind of mob with a name, a look and loot of its own. Now and then one takes the place of a wild +# spawn of its kind: only a mob that spawned in the wild, so never one in a town, from a spawner, an egg or a command. +# +# base the vanilla kind it is, such as zombie. It only ever takes the place of a spawn of that kind +# spawn where it lives, and how often it appears there: +# worlds kinds of world (normal, nether, the_end) or world names; left out for any +# biomes biomes such as desert or minecraft:swamp; left out for any +# levels [lowest, highest] level it spawns at +# time any, day or night (the Overworld's) +# min-y the lowest height it spawns at, and max-y the highest; left out for any +# chance percent of the wild spawns of its kind that fit all of the above and become it. Each variant +# of a kind rolls in the order this file lists them, and the first to succeed takes the spawn +# rankable whether it may still spawn as an elite or a champion; true when left out +# health what it multiplies the health its level gives its kind by; 1 when left out +# damage what it multiplies its hits by; 1 when left out +# defense Defense it has, as a player's armor works against mobs: 100 halves each hit +# speed percent faster it moves; below 0 is slower +# scale its size, where 1 is its kind's. It is bigger to hit, too +# knockback percent of knockback it shrugs off, up to 100 +# affixes affixes it always has (see balance.yml), on top of any its rank rolls +# abilities what it does in a fight: leap, slam, charge, volley, fireball, meteor, storm, ensnare, hook, summon, +# bulwark, frost-nova, miasma or drain. What each does, and how hard, is in balance.yml +# minions the id of the custom mob its summon calls; its own kind when left out +# equipment what it wears and holds, by slot (head, chest, legs, feet, hand, off-hand): gear: for a piece of gear's +# look, or a vanilla item such as golden_helmet. Only ever a look: it gives the mob no armor or damage and +# never drops. Illagers, witches, spiders and endermen show none of it +# loot what it drops on top of its family's material (mobs.yml) and its rank's essence, each line rolled on its +# own, chance percent of the time, with the killer's Magic Find: +# {item: , chance: 25, amount: [1, 2]} an item from items.yml +# {gear: , chance: 0.5} a piece of gear, at a rarity from drop-odds in balance.yml +# +# Its drops follow the same rules as every wild mob's (mobs.yml): players must have dealt enough of its damage. Its name +# and description are mob.kind..name and mob.kind..lore in the language files. An id is lower-case letters, +# digits and dashes, is shared by variants and bosses, and must never change once the mob is out there. +# +# A boss is summoned by a player with its sigil, an item from items.yml that the variants of its region drop. It takes +# everything a variant does but spawn and rankable, and besides: +# +# level the level it always is, which decides its tier of gear and its essence +# sigil the item that summons it. Right-clicking one takes it and, after a short ritual, the boss rises a few +# blocks in front of the player +# place where it may be summoned: worlds (as for a variant), water: true to be summoned from water, and max-y +# arena how far from where it was summoned it fights, in blocks. It is taken back if pulled out, and leaves if +# nobody is in it for idle-seconds +# bar the colour of its boss bar: pink, blue, red, green, yellow, purple or white +# phases what changes as its health falls below each percent: affixes and abilities it gains for good, and +# minions it calls at once, announced as mob.kind..phase-1, phase-2 and so on +# loot rolled once for each player who dealt at least contributor-share of its health, with their own Magic +# Find. Its gear drops at the rarities of gear-odds below, mythic among them +# +# A boss is never ranked, never despawns while its fight lasts, glows, and never teleports or goes through a portal. +rules: + # Percent of a boss's health a player must have dealt to earn a share of its loot. + contributor-share: 10 + # A contributor this close to where a boss fell finds their share there, as theirs alone. Further off, it goes + # straight to them. + loot-range: 48 + # A boss with nobody in its arena for this long leaves, and its sigil is gone. + idle-seconds: 120 + # How long a sigil's ritual takes before the boss rises. + ritual-seconds: 3 + # How far past its arena a boss may be pulled before it is taken back. + arena-margin: 8 + # Percent chance each share also holds a finished piece of the boss's tier, at gear-odds. + gear-chance: 25 + # The rarities a boss's gear drops at, weighted. The only way to a mythic piece besides a champion. + gear-odds: + rare: 40 + epic: 35 + legendary: 20 + mythic: 5 + +variants: + # ── The Overworld ───────────────────────────────────────────────────────────────────────────────────────────────── + gravewalker: + base: zombie + spawn: { worlds: [ normal ], levels: [ 3, 14 ], chance: 6 } + health: 1.8 + damage: 1.2 + defense: 40 + scale: 1.05 + knockback: 20 + abilities: [ leap ] + equipment: + head: gear:gravewalker-helmet + chest: gear:gravewalker-chestplate + feet: gear:gravewalker-boots + hand: gear:gravewalker-blade + loot: + - { item: grave-sigil, chance: 1.5 } + - { item: grave-dust, chance: 25, amount: [ 1, 2 ] } + - { gear: gravewalker-blade, chance: 0.5 } + + crypt-ghoul: + base: husk + spawn: { worlds: [ normal ], levels: [ 6, 16 ], chance: 10 } + health: 1.4 + damage: 1.15 + speed: 25 + scale: 0.85 + affixes: [ vampiric ] + abilities: [ leap ] + loot: + - { item: grave-sigil, chance: 1 } + - { item: grave-dust, chance: 30, amount: [ 1, 2 ] } + + bonecaller-sentry: + base: skeleton + spawn: { worlds: [ normal ], levels: [ 6, 16 ], chance: 6 } + health: 1.6 + damage: 1.2 + defense: 30 + abilities: [ volley ] + equipment: + head: gear:bonecaller-helmet + chest: gear:bonecaller-chestplate + hand: gear:bonecaller-bow + loot: + - { item: hollow-marrow, chance: 25, amount: [ 1, 2 ] } + - { item: bone-shard, chance: 30, amount: [ 1, 2 ] } + - { gear: bonecaller-bow, chance: 0.5 } + + frost-revenant: + base: stray + spawn: { worlds: [ normal ], levels: [ 10, 22 ], chance: 10 } + health: 1.8 + damage: 1.25 + defense: 30 + speed: 10 + scale: 1.1 + knockback: 20 + affixes: [ frostbound ] + abilities: [ frost-nova ] + equipment: + hand: gear:frostbite-edge + loot: + - { item: frost-shard, chance: 35, amount: [ 1, 2 ] } + - { gear: frostbite-edge, chance: 0.4 } + + widow-stalker: + base: spider + spawn: { worlds: [ normal ], levels: [ 12, 24 ], chance: 5, min-y: 40 } + health: 2.0 + damage: 1.25 + defense: 20 + speed: 15 + scale: 1.35 + knockback: 30 + abilities: [ ensnare, leap ] + loot: + - { item: brood-sigil, chance: 1.5 } + - { item: silk-strand, chance: 35, amount: [ 1, 3 ] } + - { item: night-membrane, chance: 10 } + + silkweaver: + base: spider + spawn: { worlds: [ normal ], levels: [ 14, 26 ], chance: 8, max-y: 0 } + health: 1.4 + damage: 1.2 + speed: 20 + scale: 0.7 + affixes: [ venomous ] + abilities: [ miasma ] + loot: + - { item: brood-sigil, chance: 1 } + - { item: silk-strand, chance: 30, amount: [ 1, 2 ] } + + hexbinder: + base: witch + spawn: { worlds: [ normal ], biomes: [ swamp, mangrove_swamp ], levels: [ 12, 30 ], chance: 15 } + health: 1.8 + damage: 1.2 + defense: 40 + abilities: [ drain, ensnare ] + loot: + - { item: tide-sigil, chance: 0.5 } + - { item: raider-mark, chance: 30, amount: [ 1, 2 ] } + + tidecaller: + base: drowned + spawn: { worlds: [ normal ], levels: [ 16, 30 ], chance: 8 } + health: 2.0 + damage: 1.25 + defense: 40 + scale: 1.1 + knockback: 20 + abilities: [ hook ] + equipment: + head: gear:tidecaller-helmet + hand: gear:tidecaller-trident + loot: + - { item: tide-sigil, chance: 1.5 } + - { item: abyssal-scale, chance: 25, amount: [ 1, 2 ] } + - { item: tidal-pearl, chance: 30, amount: [ 1, 2 ] } + - { gear: tidecaller-trident, chance: 0.4 } + + # ── The Nether ──────────────────────────────────────────────────────────────────────────────────────────────────── + crimson-warhog: + base: hoglin + spawn: { worlds: [ nether ], levels: [ 18, 36 ], chance: 8 } + health: 2.2 + damage: 1.3 + defense: 60 + speed: 10 + scale: 1.3 + knockback: 60 + abilities: [ charge ] + loot: + - { item: warhog-hide, chance: 25, amount: [ 1, 2 ] } + - { item: gilded-tusk, chance: 35, amount: [ 1, 3 ] } + + pyromancer: + base: blaze + spawn: { worlds: [ nether ], levels: [ 22, 40 ], chance: 8 } + health: 1.8 + damage: 1.3 + defense: 30 + scale: 1.2 + knockback: 30 + affixes: [ molten ] + abilities: [ fireball, meteor ] + loot: + - { item: cinder-sigil, chance: 1 } + - { item: ember-core, chance: 35, amount: [ 1, 2 ] } + + ashen-legionnaire: + base: wither_skeleton + spawn: { worlds: [ nether ], levels: [ 30, 45 ], chance: 8 } + health: 2.2 + damage: 1.3 + defense: 50 + scale: 1.15 + knockback: 40 + affixes: [ armored ] + abilities: [ charge, slam ] + equipment: + head: gear:ashen-helmet + chest: gear:ashen-chestplate + hand: gear:ashen-greatsword + loot: + - { item: cinder-sigil, chance: 1.5 } + - { item: cinder-ash, chance: 35, amount: [ 1, 2 ] } + - { gear: ashen-greatsword, chance: 0.3 } + + # ── The End ─────────────────────────────────────────────────────────────────────────────────────────────────────── + voidstalker: + base: enderman + spawn: { worlds: [ the_end ], levels: [ 30, 60 ], chance: 5 } + health: 2.0 + damage: 1.3 + defense: 40 + scale: 1.05 + knockback: 30 + affixes: [ blinking ] + abilities: [ hook, drain ] + loot: + - { item: void-sigil, chance: 1 } + - { item: null-shard, chance: 25, amount: [ 1, 2 ] } + - { item: void-fragment, chance: 30, amount: [ 1, 2 ] } + - { item: rift-shell, chance: 20 } + + starborn-sentinel: + base: enderman + spawn: { worlds: [ the_end ], levels: [ 48, 60 ], chance: 6 } + health: 2.5 + damage: 1.35 + defense: 60 + scale: 1.2 + knockback: 50 + affixes: [ warded ] + abilities: [ storm, bulwark ] + loot: + - { item: void-sigil, chance: 2 } + - { item: rift-shell, chance: 30, amount: [ 1, 2 ] } + - { item: void-fragment, chance: 30, amount: [ 1, 2 ] } + +bosses: + grave-lord: + base: zombie + level: 12 + sigil: grave-sigil + place: { worlds: [ normal ] } + arena: 24 + bar: red + health: 20 + damage: 3 + defense: 60 + speed: 5 + scale: 1.8 + knockback: 80 + abilities: [ slam, leap, summon ] + minions: gravewalker + phases: + - { below: 60, affixes: [ frenzied ], summon: 3 } + - { below: 25, affixes: [ enraged ], abilities: [ charge ] } + equipment: + head: gear:gravelord-crown + chest: gear:gravewalker-chestplate + legs: gear:gravewalker-leggings + feet: gear:gravewalker-boots + hand: gear:sepulcher-cleaver + loot: + - { item: grave-dust, chance: 100, amount: [ 6, 10 ] } + - { item: bone-shard, chance: 100, amount: [ 3, 6 ] } + - { item: hollow-marrow, chance: 60, amount: [ 1, 3 ] } + - { item: dim-essence, chance: 100, amount: [ 3, 5 ] } + - { gear: gravelord-crown, chance: 12 } + - { gear: sepulcher-cleaver, chance: 12 } + - { item: grave-sigil, chance: 5 } + + broodmother: + base: spider + level: 24 + sigil: brood-sigil + place: { worlds: [ normal ], max-y: 40 } + arena: 20 + bar: green + health: 25 + damage: 4 + defense: 40 + speed: 10 + scale: 2.6 + knockback: 90 + abilities: [ ensnare, miasma, leap, summon ] + minions: silkweaver + phases: + - { below: 50, affixes: [ venomous ], summon: 4 } + - { below: 20, affixes: [ frenzied ] } + loot: + - { item: silk-strand, chance: 100, amount: [ 8, 14 ] } + - { item: night-membrane, chance: 60, amount: [ 2, 4 ] } + - { item: glowing-essence, chance: 100, amount: [ 3, 5 ] } + - { gear: broodfang, chance: 12 } + - { gear: widowmantle, chance: 12 } + - { item: brood-sigil, chance: 5 } + + tide-tyrant: + base: drowned + level: 30 + sigil: tide-sigil + place: { worlds: [ normal ], water: true } + arena: 28 + bar: blue + health: 24 + damage: 3 + defense: 50 + scale: 2.2 + knockback: 90 + abilities: [ hook, storm, frost-nova, summon ] + minions: tidecaller + phases: + - { below: 50, abilities: [ volley ], summon: 3 } + - { below: 25, affixes: [ enraged ] } + equipment: + head: gear:crown-of-tides + chest: gear:tidecaller-chestplate + hand: gear:tyrants-harpoon + loot: + - { item: tidal-pearl, chance: 100, amount: [ 6, 12 ] } + - { item: abyssal-scale, chance: 70, amount: [ 2, 4 ] } + - { item: glowing-essence, chance: 100, amount: [ 4, 6 ] } + - { gear: tyrants-harpoon, chance: 12 } + - { gear: crown-of-tides, chance: 12 } + - { item: tide-sigil, chance: 5 } + + cinder-warlord: + base: wither_skeleton + level: 42 + sigil: cinder-sigil + place: { worlds: [ nether ] } + arena: 24 + bar: yellow + health: 22 + damage: 4.5 + defense: 70 + scale: 1.6 + knockback: 90 + abilities: [ charge, slam, fireball ] + minions: ashen-legionnaire + phases: + - { below: 60, abilities: [ bulwark ], summon: 2 } + - { below: 30, affixes: [ enraged, molten ], abilities: [ meteor ] } + equipment: + head: gear:ashen-helmet + chest: gear:cinder-aegis + legs: gear:ashen-leggings + feet: gear:ashen-boots + hand: gear:warlords-brand + loot: + - { item: cinder-ash, chance: 100, amount: [ 8, 14 ] } + - { item: ember-core, chance: 60, amount: [ 3, 6 ] } + - { item: warhog-hide, chance: 50, amount: [ 1, 3 ] } + - { item: radiant-essence, chance: 100, amount: [ 3, 5 ] } + - { gear: warlords-brand, chance: 10 } + - { gear: cinder-aegis, chance: 10 } + - { item: cinder-sigil, chance: 5 } + + void-herald: + base: enderman + level: 60 + sigil: void-sigil + place: { worlds: [ the_end ] } + arena: 28 + bar: purple + health: 12 + damage: 1.8 + defense: 80 + scale: 1.7 + knockback: 100 + affixes: [ blinking ] + abilities: [ drain, storm, hook, summon ] + minions: voidstalker + phases: + - { below: 66, abilities: [ bulwark ], summon: 3 } + - { below: 33, affixes: [ enraged ], abilities: [ meteor ] } + loot: + - { item: void-fragment, chance: 100, amount: [ 10, 16 ] } + - { item: rift-shell, chance: 100, amount: [ 4, 8 ] } + - { item: null-shard, chance: 70, amount: [ 2, 4 ] } + - { item: astral-essence, chance: 100, amount: [ 3, 6 ] } + - { gear: eclipse-scythe, chance: 8 } + - { gear: herald-crown, chance: 8 } + - { item: void-sigil, chance: 4 } diff --git a/src/main/resources/content/gear.yml b/src/main/resources/content/gear.yml new file mode 100644 index 0000000..3ca7bb8 --- /dev/null +++ b/src/main/resources/content/gear.yml @@ -0,0 +1,551 @@ +# TriTown - Gear +# +# Every piece of gear the Forge crafts and champions drop, and the reforges it can be given. /tritown reload reads this +# file again, and every piece already out there is redrawn the next time someone looks at it. +# +# A piece never lists its stats. It has a slot and a tier, which give it points to spend (balance.yml), and weights, +# which say how it spends them. So two pieces of a slot and tier are always worth the same, however each is shaped. +# +# slot weapon, bow, helmet, chestplate, leggings or boots +# tier 1 to 10: a tier is made for mobs up to six times its level, so tier 5 is for level 25 to 30 +# base the vanilla item it is made of. Between players the piece is exactly that item, enchantments and all +# model optional: a vanilla item whose look it takes instead of its base's +# trim optional, for armor: an armor trim's pattern and material +# dye optional, for leather: a colour such as '#3a5f8c' +# weights how its points are shared between stats. They are scaled to add up, so 60 and 40 mean the same as 3 and 2 +# recipe what the Forge takes to craft it: items from items.yml, and money. A piece with none is signature gear: +# only the custom mobs and bosses whose loot names it (bestiary.yml) ever drop it +# +# The stats are health, defense, damage, strength, crit-chance, crit-damage, speed, vitality and magic-find. A piece's +# name and description are gear.item..name and gear.item..lore in the language files. +# +# An id is lower-case letters, digits and dashes, and must never change once players hold the piece. +gear: + gravewalker-blade: + slot: weapon + tier: 1 + base: iron_sword + weights: { damage: 60, strength: 40 } + recipe: + items: { grave-dust: 12, bone-shard: 6, dim-essence: 1 } + money: 150 + gravewalker-helmet: + slot: helmet + tier: 1 + base: iron_helmet + trim: { pattern: sentry, material: copper } + weights: { health: 50, defense: 50 } + recipe: + items: { grave-dust: 10, dim-essence: 1 } + money: 150 + gravewalker-chestplate: + slot: chestplate + tier: 1 + base: iron_chestplate + trim: { pattern: sentry, material: copper } + weights: { health: 50, defense: 50 } + recipe: + items: { grave-dust: 16, bone-shard: 4, dim-essence: 2 } + money: 250 + gravewalker-leggings: + slot: leggings + tier: 1 + base: iron_leggings + trim: { pattern: sentry, material: copper } + weights: { health: 50, defense: 50 } + recipe: + items: { grave-dust: 14, bone-shard: 4, dim-essence: 2 } + money: 200 + gravewalker-boots: + slot: boots + tier: 1 + base: iron_boots + trim: { pattern: sentry, material: copper } + weights: { health: 40, defense: 40, speed: 20 } + recipe: + items: { grave-dust: 8, dim-essence: 1 } + money: 120 + bonecaller-bow: + slot: bow + tier: 2 + base: bow + weights: { damage: 60, crit-damage: 40 } + recipe: + items: { bone-shard: 20, silk-strand: 8, dim-essence: 2 } + money: 350 + bonecaller-helmet: + slot: helmet + tier: 2 + base: iron_helmet + trim: { pattern: rib, material: quartz } + weights: { health: 45, defense: 45, crit-damage: 10 } + recipe: + items: { bone-shard: 12, hollow-marrow: 3, dim-essence: 1 } + money: 250 + bonecaller-chestplate: + slot: chestplate + tier: 2 + base: iron_chestplate + trim: { pattern: rib, material: quartz } + weights: { health: 50, defense: 50 } + recipe: + items: { bone-shard: 18, hollow-marrow: 6, dim-essence: 2 } + money: 400 + bonecaller-leggings: + slot: leggings + tier: 2 + base: iron_leggings + trim: { pattern: rib, material: quartz } + weights: { health: 50, defense: 50 } + recipe: + items: { bone-shard: 16, hollow-marrow: 5, dim-essence: 2 } + money: 350 + bonecaller-boots: + slot: boots + tier: 2 + base: iron_boots + trim: { pattern: rib, material: quartz } + weights: { health: 40, defense: 40, speed: 20 } + recipe: + items: { bone-shard: 10, hollow-marrow: 3, dim-essence: 1 } + money: 220 + frostbite-edge: + slot: weapon + tier: 3 + base: diamond_sword + weights: { damage: 50, crit-chance: 25, crit-damage: 25 } + recipe: + items: { frost-shard: 16, bone-shard: 10, glowing-essence: 2 } + money: 600 + widowsilk-helmet: + slot: helmet + tier: 3 + base: chainmail_helmet + trim: { pattern: spire, material: quartz } + weights: { health: 40, defense: 40, crit-chance: 20 } + recipe: + items: { silk-strand: 14, night-membrane: 4, glowing-essence: 1 } + money: 450 + widowsilk-chestplate: + slot: chestplate + tier: 3 + base: chainmail_chestplate + trim: { pattern: spire, material: quartz } + weights: { health: 50, defense: 50 } + recipe: + items: { silk-strand: 22, night-membrane: 8, glowing-essence: 2 } + money: 700 + widowsilk-leggings: + slot: leggings + tier: 3 + base: chainmail_leggings + trim: { pattern: spire, material: quartz } + weights: { health: 45, defense: 45, vitality: 10 } + recipe: + items: { silk-strand: 20, night-membrane: 6, glowing-essence: 2 } + money: 600 + widowsilk-boots: + slot: boots + tier: 3 + base: chainmail_boots + trim: { pattern: spire, material: quartz } + weights: { health: 35, defense: 35, speed: 30 } + recipe: + items: { silk-strand: 12, night-membrane: 4, glowing-essence: 1 } + money: 400 + tidecaller-trident: + slot: weapon + tier: 4 + base: trident + weights: { damage: 55, strength: 45 } + recipe: + items: { tidal-pearl: 20, raider-mark: 8, glowing-essence: 3 } + money: 900 + tidecaller-helmet: + slot: helmet + tier: 4 + base: diamond_helmet + trim: { pattern: tide, material: lapis } + weights: { health: 40, defense: 40, vitality: 20 } + recipe: + items: { tidal-pearl: 14, abyssal-scale: 4, glowing-essence: 2 } + money: 800 + tidecaller-chestplate: + slot: chestplate + tier: 4 + base: diamond_chestplate + trim: { pattern: tide, material: lapis } + weights: { health: 50, defense: 50 } + recipe: + items: { tidal-pearl: 22, abyssal-scale: 8, glowing-essence: 3 } + money: 1300 + tidecaller-leggings: + slot: leggings + tier: 4 + base: diamond_leggings + trim: { pattern: tide, material: lapis } + weights: { health: 45, defense: 45, vitality: 10 } + recipe: + items: { tidal-pearl: 20, abyssal-scale: 6, glowing-essence: 3 } + money: 1100 + tidecaller-boots: + slot: boots + tier: 4 + base: diamond_boots + trim: { pattern: tide, material: lapis } + weights: { health: 40, defense: 40, speed: 20 } + recipe: + items: { tidal-pearl: 12, abyssal-scale: 4, glowing-essence: 2 } + money: 700 + emberbrand: + slot: weapon + tier: 5 + base: diamond_sword + weights: { damage: 55, strength: 30, crit-damage: 15 } + recipe: + items: { ember-core: 20, gilded-tusk: 10, glowing-essence: 4 } + money: 1800 + emberforged-helmet: + slot: helmet + tier: 5 + base: diamond_helmet + trim: { pattern: rib, material: gold } + weights: { health: 45, defense: 45, magic-find: 10 } + recipe: + items: { ember-core: 16, gilded-tusk: 6, glowing-essence: 3 } + money: 1500 + emberforged-chestplate: + slot: chestplate + tier: 5 + base: diamond_chestplate + trim: { pattern: rib, material: gold } + weights: { health: 50, defense: 50 } + recipe: + items: { ember-core: 26, gilded-tusk: 10, glowing-essence: 5 } + money: 2500 + emberforged-leggings: + slot: leggings + tier: 5 + base: diamond_leggings + trim: { pattern: rib, material: gold } + weights: { health: 50, defense: 50 } + recipe: + items: { ember-core: 22, gilded-tusk: 8, glowing-essence: 4 } + money: 2200 + emberforged-boots: + slot: boots + tier: 5 + base: diamond_boots + trim: { pattern: rib, material: gold } + weights: { health: 40, defense: 40, speed: 20 } + recipe: + items: { ember-core: 14, gilded-tusk: 6, glowing-essence: 3 } + money: 1300 + wailing-arbalest: + slot: bow + tier: 6 + base: crossbow + weights: { damage: 55, crit-damage: 45 } + recipe: + items: { wailing-tear: 16, gilded-tusk: 12, radiant-essence: 2 } + money: 3000 + warhide-helmet: + slot: helmet + tier: 6 + base: diamond_helmet + trim: { pattern: snout, material: netherite } + weights: { health: 45, defense: 45, strength: 10 } + recipe: + items: { gilded-tusk: 16, warhog-hide: 5, radiant-essence: 2 } + money: 2800 + warhide-chestplate: + slot: chestplate + tier: 6 + base: diamond_chestplate + trim: { pattern: snout, material: netherite } + weights: { health: 50, defense: 50 } + recipe: + items: { gilded-tusk: 24, warhog-hide: 9, radiant-essence: 3 } + money: 4500 + warhide-leggings: + slot: leggings + tier: 6 + base: diamond_leggings + trim: { pattern: snout, material: netherite } + weights: { health: 50, defense: 50 } + recipe: + items: { gilded-tusk: 20, warhog-hide: 7, radiant-essence: 3 } + money: 3800 + warhide-boots: + slot: boots + tier: 6 + base: diamond_boots + trim: { pattern: snout, material: netherite } + weights: { health: 40, defense: 40, speed: 20 } + recipe: + items: { gilded-tusk: 14, warhog-hide: 5, radiant-essence: 2 } + money: 2500 + ashen-greatsword: + slot: weapon + tier: 7 + base: netherite_sword + weights: { damage: 60, strength: 40 } + recipe: + items: { cinder-ash: 24, wailing-tear: 8, radiant-essence: 4 } + money: 6000 + ashen-helmet: + slot: helmet + tier: 7 + base: netherite_helmet + trim: { pattern: ward, material: redstone } + weights: { health: 45, defense: 45, crit-damage: 10 } + recipe: + items: { cinder-ash: 18, wailing-tear: 6, radiant-essence: 3 } + money: 4500 + ashen-chestplate: + slot: chestplate + tier: 7 + base: netherite_chestplate + trim: { pattern: ward, material: redstone } + weights: { health: 50, defense: 50 } + recipe: + items: { cinder-ash: 30, wailing-tear: 10, radiant-essence: 5 } + money: 7000 + ashen-leggings: + slot: leggings + tier: 7 + base: netherite_leggings + trim: { pattern: ward, material: redstone } + weights: { health: 50, defense: 50 } + recipe: + items: { cinder-ash: 26, wailing-tear: 8, radiant-essence: 4 } + money: 6000 + ashen-boots: + slot: boots + tier: 7 + base: netherite_boots + trim: { pattern: ward, material: redstone } + weights: { health: 40, defense: 40, speed: 20 } + recipe: + items: { cinder-ash: 16, wailing-tear: 6, radiant-essence: 3 } + money: 4000 + riftpiercer: + slot: bow + tier: 8 + base: bow + weights: { damage: 50, crit-chance: 20, crit-damage: 30 } + recipe: + items: { rift-shell: 16, void-fragment: 16, astral-essence: 2 } + money: 10000 + riftwalker-helmet: + slot: helmet + tier: 8 + base: netherite_helmet + trim: { pattern: vex, material: emerald } + weights: { health: 45, defense: 45, crit-chance: 10 } + recipe: + items: { void-fragment: 16, null-shard: 5, rift-shell: 4, astral-essence: 2 } + money: 9000 + riftwalker-chestplate: + slot: chestplate + tier: 8 + base: netherite_chestplate + trim: { pattern: vex, material: emerald } + weights: { health: 50, defense: 50 } + recipe: + items: { void-fragment: 26, null-shard: 9, rift-shell: 6, astral-essence: 3 } + money: 14000 + riftwalker-leggings: + slot: leggings + tier: 8 + base: netherite_leggings + trim: { pattern: vex, material: emerald } + weights: { health: 45, defense: 45, vitality: 10 } + recipe: + items: { void-fragment: 22, null-shard: 7, rift-shell: 5, astral-essence: 3 } + money: 12000 + riftwalker-boots: + slot: boots + tier: 8 + base: netherite_boots + trim: { pattern: vex, material: emerald } + weights: { health: 35, defense: 35, speed: 30 } + recipe: + items: { void-fragment: 14, null-shard: 5, rift-shell: 4, astral-essence: 2 } + money: 8000 + voidreaver: + slot: weapon + tier: 9 + base: netherite_axe + weights: { damage: 55, strength: 45 } + recipe: + items: { void-fragment: 30, rift-shell: 10, astral-essence: 4 } + money: 20000 + voidstride-helmet: + slot: helmet + tier: 9 + base: netherite_helmet + trim: { pattern: eye, material: amethyst } + weights: { health: 45, defense: 45, magic-find: 10 } + recipe: + items: { void-fragment: 20, rift-shell: 8, astral-essence: 3 } + money: 15000 + voidstride-chestplate: + slot: chestplate + tier: 9 + base: netherite_chestplate + trim: { pattern: eye, material: amethyst } + weights: { health: 50, defense: 50 } + recipe: + items: { void-fragment: 34, rift-shell: 12, astral-essence: 5 } + money: 25000 + voidstride-leggings: + slot: leggings + tier: 9 + base: netherite_leggings + trim: { pattern: eye, material: amethyst } + weights: { health: 45, defense: 45, vitality: 10 } + recipe: + items: { void-fragment: 30, rift-shell: 10, astral-essence: 4 } + money: 22000 + voidstride-boots: + slot: boots + tier: 9 + base: netherite_boots + trim: { pattern: eye, material: amethyst } + weights: { health: 35, defense: 35, speed: 30 } + recipe: + items: { void-fragment: 18, rift-shell: 8, astral-essence: 3 } + money: 14000 + starfall-blade: + slot: weapon + tier: 10 + base: netherite_sword + weights: { damage: 55, strength: 35, crit-damage: 10 } + recipe: + items: { void-fragment: 40, rift-shell: 20, astral-essence: 8 } + money: 60000 + starfall-helmet: + slot: helmet + tier: 10 + base: netherite_helmet + trim: { pattern: silence, material: diamond } + weights: { health: 50, defense: 50 } + recipe: + items: { void-fragment: 30, rift-shell: 14, astral-essence: 6 } + money: 40000 + starfall-chestplate: + slot: chestplate + tier: 10 + base: netherite_chestplate + trim: { pattern: silence, material: diamond } + weights: { health: 50, defense: 50 } + recipe: + items: { void-fragment: 48, rift-shell: 20, astral-essence: 10 } + money: 60000 + starfall-leggings: + slot: leggings + tier: 10 + base: netherite_leggings + trim: { pattern: silence, material: diamond } + weights: { health: 50, defense: 50 } + recipe: + items: { void-fragment: 42, rift-shell: 18, astral-essence: 8 } + money: 55000 + starfall-boots: + slot: boots + tier: 10 + base: netherite_boots + trim: { pattern: silence, material: diamond } + weights: { health: 40, defense: 40, speed: 20 } + recipe: + items: { void-fragment: 26, rift-shell: 12, astral-essence: 6 } + money: 38000 + + # Signature gear: no recipe, and only a boss drops it (bestiary.yml). The Forge still upgrades, refines, reforges + # and salvages it like any other piece. + gravelord-crown: + slot: helmet + tier: 2 + base: golden_helmet + weights: { health: 35, defense: 35, magic-find: 30 } + sepulcher-cleaver: + slot: weapon + tier: 2 + base: iron_axe + weights: { damage: 50, strength: 50 } + broodfang: + slot: weapon + tier: 4 + base: diamond_sword + weights: { damage: 30, crit-chance: 35, crit-damage: 35 } + widowmantle: + slot: chestplate + tier: 4 + base: chainmail_chestplate + trim: { pattern: silence, material: amethyst } + weights: { health: 40, defense: 40, speed: 20 } + tyrants-harpoon: + slot: weapon + tier: 5 + base: trident + weights: { damage: 45, strength: 35, crit-damage: 20 } + crown-of-tides: + slot: helmet + tier: 5 + base: turtle_helmet + weights: { health: 40, defense: 30, vitality: 30 } + warlords-brand: + slot: weapon + tier: 7 + base: netherite_sword + weights: { damage: 50, strength: 30, crit-damage: 20 } + cinder-aegis: + slot: chestplate + tier: 7 + base: netherite_chestplate + trim: { pattern: rib, material: redstone } + weights: { health: 50, defense: 40, vitality: 10 } + eclipse-scythe: + slot: weapon + tier: 10 + base: netherite_hoe + weights: { damage: 50, strength: 30, crit-damage: 20 } + herald-crown: + slot: helmet + tier: 10 + base: netherite_helmet + trim: { pattern: eye, material: diamond } + weights: { health: 40, defense: 40, magic-find: 20 } + +# A reforge is a small named extra the Forge can give a piece: worth reforge-share percent of its points (balance.yml), +# spent by the reforge's own weights, on the slots it fits. Its name goes before the piece's, and is +# gear.reforge. in the language files. +reforges: + sharp: + slots: [ weapon, bow ] + weights: { crit-chance: 50, crit-damage: 50 } + heroic: + slots: [ weapon ] + weights: { strength: 70, damage: 30 } + fierce: + slots: [ weapon, bow ] + weights: { strength: 50, crit-damage: 50 } + deadly: + slots: [ bow ] + weights: { damage: 60, crit-chance: 40 } + titanic: + slots: [ helmet, chestplate, leggings, boots ] + weights: { health: 70, defense: 30 } + sturdy: + slots: [ helmet, chestplate, leggings, boots ] + weights: { defense: 100 } + hardy: + slots: [ helmet, chestplate, leggings, boots ] + weights: { vitality: 50, health: 50 } + swift: + slots: [ leggings, boots ] + weights: { speed: 100 } + lucky: + slots: [ weapon, bow, helmet ] + weights: { magic-find: 100 } diff --git a/src/main/resources/content/items.yml b/src/main/resources/content/items.yml new file mode 100644 index 0000000..3a903c8 --- /dev/null +++ b/src/main/resources/content/items.yml @@ -0,0 +1,113 @@ +# TriTown - Items +# +# The materials mobs drop, and the essence ranked mobs drop besides. /tritown reload reads this file again. mobs.yml +# says which mob drops what. +# +# Each item looks like the vanilla item its model names, but is none of them: it cannot be crafted with, placed, eaten +# or used. Its name and description are item..name and item..lore in the language files, and its name takes the +# colour of its rarity: common, uncommon, rare, epic, legendary or mythic. glint makes it shimmer like an enchanted item. +# +# An id is lower-case letters, digits and dashes, and must never change once players hold the item: it is what the item +# is. Take an item out and the copies players hold stay as they are, but nothing drops or uses it any more. +items: + # From Overworld mobs. + grave-dust: + model: gunpowder + rarity: common + bone-shard: + model: bone_meal + rarity: common + silk-strand: + model: string + rarity: common + volatile-powder: + model: redstone + rarity: uncommon + frost-shard: + model: prismarine_shard + rarity: uncommon + tidal-pearl: + model: prismarine_crystals + rarity: uncommon + night-membrane: + model: phantom_membrane + rarity: uncommon + raider-mark: + model: flint + rarity: uncommon + + # From Nether mobs. + gilded-tusk: + model: gold_nugget + rarity: rare + ember-core: + model: fire_charge + rarity: rare + wailing-tear: + model: ghast_tear + rarity: rare + cinder-ash: + model: black_dye + rarity: rare + + # From End mobs. + void-fragment: + model: ender_pearl + rarity: epic + rift-shell: + model: shulker_shell + rarity: epic + + # Trophies, from the server's own mobs (bestiary.yml). + hollow-marrow: + model: bone + rarity: uncommon + abyssal-scale: + model: turtle_scute + rarity: rare + warhog-hide: + model: leather + rarity: rare + null-shard: + model: echo_shard + rarity: epic + + # Sigils, which summon the bosses of bestiary.yml. The custom mobs of each boss's region drop them. + grave-sigil: + model: skeleton_skull + rarity: rare + glint: true + brood-sigil: + model: fermented_spider_eye + rarity: rare + glint: true + tide-sigil: + model: heart_of_the_sea + rarity: epic + glint: true + cinder-sigil: + model: wither_skeleton_skull + rarity: epic + glint: true + void-sigil: + model: ender_eye + rarity: legendary + glint: true + + # Essence, from elites and champions. Its grade follows the mob's level. + dim-essence: + model: glowstone_dust + rarity: uncommon + glint: true + glowing-essence: + model: glow_ink_sac + rarity: rare + glint: true + radiant-essence: + model: amethyst_shard + rarity: epic + glint: true + astral-essence: + model: nether_star + rarity: legendary + glint: true diff --git a/src/main/resources/content/mobs.yml b/src/main/resources/content/mobs.yml new file mode 100644 index 0000000..70bddf9 --- /dev/null +++ b/src/main/resources/content/mobs.yml @@ -0,0 +1,89 @@ +# TriTown - Mobs +# +# What wild mobs drop on top of vanilla's loot. /tritown reload reads this file again. The items are defined in +# items.yml. +# +# Only a mob that spawned in the wild drops any of this — never one from a spawner, an egg or a command — and only +# when a player killed it and players dealt at least player-share percent of its health between them, so a trap, a +# lava pit or another mob doing the work earns nothing. A tamed pet's damage counts for its owner. What drops is the +# killer's, like the rest of the mob's loot. + +# Kinds of mob that drop the same material, by their Minecraft names. A kind may be in one family only; one in none +# drops no material, though a ranked one still drops essence. +families: + zombie: + mobs: [ ZOMBIE, HUSK, ZOMBIE_VILLAGER ] + material: grave-dust + skeleton: + mobs: [ SKELETON, BOGGED ] + material: bone-shard + spider: + mobs: [ SPIDER, CAVE_SPIDER ] + material: silk-strand + creeper: + mobs: [ CREEPER ] + material: volatile-powder + stray: + mobs: [ STRAY ] + material: frost-shard + drowned: + mobs: [ DROWNED, GUARDIAN ] + material: tidal-pearl + phantom: + mobs: [ PHANTOM ] + material: night-membrane + raider: + mobs: [ PILLAGER, VINDICATOR, EVOKER, RAVAGER, WITCH ] + material: raider-mark + piglin: + mobs: [ PIGLIN, PIGLIN_BRUTE, ZOMBIFIED_PIGLIN, HOGLIN, ZOGLIN ] + material: gilded-tusk + blaze: + mobs: [ BLAZE, MAGMA_CUBE ] + material: ember-core + ghast: + mobs: [ GHAST ] + material: wailing-tear + wither-skeleton: + mobs: [ WITHER_SKELETON ] + material: cinder-ash + enderman: + mobs: [ ENDERMAN, ENDERMITE ] + material: void-fragment + shulker: + mobs: [ SHULKER ] + material: rift-shell + +loot: + # The lowest level a mob drops anything extra at. + min-level: 3 + # Percent of a mob's health players must have dealt between them. + player-share: 50 + # Magic Find makes the chances below that fall short of certain likelier — a normal mob's material, a champion's + # gear — by its value in percent, up to this much. + magic-find-cap: 100 + # For each rank: the percent chance of the family's material, how many drop ([fewest, most]), how much essence, and + # the percent chance of a finished piece of gear of the tier the mob's level is made for. + normal: + chance: 4 + amount: [ 1, 1 ] + elite: + chance: 100 + amount: [ 1, 3 ] + essence: [ 1, 2 ] + champion: + chance: 100 + amount: [ 3, 6 ] + essence: [ 3, 5 ] + gear-chance: 1 + +# Which essence a ranked mob drops: the first grade whose max-level its level is within. +essence: + - item: dim-essence + max-level: 15 + - item: glowing-essence + max-level: 30 + - item: radiant-essence + max-level: 45 + - item: astral-essence + max-level: 60 diff --git a/src/main/resources/lang/en_US.yml b/src/main/resources/lang/en_US.yml index 049072c..7c39ee3 100644 --- a/src/main/resources/lang/en_US.yml +++ b/src/main/resources/lang/en_US.yml @@ -23,6 +23,9 @@ command: news: "News" storage: "Storage" protection: "Protection" + adventure: "Adventure" + mobs: "Mobs" + items: "Items" help: header: "TriTown Commands" @@ -145,6 +148,48 @@ command: owner: "That belongs to {name}." released: "Released {name}'s claim. Anyone can use it now." + stats: + description: "See your combat stats" + players-only: "Stats are shown in a menu, so only players can open them." + unavailable: "Combat stats are switched off in this world." + not-found: "{name} is not online." + + mob: + description: "Inspect mob levels and the combat balance" + usage: "Usage: /tritown mob [level]>" + players-only: "Stand somewhere in game to read its level." + inactive: "Mobs are not levelled in this world." + level: "Mobs spawning here are level {level}." + level-town: " · Town claims are always level 1." + level-hub: " · This is the spawn area, which stays at its base level." + level-ring: " · Ring {ring} out from spawn." + level-night: " · It is night, which adds to it." + level-deep: " · Below Y 0, which adds to it." + balance-header: "A zombie at each level, against a full kit of rare ★★★ gear of the tier made for it:" + balance-row: "Lv {level} (tier {tier}): {health}❤, hits for {hit} · dies in {hits} swings · each hit takes {percent}% of health" + kinds: "{amount} custom mobs: {ids}" + unknown-kind: "There is no custom mob {id}. /tritown mob kinds names them all." + spawned: "Called up a {name} at level {level}. It drops nothing beyond vanilla's loot." + bosses-header: "Each boss at its level, against one player in a full kit of rare ★★★ gear of its tier:" + bosses-row: "{name} (Lv {level}, tier {tier}): {health}❤, hits for {hit} · alone, {hits} swings · each hit takes {percent}% of health" + + item: + description: "Give out the server's own items" + usage: "Usage: /tritown item [amount]|list>" + not-found: "{name} is not online." + unknown: "There is no item {id}. /tritown item list names them all." + given: "Gave {amount}x {id} to {name}." + list: "{amount} items: {ids}" + + bestiary: + description: "See the server's own mobs you have met" + players-only: "The bestiary is a menu, so only players can open it." + + forge: + description: "Craft and improve gear" + players-only: "The Forge is a menu, so only players can open it." + unavailable: "The Forge is closed in this world." + trades: description: "Open the server's global shop" players-only: "A shop is a menu, so only players can open it." @@ -297,12 +342,17 @@ money: shop-sell: "Sold at {shop}" trade: "Player trade" storage-page: "Storage page {page}" + gear-craft: "Forged {item}" + gear-upgrade: "Upgraded {item}" + gear-refine: "Refined {item}" + gear-reforge: "Reforged {item}" # Where money comes from and where it goes, as the admin panel groups it. flow: starting-balance: "New players" shop: "Shops" storage: "Storage pages" + gear: "The Forge" towny: "Towny" admin: "Administration" payment: "Player payments" @@ -316,6 +366,7 @@ money: towny: "Towny" shop: "Shop" storage: "Storage" + gear: "The Forge" account-type: player: "player" @@ -350,6 +401,12 @@ gui: profile-rank: "Leaderboard: #{rank}" profile-town: "Town: {town}" profile-nation: "Nation: {nation}" + profile-stats: "Click to see your combat stats" + forge: "Forge" + forge-lore: "Craft gear from what mobs drop, and make it stronger." + bestiary: "Bestiary" + bestiary-lore: "Every one of the server's own mobs you have slain, and what each gave you." + bestiary-found: "Found: {found}/{total}" town: "Town" town-name: "Town: {town}" town-residents: "Residents: {amount}" @@ -597,6 +654,7 @@ gui: about-starting-balance: "Paid once to each new player." about-shop: "The server's own shops selling and buying back." about-storage: "Players buying more pages of storage." + about-gear: "Players crafting, upgrading, refining and reforging gear at the Forge." about-towny: "Upkeep, taxes, plot sales and bank movements." about-admin: "Balances given, taken, set or reset by an administrator." about-payment: "Players paying each other." @@ -1115,7 +1173,87 @@ gui: cancel: "Cancel" cancel-lore: "Go back without changing anything." -# Messages a shop sends to a player, outside the menus themselves. + # Messages a shop sends to a player, outside the menus themselves. + stats: + title: "Your Stats" + title-other: "{name}'s Stats" + profile: "Your Stats" + profile-other: "{name}" + profile-health: "❤ {health}/{max}" + profile-pvp: "Stats only count against mobs. Players fight each other as in vanilla." + stat-name: "{stat} {value}" + source-line: "• {source}: {value}" + defense-reduction: "Mobs' hits on you are {percent}% weaker." + about: + health: "How much a mob has to take off you. It is drawn over your hearts, which are always as full as it is." + defense: "Takes a share off every hit a mob lands. 100 Defense halves it." + damage: "What your weapon adds to every hit on a mob." + strength: "Adds its value in percent to every hit on a mob." + crit-chance: "The chance that a hit on a mob is critical. Jump attacks add to it." + crit-damage: "How much more a critical hit does." + speed: "Moves you faster. It is the one stat players feel too, so the server caps it." + vitality: "Makes natural regeneration and healing potions give back more." + magic-find: "Makes a mob likelier to drop its material. Looting adds to it." + source: + base: "Base" + armor: "Armor" + weapon: "Weapon" + effects: "Effects" + + bestiary: + title: "Bestiary" + variant: "{name}" + boss: "☠ {name}" + lore: "{lore}" + unknown: "???" + unknown-lore: "You have not slain this one yet." + lives: "Lives in {worlds}, from level {min} to {max}." + summoned: "A level {level} boss, summoned with {sigil}." + abilities: "Abilities: {abilities}" + affixes: "Affixes: {affixes}" + kills: "Slain: {amount} {stars}" + drops: "Its own loot you have found: {found}/{total}" + drop: " · {name}" + progress: "Found {found} of {total}" + progress-lore: "Slay one of each to fill in its page. A star marks every 10, 100 and 1,000 slain." + world: + normal: "the Overworld" + nether: "the Nether" + end: "the End" + any: "any world" + + forge: + title: "The Forge" + craft: "Craft" + craft-lore: "Make new gear from what mobs drop." + upgrade: "Upgrade" + upgrade-lore: "Adds a star: +{bonus}% to its stats. ({stars}/{max})" + refine: "Refine" + refine-lore: "Raises its rarity from {from} to {to}." + reforge: "Reforge" + reforge-lore: "Gives it a new reforge, picked at random. Now: {reforge}" + salvage: "Salvage" + salvage-lore: "Breaks it down for good into:" + salvage-item: "• {amount}x {name}" + click-act: "Click to do it" + empty: "Nothing in hand" + empty-lore: "Hold a piece of gear in your main hand to upgrade, refine, reforge or salvage it." + cost-item: "✔ {have}/{need} {name}" + cost-item-missing: "✘ {have}/{need} {name}" + cost-money: "✔ {amount}" + cost-money-missing: "✘ {amount}" + confirm: "Confirm" + upgrade-confirm: "Upgrade this piece?" + refine-confirm: "Refine this piece?" + reforge-confirm: "Reforge this piece?" + salvage-confirm: "Salvage this piece for good?" + + forge-recipes: + title: "The Forge: Craft" + tier: "Tier {tier}" + click: "Click to forge it" + confirm: "Forge this piece?" + shop: traded: "Traded {amount}x {item} for {price}." traded-items: "Traded {amount}x {item}." @@ -1203,6 +1341,478 @@ trade: shutdown: "The server is stopping. The trade was called off and everything you put up is back." +# Combat. Each stat is named with the symbol and colour it is shown in everywhere. +combat: + stat: + health: "❤ Health" + defense: "❈ Defense" + damage: "⚔ Damage" + strength: "❁ Strength" + crit-chance: "☣ Crit Chance" + crit-damage: "☠ Crit Damage" + speed: "✦ Speed" + vitality: "♨ Vitality" + magic-find: "✯ Magic Find" + + hud: + bar: "❤ {health}/{max} ❈ {defense}" + + # Floats up from a mob that was hit. Everyone nearby sees the same one, so it is in the server's language. + indicator: + hit: "{amount}" + crit: "✧{amount}✧" + +mob: + # A levelled mob's name. Everyone sees the same one, so it is in the server's language. {name} is the kind of mob, + # which each player's client writes in its own language. {badge} is the rank's, and {affixes} lists each affix as + # affix-name writes it. + nameplate: "[Lv{level}] {badge}{affixes}{name} {health}/{max}❤" + affix-name: "{name} " + # A custom mob's own name, in place of its kind's. + kind-name: "<#f0c060>{name}" + + rank: + elite: "★ " + champion: "★★ " + boss: "☠ " + + affix: + armored: "Armored" + frenzied: "Frenzied" + vampiric: "Vampiric" + enraged: "Enraged" + molten: "Molten" + frostbound: "Frostbound" + venomous: "Venomous" + volatile: "Volatile" + summoner: "Summoner" + blinking: "Blinking" + warded: "Warded" + + # What a custom mob can do, as the bestiary lists it. + ability: + leap: "Leap" + slam: "Slam" + charge: "Charge" + volley: "Volley" + fireball: "Fireball" + meteor: "Meteor" + storm: "Storm" + ensnare: "Ensnare" + hook: "Hook" + summon: "Summon" + bulwark: "Bulwark" + frost-nova: "Frost Nova" + miasma: "Miasma" + drain: "Drain" + + # The custom mobs in plugins/TriTown/content/bestiary.yml, by id. The name is on the mob's nameplate, in the server's + # language like the rest of it; the description is what the bestiary says of it. + kind: + gravewalker: + name: "Gravewalker" + lore: "It climbed out in the armor it was buried in." + crypt-ghoul: + name: "Crypt Ghoul" + lore: "It feeds on whatever the desert leaves behind." + bonecaller-sentry: + name: "Bonecaller Sentry" + lore: "It still keeps watch for a lord long dead." + frost-revenant: + name: "Frost Revenant" + lore: "It carries the cold of the grave into every fight." + widow-stalker: + name: "Widow Stalker" + lore: "It was watching you long before you saw it." + silkweaver: + name: "Silkweaver" + lore: "Small, quick, and never alone for long." + hexbinder: + name: "Hexbinder" + lore: "Her brews are the least of your worries." + tidecaller: + name: "Tidecaller" + lore: "When it calls, the sea answers." + crimson-warhog: + name: "Crimson Warhog" + lore: "The oldest and angriest of the crimson forest." + pyromancer: + name: "Pyromancer" + lore: "A blaze that learned to aim." + ashen-legionnaire: + name: "Ashen Legionnaire" + lore: "One of the fortress's last loyal soldiers." + voidstalker: + name: "Voidstalker" + lore: "Look away, and it is already behind you." + starborn-sentinel: + name: "Starborn Sentinel" + lore: "It guards the edge of the world. From you." + grave-lord: + name: "Grave Lord" + lore: "Master of every grave between here and the horizon." + phase-1: "{name} calls the dead to rise!" + phase-2: "{name} is consumed by fury!" + broodmother: + name: "Broodmother" + lore: "The mother of every web in the world." + phase-1: "{name} spills its brood!" + phase-2: "{name} skitters into a frenzy!" + tide-tyrant: + name: "Tide Tyrant" + lore: "The cruellest king the sea ever had." + phase-1: "{name} raises the tide!" + phase-2: "{name} rages like a storm at sea!" + cinder-warlord: + name: "Cinder Warlord" + lore: "The last general of the fortresses." + phase-1: "{name} calls its legion and raises its guard!" + phase-2: "{name} bursts into flame!" + void-herald: + name: "Void Herald" + lore: "It speaks for the nothing beyond the stars." + phase-1: "{name} tears the void open!" + phase-2: "{name} brings the sky down!" + + + +# A boss's fight, from its summoning to its fall. {name} is boss.name. +boss: + name: "☠ {name}" + # The boss bar each player near its arena sees, in their own language. + bar: "☠ {name} · Lv{level}" + subtitle: "Level {level}" + ritual: "The sigil flares, and {name} stirs…" + summoned: "{player} has summoned {name}!" + defeated: "{name} has fallen to {players}!" + defeated-alone: "{name} has fallen." + departed: "{name} tires of waiting, and leaves." + reward: "Your share of the spoils: {items}" + reward-empty: "Your share of the spoils came to nothing this time." + no-share: "You did not deal enough of the damage to earn a share of the spoils." + refused: + disabled: "Bosses cannot be summoned on this server." + inactive: "Nothing answers a sigil in this world." + town: "{name} will not be summoned inside a town." + place: "{name} cannot be summoned here. Its sigil says where." + crowded: "Another boss is too close to summon {name} here." + room: "There is no room here for {name}." + +# The items in plugins/TriTown/content/items.yml, by id. A name takes the colour of the item's rarity, so +# names carry no colour of their own. Keep each lore line short: it is one line of the item's tooltip. +item: + grave-dust: + name: "Grave Dust" + lore: "Scraped off the walking dead." + bone-shard: + name: "Bone Shard" + lore: "Splintered from a skeleton's frame." + silk-strand: + name: "Silk Strand" + lore: "Stronger than any web a spider leaves." + volatile-powder: + name: "Volatile Powder" + lore: "Still warm. Handle gently." + frost-shard: + name: "Frost Shard" + lore: "It never melts, even in your hand." + tidal-pearl: + name: "Tidal Pearl" + lore: "The sea's weight, held in a bead." + night-membrane: + name: "Night Membrane" + lore: "Torn from what hunts the sleepless." + raider-mark: + name: "Raider's Mark" + lore: "Carried by those who ride against villages." + gilded-tusk: + name: "Gilded Tusk" + lore: "Gold, and something that was proud of it." + ember-core: + name: "Ember Core" + lore: "A blaze's heart, still burning." + wailing-tear: + name: "Wailing Tear" + lore: "It still sobs when held to the ear." + cinder-ash: + name: "Cinder Ash" + lore: "What is left of a wither skeleton's bones." + void-fragment: + name: "Void Fragment" + lore: "A piece of the nothing between the stars." + rift-shell: + name: "Rift Shell" + lore: "It opens onto somewhere else." + hollow-marrow: + name: "Hollow Marrow" + lore: "The last of a sentry's strength, still humming." + abyssal-scale: + name: "Abyssal Scale" + lore: "Shed by something that lives far deeper than you." + warhog-hide: + name: "Warhog Hide" + lore: "Tough enough to turn a sword. Mostly." + null-shard: + name: "Null Shard" + lore: "It weighs nothing, and takes the light with it." + grave-sigil: + name: "Grave Sigil" + lore: "Raise it in the Overworld's wilds to summon the Grave Lord." + brood-sigil: + name: "Brood Sigil" + lore: "Raise it below Y 40 to summon the Broodmother." + tide-sigil: + name: "Tide Sigil" + lore: "Raise it in the Overworld's water to summon the Tide Tyrant." + cinder-sigil: + name: "Cinder Sigil" + lore: "Raise it in the Nether to summon the Cinder Warlord." + void-sigil: + name: "Void Sigil" + lore: "Raise it in the End to summon the Void Herald." + dim-essence: + name: "Dim Essence" + lore: "Power drawn from the strong." + glowing-essence: + name: "Glowing Essence" + lore: "Power drawn from the stronger." + radiant-essence: + name: "Radiant Essence" + lore: "Power drawn from the strongest." + astral-essence: + name: "Astral Essence" + lore: "Power from beyond the edge of the world." + +# The label at the foot of an item's tooltip. +rarity: + common: "COMMON" + uncommon: "UNCOMMON" + rare: "RARE" + epic: "EPIC" + legendary: "LEGENDARY" + mythic: "MYTHIC" + + +# What the Forge tells a player once it has done something, or why it could not. +forge: + crafted: "You forged {name}." + upgraded: "{name} now has {stars}★." + refined: "{name} is now {rarity}." + reforged: "It is now {name}." + salvaged: "You broke {name} down into {items}." + error: + not-gear: "Hold a piece of gear in your main hand." + maxed: "It can go no further." + missing-items: "You are missing some of what this takes." + cannot-afford: "You cannot afford that." + unavailable: "The Forge cannot do that right now." + +# The gear in plugins/TriTown/content/gear.yml, by id. A name takes the colour of the piece's rarity, so names +# carry no colour of their own. Keep each lore line short: it is one line of the piece's tooltip. +gear: + # Where it goes, after its rarity at the foot of its tooltip. + slot: + weapon: "WEAPON" + bow: "BOW" + helmet: "HELMET" + chestplate: "CHESTPLATE" + leggings: "LEGGINGS" + boots: "BOOTS" + # Followed by the vanilla item the piece counts as between players. + pvp: "Against players:" + reforge: + sharp: "Sharp" + heroic: "Heroic" + fierce: "Fierce" + deadly: "Deadly" + titanic: "Titanic" + sturdy: "Sturdy" + hardy: "Hardy" + swift: "Swift" + lucky: "Lucky" + item: + gravewalker-blade: + name: "Gravewalker Blade" + lore: "Sharpened on a headstone." + gravewalker-helmet: + name: "Gravewalker Helm" + lore: "Worn by those who dig where others lie." + gravewalker-chestplate: + name: "Gravewalker Plate" + lore: "Heavy with the soil of a hundred graves." + gravewalker-leggings: + name: "Gravewalker Greaves" + lore: "They never tire of walking." + gravewalker-boots: + name: "Gravewalker Boots" + lore: "Quiet on the stairs of any crypt." + bonecaller-bow: + name: "Bonecaller Bow" + lore: "Its string hums like a rib cage in the wind." + bonecaller-helmet: + name: "Bonecaller Helm" + lore: "It rattles when the dead are near." + bonecaller-chestplate: + name: "Bonecaller Hauberk" + lore: "Ribbed like the last thing that wore it." + bonecaller-leggings: + name: "Bonecaller Greaves" + lore: "Joints that never tire." + bonecaller-boots: + name: "Bonecaller Boots" + lore: "They leave no footprints, only dust." + frostbite-edge: + name: "Frostbite Edge" + lore: "Cold enough to burn." + widowsilk-helmet: + name: "Widowsilk Hood" + lore: "Woven in the dark, for the dark." + widowsilk-chestplate: + name: "Widowsilk Vest" + lore: "Light enough to forget you wear it." + widowsilk-leggings: + name: "Widowsilk Leggings" + lore: "Every thread was once a trap." + widowsilk-boots: + name: "Widowsilk Boots" + lore: "Soft as a spider's step." + tidecaller-trident: + name: "Tidecaller Trident" + lore: "It remembers every wave it ever parted." + tidecaller-helmet: + name: "Tidecaller Helm" + lore: "The sea still roars inside it." + tidecaller-chestplate: + name: "Tidecaller Mail" + lore: "Scales that never rust." + tidecaller-leggings: + name: "Tidecaller Greaves" + lore: "Made for wading, not for running." + tidecaller-boots: + name: "Tidecaller Boots" + lore: "Always a little damp." + emberbrand: + name: "Emberbrand" + lore: "Its edge glows long after the fight." + emberforged-helmet: + name: "Emberforged Helm" + lore: "Still warm from the forge that made it." + emberforged-chestplate: + name: "Emberforged Cuirass" + lore: "Hammered on the anvil of a fortress." + emberforged-leggings: + name: "Emberforged Greaves" + lore: "Sparks fly with every stride." + emberforged-boots: + name: "Emberforged Sabatons" + lore: "They leave scorched footprints." + wailing-arbalest: + name: "Wailing Arbalest" + lore: "Each bolt leaves with a scream." + warhide-helmet: + name: "Warhide Helm" + lore: "Tusks included." + warhide-chestplate: + name: "Warhide Cuirass" + lore: "Stitched from a hide that did not want to be taken." + warhide-leggings: + name: "Warhide Leggings" + lore: "The crimson forest still clings to them." + warhide-boots: + name: "Warhide Stompers" + lore: "The ground knows when you walk." + ashen-greatsword: + name: "Ashen Greatsword" + lore: "Too heavy for anyone who has not earned it." + ashen-helmet: + name: "Ashen Knight's Helm" + lore: "A knight wore it once. Only ash is left of him." + ashen-chestplate: + name: "Ashen Knight's Plate" + lore: "Blackened, and all the stronger for it." + ashen-leggings: + name: "Ashen Knight's Greaves" + lore: "They march even when no one wears them." + ashen-boots: + name: "Ashen Knight's Sabatons" + lore: "Soot drifts up from every step." + riftpiercer: + name: "Riftpiercer" + lore: "Its arrows arrive a moment before they leave." + riftwalker-helmet: + name: "Riftwalker Hood" + lore: "It sees a moment before you do." + riftwalker-chestplate: + name: "Riftwalker Plate" + lore: "Heavier on one side of the rift than the other." + riftwalker-leggings: + name: "Riftwalker Greaves" + lore: "Each step lands a little further than it should." + riftwalker-boots: + name: "Riftwalker Treads" + lore: "They have walked where there was no ground." + voidreaver: + name: "Voidreaver" + lore: "It cuts the space between things." + voidstride-helmet: + name: "Voidstride Crown" + lore: "It sees what stands behind you." + voidstride-chestplate: + name: "Voidstride Carapace" + lore: "Nothing can keep hold of it for long." + voidstride-leggings: + name: "Voidstride Greaves" + lore: "Every step lands a little further than it should." + voidstride-boots: + name: "Voidstride Treads" + lore: "Walk as the endermen walk." + starfall-blade: + name: "Starfall Blade" + lore: "Where it strikes, the sky follows." + starfall-helmet: + name: "Starfall Circlet" + lore: "A crown for whoever outlasts the End." + starfall-chestplate: + name: "Starfall Aegis" + lore: "Forged from a star that fell too far." + starfall-leggings: + name: "Starfall Greaves" + lore: "They shine even in the void." + starfall-boots: + name: "Starfall Striders" + lore: "The ground is only a suggestion." + gravelord-crown: + name: "Gravelord's Crown" + lore: "Heavy with the names of the buried." + sepulcher-cleaver: + name: "Sepulcher Cleaver" + lore: "It opened a hundred tombs, and closed more." + broodfang: + name: "Broodfang" + lore: "Still wet with venom." + widowmantle: + name: "Widowmantle" + lore: "Spun from the Broodmother's own silk." + tyrants-harpoon: + name: "Tyrant's Harpoon" + lore: "It drags the sea along behind it." + crown-of-tides: + name: "Crown of Tides" + lore: "The tide rises and falls with whoever wears it." + warlords-brand: + name: "Warlord's Brand" + lore: "Forged in the fortress's last fire." + cinder-aegis: + name: "Cinder Aegis" + lore: "Embers still glow between its plates." + eclipse-scythe: + name: "Eclipse Scythe" + lore: "It harvests light." + herald-crown: + name: "Herald's Crown" + lore: "Whoever wears it hears the void speak." + + # The sidebar. Which lines appear, and in what order, is set per board in # config.yml; the wording and colours are set here. Values are written as # %marker% and are listed in config.yml. @@ -1237,13 +1847,16 @@ scoreboard: plot-nation: " Nation %plot_nation%" plot-summary: " %plot_type% · %plot_owner%" plot-price: " For sale %plot_price%" - wilderness: " Wilderness" + wilderness: " Wilderness%danger%" coords: " Chunk %plot_coords%" balance: " Balance %balance%" enemy-warning: "» ENEMY LAND «" townless-hint: " You have no town yet" townless-action: " Open /tm to join one" + # Filled into %danger%: how dangerous the wild is where the player stands. + danger: " · ☠ Lv {level}" + warning: ruined: "Ruined" bankrupt: "Bankrupt" diff --git a/src/main/resources/lang/zh_CN.yml b/src/main/resources/lang/zh_CN.yml index ead2674..5fe6edf 100644 --- a/src/main/resources/lang/zh_CN.yml +++ b/src/main/resources/lang/zh_CN.yml @@ -23,6 +23,9 @@ command: news: "公告" storage: "仓库" protection: "物品保护" + adventure: "冒险" + mobs: "怪物" + items: "物品" help: header: "TriTown 命令" @@ -145,6 +148,48 @@ command: owner: "它归属于 {name}。" released: "已解除 {name} 的归属。现在所有人都可以使用。" + stats: + description: "查看你的战斗属性" + players-only: "属性在菜单中显示,只有玩家能打开。" + unavailable: "这个世界没有开启战斗属性。" + not-found: "{name} 不在线。" + + mob: + description: "查看怪物等级与战斗平衡" + usage: "用法:/tritown mob [level]>" + players-only: "要在游戏中站在某处才能查看那里的等级。" + inactive: "这个世界的怪物没有等级。" + level: "在这里生成的怪物为 {level} 级。" + level-town: " · 城镇领地内始终为 1 级。" + level-hub: " · 这里是出生区域,保持基础等级。" + level-ring: " · 距出生点第 {ring} 圈。" + level-night: " · 现在是夜晚,等级更高。" + level-deep: " · 位于 Y 0 以下,等级更高。" + balance-header: "各等级的僵尸对上一整套为该等级打造的稀有 ★★★ 装备:" + balance-row: "Lv {level} ({tier} 阶):{health}❤,每击 {hit} · {hits} 下击杀 · 每击扣除生命的 {percent}%" + kinds: "共 {amount} 种自定义怪物:{ids}" + unknown-kind: "没有名为 {id} 的自定义怪物。/tritown mob kinds 会列出全部。" + spawned: "已召出一只 {level} 级的 {name}。它只会掉落原版战利品。" + bosses-header: "各首领在其等级下,对上一名身着该阶稀有 ★★★ 整套装备的玩家:" + bosses-row: "{name} (Lv {level},{tier} 阶):{health}❤,每击 {hit} · 单人需 {hits} 下 · 每击扣除生命的 {percent}%" + + item: + description: "发放服务器自己的物品" + usage: "用法:/tritown item [amount]|list>" + not-found: "{name} 不在线。" + unknown: "没有物品 {id}。/tritown item list 会列出所有物品。" + given: "已将 {amount}x {id} 发给 {name}。" + list: "共 {amount} 种物品:{ids}" + + bestiary: + description: "查看你遇到过的服务器专属怪物" + players-only: "图鉴是一个菜单,只有玩家才能打开。" + + forge: + description: "打造与强化装备" + players-only: "锻造台是菜单,只有玩家能打开。" + unavailable: "这个世界的锻造台已关闭。" + trades: description: "打开服务器的全局商店" players-only: "商店是菜单,只有玩家能打开。" @@ -296,12 +341,17 @@ money: shop-sell: "在 {shop} 出售" trade: "玩家交易" storage-page: "仓库第 {page} 页" + gear-craft: "打造 {item}" + gear-upgrade: "升星 {item}" + gear-refine: "精炼 {item}" + gear-reforge: "重铸 {item}" # 资金的来源与去向,管理面板按此归类统计。 flow: starting-balance: "新玩家" shop: "商店" storage: "仓库页" + gear: "锻造台" towny: "Towny" admin: "管理操作" payment: "玩家转账" @@ -315,6 +365,7 @@ money: towny: "Towny" shop: "商店" storage: "仓库" + gear: "锻造台" account-type: player: "玩家" @@ -349,6 +400,12 @@ gui: profile-rank: "财富排行:第 {rank} 名" profile-town: "城镇:{town}" profile-nation: "国家:{nation}" + profile-stats: "点击查看你的战斗属性" + forge: "锻造台" + forge-lore: "用怪物的掉落物打造装备,并让它变得更强。" + bestiary: "图鉴" + bestiary-lore: "你击杀过的每一种服务器专属怪物,以及它们给了你什么。" + bestiary-found: "已发现:{found}/{total}" town: "城镇" town-name: "城镇:{town}" town-residents: "居民:{amount}" @@ -596,6 +653,7 @@ gui: about-starting-balance: "每名新玩家只发放一次。" about-shop: "服务器自己的商店卖出与回收。" about-storage: "玩家购买更多仓库页。" + about-gear: "玩家在锻造台打造、升星、精炼和重铸装备。" about-towny: "维护费、税收、地块交易与银行存取。" about-admin: "管理员发放、收回、设定或重置余额。" about-payment: "玩家之间的转账。" @@ -1115,7 +1173,87 @@ gui: cancel: "取消" cancel-lore: "返回,不做任何修改。" -# 商店在菜单之外发给玩家的消息。 + # 商店在菜单之外发给玩家的消息。 + stats: + title: "我的属性" + title-other: "{name} 的属性" + profile: "我的属性" + profile-other: "{name}" + profile-health: "❤ {health}/{max}" + profile-pvp: "属性只对怪物生效,玩家之间的战斗与原版相同。" + stat-name: "{stat} {value}" + source-line: "• {source}:{value}" + defense-reduction: "怪物对你的伤害减少 {percent}%。" + about: + health: "怪物需要打掉你多少生命。它显示在你的红心上,红心始终与它保持同样的比例。" + defense: "减少怪物每次攻击的伤害,100 点防御可减半。" + damage: "你的武器为每次攻击怪物增加的伤害。" + strength: "按其数值的百分比提高你对怪物的每次伤害。" + crit-chance: "攻击怪物时触发暴击的几率,跳劈会提高它。" + crit-damage: "暴击额外造成的伤害。" + speed: "让你移动得更快。这是唯一一项在玩家之间也生效的属性,所以服务器为它设了上限。" + vitality: "让自然恢复和治疗药水恢复更多生命。" + magic-find: "提高怪物掉落其材料的几率。抢夺附魔会提高它。" + source: + base: "基础" + armor: "盔甲" + weapon: "武器" + effects: "状态效果" + + bestiary: + title: "图鉴" + variant: "{name}" + boss: "☠ {name}" + lore: "{lore}" + unknown: "???" + unknown-lore: "你还没有击杀过它。" + lives: "栖息于{worlds},等级 {min} 至 {max}。" + summoned: "{level} 级首领,用{sigil}召唤。" + abilities: "技能:{abilities}" + affixes: "词缀:{affixes}" + kills: "已击杀:{amount} {stars}" + drops: "已发现的专属战利品:{found}/{total}" + drop: " · {name}" + progress: "已发现 {found}/{total}" + progress-lore: "每种击杀一只即可填满它的一页。每击杀 10、100、1,000 只各得一颗星。" + world: + normal: "主世界" + nether: "下界" + end: "末地" + any: "任意世界" + + forge: + title: "锻造台" + craft: "打造" + craft-lore: "用怪物的掉落物打造新装备。" + upgrade: "升星" + upgrade-lore: "增加一颗星:属性 +{bonus}%。({stars}/{max})" + refine: "精炼" + refine-lore: "将稀有度从 {from} 提升到 {to}。" + reforge: "重铸" + reforge-lore: "随机赋予一个新的重铸。当前:{reforge}" + salvage: "分解" + salvage-lore: "将它永久分解为:" + salvage-item: "• {amount}x {name}" + click-act: "点击执行" + empty: "手中没有装备" + empty-lore: "在主手拿着一件装备,即可对它升星、精炼、重铸或分解。" + cost-item: "✔ {have}/{need} {name}" + cost-item-missing: "✘ {have}/{need} {name}" + cost-money: "✔ {amount}" + cost-money-missing: "✘ {amount}" + confirm: "确认" + upgrade-confirm: "为这件装备升星?" + refine-confirm: "精炼这件装备?" + reforge-confirm: "重铸这件装备?" + salvage-confirm: "永久分解这件装备?" + + forge-recipes: + title: "锻造台:打造" + tier: "阶级 {tier}" + click: "点击打造" + confirm: "打造这件装备?" + shop: traded: "以 {price} 成交 {amount}个 {item}。" traded-items: "成交 {amount}个 {item}。" @@ -1203,6 +1341,477 @@ trade: shutdown: "服务器正在关闭,交易已取消,你放上的东西都已退回。" +# 战斗。各项属性在任何地方都以这里的符号和颜色显示。 +combat: + stat: + health: "❤ 生命值" + defense: "❈ 防御" + damage: "⚔ 伤害" + strength: "❁ 力量" + crit-chance: "☣ 暴击率" + crit-damage: "☠ 暴击伤害" + speed: "✦ 速度" + vitality: "♨ 活力" + magic-find: "✯ 魔法寻宝" + + hud: + bar: "❤ {health}/{max} ❈ {defense}" + + # 从被击中的怪物身上飘起。附近的人看到的是同一个,所以使用服务器的语言。 + indicator: + hit: "{amount}" + crit: "✧{amount}✧" + +mob: + # 有等级的怪物的名字。所有人看到的是同一个,所以使用服务器的语言。{name} 是怪物的种类,由每位玩家的客户端以自己的语言显示。 + # {badge} 是等阶的标记,{affixes} 按 affix-name 的写法列出每个词缀。 + nameplate: "[Lv{level}] {badge}{affixes}{name} {health}/{max}❤" + affix-name: "{name} " + # 自定义怪物自己的名字,代替其种类的名字。 + kind-name: "<#f0c060>{name}" + + rank: + elite: "★ " + champion: "★★ " + boss: "☠ " + + affix: + armored: "重甲" + frenzied: "迅捷" + vampiric: "吸血" + enraged: "暴怒" + molten: "熔火" + frostbound: "霜缚" + venomous: "剧毒" + volatile: "爆裂" + summoner: "召唤" + blinking: "闪现" + warded: "护盾" + + # 自定义怪物的技能,按图鉴的写法列出。 + ability: + leap: "飞扑" + slam: "震地" + charge: "冲锋" + volley: "箭雨" + fireball: "火球" + meteor: "陨石" + storm: "雷暴" + ensnare: "缠网" + hook: "钩拽" + summon: "召唤" + bulwark: "壁垒" + frost-nova: "冰霜新星" + miasma: "瘴气" + drain: "汲取" + + # plugins/TriTown/content/bestiary.yml 中的自定义怪物,按 id 排列。名字显示在怪物的名牌上,与名牌其余部分一样使用服务器的语言; + # 描述是图鉴中对它的介绍。 + kind: + gravewalker: + name: "墓行者" + lore: "它穿着下葬时的盔甲爬了出来。" + crypt-ghoul: + name: "墓穴食尸鬼" + lore: "沙漠留下的一切,它都吃。" + bonecaller-sentry: + name: "唤骨哨兵" + lore: "它仍在为一位早已死去的领主守望。" + frost-revenant: + name: "霜寒亡魂" + lore: "它把坟墓的寒意带进每一场战斗。" + widow-stalker: + name: "寡妇潜猎者" + lore: "早在你看见它之前,它就在盯着你了。" + silkweaver: + name: "织丝蛛" + lore: "小巧,迅捷,而且从不落单太久。" + hexbinder: + name: "咒缚女巫" + lore: "她的药水是你最不需要担心的。" + tidecaller: + name: "唤潮者" + lore: "它一呼唤,大海便会回应。" + crimson-warhog: + name: "绯红战猪" + lore: "绯红森林里最老、也最暴躁的一头。" + pyromancer: + name: "炎术师" + lore: "一只学会了瞄准的烈焰人。" + ashen-legionnaire: + name: "灰烬军团兵" + lore: "要塞最后的忠诚士兵之一。" + voidstalker: + name: "虚空潜行者" + lore: "你一转开视线,它就已经在你身后。" + starborn-sentinel: + name: "星裔哨兵" + lore: "它守护着世界的边缘——防的是你。" + grave-lord: + name: "墓王" + lore: "从此处到地平线间每一座坟墓的主人。" + phase-1: "{name} 唤起了亡者!" + phase-2: "{name} 陷入了狂怒!" + broodmother: + name: "巢母" + lore: "世间一切蛛网之母。" + phase-1: "{name} 放出了它的幼崽!" + phase-2: "{name} 疯狂地爬动起来!" + tide-tyrant: + name: "潮汐暴君" + lore: "大海有史以来最残酷的君王。" + phase-1: "{name} 掀起了潮水!" + phase-2: "{name} 如海上风暴般狂怒!" + cinder-warlord: + name: "余烬督军" + lore: "要塞的最后一位将军。" + phase-1: "{name} 召集军团,举起了盾牌!" + phase-2: "{name} 燃起了烈焰!" + void-herald: + name: "虚空使者" + lore: "它替群星之外的虚无发声。" + phase-1: "{name} 撕开了虚空!" + phase-2: "{name} 让天空坠落!" + + + +# 首领战,从召唤到倒下。{name} 使用 boss.name 的写法。 +boss: + name: "☠ {name}" + # 首领竞技场附近的每位玩家看到的首领血条,使用各自的语言。 + bar: "☠ {name} · Lv{level}" + subtitle: "等级 {level}" + ritual: "印记闪耀,{name} 正在苏醒……" + summoned: "{player} 召唤了 {name}!" + defeated: "{name} 倒在了 {players} 手下!" + defeated-alone: "{name} 倒下了。" + departed: "{name} 等得不耐烦,离开了。" + reward: "你分得的战利品:{items}" + reward-empty: "这一次,你分得的战利品一无所获。" + no-share: "你造成的伤害不足,没有分到战利品。" + refused: + disabled: "本服务器无法召唤首领。" + inactive: "在这个世界里,没有东西回应印记。" + town: "{name} 不会在城镇中被召唤。" + place: "{name} 无法在这里被召唤。印记上写明了地点。" + crowded: "附近已有另一位首领,无法在此召唤 {name}。" + room: "这里没有足够的空间容纳 {name}。" + +# plugins/TriTown/content/items.yml 中的物品,按 id 排列。名称会使用物品稀有度的颜色,所以名称本身不带颜色。 +# 每行描述请保持简短:它是物品提示框中的一行。 +item: + grave-dust: + name: "墓土" + lore: "从行尸身上刮下的尘土。" + bone-shard: + name: "碎骨" + lore: "从骷髅骨架上崩下的碎片。" + silk-strand: + name: "蛛丝" + lore: "比寻常蛛网更坚韧的丝线。" + volatile-powder: + name: "不稳定火药" + lore: "余温未散,轻拿轻放。" + frost-shard: + name: "霜晶碎片" + lore: "握在手中也不会融化。" + tidal-pearl: + name: "潮汐珍珠" + lore: "一颗珠子里藏着大海的重量。" + night-membrane: + name: "夜幕薄膜" + lore: "取自猎捕失眠者的怪物。" + raider-mark: + name: "袭击者徽记" + lore: "袭击村庄之人的随身之物。" + gilded-tusk: + name: "镀金獠牙" + lore: "黄金,以及曾以此为傲的某物。" + ember-core: + name: "余烬之核" + lore: "烈焰人的心脏,仍在燃烧。" + wailing-tear: + name: "哀嚎之泪" + lore: "贴近耳边,仍能听见啜泣。" + cinder-ash: + name: "余烬骨灰" + lore: "凋灵骷髅骨头燃尽后的残余。" + void-fragment: + name: "虚空碎片" + lore: "群星之间那片虚无的一角。" + rift-shell: + name: "裂隙之壳" + lore: "打开它,里面是别处。" + hollow-marrow: + name: "空洞骨髓" + lore: "哨兵最后的力量,至今仍在嗡鸣。" + abyssal-scale: + name: "深渊之鳞" + lore: "来自某个比你潜得深得多的东西。" + warhog-hide: + name: "战猪皮" + lore: "硬到能挡开剑刃——大多数时候。" + null-shard: + name: "虚无碎片" + lore: "它毫无重量,却会把光一并带走。" + grave-sigil: + name: "墓之印记" + lore: "在主世界的荒野中举起它,召唤墓王。" + brood-sigil: + name: "巢之印记" + lore: "在 Y 40 以下举起它,召唤巢母。" + tide-sigil: + name: "潮之印记" + lore: "在主世界的水中举起它,召唤潮汐暴君。" + cinder-sigil: + name: "烬之印记" + lore: "在下界举起它,召唤余烬督军。" + void-sigil: + name: "虚空印记" + lore: "在末地举起它,召唤虚空使者。" + dim-essence: + name: "黯淡精华" + lore: "从强者身上汲取的力量。" + glowing-essence: + name: "辉光精华" + lore: "从更强者身上汲取的力量。" + radiant-essence: + name: "璀璨精华" + lore: "从最强者身上汲取的力量。" + astral-essence: + name: "星界精华" + lore: "来自世界尽头之外的力量。" + +# 物品提示框底部的标签。 +rarity: + common: "普通" + uncommon: "优秀" + rare: "稀有" + epic: "史诗" + legendary: "传说" + mythic: "神话" + + +# 锻造台完成某项操作后告诉玩家的话,或无法完成的原因。 +forge: + crafted: "你打造出了 {name}。" + upgraded: "{name} 现在拥有 {stars}★。" + refined: "{name} 现在是 {rarity} 品质。" + reforged: "它现在是 {name}。" + salvaged: "你将 {name} 分解成了 {items}。" + error: + not-gear: "请在主手拿着一件装备。" + maxed: "它已经无法再强化了。" + missing-items: "你缺少所需的部分材料。" + cannot-afford: "你付不起这笔钱。" + unavailable: "锻造台现在无法这么做。" + +# plugins/TriTown/content/gear.yml 中的装备,按 id 排列。名称会使用装备稀有度的颜色,所以名称本身不带颜色。 +# 每行描述请保持简短:它是装备提示框中的一行。 +gear: + # 它的部位,显示在提示框底部的稀有度之后。 + slot: + weapon: "武器" + bow: "弓" + helmet: "头盔" + chestplate: "胸甲" + leggings: "护腿" + boots: "靴子" + # 后接这件装备在玩家之间视为的原版物品。 + pvp: "对玩家时:" + reforge: + sharp: "锋利" + heroic: "英勇" + fierce: "凶猛" + deadly: "致命" + titanic: "泰坦" + sturdy: "坚固" + hardy: "强健" + swift: "轻捷" + lucky: "幸运" + item: + gravewalker-blade: + name: "墓行者之刃" + lore: "在墓碑上磨利。" + gravewalker-helmet: + name: "墓行者头盔" + lore: "为在他人长眠之处挖掘者所戴。" + gravewalker-chestplate: + name: "墓行者胸甲" + lore: "沾满了百座坟墓的泥土。" + gravewalker-leggings: + name: "墓行者护腿" + lore: "它们从不厌倦行走。" + gravewalker-boots: + name: "墓行者靴子" + lore: "踏上任何墓穴的台阶都悄无声息。" + bonecaller-bow: + name: "唤骨长弓" + lore: "弓弦在风中嗡鸣,如同一副肋骨。" + bonecaller-helmet: + name: "唤骨头盔" + lore: "亡者靠近时,它会咯咯作响。" + bonecaller-chestplate: + name: "唤骨锁甲" + lore: "肋骨般的纹路,一如它的上一位主人。" + bonecaller-leggings: + name: "唤骨护腿" + lore: "永不疲倦的关节。" + bonecaller-boots: + name: "唤骨靴子" + lore: "只留下尘土,不留下脚印。" + frostbite-edge: + name: "霜咬之刃" + lore: "冷到足以灼伤。" + widowsilk-helmet: + name: "寡妇丝兜帽" + lore: "于黑暗中织成,为黑暗而生。" + widowsilk-chestplate: + name: "寡妇丝背心" + lore: "轻得让你忘了自己穿着它。" + widowsilk-leggings: + name: "寡妇丝护腿" + lore: "每一根丝都曾是一张陷阱。" + widowsilk-boots: + name: "寡妇丝靴子" + lore: "如蜘蛛落足般轻柔。" + tidecaller-trident: + name: "唤潮三叉戟" + lore: "它记得自己分开过的每一道浪。" + tidecaller-helmet: + name: "唤潮头盔" + lore: "大海仍在其中咆哮。" + tidecaller-chestplate: + name: "唤潮鳞甲" + lore: "永不生锈的鳞片。" + tidecaller-leggings: + name: "唤潮护腿" + lore: "为涉水而生,而非奔跑。" + tidecaller-boots: + name: "唤潮靴子" + lore: "总是有点潮湿。" + emberbrand: + name: "余烬烙刃" + lore: "战斗过后,它的刃口仍久久发光。" + emberforged-helmet: + name: "余烬锻造头盔" + lore: "锻造它的熔炉余温未散。" + emberforged-chestplate: + name: "余烬锻造胸甲" + lore: "在要塞的铁砧上锤打而成。" + emberforged-leggings: + name: "余烬锻造护腿" + lore: "每一步都迸出火星。" + emberforged-boots: + name: "余烬锻造战靴" + lore: "它们留下焦黑的脚印。" + wailing-arbalest: + name: "哀嚎劲弩" + lore: "每一支弩箭都伴着尖啸离弦。" + warhide-helmet: + name: "战皮头盔" + lore: "獠牙附赠。" + warhide-chestplate: + name: "战皮胸甲" + lore: "缝自一张不甘被剥下的皮。" + warhide-leggings: + name: "战皮护腿" + lore: "绯红森林的气息仍附着其上。" + warhide-boots: + name: "战皮重靴" + lore: "你每走一步,大地都知道。" + ashen-greatsword: + name: "灰烬巨剑" + lore: "对未曾赢得它的人来说太过沉重。" + ashen-helmet: + name: "灰烬骑士头盔" + lore: "曾有一位骑士戴过它,如今他只剩下灰烬。" + ashen-chestplate: + name: "灰烬骑士胸甲" + lore: "被熏得漆黑,也因此更加坚固。" + ashen-leggings: + name: "灰烬骑士护腿" + lore: "即便无人穿着,它们也在行进。" + ashen-boots: + name: "灰烬骑士战靴" + lore: "每一步都飘起煤灰。" + riftpiercer: + name: "裂隙穿刺者" + lore: "它的箭在离弦之前便已抵达。" + riftwalker-helmet: + name: "裂隙行者兜帽" + lore: "它总比你早一刻看见。" + riftwalker-chestplate: + name: "裂隙行者胸甲" + lore: "在裂隙的一侧比另一侧更重。" + riftwalker-leggings: + name: "裂隙行者护腿" + lore: "每一步都比应有的更远一点。" + riftwalker-boots: + name: "裂隙行者战靴" + lore: "它们走过没有地面的地方。" + voidreaver: + name: "虚空收割者" + lore: "它斩开万物之间的空隙。" + voidstride-helmet: + name: "虚空行者之冠" + lore: "它能看见站在你身后的东西。" + voidstride-chestplate: + name: "虚空行者甲壳" + lore: "任何东西都无法长久地抓住它。" + voidstride-leggings: + name: "虚空行者护腿" + lore: "每一步都落得比该落的更远一些。" + voidstride-boots: + name: "虚空行者之履" + lore: "如末影人一般行走。" + starfall-blade: + name: "星陨之刃" + lore: "它落在哪里,天空便追随到哪里。" + starfall-helmet: + name: "星陨头环" + lore: "献给在末地坚持到最后之人的王冠。" + starfall-chestplate: + name: "星陨神铠" + lore: "由坠落太远的星辰锻成。" + starfall-leggings: + name: "星陨护腿" + lore: "即使在虚空中也熠熠生辉。" + starfall-boots: + name: "星陨疾靴" + lore: "大地只是一个建议。" + gravelord-crown: + name: "墓王之冠" + lore: "沉甸甸地刻满了逝者的名字。" + sepulcher-cleaver: + name: "墓穴劈斧" + lore: "它劈开过上百座坟墓,也封上过更多。" + broodfang: + name: "巢牙" + lore: "毒液仍未干涸。" + widowmantle: + name: "寡妇斗篷" + lore: "由巢母亲自吐出的丝织成。" + tyrants-harpoon: + name: "暴君鱼叉" + lore: "它身后拖着整片大海。" + crown-of-tides: + name: "潮汐之冠" + lore: "潮水随佩戴者起落。" + warlords-brand: + name: "督军烙刃" + lore: "锻造于要塞最后的火焰之中。" + cinder-aegis: + name: "余烬神盾甲" + lore: "甲片之间的余烬仍在发光。" + eclipse-scythe: + name: "日蚀镰刀" + lore: "它收割光芒。" + herald-crown: + name: "使者之冠" + lore: "戴上它的人会听见虚空的低语。" + + scoreboard: # 服务器名称。各帧之间只有渐变相位不同,循环播放时高光会从名称上扫过。 title: @@ -1233,13 +1842,16 @@ scoreboard: plot-nation: " 国家 %plot_nation%" plot-summary: " %plot_type% · %plot_owner%" plot-price: " 出售中 %plot_price%" - wilderness: " 荒野" + wilderness: " 荒野%danger%" coords: " 区块 %plot_coords%" balance: " 余额 %balance%" enemy-warning: "» 敌对领地 «" townless-hint: " 你还没有城镇" townless-action: " 使用 /tm 加入一个" + # 填入 %danger%:玩家所在处的荒野有多危险。 + danger: " · ☠ Lv {level}" + warning: ruined: "已废墟" bankrupt: "已破产" diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/combat/BalanceSimulationTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/combat/BalanceSimulationTest.kt new file mode 100644 index 0000000..a9ab1ec --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/combat/BalanceSimulationTest.kt @@ -0,0 +1,132 @@ +package net.trilleo.mc.plugins.tritown.combat + +import net.trilleo.mc.plugins.tritown.combat.BalanceSimulator.Foe +import net.trilleo.mc.plugins.tritown.combat.BalanceSimulator.Kit +import net.trilleo.mc.plugins.tritown.content.Balance +import kotlin.math.abs +import kotlin.math.ceil +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Holds the bundled balance to the targets the combat layer is designed + * around. A change to `balance.yml` or to [DamageMath] that breaks one of these + * changes how the game feels, and has to be a decision rather than an + * accident. + */ +class BalanceSimulationTest { + + private companion object { + const val LEVELS_PER_TIER = 6 + } + + private val balance = Balance.DEFAULT + private val kits = mapOf("iron" to Kit.IRON, "diamond" to Kit.DIAMOND, "netherite" to Kit.NETHERITE) + + /** The anchor: at level 1 with vanilla gear, a fight is the vanilla fight. */ + @Test + fun `a level-1 zombie dies in as many swings as in vanilla`() { + kits.forEach { (name, kit) -> + val vanilla = ceil(Foe.ZOMBIE.health / (1.0 + kit.attackDamage)).toInt() + assertEquals(vanilla, BalanceSimulator.row(balance, 1, kit).hitsToKill, "$name sword") + } + } + + @Test + fun `a level-1 zombie's hit takes what it does in vanilla, give or take 15 percent`() { + kits.forEach { (name, kit) -> + val vanilla = vanillaShareTaken(Foe.ZOMBIE.hit, kit) + val ours = BalanceSimulator.row(balance, 1, kit).shareTaken + assertTrue(abs(ours - vanilla) / vanilla <= 0.15, "$name armor: $ours of health against vanilla's $vanilla") + } + } + + @Test + fun `the default crit chance adds at most 15 percent to an average swing`() { + val sheet = StatSheet.of( + Stat.CRIT_CHANCE to balance.player.critChance, + Stat.CRIT_DAMAGE to balance.player.critDamage, + ) + val average = 1.0 + DamageMath.critChance(sheet, jumpAttack = false, balance) * sheet[Stat.CRIT_DAMAGE] / 100.0 + assertTrue(average <= 1.15, "an average swing is $average of a plain one") + } + + @Test + fun `every level up is harder than the last with the same gear`() { + val rows = (1..60).map { BalanceSimulator.row(balance, it, Kit.DIAMOND) } + rows.zipWithNext().forEach { (lower, higher) -> + assertTrue(higher.hitsToKill >= lower.hitsToKill, "level ${higher.level} dies faster than ${lower.level}") + assertTrue(higher.shareTaken > lower.shareTaken, "level ${higher.level} hits softer than ${lower.level}") + } + } + + /** + * Vanilla gear is where the world starts, not where it ends: by the + * Overworld's cap it should be hopeless, which is what makes the gear + * above it worth having. + */ + @Test + fun `vanilla diamond is outclassed by the Overworld's cap`() { + val row = BalanceSimulator.row(balance, 30, Kit.DIAMOND) + assertTrue(row.hitsToKill >= 50, "a level-30 zombie dies in ${row.hitsToKill} diamond swings") + assertTrue(row.shareTaken >= 0.5, "a level-30 zombie takes only ${row.shareTaken} a hit through diamond") + } + + /** + * The heart of the design: a player in a rare, three-star kit of the tier + * made for a level kills a mob of that level in three to five swings, and + * each of its hits takes three to eight percent of their health. + */ + @Test + fun `on-level gear fights on-level mobs in the target band`() { + for (tier in 1..10) { + val row = BalanceSimulator.gearRow(balance, tier * LEVELS_PER_TIER, tier) + assertTrue(row.hitsToKill in 3..5, "tier $tier kills its level in ${row.hitsToKill} swings") + assertTrue(row.shareTaken in 0.03..0.08, "tier $tier takes ${row.shareTaken} of its health a hit") + } + } + + @Test + fun `an elite of your level is a real fight`() { + val elite = Foe(Foe.ZOMBIE.health * balance.ranks.elite.health, Foe.ZOMBIE.hit * balance.ranks.elite.damage) + for (tier in 1..10) { + val row = BalanceSimulator.gearRow(balance, tier * LEVELS_PER_TIER, tier, foe = elite) + assertTrue(row.hitsToKill in 12..20, "a tier-$tier elite takes ${row.hitsToKill} swings") + } + } + + /** + * A mob's health about doubles every six levels (`health-growth^6`), so a + * tier of gear is worth about twice the damage, and a player a tier behind + * needs about twice the swings — a little more, once swings are counted + * whole. Harder, but still a fight worth taking. + */ + @Test + fun `a tier behind is harder but still a fight`() { + for (tier in 2..10) { + val level = tier * LEVELS_PER_TIER + val onLevel = BalanceSimulator.gearRow(balance, level, tier) + val behind = BalanceSimulator.gearRow(balance, level, tier - 1) + assertTrue( + behind.hitsToKill <= onLevel.hitsToKill * 2.5, + "a tier behind at $level needs ${behind.hitsToKill} swings" + ) + assertTrue(behind.shareTaken <= 0.12, "a tier behind at $level takes ${behind.shareTaken} a hit") + } + } + + @Test + fun `two tiers ahead makes a mob trivial`() { + for (tier in 1..8) { + val row = BalanceSimulator.gearRow(balance, tier * LEVELS_PER_TIER, tier + 2) + assertTrue(row.hitsToKill <= 2, "two tiers ahead at tier $tier still needs ${row.hitsToKill} swings") + } + } + + /** Vanilla's own armor formula, for a hit of [damage] on a player wearing [kit]. */ + private fun vanillaShareTaken(damage: Double, kit: Kit): Double { + val effective = maxOf(kit.armor / 5.0, kit.armor - 4.0 * damage / (kit.toughness + 8.0)).coerceAtMost(20.0) + return damage * (1.0 - effective / 25.0) / DamageMath.VANILLA_PLAYER_HEALTH + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/combat/BossBalanceTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/combat/BossBalanceTest.kt new file mode 100644 index 0000000..e8f5ba8 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/combat/BossBalanceTest.kt @@ -0,0 +1,62 @@ +package net.trilleo.mc.plugins.tritown.combat + +import net.trilleo.mc.plugins.tritown.combat.BalanceSimulator.Foe +import net.trilleo.mc.plugins.tritown.content.Balance +import net.trilleo.mc.plugins.tritown.content.ContentParser +import net.trilleo.mc.plugins.tritown.gear.ForgeCosts +import org.yaml.snakeyaml.Yaml +import kotlin.test.Test +import kotlin.test.assertTrue + +/** + * Holds the bundled bosses to what a boss fight is meant to be: something a + * group takes on in gear of the boss's tier, which one player could win alone + * only slowly, and whose every hit is felt. + */ +class BossBalanceTest { + + /** The vanilla health and melee hit of each kind a bundled boss is. A costume never adds to the hit. */ + private val foes = mapOf( + "ZOMBIE" to Foe(health = 20.0, hit = 3.0), + "SPIDER" to Foe(health = 16.0, hit = 2.0), + "DROWNED" to Foe(health = 20.0, hit = 3.0), + "WITHER_SKELETON" to Foe(health = 20.0, hit = 2.0), + "ENDERMAN" to Foe(health = 40.0, hit = 7.0), + ) + + private val balance = Balance.DEFAULT + private val bosses = run { + val items = ContentParser.items(load("content/items.yml")).value.keys + val gear = ContentParser.gear(load("content/gear.yml"), items).value.gear.keys + ContentParser.bestiary(load("content/bestiary.yml"), items, gear).value.kinds.values.filter { it.boss != null } + } + + @Test + fun `one player in gear of a boss's tier needs 60 to 200 swings to bring it down`() { + bosses.forEach { def -> + val row = row(def.id) + assertTrue(row.hitsToKill in 60..200, "${def.id} takes ${row.hitsToKill} swings alone") + } + } + + @Test + fun `each of a boss's hits takes 10 to 30 percent of the health of a player in gear of its tier`() { + bosses.forEach { def -> + val share = row(def.id).shareTaken * 100.0 + assertTrue(share in 10.0..30.0, "${def.id} takes $share% a hit") + } + } + + private fun row(id: String): BalanceSimulator.Row { + val def = bosses.first { it.id == id } + val boss = def.boss!! + val foe = foes[def.base] ?: error("${def.base} has no vanilla numbers here; add them") + val tier = (boss.level + ForgeCosts.LEVELS_PER_TIER - 1) / ForgeCosts.LEVELS_PER_TIER + return BalanceSimulator.bossRow(balance, boss.level, tier, foe, def.health, def.damage, def.defense) + } + + private fun load(path: String): Map<*, *> { + val stream = checkNotNull(javaClass.classLoader.getResourceAsStream(path)) { "$path is not bundled" } + return stream.reader(Charsets.UTF_8).use { Yaml().load>(it) } + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/combat/DamageMathTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/combat/DamageMathTest.kt new file mode 100644 index 0000000..524dbaa --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/combat/DamageMathTest.kt @@ -0,0 +1,87 @@ +package net.trilleo.mc.plugins.tritown.combat + +import net.trilleo.mc.plugins.tritown.content.Balance +import kotlin.test.Test +import kotlin.test.assertEquals + +class DamageMathTest { + + private val balance = Balance.DEFAULT + + @Test + fun `defense takes a growing but never complete share`() { + assertEquals(1.0, DamageMath.defenseMultiplier(0.0)) + assertEquals(0.5, DamageMath.defenseMultiplier(100.0)) + assertEquals(0.25, DamageMath.defenseMultiplier(300.0)) + assertEquals(1.0, DamageMath.defenseMultiplier(-50.0)) + } + + @Test + fun `a full vanilla swing with a vanilla sword is the lens times vanilla`() { + val ironSword = StatSheet.of(Stat.DAMAGE to 5.0 * balance.lens) + val share = DamageMath.vanillaShare(vanillaHit = 6.0, attackDamage = 6.0, vanillaCrit = false) + + assertEquals(1.0, share) + assertEquals(30.0, DamageMath.meleeHit(ironSword, balance, share, crit = false)) + } + + @Test + fun `vanilla's jump crit is divided back out of the share`() { + assertEquals(1.0, DamageMath.vanillaShare(vanillaHit = 9.0, attackDamage = 6.0, vanillaCrit = true), 1e-9) + } + + @Test + fun `strength and crits multiply a hit`() { + val sheet = StatSheet.of(Stat.DAMAGE to 95.0, Stat.STRENGTH to 50.0, Stat.CRIT_DAMAGE to 100.0) + + assertEquals(150.0, DamageMath.meleeHit(sheet, balance, 1.0, crit = false), 1e-9) + assertEquals(300.0, DamageMath.meleeHit(sheet, balance, 1.0, crit = true), 1e-9) + } + + @Test + fun `weakness can take a hit to nothing but not below`() { + val sheet = StatSheet.of(Stat.STRENGTH to -150.0) + assertEquals(0.0, DamageMath.strengthMultiplier(sheet)) + } + + @Test + fun `crit chance adds the jump attack and stays a probability`() { + val sheet = StatSheet.of(Stat.CRIT_CHANCE to 90.0) + + assertEquals(0.9, DamageMath.critChance(sheet, jumpAttack = false, balance), 1e-9) + assertEquals(1.0, DamageMath.critChance(sheet, jumpAttack = true, balance)) + } + + @Test + fun `a level-1 mob is the lens times vanilla, and grows from there`() { + assertEquals(15.0, DamageMath.mobHit(3.0, balance, level = 1)) + assertEquals(100.0, DamageMath.mobMaxHealth(20.0, balance, level = 1)) + assertEquals(15.0 * 1.1, DamageMath.mobHit(3.0, balance, level = 2), 1e-9) + assertEquals(100.0 * 1.13 * 1.13, DamageMath.mobMaxHealth(20.0, balance, level = 3), 1e-9) + } + + @Test + fun `the environment only ever hurts a mob as it would at level 1`() { + assertEquals(20.0, DamageMath.environmentHit(4.0, balance)) + } + + @Test + fun `health boost adds to the pool in proportion`() { + assertEquals(100.0, DamageMath.playerMaxHealth(100.0, vanillaMax = 20.0)) + assertEquals(120.0, DamageMath.playerMaxHealth(100.0, vanillaMax = 24.0)) + } + + @Test + fun `converting to vanilla and back is lossless`() { + val vanilla = DamageMath.toVanilla(rpg = 2_500.0, rpgMax = 50_000.0, vanillaMax = 20.0) + + assertEquals(1.0, vanilla, 1e-9) + assertEquals(2_500.0, DamageMath.toRpg(vanilla, rpgMax = 50_000.0, vanillaMax = 20.0), 1e-9) + } + + @Test + fun `vanilla armor becomes defense by its points`() { + assertEquals(120.0, DamageMath.armorDefense(armor = 15.0, toughness = 0.0, balance)) + assertEquals(320.0, DamageMath.armorDefense(armor = 20.0, toughness = 8.0, balance)) + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/combat/VanillaReductionsTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/combat/VanillaReductionsTest.kt new file mode 100644 index 0000000..07510f2 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/combat/VanillaReductionsTest.kt @@ -0,0 +1,51 @@ +package net.trilleo.mc.plugins.tritown.combat + +import net.trilleo.mc.plugins.tritown.combat.VanillaReductions.Kind +import net.trilleo.mc.plugins.tritown.combat.VanillaReductions.Reduction +import kotlin.test.Test +import kotlin.test.assertEquals + +class VanillaReductionsTest { + + @Test + fun `armor is taken out and the rest keep their share`() { + // Vanilla: a 10 hit, armor takes 4, Resistance I then takes 20% of the 6 left, Protection 40% of the rest. + val reductions = listOf( + Reduction(Kind.ARMOR, -4.0), + Reduction(Kind.SHARE, -1.2), + Reduction(Kind.SHARE, -1.92), + Reduction(Kind.ABSORPTION, 0.0), + ) + + val rescaled = VanillaReductions.rescale(10.0, reductions, base = 5.0, absorption = 0.0) + + assertEquals(0.0, rescaled[0]) + assertEquals(-1.0, rescaled[1], 1e-9) + assertEquals(-1.6, rescaled[2], 1e-9) + assertEquals(0.0, rescaled[3], 1e-9) + } + + @Test + fun `absorption soaks up what it can hold of the new hit`() { + val reductions = listOf(Reduction(Kind.ARMOR, -2.0), Reduction(Kind.ABSORPTION, -8.0)) + + assertEquals(listOf(0.0, -3.0), VanillaReductions.rescale(10.0, reductions, base = 3.0, absorption = 4.0)) + assertEquals(listOf(0.0, -4.0), VanillaReductions.rescale(10.0, reductions, base = 9.0, absorption = 4.0)) + } + + @Test + fun `a raised shield still blocks the whole hit`() { + val reductions = listOf(Reduction(Kind.SHARE, -7.0), Reduction(Kind.ARMOR, 0.0)) + + val rescaled = VanillaReductions.rescale(7.0, reductions, base = 0.4, absorption = 0.0) + + assertEquals(-0.4, rescaled[0], 1e-9) + } + + @Test + fun `nothing is taken from a hit vanilla had already reduced to nothing`() { + val reductions = listOf(Reduction(Kind.SHARE, -5.0), Reduction(Kind.SHARE, 0.0)) + + assertEquals(listOf(-2.0, 0.0), VanillaReductions.rescale(5.0, reductions, base = 2.0, absorption = 0.0)) + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/content/BalanceParserTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/content/BalanceParserTest.kt new file mode 100644 index 0000000..a05dbed --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/content/BalanceParserTest.kt @@ -0,0 +1,60 @@ +package net.trilleo.mc.plugins.tritown.content + +import net.trilleo.mc.plugins.tritown.mobs.Ability +import org.yaml.snakeyaml.Yaml +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BalanceParserTest { + + @Test + fun `the bundled file is exactly the defaults`() { + val stream = checkNotNull(javaClass.classLoader.getResourceAsStream("content/balance.yml")) { + "content/balance.yml is not bundled" + } + val root = stream.reader(Charsets.UTF_8).use { Yaml().load>(it) } + + val result = BalanceParser.parse(root) + + assertEquals(emptyList(), result.warnings) + assertEquals(Balance.DEFAULT, result.balance) + } + + @Test + fun `a missing value takes the default quietly`() { + val result = BalanceParser.parse(mapOf("player" to mapOf("health" to 250))) + + assertEquals(250.0, result.balance.player.health) + assertEquals(Balance.DEFAULT.lens, result.balance.lens) + assertEquals(emptyList(), result.warnings) + } + + @Test + fun `an ability keeps the defaults it does not name, and one that does not exist is said`() { + val result = BalanceParser.parse( + mapOf("abilities" to mapOf("slam" to mapOf("damage" to 9), "sneeze" to mapOf("damage" to 1))) + ) + val slam = result.balance.abilities[Ability.SLAM] + + assertEquals(9.0, slam.damage) + assertEquals(Balance.DEFAULT.abilities[Ability.SLAM].radius, slam.radius) + assertEquals(1, result.warnings.size) + assertTrue(result.warnings.single().startsWith("abilities.sneeze")) + } + + @Test + fun `an unusable value takes the default and says so`() { + val result = BalanceParser.parse( + mapOf( + "lens" to "five", + "mobs" to mapOf("health-growth" to 0.5), + ) + ) + + assertEquals(Balance.DEFAULT.lens, result.balance.lens) + assertEquals(Balance.DEFAULT.mobs.healthGrowth, result.balance.mobs.healthGrowth) + assertEquals(2, result.warnings.size) + assertTrue(result.warnings.any { "mobs.health-growth" in it }) + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/content/BestiaryParserTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/content/BestiaryParserTest.kt new file mode 100644 index 0000000..e03e00a --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/content/BestiaryParserTest.kt @@ -0,0 +1,156 @@ +package net.trilleo.mc.plugins.tritown.content + +import net.trilleo.mc.plugins.tritown.mobs.Ability +import net.trilleo.mc.plugins.tritown.mobs.Affix +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class BestiaryParserTest { + + private val items = setOf("grave-dust") + private val gear = setOf("gravewalker-blade") + + @Test + fun `a variant reads whole, with its defaults filled in`() { + val result = parse( + "walker" to mapOf( + "base" to "zombie", + "spawn" to mapOf( + "worlds" to listOf("Normal"), + "biomes" to listOf("desert"), + "levels" to listOf(3, 9), + "chance" to 5, + ), + "affixes" to listOf("vampiric"), + "equipment" to mapOf("hand" to "gear:gravewalker-blade", "off-hand" to "shield"), + "loot" to listOf(mapOf("item" to "grave-dust", "chance" to 20, "amount" to listOf(1, 2))), + ) + ) + val def = result.value.kinds.getValue("walker") + + assertEquals(emptyList(), result.warnings) + assertEquals("ZOMBIE", def.base) + assertEquals(setOf("normal"), def.spawn?.worlds) + assertEquals(setOf("minecraft:desert"), def.spawn?.biomes) + assertEquals(3, def.spawn?.minLevel) + assertEquals(SpawnTime.ANY, def.spawn?.time) + assertEquals(1.0, def.health) + assertTrue(def.rankable) + assertEquals(setOf(Affix.VAMPIRIC), def.affixes) + assertEquals(Costume.Gear("gravewalker-blade"), def.equipment[CostumeSlot.HAND]) + assertEquals(Costume.Vanilla("SHIELD"), def.equipment[CostumeSlot.OFF_HAND]) + assertEquals(listOf(LootEntry.Item("grave-dust", 20.0, 1, 2)), def.loot) + assertEquals(listOf(def), result.value.variantsFor("ZOMBIE")) + } + + @Test + fun `what names nothing is left out and said`() { + val result = parse( + "walker" to mapOf( + "base" to "zombie", + "spawn" to mapOf("chance" to 5, "time" to "dusk"), + "affixes" to listOf("sleepy"), + "equipment" to mapOf("hand" to "gear:no-such-blade", "tail" to "stick"), + "loot" to listOf(mapOf("item" to "no-such-dust", "chance" to 20)), + ) + ) + val def = result.value.kinds.getValue("walker") + + assertEquals(5, result.warnings.size, result.warnings.toString()) + assertEquals(SpawnTime.ANY, def.spawn?.time) + assertEquals(emptySet(), def.affixes) + assertEquals(emptyMap(), def.equipment) + assertEquals(emptyList(), def.loot) + } + + @Test + fun `abilities read by their names, and minions must be a custom mob in the file`() { + val result = parse( + "walker" to mapOf( + "base" to "zombie", + "spawn" to mapOf("chance" to 5), + "abilities" to listOf("leap", "frost-nova", "leap", "sneeze"), + "minions" to "crawler", + ), + "crawler" to mapOf("base" to "spider", "spawn" to mapOf("chance" to 5), "minions" to "nobody"), + ) + + assertEquals(listOf(Ability.LEAP, Ability.FROST_NOVA), result.value.kinds.getValue("walker").abilities) + assertEquals("crawler", result.value.kinds.getValue("walker").minions) + assertEquals(null, result.value.kinds.getValue("crawler").minions) + assertEquals(2, result.warnings.size, result.warnings.toString()) + } + + @Test + fun `a boss reads its level, sigil, place, arena and phases, highest first`() { + val result = ContentParser.bestiary( + mapOf( + "bosses" to mapOf( + "lord" to mapOf( + "base" to "zombie", + "level" to 12, + "sigil" to "grave-dust", + "place" to mapOf("worlds" to listOf("normal"), "water" to true, "max-y" to 40), + "arena" to 20, + "bar" to "purple", + "phases" to listOf( + mapOf("below" to 25, "affixes" to listOf("enraged")), + mapOf("below" to 60, "summon" to 3, "abilities" to listOf("charge")), + ), + ), + ), + "rules" to mapOf("contributor-share" to 15, "gear-odds" to mapOf("mythic" to 1)), + ), + items, + gear, + ) + val def = result.value.kinds.getValue("lord") + val boss = def.boss!! + + assertEquals(emptyList(), result.warnings) + assertEquals(null, def.spawn) + assertEquals(12, boss.level) + assertEquals(SummonPlace(setOf("normal"), water = true, maxY = 40), boss.place) + assertEquals("purple", boss.bar) + assertEquals(listOf(60.0, 25.0), boss.phases.map { it.below }) + assertEquals(listOf(Ability.CHARGE), boss.phases[0].abilities) + assertEquals(3, boss.phases[0].summon) + assertEquals(def, result.value.bossForSigil("grave-dust")) + assertEquals(15.0, result.value.rules.contributorShare) + assertEquals(mapOf(Rarity.MYTHIC to 1.0), result.value.rules.gearOdds) + assertEquals(emptyList(), result.value.variantsFor("ZOMBIE")) + } + + @Test + fun `a boss needs a sigil of its own`() { + val result = ContentParser.bestiary( + mapOf( + "bosses" to linkedMapOf( + "first" to mapOf("base" to "zombie", "sigil" to "grave-dust"), + "second" to mapOf("base" to "zombie", "sigil" to "grave-dust"), + "third" to mapOf("base" to "zombie", "sigil" to "no-such-sigil"), + ), + ), + items, + gear, + ) + + assertEquals(setOf("first"), result.value.kinds.keys) + assertEquals(2, result.warnings.size, result.warnings.toString()) + } + + @Test + fun `a variant with no base or nowhere to spawn is left out`() { + val result = parse( + "nobody" to mapOf("spawn" to mapOf("chance" to 5)), + "nowhere" to mapOf("base" to "zombie"), + ) + + assertEquals(emptyMap(), result.value.kinds) + assertEquals(2, result.warnings.size) + } + + private fun parse(vararg variants: Pair) = + ContentParser.bestiary(mapOf("variants" to linkedMapOf(*variants)), items, gear) +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/content/ContentFilesTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/content/ContentFilesTest.kt new file mode 100644 index 0000000..be6ee32 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/content/ContentFilesTest.kt @@ -0,0 +1,157 @@ +package net.trilleo.mc.plugins.tritown.content + +import net.trilleo.mc.plugins.tritown.gear.ForgeCosts +import org.yaml.snakeyaml.Yaml +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +/** + * Guards the bundled content files: they parse without a warning, and every + * item, piece of gear, reforge and custom mob they define is named in every + * language — and nothing is translated that none of them uses. + * + * Content ids come from YAML rather than Kotlin, so `LangFilesTest`'s source + * scan cannot see their keys; this is what holds them instead. + */ +class ContentFilesTest { + + private val languages = listOf("en_US", "zh_CN").associateWith { flatten(load("lang/$it.yml")) } + private val items = ContentParser.items(load("content/items.yml")) + private val gear = ContentParser.gear(load("content/gear.yml"), items.value.keys) + private val bestiary = ContentParser.bestiary(load("content/bestiary.yml"), items.value.keys, gear.value.gear.keys) + + /** Every key a custom mob is named or described by, and every boss phase's line. */ + private val kindKeys = bestiary.value.kinds.values.flatMap { def -> + listOf(def.nameKey, def.loreKey) + def.boss?.phases.orEmpty().indices.map(def::phaseKey) + }.toSet() + + private val variants = bestiary.value.kinds.values.filter { it.boss == null } + private val bosses = bestiary.value.kinds.values.mapNotNull { def -> def.boss?.let { def to it } } + + /** Every key a piece of gear or a reforge is named by. */ + private val gearKeys = gear.value.gear.values.flatMap { listOf(it.nameKey, it.loreKey) }.toSet() + + gear.value.reforges.values.map { it.key } + + @Test + fun `the bundled items parse without a warning`() { + assertEquals(emptyList(), items.warnings) + } + + @Test + fun `the bundled loot parses without a warning`() { + assertEquals(emptyList(), ContentParser.loot(load("content/mobs.yml"), items.value.keys).warnings) + } + + @Test + fun `every item is named and described in every language`() { + val wanted = items.value.values.flatMap { listOf(it.nameKey, it.loreKey) }.toSet() + languages.forEach { (id, values) -> + assertEquals(emptySet(), wanted - values.keys, "Item keys missing from $id.yml") + } + } + + @Test + fun `no language translates an item that does not exist`() { + val wanted = items.value.values.flatMap { listOf(it.nameKey, it.loreKey) }.toSet() + languages.forEach { (id, values) -> + val orphans = values.keys.filter { it.startsWith(ContentItemDef.KEY_PREFIX) } - wanted + assertEquals(emptySet(), orphans.toSet(), "Item keys in $id.yml that no item uses") + } + } + + @Test + fun `the bundled gear parses without a warning`() { + assertEquals(emptyList(), gear.warnings) + } + + @Test + fun `every piece of gear and reforge is named in every language`() { + languages.forEach { (id, values) -> + assertEquals(emptySet(), gearKeys - values.keys, "Gear keys missing from $id.yml") + } + } + + @Test + fun `no language translates gear that does not exist`() { + languages.forEach { (id, values) -> + val orphans = values.keys.filter { + it.startsWith(GearDef.KEY_PREFIX) || it.startsWith(ReforgeDef.KEY_PREFIX) + } - gearKeys + assertEquals(emptySet(), orphans.toSet(), "Gear keys in $id.yml that no gear uses") + } + } + + @Test + fun `the bundled bestiary parses without a warning`() { + assertEquals(emptyList(), bestiary.warnings) + } + + @Test + fun `every custom mob is named and described in every language`() { + languages.forEach { (id, values) -> + assertEquals(emptySet(), kindKeys - values.keys, "Custom mob keys missing from $id.yml") + } + } + + @Test + fun `no language translates a custom mob that does not exist`() { + languages.forEach { (id, values) -> + val orphans = values.keys.filter { it.startsWith(MobKindDef.KEY_PREFIX) } - kindKeys + assertEquals(emptySet(), orphans.toSet(), "Custom mob keys in $id.yml that no custom mob uses") + } + } + + @Test + fun `every variant has somewhere to live and a chance to appear there`() { + variants.forEach { def -> + val spawn = def.spawn + assertTrue(spawn != null && spawn.chance > 0.0, "${def.id} never appears") + assertTrue(spawn.minLevel <= spawn.maxLevel, "${def.id} spawns at no level") + } + } + + @Test + fun `every boss has phases, and a sigil some variant drops`() { + assertTrue(bosses.isNotEmpty()) + bosses.forEach { (def, boss) -> + assertTrue(boss.phases.isNotEmpty(), "${def.id} has no phases") + val sources = variants.filter { variant -> variant.loot.any { (it as? LootEntry.Item)?.id == boss.sigil } } + assertTrue(sources.isNotEmpty(), "No variant drops ${boss.sigil}, so ${def.id} can never be summoned") + } + } + + @Test + fun `every piece has a tier from 1 to 10, and a recipe unless a custom mob drops it`() { + val dropped = + bestiary.value.kinds.values.flatMap { it.loot }.filterIsInstance().map { it.id }.toSet() + gear.value.gear.values.forEach { def -> + assertTrue(def.recipe != null || def.id in dropped, "${def.id} has no recipe, and nothing drops it") + assertTrue(def.tier in 1..10, "${def.id} is tier ${def.tier}") + } + } + + @Test + fun `signature gear drops at the tier its boss's level is made for`() { + bosses.forEach { (def, boss) -> + val tier = (boss.level + ForgeCosts.LEVELS_PER_TIER - 1) / ForgeCosts.LEVELS_PER_TIER + def.loot.filterIsInstance().mapNotNull { gear.value.gear[it.id] } + .filter { it.recipe == null } + .forEach { piece -> assertEquals(tier, piece.tier, "${piece.id} drops from ${def.id}, of tier $tier") } + } + } + + private fun load(path: String): Map<*, *> { + val stream = checkNotNull(javaClass.classLoader.getResourceAsStream(path)) { "$path is not bundled" } + return stream.reader(Charsets.UTF_8).use { Yaml().load>(it) } + } + + private fun flatten(map: Map<*, *>, prefix: String = ""): Map = + map.entries.flatMap { (key, value) -> + val path = prefix + key + if (value is Map<*, *>) flatten( + value, + "$path." + ).entries.map { it.toPair() } else listOf(path to value.toString()) + }.toMap() +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/gear/ForgeCostsTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/gear/ForgeCostsTest.kt new file mode 100644 index 0000000..2967b00 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/gear/ForgeCostsTest.kt @@ -0,0 +1,71 @@ +package net.trilleo.mc.plugins.tritown.gear + +import net.trilleo.mc.plugins.tritown.combat.Stat +import net.trilleo.mc.plugins.tritown.content.* +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class ForgeCostsTest { + + private val forge = ForgeTuning.DEFAULT + private val gear = GearTuning.DEFAULT + private val loot = LootTable.EMPTY.copy( + essence = listOf( + LootTable.EssenceGrade("dim-essence", 15), + LootTable.EssenceGrade("glowing-essence", 30), + LootTable.EssenceGrade("radiant-essence", 45), + LootTable.EssenceGrade("astral-essence", 60), + ), + ) + + private fun def(tier: Int) = + GearDef("test", GearSlot.WEAPON, tier, "IRON_SWORD", null, null, null, mapOf(Stat.DAMAGE to 1.0), null) + + private fun data(rarity: Rarity = Rarity.COMMON, stars: Int = 0) = + GearData("test", rarity, stars, emptyMap(), null, 0) + + @Test + fun `essence is the grade a mob of the piece's tier drops`() { + assertEquals("dim-essence", ForgeCosts.essence(2, loot)) + assertEquals("glowing-essence", ForgeCosts.essence(3, loot)) + assertEquals("radiant-essence", ForgeCosts.essence(7, loot)) + assertEquals("astral-essence", ForgeCosts.essence(10, loot)) + } + + @Test + fun `each star costs more than the last, and money grows with the tier`() { + val first = ForgeCosts.upgrade(def(1), data(stars = 0), gear, forge, loot)!! + val third = ForgeCosts.upgrade(def(1), data(stars = 2), gear, forge, loot)!! + val firstAtFive = ForgeCosts.upgrade(def(5), data(stars = 0), gear, forge, loot)!! + + assertEquals(mapOf("dim-essence" to forge.upgradeEssence), first.items) + assertEquals(mapOf("dim-essence" to forge.upgradeEssence * 3), third.items) + assertEquals(forge.upgradeMoney * 3, third.money, 1e-9) + assertEquals( + forge.upgradeMoney * forge.moneyGrowth * forge.moneyGrowth * forge.moneyGrowth * forge.moneyGrowth, + firstAtFive.money, + 1e-6 + ) + } + + @Test + fun `a piece with every star cannot be upgraded`() { + assertNull(ForgeCosts.upgrade(def(1), data(stars = gear.maxStars), gear, forge, loot)) + } + + @Test + fun `the forge refines no higher than its cap`() { + assertEquals(Rarity.UNCOMMON, ForgeCosts.nextRarity(Rarity.COMMON)) + assertNull(ForgeCosts.refine(def(1), data(forge.refineCap), forge, loot)) + assertNull(ForgeCosts.refine(def(1), data(Rarity.MYTHIC), forge, loot)) + val toRare = ForgeCosts.refine(def(1), data(Rarity.UNCOMMON), forge, loot)!! + assertEquals(mapOf("dim-essence" to forge.refineEssence * 2), toRare.items) + } + + @Test + fun `salvage gives back essence for rarity and stars`() { + val returned = ForgeCosts.salvage(def(3), data(Rarity.RARE, stars = 2), forge, loot) + assertEquals(mapOf("glowing-essence" to forge.salvageEssence * 3 + 2), returned) + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/gear/GearStatsTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/gear/GearStatsTest.kt new file mode 100644 index 0000000..90d1b19 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/gear/GearStatsTest.kt @@ -0,0 +1,122 @@ +package net.trilleo.mc.plugins.tritown.gear + +import net.trilleo.mc.plugins.tritown.combat.Stat +import net.trilleo.mc.plugins.tritown.content.* +import kotlin.math.abs +import kotlin.math.pow +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class GearStatsTest { + + private val tuning = GearTuning.DEFAULT + + private fun def(slot: GearSlot, tier: Int, vararg weights: Pair) = + GearDef("test", slot, tier, "IRON_SWORD", null, null, null, weights.toMap(), null) + + private fun data(rarity: Rarity = Rarity.COMMON, stars: Int = 0, reforge: String? = null) = + GearData("test", rarity, stars, emptyMap(), reforge, 0) + + /** + * How many of its points a piece spent, worked back from its stats — the + * same for every piece of a slot and tier, however it is shaped. + */ + private fun spent(def: GearDef): Double { + val stats = GearStats.of(def, data(), tuning, null) + val roll = GearStats.averageRoll(tuning) / 100.0 + return Stat.entries.sumOf { stat -> + val curve = tuning.curve(stat) + if (curve.per100 == 0.0) 0.0 else stats[stat] / roll / curve.per100 * 100.0 / curve.growth.pow(def.tier - 1) + } + } + + @Test + fun `every piece of a slot and tier spends the same points, however it is shaped`() { + for (tier in 1..10) { + val shapes = listOf( + def(GearSlot.WEAPON, tier, Stat.DAMAGE to 0.6, Stat.STRENGTH to 0.4), + def(GearSlot.WEAPON, tier, Stat.DAMAGE to 0.5, Stat.CRIT_CHANCE to 0.25, Stat.CRIT_DAMAGE to 0.25), + def(GearSlot.WEAPON, tier, Stat.MAGIC_FIND to 1.0), + ) + shapes.forEach { assertEquals(tuning.points(GearSlot.WEAPON), spent(it), 1e-6) } + } + } + + @Test + fun `rarity and stars scale a piece as tuned`() { + val sword = def(GearSlot.WEAPON, 3, Stat.DAMAGE to 1.0) + val common = GearStats.of(sword, data(), tuning, null)[Stat.DAMAGE] + val legendary = GearStats.of(sword, data(Rarity.LEGENDARY), tuning, null)[Stat.DAMAGE] + val starred = GearStats.of(sword, data(stars = 3), tuning, null)[Stat.DAMAGE] + val overStarred = GearStats.of(sword, data(stars = 99), tuning, null)[Stat.DAMAGE] + + assertEquals(tuning.rarityMultiplier(Rarity.LEGENDARY), legendary / common, 1e-9) + assertEquals(1.0 + 3 * tuning.starBonus / 100.0, starred / common, 1e-9) + assertEquals(tuning.starMultiplier(tuning.maxStars), overStarred / common, 1e-9) + } + + @Test + fun `each stat is scaled by its own roll`() { + val sword = def(GearSlot.WEAPON, 1, Stat.DAMAGE to 0.5, Stat.STRENGTH to 0.5) + val rolled = GearData("test", Rarity.COMMON, 0, mapOf(Stat.DAMAGE to 100, Stat.STRENGTH to 90), null, 0) + val stats = GearStats.of(sword, rolled, tuning, null) + val average = GearStats.of(sword, data(), tuning, null) + val averageRoll = GearStats.averageRoll(tuning).toDouble() + + assertEquals(100.0 / averageRoll, stats[Stat.DAMAGE] / average[Stat.DAMAGE], 1e-9) + assertEquals(90.0 / averageRoll, stats[Stat.STRENGTH] / average[Stat.STRENGTH], 1e-9) + } + + @Test + fun `a reforge adds its share, and only on a slot it fits`() { + val sharp = ReforgeDef("sharp", setOf(GearSlot.WEAPON), mapOf(Stat.CRIT_DAMAGE to 1.0)) + val sword = def(GearSlot.WEAPON, 2, Stat.DAMAGE to 1.0) + val helmet = def(GearSlot.HELMET, 2, Stat.HEALTH to 1.0) + + val reforged = GearStats.of(sword, data(reforge = "sharp"), tuning, sharp)[Stat.CRIT_DAMAGE] + val expected = + tuning.curve(Stat.CRIT_DAMAGE).value(tuning.points(GearSlot.WEAPON) * tuning.reforgeShare / 100.0, 2) + assertEquals(expected, reforged, 1e-9) + assertEquals(0.0, GearStats.of(helmet, data(reforge = "sharp"), tuning, sharp)[Stat.CRIT_DAMAGE]) + } + + /** The tier is the progression and rarity the chase: the rarest piece of a tier is weaker than a rare one of the next. */ + @Test + fun `rarity never beats a tier on the stats that grow`() { + listOf(Stat.DAMAGE, Stat.STRENGTH, Stat.HEALTH, Stat.DEFENSE).forEach { stat -> + for (tier in 1..9) { + val piece = { t: Int -> def(GearSlot.WEAPON, t, stat to 1.0) } + val mythic = GearStats.of(piece(tier), data(Rarity.MYTHIC, stars = 3), tuning, null)[stat] + val nextRare = GearStats.of(piece(tier + 1), data(Rarity.RARE, stars = 3), tuning, null)[stat] + assertTrue(mythic < nextRare, "a mythic tier-$tier piece's $stat beats a rare tier-${tier + 1} one") + } + } + } + + @Test + fun `rolls stay in range, one per stat`() { + val sword = def(GearSlot.WEAPON, 1, Stat.DAMAGE to 0.5, Stat.STRENGTH to 0.5) + val random = Random(1) + repeat(1_000) { + val rolls = GearStats.roll(sword, tuning, random) + assertEquals(setOf(Stat.DAMAGE, Stat.STRENGTH), rolls.keys) + rolls.values.forEach { assertTrue(it in tuning.rollMin..tuning.rollMax) } + } + } + + @Test + fun `rarities are picked as weighted`() { + val random = Random(2) + val rolls = 100_000 + val picks = List(rolls) { GearStats.pick(tuning.craftOdds, random) }.groupingBy { it }.eachCount() + val total = tuning.craftOdds.values.sum() + tuning.craftOdds.forEach { (rarity, weight) -> + val expected = weight / total + val actual = (picks[rarity] ?: 0).toDouble() / rolls + assertTrue(abs(actual - expected) <= expected * 0.1, "$rarity picked $actual of the time, not $expected") + } + assertEquals(null, picks[Rarity.MYTHIC], "the Forge never crafts a mythic") + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/mobs/DamageLedgerTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/mobs/DamageLedgerTest.kt new file mode 100644 index 0000000..b09de09 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/mobs/DamageLedgerTest.kt @@ -0,0 +1,53 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import java.util.* +import kotlin.test.Test +import kotlin.test.assertEquals + +class DamageLedgerTest { + + private val mob = UUID.randomUUID() + private val alex = UUID.randomUUID() + private val sam = UUID.randomUUID() + + @Test + fun `every player's damage counts towards the share`() { + val ledger = DamageLedger() + ledger.credit(mob, alex, 30.0) + ledger.credit(mob, sam, 20.0) + ledger.credit(mob, alex, 10.0) + + assertEquals(0.6, ledger.share(mob, max = 100.0), 1e-9) + } + + @Test + fun `each player's share is their own`() { + val ledger = DamageLedger() + ledger.credit(mob, alex, 30.0) + ledger.credit(mob, sam, 5.0) + ledger.credit(mob, alex, 10.0) + + val shares = ledger.shares(mob, max = 100.0) + assertEquals(0.4, shares.getValue(alex), 1e-9) + assertEquals(0.05, shares.getValue(sam), 1e-9) + assertEquals(emptyMap(), ledger.shares(UUID.randomUUID(), max = 100.0)) + } + + @Test + fun `nothing is credited for nothing`() { + val ledger = DamageLedger() + ledger.credit(mob, alex, 0.0) + ledger.credit(mob, alex, -5.0) + + assertEquals(0.0, ledger.share(mob, max = 100.0)) + } + + @Test + fun `a forgotten mob starts over`() { + val ledger = DamageLedger() + ledger.credit(mob, alex, 80.0) + ledger.forget(mob) + + assertEquals(0.0, ledger.share(mob, max = 100.0)) + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/mobs/LevelZoneTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/mobs/LevelZoneTest.kt new file mode 100644 index 0000000..2d7437c --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/mobs/LevelZoneTest.kt @@ -0,0 +1,41 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import kotlin.test.Test +import kotlin.test.assertEquals + +class LevelZoneTest { + + private val overworld = LevelZone(base = 1, spawnRadius = 300.0, ringWidth = 500.0, perRing = 2, cap = 30) + + @Test + fun `the spawn area stays at its base, bonuses and all`() { + assertEquals(0, overworld.ring(0.0)) + assertEquals(0, overworld.ring(300.0)) + assertEquals(1, overworld.level(250.0, bonus = 4)) + } + + @Test + fun `each ring out is harder than the one inside it`() { + assertEquals(1, overworld.ring(300.1)) + assertEquals(3, overworld.level(300.1, bonus = 0)) + assertEquals(1, overworld.ring(799.9)) + assertEquals(2, overworld.ring(800.0)) + assertEquals(5, overworld.level(800.0, bonus = 0)) + } + + @Test + fun `bonuses count outside the spawn area`() { + assertEquals(7, overworld.level(1_000.0, bonus = 2)) + } + + @Test + fun `levels stop at the cap`() { + assertEquals(30, overworld.level(1_000_000.0, bonus = 10)) + } + + @Test + fun `a cap below the base is lifted to it`() { + val zone = LevelZone(base = 10, spawnRadius = 0.0, ringWidth = 100.0, perRing = 5, cap = 5) + assertEquals(10, zone.level(1_000.0, bonus = 0)) + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/mobs/LootRollerTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/mobs/LootRollerTest.kt new file mode 100644 index 0000000..1a8309c --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/mobs/LootRollerTest.kt @@ -0,0 +1,153 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import net.trilleo.mc.plugins.tritown.content.LootEntry +import net.trilleo.mc.plugins.tritown.content.LootTable +import kotlin.math.abs +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class LootRollerTest { + + private val table = LootTable( + families = mapOf("ZOMBIE" to LootTable.Family("zombie", "grave-dust")), + rules = LootTable.Rules(minLevel = 3, playerShare = 50.0, magicFindCap = 100.0), + normal = LootTable.Drop( + chance = 4.0, + minAmount = 1, + maxAmount = 1, + minEssence = 0, + maxEssence = 0, + gearChance = 0.0 + ), + elite = LootTable.Drop( + chance = 100.0, + minAmount = 1, + maxAmount = 3, + minEssence = 1, + maxEssence = 2, + gearChance = 0.0 + ), + champion = LootTable.Drop( + chance = 100.0, + minAmount = 3, + maxAmount = 6, + minEssence = 3, + maxEssence = 5, + gearChance = 1.0 + ), + essence = listOf( + LootTable.EssenceGrade("dim-essence", 15), + LootTable.EssenceGrade("glowing-essence", 30), + ), + ) + + @Test + fun `nothing drops below the minimum level`() { + repeat(1_000) { + assertEquals(emptyList(), LootRoller.roll(table, "ZOMBIE", MobRank.CHAMPION, 2, 0.0, Random(it))) + } + } + + @Test + fun `a normal mob drops its material as often as tuned`() { + assertClose(4.0, materialRate(magicFind = 0.0)) + } + + @Test + fun `magic find multiplies the chance, up to its cap`() { + assertClose(8.0, materialRate(magicFind = 100.0)) + assertClose(8.0, materialRate(magicFind = 500.0)) + } + + @Test + fun `an elite always drops its material and essence in range`() { + repeat(1_000) { + val drops = LootRoller.roll(table, "ZOMBIE", MobRank.ELITE, 10, 0.0, Random(it)) + .associate { d -> d.item to d.amount } + assertTrue(drops.getValue("grave-dust") in 1..3) + assertTrue(drops.getValue("dim-essence") in 1..2) + } + } + + @Test + fun `essence follows the level, and the last grade covers everything above`() { + val at20 = LootRoller.roll(table, "ZOMBIE", MobRank.ELITE, 20, 0.0, Random(1)).map { it.item } + val at99 = LootRoller.roll(table, "ZOMBIE", MobRank.ELITE, 99, 0.0, Random(1)).map { it.item } + assertTrue("glowing-essence" in at20) + assertTrue("glowing-essence" in at99) + } + + @Test + fun `a mob of no family drops essence only`() { + val drops = LootRoller.roll(table, "COW", MobRank.CHAMPION, 10, 0.0, Random(3)).map { it.item } + assertEquals(listOf("dim-essence"), drops) + assertEquals(emptyList(), LootRoller.roll(table, "COW", MobRank.NORMAL, 10, 0.0, Random(3))) + } + + @Test + fun `champions drop gear as often as tuned, and magic find helps`() { + assertClose(1.0, gearRate(magicFind = 0.0)) + assertClose(2.0, gearRate(magicFind = 100.0)) + } + + @Test + fun `nothing below a champion drops gear here, and nothing below the minimum level`() { + repeat(1_000) { + assertTrue(!LootRoller.dropsGear(table, MobRank.ELITE, 30, 100.0, Random(it))) + assertTrue(!LootRoller.dropsGear(table, MobRank.CHAMPION, 2, 100.0, Random(it))) + } + } + + @Test + fun `a custom mob's own loot rolls each line on its own, in its amounts`() { + val entries = listOf( + LootEntry.Item("grave-dust", chance = 100.0, min = 2, max = 4), + LootEntry.Gear("gravewalker-blade", chance = 100.0), + ) + repeat(1_000) { + val won = LootRoller.rollEntries(table, entries, 10, 0.0, Random(it)) + assertEquals(2, won.size) + assertTrue(won[0].amount in 2..4) + assertEquals(1, won[1].amount) + } + } + + @Test + fun `a custom mob's own loot is as likely as tuned, and magic find helps`() { + val entries = listOf(LootEntry.Item("bone-shard", chance = 1.5, min = 1, max = 1)) + val rate = { magicFind: Double -> + val random = Random(11) + val rolls = 200_000 + (1..rolls).count { + LootRoller.rollEntries(table, entries, 10, magicFind, random).isNotEmpty() + } * 100.0 / rolls + } + assertClose(1.5, rate(0.0)) + assertClose(3.0, rate(100.0)) + } + + @Test + fun `a custom mob's own loot follows the minimum level too`() { + val entries = listOf(LootEntry.Item("grave-dust", chance = 100.0, min = 1, max = 1)) + assertEquals(emptyList(), LootRoller.rollEntries(table, entries, 2, 0.0, Random(1))) + } + + private fun gearRate(magicFind: Double): Double { + val random = Random(7) + val rolls = 200_000 + return (1..rolls).count { LootRoller.dropsGear(table, MobRank.CHAMPION, 30, magicFind, random) } * 100.0 / rolls + } + + private fun materialRate(magicFind: Double): Double { + val random = Random(42) + val rolls = 100_000 + val drops = + (1..rolls).count { LootRoller.roll(table, "ZOMBIE", MobRank.NORMAL, 10, magicFind, random).isNotEmpty() } + return drops * 100.0 / rolls + } + + private fun assertClose(expected: Double, actual: Double) = + assertTrue(abs(actual - expected) <= expected * 0.1, "expected about $expected%, rolled $actual%") +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobRollTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobRollTest.kt new file mode 100644 index 0000000..dfa19c1 --- /dev/null +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/mobs/MobRollTest.kt @@ -0,0 +1,143 @@ +package net.trilleo.mc.plugins.tritown.mobs + +import net.trilleo.mc.plugins.tritown.content.* +import kotlin.math.abs +import kotlin.random.Random +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertTrue + +class MobRollTest { + + private val balance = Balance.DEFAULT + private val rolls = 200_000 + + @Test + fun `nothing ranks below the elite level`() { + val random = Random(1) + repeat(10_000) { + assertEquals(MobRank.NORMAL, MobRoll.rank(balance.ranks.elite.minLevel - 1, balance, random)) + } + } + + @Test + fun `elites appear as often as tuned, and champions not before their level`() { + val random = Random(2) + val ranks = List(rolls) { MobRoll.rank(balance.ranks.champion.minLevel - 1, balance, random) } + + assertEquals(0, ranks.count { it == MobRank.CHAMPION }) + assertClose(balance.ranks.elite.chance, ranks.count { it == MobRank.ELITE }) + } + + @Test + fun `champions are rolled first, and elites from what is left`() { + val random = Random(3) + val ranks = List(rolls) { MobRoll.rank(balance.ranks.champion.minLevel, balance, random) } + + assertClose(balance.ranks.champion.chance, ranks.count { it == MobRank.CHAMPION }) + val eliteShare = balance.ranks.elite.chance * (1.0 - balance.ranks.champion.chance / 100.0) + assertClose(eliteShare, ranks.count { it == MobRank.ELITE }) + } + + @Test + fun `a rank's affixes are distinct and as many as it allows`() { + val random = Random(4) + listOf(MobRank.ELITE, MobRank.CHAMPION).forEach { rank -> + val tuning = rank.tuning(balance)!! + repeat(1_000) { + val affixes = MobRoll.affixes(rank, balance, random) + assertEquals(affixes.size, affixes.toSet().size, "$rank rolled a duplicate: $affixes") + assertTrue(affixes.size in tuning.minAffixes..tuning.maxAffixes, "$rank rolled ${affixes.size}") + } + } + } + + @Test + fun `a normal mob has no affixes`() { + assertEquals(emptyList(), MobRoll.affixes(MobRank.NORMAL, balance, Random(5))) + } + + @Test + fun `a custom mob only takes spawns of its own kind, where it lives and at its levels`() { + val catalog = BestiaryCatalog(mapOf("walker" to kind("walker", "ZOMBIE", chance = 100.0))) + val random = Random(6) + + assertEquals("walker", MobRoll.kind(catalog, "ZOMBIE", 5, overworld, random)?.id) + assertEquals(null, MobRoll.kind(catalog, "SKELETON", 5, overworld, random)) + assertEquals(null, MobRoll.kind(catalog, "ZOMBIE", 2, overworld, random)) + assertEquals(null, MobRoll.kind(catalog, "ZOMBIE", 11, overworld, random)) + assertEquals(null, MobRoll.kind(catalog, "ZOMBIE", 5, overworld.copy(environment = "nether"), random)) + assertEquals(null, MobRoll.kind(catalog, "ZOMBIE", 5, overworld.copy(biome = "minecraft:desert"), random)) + assertEquals(null, MobRoll.kind(catalog, "ZOMBIE", 5, overworld.copy(y = -20), random)) + assertEquals(null, MobRoll.kind(catalog, "ZOMBIE", 5, overworld.copy(night = false), random)) + } + + @Test + fun `a world named in a spawn rule counts as well as its kind of world`() { + val catalog = + BestiaryCatalog(mapOf("walker" to kind("walker", "ZOMBIE", chance = 100.0, worlds = setOf("mining")))) + + assertEquals("walker", MobRoll.kind(catalog, "ZOMBIE", 5, overworld.copy(world = "mining"), Random(7))?.id) + assertEquals(null, MobRoll.kind(catalog, "ZOMBIE", 5, overworld, Random(7))) + } + + @Test + fun `a custom mob appears as often as tuned`() { + val catalog = BestiaryCatalog(mapOf("walker" to kind("walker", "ZOMBIE", chance = 6.0))) + val random = Random(8) + val count = (1..rolls).count { MobRoll.kind(catalog, "ZOMBIE", 5, overworld, random) != null } + + assertClose(6.0, count) + } + + @Test + fun `variants of a kind roll in order, and the second only gets what the first leaves`() { + val catalog = BestiaryCatalog( + linkedMapOf( + "first" to kind("first", "ZOMBIE", chance = 10.0), + "second" to kind("second", "ZOMBIE", chance = 10.0), + ) + ) + val random = Random(9) + val kinds = List(rolls) { MobRoll.kind(catalog, "ZOMBIE", 5, overworld, random)?.id } + + assertClose(10.0, kinds.count { it == "first" }) + assertClose(9.0, kinds.count { it == "second" }) + } + + private val overworld = SpawnPlace("normal", "world", "minecraft:plains", y = 64, night = true) + + private fun kind(id: String, base: String, chance: Double, worlds: Set = setOf("normal")) = MobKindDef( + id = id, + base = base, + spawn = SpawnRule( + worlds = worlds, + biomes = setOf("minecraft:plains"), + minLevel = 3, + maxLevel = 10, + chance = chance, + time = SpawnTime.NIGHT, + minY = 0, + maxY = null, + ), + boss = null, + rankable = true, + health = 1.0, + damage = 1.0, + defense = 0.0, + speed = 0.0, + scale = 1.0, + knockback = 0.0, + affixes = emptySet(), + abilities = emptyList(), + minions = null, + equipment = emptyMap(), + loot = emptyList(), + ) + + /** [count] out of [rolls] is [percent] percent, within a tenth of it. */ + private fun assertClose(percent: Double, count: Int) { + val actual = count * 100.0 / rolls + assertTrue(abs(actual - percent) <= percent * 0.1, "expected about $percent%, rolled $actual%") + } +} diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrameTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrameTest.kt index af571fa..0f28f4c 100644 --- a/src/test/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrameTest.kt +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/registration/GUIFrameTest.kt @@ -75,25 +75,4 @@ class GUIFrameTest { assertEquals(listOf(2, 3, 5, 6), GUIFrame.packedColumns(4)) assertEquals(listOf(1, 2, 3, 5, 6, 7), GUIFrame.packedColumns(6)) } - - @Test - fun `a few items sit in the middle of the menu`() { - assertEquals(listOf(22), GUIFrame.centeredSlots(6, 1)) - assertEquals(listOf(21, 23), GUIFrame.centeredSlots(6, 2)) - } - - @Test - fun `a partial page centres its last row under full ones`() { - assertEquals((19..25).toList() + listOf(30, 32), GUIFrame.centeredSlots(6, 9)) - } - - @Test - fun `a full page is exactly the framed content`() { - assertEquals(GUIFrame.contentSlots(6), GUIFrame.centeredSlots(6, 28)) - } - - @Test - fun `nothing to show takes no slots`() { - assertEquals(emptyList(), GUIFrame.centeredSlots(6, 0)) - } } diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/storage/SlotFillTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/storage/SlotFillTest.kt index ef5225d..23a4140 100644 --- a/src/test/kotlin/net/trilleo/mc/plugins/tritown/storage/SlotFillTest.kt +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/storage/SlotFillTest.kt @@ -1,10 +1,6 @@ package net.trilleo.mc.plugins.tritown.storage -import kotlin.test.Test -import kotlin.test.assertContentEquals -import kotlin.test.assertEquals -import kotlin.test.assertFalse -import kotlin.test.assertNull +import kotlin.test.* /** * Depositing and sorting move a player's items around without them watching diff --git a/src/test/kotlin/net/trilleo/mc/plugins/tritown/utils/LangFilesTest.kt b/src/test/kotlin/net/trilleo/mc/plugins/tritown/utils/LangFilesTest.kt index 299d6dc..f3e512b 100644 --- a/src/test/kotlin/net/trilleo/mc/plugins/tritown/utils/LangFilesTest.kt +++ b/src/test/kotlin/net/trilleo/mc/plugins/tritown/utils/LangFilesTest.kt @@ -16,8 +16,13 @@ class LangFilesTest { private val english = languages.getValue("en_US") private val config = loadConfig() - /** Keys built at runtime (`"money.source.$source"`), which the source scan cannot see. */ - private val dynamicPrefixes = listOf("command.", "money.source.") + /** + * Keys built at runtime (`"money.source.$source"`), which the source scan + * cannot see. Content items', gear's and custom mobs' keys are held to the + * content files instead, by `ContentFilesTest`. + */ + private val dynamicPrefixes = + listOf("command.", "money.source.", "item.", "gear.item.", "gear.reforge.", "mob.kind.") private val keyLiteral = Regex("\"([a-z][a-z0-9-]*(?:\\.[a-z0-9-]+)+)\"") private val placeholder = Regex("\\{([a-z]+)}") @@ -61,14 +66,17 @@ class LangFilesTest { * * `config.yml`'s own paths are subtracted, because a settings block and a * translation section can share a name — `scoreboard` is both — and code - * naming a setting is not using a translation. + * naming a setting is not using a translation. So are file names, which a + * section can share too: `gear.yml` is a content file, not a key. */ private fun usedKeys(): Set { val sections = english.keys.map { it.substringBefore('.') }.toSet() val inCode = File("src/main/kotlin").walk() .filter { it.extension == "kt" } .flatMap { file -> keyLiteral.findAll(comment.replace(file.readText(), "")).map { it.groupValues[1] } } - return (inCode + sidebarKeys()).filter { it.substringBefore('.') in sections }.toSet() - configPaths() + return (inCode + sidebarKeys()) + .filter { it.substringBefore('.') in sections && !it.endsWith(".yml") } + .toSet() - configPaths() } /**