selector and web
Build / build (push) Has been cancelled

This commit is contained in:
gpatruno
2026-06-07 00:35:18 +02:00
parent 211779785d
commit 836499d10d
18 changed files with 1189 additions and 25 deletions
@@ -110,11 +110,13 @@ public final class CharacterCommand extends AbstractCommandCollection {
}
try {
String name = context.get(nameArg);
CharacterService.CharacterCreationResult result;
if (name == null || name.isBlank()) {
name = characters.nextDefaultName(playerRef.getUuid());
result = characters.createDefaultCharacter(
playerRef.getUuid(), playerRef.getUsername());
} else {
result = characters.createCharacter(playerRef.getUuid(), name);
}
CharacterService.CharacterCreationResult result =
characters.createCharacter(playerRef.getUuid(), name);
if (result.ok() && result.profile() != null) {
context.sendMessage(Message.raw(
"Personnage créé : " + result.profile().getDisplayName()));
@@ -109,6 +109,15 @@ public final class CharacterRepository {
return profile;
}
public boolean delete(@Nonnull UUID accountUuid, @Nonnull UUID characterId) throws SQLException {
String sql = "DELETE FROM characters WHERE id = ? AND account_uuid = ?";
try (PreparedStatement statement = connection().prepareStatement(sql)) {
statement.setString(1, characterId.toString());
statement.setString(2, accountUuid.toString());
return statement.executeUpdate() > 0;
}
}
@Nonnull
private List<PlayerProfile> findByQuery(@Nonnull String whereClause, @Nonnull String... params)
throws SQLException {
@@ -87,6 +87,25 @@ public final class CharacterService {
if (name == null) {
return CharacterCreationResult.invalidName();
}
return createCharacterInternal(accountUuid, name);
}
/**
* Creates a character named after the player's account username.
* Uses {@code "pseudo 2"}, {@code "pseudo 3"}, … when the base name is already taken.
*/
@Nonnull
public CharacterCreationResult createDefaultCharacter(
@Nonnull UUID accountUuid,
@Nonnull String playerUsername) throws SQLException {
String name = defaultCharacterName(accountUuid, playerUsername);
return createCharacterInternal(accountUuid, name);
}
@Nonnull
private CharacterCreationResult createCharacterInternal(
@Nonnull UUID accountUuid,
@Nonnull String name) throws SQLException {
if (characters.countByAccount(accountUuid) >= config.getMaxCharactersPerAccount()) {
return CharacterCreationResult.limitReached(config.getMaxCharactersPerAccount());
}
@@ -105,9 +124,54 @@ public final class CharacterService {
}
@Nonnull
public String nextDefaultName(@Nonnull UUID accountUuid) throws SQLException {
int count = characters.countByAccount(accountUuid);
return "Perso " + (count + 1);
public String defaultCharacterName(
@Nonnull UUID accountUuid,
@Nonnull String playerUsername) throws SQLException {
String base = sanitizeCharacterName(playerUsername);
if (!characters.nameExists(accountUuid, base)) {
return base;
}
for (int suffix = 2; suffix <= config.getMaxCharactersPerAccount() + 1; suffix++) {
String suffixText = " " + suffix;
int maxBaseLength = MAX_NAME_LENGTH - suffixText.length();
String truncatedBase = base.length() > maxBaseLength
? base.substring(0, Math.max(1, maxBaseLength)).trim()
: base;
String candidate = truncatedBase + suffixText;
if (!characters.nameExists(accountUuid, candidate)) {
return candidate;
}
}
return base + " " + System.currentTimeMillis();
}
@Nonnull
public CharacterDeletionResult deleteCharacter(
@Nonnull UUID accountUuid,
@Nonnull UUID characterId) throws SQLException {
Optional<PlayerProfile> found = characters.findById(characterId);
if (found.isEmpty() || !found.get().getAccountUuid().equals(accountUuid)) {
return CharacterDeletionResult.notFound();
}
PlayerAccount account = accounts.findByUuid(accountUuid).orElse(null);
if (account != null && characterId.equals(account.getActiveCharacterId())) {
accounts.setActiveCharacter(accountUuid, null);
account.setActiveCharacterId(null);
account.touch();
accounts.save(account);
}
if (!characters.delete(accountUuid, characterId)) {
return CharacterDeletionResult.notFound();
}
logger.at(Level.INFO).log(
"Deleted character %s (%s) for account %s",
found.get().getDisplayName(),
characterId,
accountUuid);
return CharacterDeletionResult.success(found.get().getDisplayName());
}
public void openCharacterSelector(
@@ -224,6 +288,18 @@ public final class CharacterService {
}
}
@Nullable
private static String sanitizeCharacterName(@Nonnull String rawName) {
String name = rawName.trim();
if (name.isEmpty()) {
return "Joueur";
}
if (name.length() > MAX_NAME_LENGTH) {
name = name.substring(0, MAX_NAME_LENGTH);
}
return name;
}
@Nullable
private static String normalizeName(@Nonnull String rawName) {
String name = rawName.trim();
@@ -269,4 +345,18 @@ public final class CharacterService {
false, null, "Limite de " + max + " personnages atteinte.");
}
}
public record CharacterDeletionResult(
boolean ok,
@Nullable String deletedName,
@Nullable String errorMessage) {
static CharacterDeletionResult success(@Nonnull String deletedName) {
return new CharacterDeletionResult(true, deletedName, null);
}
static CharacterDeletionResult notFound() {
return new CharacterDeletionResult(false, null, "Personnage introuvable.");
}
}
}
@@ -29,9 +29,6 @@ import java.util.logging.Logger;
/**
* Mandatory character selector shown on join before gameplay begins.
* <p>
* Character rows are predefined in {@code MmorpgCharacterSelect.ui} — {@code appendInline}
* only supports simple widgets (e.g. {@code Label}), not nested {@code Group}/{@code TextButton}.
*/
public final class CharacterSelectPage extends InteractiveCustomUIPage<CharacterSelectPage.UiEvent> {
@@ -44,6 +41,11 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
private final CharacterService characters;
private final int maxCharacters;
@Nullable
private UUID pendingDeleteId;
@Nullable
private String pendingDeleteName;
public CharacterSelectPage(
@Nonnull PlayerRef playerRef,
@Nonnull UUID accountUuid,
@@ -67,6 +69,7 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
ui.set("#Subtitle.Text", escape("Compte : " + playerRef.getUsername()));
bindActions(events);
renderCharacterList(ui, events, loadCharacters());
applyConfirmPanel(ui, events);
}
@Override
@@ -80,17 +83,36 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
}
if ("refresh".equals(event.action())) {
clearPendingDelete();
refresh(ref, store);
return;
}
if ("create".equals(event.action())) {
clearPendingDelete();
handleCreate(ref, store);
return;
}
if ("select".equals(event.action()) && event.characterId() != null) {
clearPendingDelete();
handleSelect(ref, store, event.characterId());
return;
}
if ("delete".equals(event.action()) && event.characterId() != null) {
handleDeleteRequest(ref, store, event.characterId());
return;
}
if ("confirm_delete".equals(event.action())) {
handleConfirmDelete(ref, store);
return;
}
if ("cancel_delete".equals(event.action())) {
clearPendingDelete();
refresh(ref, store);
}
}
@@ -101,16 +123,16 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
setStatus(ref, store, "Limite de " + maxCharacters + " personnages atteinte.");
return;
}
String name = characters.nextDefaultName(accountUuid);
CharacterService.CharacterCreationResult result = characters.createCharacter(accountUuid, name);
CharacterService.CharacterCreationResult result =
characters.createDefaultCharacter(accountUuid, playerRef.getUsername());
if (!result.ok()) {
setStatus(ref, store, result.errorMessage() != null ? result.errorMessage() : "Creation impossible.");
setStatus(ref, store, result.errorMessage() != null ? result.errorMessage() : "Création impossible.");
return;
}
refresh(ref, store);
} catch (SQLException e) {
LOGGER.log(Level.WARNING, "Character creation failed", e);
setStatus(ref, store, "Erreur lors de la creation du personnage.");
setStatus(ref, store, "Erreur lors de la création du personnage.");
}
}
@@ -126,11 +148,52 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
}
}
private void handleDeleteRequest(
@Nonnull Ref<EntityStore> ref,
@Nonnull Store<EntityStore> store,
@Nonnull UUID characterId) {
try {
var found = characters.findCharacter(accountUuid, characterId.toString());
if (found.isEmpty()) {
setStatus(ref, store, "Personnage introuvable.");
return;
}
pendingDeleteId = characterId;
pendingDeleteName = found.get().getDisplayName();
refresh(ref, store);
} catch (SQLException e) {
LOGGER.log(Level.WARNING, "Character delete request failed", e);
setStatus(ref, store, "Erreur lors de la préparation de la suppression.");
}
}
private void handleConfirmDelete(@Nonnull Ref<EntityStore> ref, @Nonnull Store<EntityStore> store) {
if (pendingDeleteId == null) {
refresh(ref, store);
return;
}
try {
CharacterService.CharacterDeletionResult result =
characters.deleteCharacter(accountUuid, pendingDeleteId);
clearPendingDelete();
if (result.ok()) {
setStatus(ref, store, "Personnage supprimé : " + result.deletedName());
} else {
setStatus(ref, store, result.errorMessage() != null ? result.errorMessage() : "Suppression impossible.");
}
} catch (SQLException e) {
LOGGER.log(Level.WARNING, "Character deletion failed", e);
clearPendingDelete();
setStatus(ref, store, "Erreur lors de la suppression du personnage.");
}
}
private void refresh(@Nonnull Ref<EntityStore> ref, @Nonnull Store<EntityStore> store) {
UICommandBuilder ui = new UICommandBuilder();
UIEventBuilder events = new UIEventBuilder();
bindActions(events);
renderCharacterList(ui, events, loadCharacters());
applyConfirmPanel(ui, events);
sendUpdate(ui, events, false);
}
@@ -142,6 +205,7 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
UIEventBuilder events = new UIEventBuilder();
bindActions(events);
renderCharacterList(ui, events, loadCharacters());
applyConfirmPanel(ui, events);
ui.set("#FooterHint.Text", escape(message));
sendUpdate(ui, events, false);
}
@@ -184,9 +248,37 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
EventData.of("Action", "select")
.append("CharacterId", profile.getCharacterId().toString()),
false);
events.addEventBinding(
CustomUIEventBindingType.Activating,
"#CharDelete" + slot,
EventData.of("Action", "delete")
.append("CharacterId", profile.getCharacterId().toString()),
false);
}
}
private void applyConfirmPanel(@Nonnull UICommandBuilder ui, @Nonnull UIEventBuilder events) {
if (pendingDeleteId == null || pendingDeleteName == null) {
ui.set("#ConfirmPanel.Visible", false);
return;
}
ui.set("#ConfirmPanel.Visible", true);
ui.set(
"#ConfirmMessage.Text",
escape("Supprimer « " + pendingDeleteName + " » ?\nInventaire et progression seront perdus."));
events.addEventBinding(
CustomUIEventBindingType.Activating,
"#ConfirmDeleteButton",
EventData.of("Action", "confirm_delete"),
false);
events.addEventBinding(
CustomUIEventBindingType.Activating,
"#CancelDeleteButton",
EventData.of("Action", "cancel_delete"),
false);
}
private void bindActions(@Nonnull UIEventBuilder events) {
events.addEventBinding(
CustomUIEventBindingType.Activating,
@@ -200,6 +292,11 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
false);
}
private void clearPendingDelete() {
pendingDeleteId = null;
pendingDeleteName = null;
}
@Nonnull
private static String formatSummary(@Nonnull PlayerProfile profile) {
PlayerClass playerClass = ClassCatalog.byId(profile.getClassId());
@@ -3,6 +3,7 @@
@MmorpgAccent = #f5c842;
@MmorpgText = #e8e8ec;
@MmorpgMuted = #878e9c;
@MmorpgDanger = #c9302c;
Group {
LayoutMode: Center;
@@ -49,9 +50,14 @@ Group {
FlexWeight: 1;
}
TextButton #CharDelete0 {
Text: "Suppr.";
Anchor: (Width: 72, Right: 6);
}
TextButton #CharSelect0 {
Text: "Jouer";
Anchor: (Width: 100);
Anchor: (Width: 88);
}
}
@@ -67,9 +73,14 @@ Group {
FlexWeight: 1;
}
TextButton #CharDelete1 {
Text: "Suppr.";
Anchor: (Width: 72, Right: 6);
}
TextButton #CharSelect1 {
Text: "Jouer";
Anchor: (Width: 100);
Anchor: (Width: 88);
}
}
@@ -85,9 +96,14 @@ Group {
FlexWeight: 1;
}
TextButton #CharDelete2 {
Text: "Suppr.";
Anchor: (Width: 72, Right: 6);
}
TextButton #CharSelect2 {
Text: "Jouer";
Anchor: (Width: 100);
Anchor: (Width: 88);
}
}
@@ -103,9 +119,14 @@ Group {
FlexWeight: 1;
}
TextButton #CharDelete3 {
Text: "Suppr.";
Anchor: (Width: 72, Right: 6);
}
TextButton #CharSelect3 {
Text: "Jouer";
Anchor: (Width: 100);
Anchor: (Width: 88);
}
}
@@ -121,9 +142,43 @@ Group {
FlexWeight: 1;
}
TextButton #CharDelete4 {
Text: "Suppr.";
Anchor: (Width: 72, Right: 6);
}
TextButton #CharSelect4 {
Text: "Jouer";
Anchor: (Width: 100);
Anchor: (Width: 88);
}
}
}
Group #ConfirmPanel {
LayoutMode: Top;
Visible: false;
Background: #2a1515(0.95);
Padding: (Full: 12);
Anchor: (Bottom: 8);
Label #ConfirmMessage {
Style: (FontSize: 14, TextColor: @MmorpgText, Wrap: true, HorizontalAlignment: Center);
Text: "Confirmer la suppression ?";
Anchor: (Bottom: 10);
}
Group #ConfirmActions {
LayoutMode: Left;
TextButton #ConfirmDeleteButton {
Text: "Confirmer la suppression";
FlexWeight: 1;
Anchor: (Right: 8);
}
TextButton #CancelDeleteButton {
Text: "Annuler";
FlexWeight: 1;
}
}
}