Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 53 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
# CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

## O que é

Plugin Paper/Spigot (Java 21) que consome a API da **CentralCart** para: exibir os top 3 doadores do mês anterior, criar/atualizar NPCs (via Citizens) desses doadores, distribuir recompensas e fazer broadcast de novos posts do blog da loja. Nome do artefato: `centralCartTopPlugin`. Pacote raiz: `plugin.centralCartTopPlugin`.

## Build, deploy e execução

> ⚠️ **Gotcha de JDK (lê isto antes de buildar).** O `~/.gradle/gradle.properties` global desta máquina força `org.gradle.java.home=jdk-25.0.3` (para projetos Fabric). O **Gradle 8.8 não roda sobre Java 25** e falha com `Unsupported class file major version 69`. Há um **JDK 21** em `C:\Program Files\Java\jdk-21` (alvo do projeto). Sobrescreva por build, **sem** alterar o gradle.properties global:

```bash
# Compilar
./gradlew compileJava --no-daemon -Dorg.gradle.java.home="C:\Program Files\Java\jdk-21"

# Gerar o jar (sai em build/libs/centralCartTopPlugin-<version>.jar; inclui o gson shaded)
./gradlew jar --no-daemon -Dorg.gradle.java.home="C:\Program Files\Java\jdk-21"

# Copiar o jar para o servidor local (D:/AUSTV/localhost/plugins)
./gradlew copyJar --no-daemon -Dorg.gradle.java.home="C:\Program Files\Java\jdk-21"

# Subir um servidor Paper de teste (plugin run-paper, MC 1.21)
./gradlew runServer --no-daemon -Dorg.gradle.java.home="C:\Program Files\Java\jdk-21"
```

Não há suíte de testes automatizados — a validação é via build + teste no servidor (comandos `/test*`).

A versão vive em `build.gradle` (`version = '...'`) e é injetada no `plugin.yml` via `processResources` (`expand`, o `plugin.yml` usa `${version}`). **Convenção do dono do repo: sempre incrementar a versão em `build.gradle` a cada modificação.**

## Arquitetura

Camadas sob `src/main/java/plugin/centralCartTopPlugin/`:

- **`CentralCartTopPlugin`** — entrypoint. No `onEnable`: `saveDefaultConfig()` → `mergeConfigDefaults()` → inicializa serviços → registra comandos/listeners → carrega NPCs após `STARTUP_DELAY_TICKS` → inicia as tasks. `reloadServices()` recria serviços e re-registra comandos (usado pelo `/centralcartreload`).
- **`service/`** — `CentralCartApiService` (top doadores, com `TopDonatorsCache`), `BlogPostService` (posts do blog), `RewardsManager`. Os serviços de rede expõem `CompletableFuture` e fazem I/O fora da main thread.
- **`task/`** — `MonthlyNpcUpdateTask` (timer horário; atualiza NPCs no dia 1º), `BlogPostCheckTask` (a cada 5 min; detecta posts novos).
- **`service/TopNpcManager`** — toda a integração com Citizens (criação/atualização/skin/posição dos NPCs).
- **`manager/MessagesManager`** + **`util/MessageFormatter`** — mensagens externalizadas em `messages.yml`; formatter aceita **códigos legados `&`/`§` E tags MiniMessage** na mesma string.
- **`util/`** — `Constants` (defaults, URLs, intervalos), `PluginUtils` (mapeamento posição→key, normalização de domínio, leitura de corpo de erro HTTP), `DateTimeUtil` (parsing de datas da API), `BlogNotifier` (montagem de placeholders + broadcast do blog, compartilhado entre task e comando de teste).

### Invariantes que não são óbvias

- **A API CentralCart é multi-tenant.** TODA chamada (`top_customers` e `webstore/post`) precisa do header **`x-store-domain`** com o domínio da loja (`api.store_domain`, ex.: `loja.austv.net`). Sem ele a API responde **`404 "Store not found"`** — foi a causa de NPCs e broadcast de blog pararem de funcionar. O token vai em `Authorization: Bearer`. Mantenha os dois sempre que adicionar novos endpoints.
- **Config self-healing.** `mergeConfigDefaults()` (`copyDefaults(true)` + `saveConfig()`) mescla chaves novas em `config.yml` já existentes — `saveDefaultConfig()` sozinho **não** atualiza arquivos existentes. Ao adicionar uma chave de config, garanta que ela exista no `config.yml` embutido, senão servidores antigos nunca a recebem.
- **Thread safety.** Serviços fazem HTTP em async (`CompletableFuture`/`runTaskAsynchronously`). Qualquer interação com Bukkit/Citizens (spawn de NPC, `broadcast`, `saveConfig`) DEVE voltar à main thread via `Bukkit.getScheduler().runTask(...)`. As tasks e comandos já seguem esse padrão — preserve-o.
- **NPCs são reutilizados, não recriados.** `TopNpcManager` persiste `npcs.saved_ids` (posição→id do NPC) no config; o Citizens recarrega os NPCs entre reinícios. Não destrua NPCs no `onDisable`.
- **Citizens é `softdepend`.** Sempre cheque `npcManager.isCitizensEnabled()` antes de mexer com NPCs.
- **Detecção de post novo.** `BlogPostCheckTask` faz *seeding* na primeira execução (marca o post mais recente como visto sem anunciar) e depois anuncia todos os posts com `id` maior que `blog.last_seen_post_id` (comparação numérica). A API entrega os posts em ordem decrescente de `id`.

## Configuração (`src/main/resources/`)

`config.yml` (API/token/store_domain, NPCs, blog), `messages.yml` (textos), `rewards.yml` (recompensas), `plugin.yml` (comandos/permissões). Permissão de admin: `centralcart.admin`. Detalhes de comandos no `README.md`.
13 changes: 7 additions & 6 deletions build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ plugins {
}

group = 'com.centralcart'
version = '1.1.0'
version = '1.2.0'

compileJava.options.encoding('UTF-8')

Expand Down Expand Up @@ -68,9 +68,10 @@ processResources {
}
}

task copyJar {
copy {
from 'build/libs/centralCartTopPlugin-1.1.0.jar'
into 'D:/AUSTV/localhost/plugins'
}
// Copia o jar final para a pasta de plugins do servidor local.
// Usa a saída da task 'jar' (cria a dependência automaticamente) e a versão dinâmica,
// evitando o nome de arquivo fixo que quebrava a cada bump de versão.
tasks.register('copyJar', Copy) {
from tasks.named('jar')
into 'D:/AUSTV/localhost/plugins'
}
52 changes: 41 additions & 11 deletions src/main/java/plugin/centralCartTopPlugin/CentralCartTopPlugin.java
Original file line number Diff line number Diff line change
@@ -1,6 +1,8 @@
package plugin.centralCartTopPlugin;

import org.bukkit.Bukkit;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.PluginCommand;
import org.bukkit.plugin.java.JavaPlugin;
import plugin.centralCartTopPlugin.command.CacheInfoCommand;
import plugin.centralCartTopPlugin.command.MessagesCommand;
Expand Down Expand Up @@ -38,8 +40,9 @@ public final class CentralCartTopPlugin extends JavaPlugin {
public void onEnable() {
getLogger().info("§a[CentralCartTopPlugin] Plugin iniciado com sucesso!");

// Salva o config.yml padrão se não existir
// Salva o config.yml padrão se não existir e mescla chaves novas em configs já existentes
saveDefaultConfig();
mergeConfigDefaults();

// Inicializa o gerenciador de mensagens PRIMEIRO
messagesManager = new MessagesManager(this);
Expand Down Expand Up @@ -122,6 +125,20 @@ public void onDisable() {
getLogger().info("§c[CentralCartTopPlugin] Plugin desabilitado!");
}

/**
* Mescla no config.yml do servidor as chaves que foram adicionadas em versões posteriores
* do plugin (ex.: {@code api.store_domain}, seção {@code blog}). {@code saveDefaultConfig()}
* não atualiza arquivos já existentes, então sem isto servidores antigos ficam sem as chaves
* novas — foi a causa do broadcast de blog não funcionar (domínio ausente -> 404).
*
* <p>Valores já definidos pelo usuário são preservados; apenas chaves ausentes recebem o
* valor padrão embutido no jar.
*/
private void mergeConfigDefaults() {
getConfig().options().copyDefaults(true);
saveConfig();
}

/**
* Inicializa os serviços do plugin
*/
Expand Down Expand Up @@ -151,16 +168,29 @@ private void initializeServices() {
* Registra todos os comandos do plugin
*/
private void registerCommands() {
getCommand("topdonadores").setExecutor(new TopDonadoresCommand(this));
getCommand("spawntopnpcs").setExecutor(new SpawnTopNpcsCommand(this, apiService, npcManager));
getCommand("removetopnpcs").setExecutor(new RemoveTopNpcsCommand(this, npcManager));
getCommand("centralcartreload").setExecutor(new ReloadCommand(this));
getCommand("testschedule").setExecutor(new TestScheduleCommand(this));
getCommand("scheduleinfo").setExecutor(new ScheduleInfoCommand(this));
getCommand("testrewards").setExecutor(new TestRewardsCommand(this));
getCommand("cacheinfo").setExecutor(new CacheInfoCommand(this));
getCommand("messages").setExecutor(new MessagesCommand(this));
getCommand("testblogpost").setExecutor(new TestBlogPostCommand(this));
registerCommand("topdonadores", new TopDonadoresCommand(this));
registerCommand("spawntopnpcs", new SpawnTopNpcsCommand(this, apiService, npcManager));
registerCommand("removetopnpcs", new RemoveTopNpcsCommand(this, npcManager));
registerCommand("centralcartreload", new ReloadCommand(this));
registerCommand("testschedule", new TestScheduleCommand(this));
registerCommand("scheduleinfo", new ScheduleInfoCommand(this));
registerCommand("testrewards", new TestRewardsCommand(this));
registerCommand("cacheinfo", new CacheInfoCommand(this));
registerCommand("messages", new MessagesCommand(this));
registerCommand("testblogpost", new TestBlogPostCommand(this));
}

/**
* Registra um executor para um comando declarado no plugin.yml. Se o comando não existir
* (typo no nome ou ausente do plugin.yml), loga um aviso em vez de estourar NPE.
*/
private void registerCommand(String name, CommandExecutor executor) {
PluginCommand command = getCommand(name);
if (command != null) {
command.setExecutor(executor);
} else {
getLogger().warning("Comando '" + name + "' não encontrado no plugin.yml — não foi registrado.");
}
}

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,36 +1,23 @@
package plugin.centralCartTopPlugin.command;

import net.kyori.adventure.text.Component;
import org.bukkit.Bukkit;
import org.bukkit.command.Command;
import org.bukkit.command.CommandExecutor;
import org.bukkit.command.CommandSender;
import org.jetbrains.annotations.NotNull;
import plugin.centralCartTopPlugin.CentralCartTopPlugin;
import plugin.centralCartTopPlugin.model.BlogPost;
import plugin.centralCartTopPlugin.util.MessageFormatter;

import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.DateTimeParseException;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import plugin.centralCartTopPlugin.util.BlogNotifier;
import plugin.centralCartTopPlugin.util.Constants;

import java.util.logging.Level;

/**
* Testa o broadcast de novo post do blog usando o MESMO caminho da task automática
* (via {@link BlogNotifier}). Não altera {@code last_seen_post_id}.
*/
public class TestBlogPostCommand implements CommandExecutor {

private static final DateTimeFormatter[] PARSERS = {
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss.SSSSSS'Z'"),
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss'Z'"),
DateTimeFormatter.ofPattern("yyyy-MM-dd'T'HH:mm:ss"),
DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss"),
};

private static final DateTimeFormatter TIME_FORMAT = DateTimeFormatter.ofPattern("HH:mm");
private static final DateTimeFormatter DATE_FORMAT = DateTimeFormatter.ofPattern("dd/MM/yyyy");

private final CentralCartTopPlugin plugin;

public TestBlogPostCommand(CentralCartTopPlugin plugin) {
Expand All @@ -39,11 +26,17 @@ public TestBlogPostCommand(CentralCartTopPlugin plugin) {

@Override
public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command, @NotNull String label, @NotNull String[] args) {
if (!sender.hasPermission("centralcart.admin")) {
if (!sender.hasPermission(Constants.PERMISSION_ADMIN)) {
sender.sendMessage("§c§l[CentralCart] §cVocê não tem permissão para usar este comando.");
return true;
}

if (!plugin.getBlogPostService().isConfigured()) {
sender.sendMessage("§c§l[Blog] §capi.store_domain não está configurado no config.yml.");
sender.sendMessage("§7Configure §fapi.store_domain: \"loja.austv.net\" §7e use §f/centralcartreload§7.");
return true;
}

sender.sendMessage("§e§l[Blog] §eBuscando último post na API...");

plugin.getBlogPostService().getLatestPost().thenAccept(optPost -> {
Expand All @@ -63,8 +56,6 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command
return;
}

Map<String, String> placeholders = buildPlaceholders(post);

Bukkit.getScheduler().runTask(plugin, () -> {
sender.sendMessage("§a§l[Blog] §aPost obtido com sucesso!");
sender.sendMessage("§7ID: §f" + post.getId());
Expand All @@ -73,13 +64,10 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command
sender.sendMessage("§7Data: §f" + post.getCreatedAt());
sender.sendMessage("§e§l[Blog] §eDisparando broadcast...");

List<String> lines = plugin.getConfig().getStringList("blog.notification.lines");
for (String line : lines) {
Component component = MessageFormatter.parse(line, placeholders);
Bukkit.getServer().broadcast(component);
}
int sent = BlogNotifier.broadcast(plugin, post);

sender.sendMessage("§a§l[Blog] §aBroadcast enviado! (last_seen_post_id NÃO foi alterado)");
sender.sendMessage("§a§l[Blog] §aBroadcast enviado (" + sent
+ " linha(s))! §7(last_seen_post_id NÃO foi alterado)");
});

}).exceptionally(throwable -> {
Expand All @@ -92,46 +80,4 @@ public boolean onCommand(@NotNull CommandSender sender, @NotNull Command command

return true;
}

private Map<String, String> buildPlaceholders(BlogPost post) {
Map<String, String> map = new HashMap<>();
map.put("title", post.getTitle() != null ? post.getTitle() : "");
map.put("url", post.getUrl() != null ? post.getUrl() : "");

String time = "";
String date = "";

if (post.getCreatedAt() != null && !post.getCreatedAt().isEmpty()) {
LocalDateTime dt = tryParseDateTime(post.getCreatedAt());
if (dt != null) {
time = dt.format(TIME_FORMAT);
date = dt.format(DATE_FORMAT);
} else {
time = post.getCreatedAt();
date = post.getCreatedAt();
}
}

map.put("time", time);
map.put("date", date);
return map;
}

private LocalDateTime tryParseDateTime(String raw) {
// Tenta primeiro como OffsetDateTime (ex: "2024-03-03T23:43:12.000-03:00")
try {
return OffsetDateTime.parse(raw, DateTimeFormatter.ISO_OFFSET_DATE_TIME).toLocalDateTime();
} catch (DateTimeParseException ignored) {
// segue para os formatos legados
}

for (DateTimeFormatter fmt : PARSERS) {
try {
return LocalDateTime.parse(raw, fmt);
} catch (DateTimeParseException ignored) {
// tenta próximo formato
}
}
return null;
}
}
Loading
Loading