+5
-3
@@ -110,11 +110,13 @@ public final class CharacterCommand extends AbstractCommandCollection {
|
|||||||
}
|
}
|
||||||
try {
|
try {
|
||||||
String name = context.get(nameArg);
|
String name = context.get(nameArg);
|
||||||
|
CharacterService.CharacterCreationResult result;
|
||||||
if (name == null || name.isBlank()) {
|
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) {
|
if (result.ok() && result.profile() != null) {
|
||||||
context.sendMessage(Message.raw(
|
context.sendMessage(Message.raw(
|
||||||
"Personnage créé : " + result.profile().getDisplayName()));
|
"Personnage créé : " + result.profile().getDisplayName()));
|
||||||
|
|||||||
+9
@@ -109,6 +109,15 @@ public final class CharacterRepository {
|
|||||||
return profile;
|
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
|
@Nonnull
|
||||||
private List<PlayerProfile> findByQuery(@Nonnull String whereClause, @Nonnull String... params)
|
private List<PlayerProfile> findByQuery(@Nonnull String whereClause, @Nonnull String... params)
|
||||||
throws SQLException {
|
throws SQLException {
|
||||||
|
|||||||
+93
-3
@@ -87,6 +87,25 @@ public final class CharacterService {
|
|||||||
if (name == null) {
|
if (name == null) {
|
||||||
return CharacterCreationResult.invalidName();
|
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()) {
|
if (characters.countByAccount(accountUuid) >= config.getMaxCharactersPerAccount()) {
|
||||||
return CharacterCreationResult.limitReached(config.getMaxCharactersPerAccount());
|
return CharacterCreationResult.limitReached(config.getMaxCharactersPerAccount());
|
||||||
}
|
}
|
||||||
@@ -105,9 +124,54 @@ public final class CharacterService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Nonnull
|
@Nonnull
|
||||||
public String nextDefaultName(@Nonnull UUID accountUuid) throws SQLException {
|
public String defaultCharacterName(
|
||||||
int count = characters.countByAccount(accountUuid);
|
@Nonnull UUID accountUuid,
|
||||||
return "Perso " + (count + 1);
|
@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(
|
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
|
@Nullable
|
||||||
private static String normalizeName(@Nonnull String rawName) {
|
private static String normalizeName(@Nonnull String rawName) {
|
||||||
String name = rawName.trim();
|
String name = rawName.trim();
|
||||||
@@ -269,4 +345,18 @@ public final class CharacterService {
|
|||||||
false, null, "Limite de " + max + " personnages atteinte.");
|
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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+104
-7
@@ -29,9 +29,6 @@ import java.util.logging.Logger;
|
|||||||
|
|
||||||
/**
|
/**
|
||||||
* Mandatory character selector shown on join before gameplay begins.
|
* 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> {
|
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 CharacterService characters;
|
||||||
private final int maxCharacters;
|
private final int maxCharacters;
|
||||||
|
|
||||||
|
@Nullable
|
||||||
|
private UUID pendingDeleteId;
|
||||||
|
@Nullable
|
||||||
|
private String pendingDeleteName;
|
||||||
|
|
||||||
public CharacterSelectPage(
|
public CharacterSelectPage(
|
||||||
@Nonnull PlayerRef playerRef,
|
@Nonnull PlayerRef playerRef,
|
||||||
@Nonnull UUID accountUuid,
|
@Nonnull UUID accountUuid,
|
||||||
@@ -67,6 +69,7 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
|
|||||||
ui.set("#Subtitle.Text", escape("Compte : " + playerRef.getUsername()));
|
ui.set("#Subtitle.Text", escape("Compte : " + playerRef.getUsername()));
|
||||||
bindActions(events);
|
bindActions(events);
|
||||||
renderCharacterList(ui, events, loadCharacters());
|
renderCharacterList(ui, events, loadCharacters());
|
||||||
|
applyConfirmPanel(ui, events);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
@@ -80,17 +83,36 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
|
|||||||
}
|
}
|
||||||
|
|
||||||
if ("refresh".equals(event.action())) {
|
if ("refresh".equals(event.action())) {
|
||||||
|
clearPendingDelete();
|
||||||
refresh(ref, store);
|
refresh(ref, store);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("create".equals(event.action())) {
|
if ("create".equals(event.action())) {
|
||||||
|
clearPendingDelete();
|
||||||
handleCreate(ref, store);
|
handleCreate(ref, store);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if ("select".equals(event.action()) && event.characterId() != null) {
|
if ("select".equals(event.action()) && event.characterId() != null) {
|
||||||
|
clearPendingDelete();
|
||||||
handleSelect(ref, store, event.characterId());
|
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.");
|
setStatus(ref, store, "Limite de " + maxCharacters + " personnages atteinte.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
String name = characters.nextDefaultName(accountUuid);
|
CharacterService.CharacterCreationResult result =
|
||||||
CharacterService.CharacterCreationResult result = characters.createCharacter(accountUuid, name);
|
characters.createDefaultCharacter(accountUuid, playerRef.getUsername());
|
||||||
if (!result.ok()) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
refresh(ref, store);
|
refresh(ref, store);
|
||||||
} catch (SQLException e) {
|
} catch (SQLException e) {
|
||||||
LOGGER.log(Level.WARNING, "Character creation failed", 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) {
|
private void refresh(@Nonnull Ref<EntityStore> ref, @Nonnull Store<EntityStore> store) {
|
||||||
UICommandBuilder ui = new UICommandBuilder();
|
UICommandBuilder ui = new UICommandBuilder();
|
||||||
UIEventBuilder events = new UIEventBuilder();
|
UIEventBuilder events = new UIEventBuilder();
|
||||||
bindActions(events);
|
bindActions(events);
|
||||||
renderCharacterList(ui, events, loadCharacters());
|
renderCharacterList(ui, events, loadCharacters());
|
||||||
|
applyConfirmPanel(ui, events);
|
||||||
sendUpdate(ui, events, false);
|
sendUpdate(ui, events, false);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -142,6 +205,7 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
|
|||||||
UIEventBuilder events = new UIEventBuilder();
|
UIEventBuilder events = new UIEventBuilder();
|
||||||
bindActions(events);
|
bindActions(events);
|
||||||
renderCharacterList(ui, events, loadCharacters());
|
renderCharacterList(ui, events, loadCharacters());
|
||||||
|
applyConfirmPanel(ui, events);
|
||||||
ui.set("#FooterHint.Text", escape(message));
|
ui.set("#FooterHint.Text", escape(message));
|
||||||
sendUpdate(ui, events, false);
|
sendUpdate(ui, events, false);
|
||||||
}
|
}
|
||||||
@@ -184,9 +248,37 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
|
|||||||
EventData.of("Action", "select")
|
EventData.of("Action", "select")
|
||||||
.append("CharacterId", profile.getCharacterId().toString()),
|
.append("CharacterId", profile.getCharacterId().toString()),
|
||||||
false);
|
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) {
|
private void bindActions(@Nonnull UIEventBuilder events) {
|
||||||
events.addEventBinding(
|
events.addEventBinding(
|
||||||
CustomUIEventBindingType.Activating,
|
CustomUIEventBindingType.Activating,
|
||||||
@@ -200,6 +292,11 @@ public final class CharacterSelectPage extends InteractiveCustomUIPage<Character
|
|||||||
false);
|
false);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void clearPendingDelete() {
|
||||||
|
pendingDeleteId = null;
|
||||||
|
pendingDeleteName = null;
|
||||||
|
}
|
||||||
|
|
||||||
@Nonnull
|
@Nonnull
|
||||||
private static String formatSummary(@Nonnull PlayerProfile profile) {
|
private static String formatSummary(@Nonnull PlayerProfile profile) {
|
||||||
PlayerClass playerClass = ClassCatalog.byId(profile.getClassId());
|
PlayerClass playerClass = ClassCatalog.byId(profile.getClassId());
|
||||||
|
|||||||
+60
-5
@@ -3,6 +3,7 @@
|
|||||||
@MmorpgAccent = #f5c842;
|
@MmorpgAccent = #f5c842;
|
||||||
@MmorpgText = #e8e8ec;
|
@MmorpgText = #e8e8ec;
|
||||||
@MmorpgMuted = #878e9c;
|
@MmorpgMuted = #878e9c;
|
||||||
|
@MmorpgDanger = #c9302c;
|
||||||
|
|
||||||
Group {
|
Group {
|
||||||
LayoutMode: Center;
|
LayoutMode: Center;
|
||||||
@@ -49,9 +50,14 @@ Group {
|
|||||||
FlexWeight: 1;
|
FlexWeight: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextButton #CharDelete0 {
|
||||||
|
Text: "Suppr.";
|
||||||
|
Anchor: (Width: 72, Right: 6);
|
||||||
|
}
|
||||||
|
|
||||||
TextButton #CharSelect0 {
|
TextButton #CharSelect0 {
|
||||||
Text: "Jouer";
|
Text: "Jouer";
|
||||||
Anchor: (Width: 100);
|
Anchor: (Width: 88);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -67,9 +73,14 @@ Group {
|
|||||||
FlexWeight: 1;
|
FlexWeight: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextButton #CharDelete1 {
|
||||||
|
Text: "Suppr.";
|
||||||
|
Anchor: (Width: 72, Right: 6);
|
||||||
|
}
|
||||||
|
|
||||||
TextButton #CharSelect1 {
|
TextButton #CharSelect1 {
|
||||||
Text: "Jouer";
|
Text: "Jouer";
|
||||||
Anchor: (Width: 100);
|
Anchor: (Width: 88);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -85,9 +96,14 @@ Group {
|
|||||||
FlexWeight: 1;
|
FlexWeight: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextButton #CharDelete2 {
|
||||||
|
Text: "Suppr.";
|
||||||
|
Anchor: (Width: 72, Right: 6);
|
||||||
|
}
|
||||||
|
|
||||||
TextButton #CharSelect2 {
|
TextButton #CharSelect2 {
|
||||||
Text: "Jouer";
|
Text: "Jouer";
|
||||||
Anchor: (Width: 100);
|
Anchor: (Width: 88);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -103,9 +119,14 @@ Group {
|
|||||||
FlexWeight: 1;
|
FlexWeight: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextButton #CharDelete3 {
|
||||||
|
Text: "Suppr.";
|
||||||
|
Anchor: (Width: 72, Right: 6);
|
||||||
|
}
|
||||||
|
|
||||||
TextButton #CharSelect3 {
|
TextButton #CharSelect3 {
|
||||||
Text: "Jouer";
|
Text: "Jouer";
|
||||||
Anchor: (Width: 100);
|
Anchor: (Width: 88);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -121,9 +142,43 @@ Group {
|
|||||||
FlexWeight: 1;
|
FlexWeight: 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
TextButton #CharDelete4 {
|
||||||
|
Text: "Suppr.";
|
||||||
|
Anchor: (Width: 72, Right: 6);
|
||||||
|
}
|
||||||
|
|
||||||
TextButton #CharSelect4 {
|
TextButton #CharSelect4 {
|
||||||
Text: "Jouer";
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-2
@@ -1,5 +1,4 @@
|
|||||||
PORT=3000
|
PORT=3000
|
||||||
# Chemin vers la base SQLite du plugin MMORPG (relatif à Webapp/ ou absolu)
|
|
||||||
DB_PATH=../mmorpg.db
|
DB_PATH=../mmorpg.db
|
||||||
# Secret pour les sessions de connexion (changez en production)
|
HYTALE_ASSETS_ZIP=/home/bat/.var/app/com.hypixel.HytaleLauncher/data/Hytale/install/release/package/game/latest/Assets.zip
|
||||||
SESSION_SECRET=change-me-in-production
|
SESSION_SECRET=change-me-in-production
|
||||||
|
|||||||
@@ -52,6 +52,8 @@ Site accessible sur [http://localhost:3000](http://localhost:3000).
|
|||||||
|
|
||||||
Pour modifier le mot de passe, réutilisez la commande en jeu.
|
Pour modifier le mot de passe, réutilisez la commande en jeu.
|
||||||
|
|
||||||
|
Les icônes d'inventaire sont extraites de `Assets.zip` via la commande `unzip` (présente sur Arch : `pacman -S unzip`). Chemin auto via `Hytale/hytale.properties` ou `HYTALE_ASSETS_ZIP` dans `.env`.
|
||||||
|
|
||||||
## Stack
|
## Stack
|
||||||
|
|
||||||
- [Express](https://expressjs.com/) — serveur HTTP
|
- [Express](https://expressjs.com/) — serveur HTTP
|
||||||
|
|||||||
Generated
+10
@@ -8,6 +8,7 @@
|
|||||||
"name": "disklexar-web",
|
"name": "disklexar-web",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"adm-zip": "^0.5.16",
|
||||||
"bcrypt": "^5.1.1",
|
"bcrypt": "^5.1.1",
|
||||||
"better-sqlite3": "^11.8.1",
|
"better-sqlite3": "^11.8.1",
|
||||||
"dotenv": "^16.4.7",
|
"dotenv": "^16.4.7",
|
||||||
@@ -58,6 +59,15 @@
|
|||||||
"node": ">= 0.6"
|
"node": ">= 0.6"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/adm-zip": {
|
||||||
|
"version": "0.5.17",
|
||||||
|
"resolved": "https://registry.npmjs.org/adm-zip/-/adm-zip-0.5.17.tgz",
|
||||||
|
"integrity": "sha512-+Ut8d9LLqwEvHHJl1+PIHqoyDxFgVN847JTVM3Izi3xHDWPE4UtzzXysMZQs64DMcrJfBeS/uoEP4AD3HQHnQQ==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=12.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/agent-base": {
|
"node_modules/agent-base": {
|
||||||
"version": "6.0.2",
|
"version": "6.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||||
|
|||||||
@@ -494,6 +494,201 @@ code {
|
|||||||
color: #fecaca;
|
color: #fecaca;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inventory-panel-embedded {
|
||||||
|
margin-bottom: 0;
|
||||||
|
padding: 0;
|
||||||
|
border: none;
|
||||||
|
box-shadow: none;
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-inventory-block {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-inventory-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: flex-start;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-inventory-head h2 {
|
||||||
|
margin: 0 0 0.25rem;
|
||||||
|
font-size: 1.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.character-meta {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.section-intro {
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-embedded-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-panel {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-sections {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: minmax(88px, auto) minmax(88px, auto) 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"hotbar hotbar hotbar"
|
||||||
|
"storage storage storage"
|
||||||
|
"armor utility .";
|
||||||
|
gap: 1.25rem;
|
||||||
|
align-items: start;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-section--hotbar {
|
||||||
|
grid-area: hotbar;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-section--storage {
|
||||||
|
grid-area: storage;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-section--armor {
|
||||||
|
grid-area: armor;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-section--utility {
|
||||||
|
grid-area: utility;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-section--hotbar .inventory-grid,
|
||||||
|
.inventory-section--storage .inventory-grid {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 900px) {
|
||||||
|
.inventory-sections {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
grid-template-areas:
|
||||||
|
"hotbar"
|
||||||
|
"storage"
|
||||||
|
"armor"
|
||||||
|
"utility";
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-section--armor .inventory-grid,
|
||||||
|
.inventory-section--utility .inventory-grid {
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-section-head {
|
||||||
|
display: flex;
|
||||||
|
align-items: baseline;
|
||||||
|
justify-content: space-between;
|
||||||
|
margin-bottom: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-section-head h3 {
|
||||||
|
margin: 0;
|
||||||
|
font-size: 0.95rem;
|
||||||
|
color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-grid {
|
||||||
|
display: grid;
|
||||||
|
gap: 0.4rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-grid.cols-9 {
|
||||||
|
grid-template-columns: repeat(9, minmax(0, 1fr));
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-grid.cols-1 {
|
||||||
|
grid-template-columns: repeat(1, minmax(0, 1fr));
|
||||||
|
max-width: 120px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-slot {
|
||||||
|
aspect-ratio: 1;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: rgba(0, 0, 0, 0.2);
|
||||||
|
padding: 0.35rem;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: space-between;
|
||||||
|
min-height: 72px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-slot.empty {
|
||||||
|
opacity: 0.35;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-slot.filled {
|
||||||
|
border-color: rgba(94, 179, 255, 0.35);
|
||||||
|
background: rgba(94, 179, 255, 0.08);
|
||||||
|
align-items: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-icon {
|
||||||
|
width: 44px;
|
||||||
|
height: 44px;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.65rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.item-row-icon {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
object-fit: contain;
|
||||||
|
image-rendering: pixelated;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-name {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
line-height: 1.15;
|
||||||
|
font-weight: 600;
|
||||||
|
word-break: break-word;
|
||||||
|
margin-top: 0.15rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-qty {
|
||||||
|
align-self: flex-end;
|
||||||
|
font-size: 0.72rem;
|
||||||
|
color: var(--accent);
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.slot-dur {
|
||||||
|
font-size: 0.62rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-list {
|
||||||
|
margin-top: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.inventory-list summary {
|
||||||
|
cursor: pointer;
|
||||||
|
color: var(--accent);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
@media (max-width: 768px) {
|
||||||
.layout {
|
.layout {
|
||||||
grid-template-columns: 1fr;
|
grid-template-columns: 1fr;
|
||||||
@@ -517,4 +712,8 @@ code {
|
|||||||
display: block;
|
display: block;
|
||||||
overflow-x: auto;
|
overflow-x: auto;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.inventory-grid.cols-9 {
|
||||||
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Objet inconnu">
|
||||||
|
<rect width="64" height="64" rx="8" fill="#1c2430"/>
|
||||||
|
<rect x="8" y="8" width="48" height="48" rx="6" fill="#2a3544" stroke="#3a4554"/>
|
||||||
|
<path d="M22 42 L32 22 L42 42 Z" fill="none" stroke="#5eb3ff" stroke-width="3" stroke-linejoin="round"/>
|
||||||
|
<circle cx="32" cy="46" r="3" fill="#878e9c"/>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 401 B |
+94
-1
@@ -8,12 +8,28 @@ const auth = require("./src/auth");
|
|||||||
const labels = require("./src/labels");
|
const labels = require("./src/labels");
|
||||||
const fmt = require("./src/format");
|
const fmt = require("./src/format");
|
||||||
|
|
||||||
|
const inventory = require("./src/inventory");
|
||||||
|
const itemIcons = require("./src/itemIcons");
|
||||||
|
|
||||||
const app = express();
|
const app = express();
|
||||||
const PORT = process.env.PORT || 3000;
|
const PORT = process.env.PORT || 3000;
|
||||||
|
|
||||||
app.set("view engine", "ejs");
|
app.set("view engine", "ejs");
|
||||||
app.set("views", path.join(__dirname, "views"));
|
app.set("views", path.join(__dirname, "views"));
|
||||||
app.use(express.static(path.join(__dirname, "public")));
|
app.use(express.static(path.join(__dirname, "public")));
|
||||||
|
|
||||||
|
app.get("/icons/items/:itemId.png", (req, res) => {
|
||||||
|
const itemId = decodeURIComponent(req.params.itemId);
|
||||||
|
const data = itemIcons.getIconData(itemId);
|
||||||
|
if (!data) {
|
||||||
|
res.type("image/svg+xml");
|
||||||
|
return res.sendFile(path.join(__dirname, "public/icons/unknown.svg"));
|
||||||
|
}
|
||||||
|
res.set("Content-Type", "image/png");
|
||||||
|
res.set("Cache-Control", "public, max-age=86400");
|
||||||
|
res.send(data);
|
||||||
|
});
|
||||||
|
|
||||||
app.use(express.urlencoded({ extended: false }));
|
app.use(express.urlencoded({ extended: false }));
|
||||||
|
|
||||||
app.use(
|
app.use(
|
||||||
@@ -38,6 +54,8 @@ app.use((req, res, next) => {
|
|||||||
];
|
];
|
||||||
res.locals.fmt = fmt;
|
res.locals.fmt = fmt;
|
||||||
res.locals.auth = auth;
|
res.locals.auth = auth;
|
||||||
|
res.locals.db = db;
|
||||||
|
res.locals.itemIcons = itemIcons;
|
||||||
res.locals.dbStatus = db.getDbStatus();
|
res.locals.dbStatus = db.getDbStatus();
|
||||||
res.locals.sessionUser = req.session.playerUuid
|
res.locals.sessionUser = req.session.playerUuid
|
||||||
? {
|
? {
|
||||||
@@ -48,6 +66,67 @@ app.use((req, res, next) => {
|
|||||||
next();
|
next();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
function loadPlayerInventory(player) {
|
||||||
|
const characterId = inventory.resolveCharacterId(player);
|
||||||
|
const slots = db.getCharacterInventory(characterId);
|
||||||
|
return inventory.buildInventoryView(slots);
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichCharacter(character) {
|
||||||
|
if (!character) return null;
|
||||||
|
|
||||||
|
const classLabel = labels.labelClass(
|
||||||
|
character.class_id,
|
||||||
|
db.getReferenceLabel("classes", character.class_id),
|
||||||
|
);
|
||||||
|
const raceLabel = labels.labelRace(
|
||||||
|
character.race_id,
|
||||||
|
db.getReferenceLabel("races", character.race_id),
|
||||||
|
);
|
||||||
|
const jobs = labels.parseJsonArray(character.jobs).map(labels.labelJob);
|
||||||
|
const powers = labels.parseJsonArray(character.powers).map(labels.labelPower);
|
||||||
|
|
||||||
|
return {
|
||||||
|
...character,
|
||||||
|
display_name: character.name ?? character.display_name,
|
||||||
|
classLabel,
|
||||||
|
raceLabel,
|
||||||
|
jobs,
|
||||||
|
powers,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadAccountCharacters(accountUuid) {
|
||||||
|
if (db.hasCharactersTable()) {
|
||||||
|
const activeCharacterId = db.getActiveCharacterId(accountUuid);
|
||||||
|
return db.getCharactersByAccountUuid(accountUuid).map((character) => {
|
||||||
|
const enriched = enrichCharacter(character);
|
||||||
|
return {
|
||||||
|
...enriched,
|
||||||
|
character_id: character.id,
|
||||||
|
isActive: character.id === activeCharacterId,
|
||||||
|
playerInventory: inventory.buildInventoryView(
|
||||||
|
db.getCharacterInventory(character.id),
|
||||||
|
),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const player = enrichPlayer(db.getPlayerByUuid(accountUuid));
|
||||||
|
if (!player) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
|
||||||
|
return [
|
||||||
|
{
|
||||||
|
...player,
|
||||||
|
character_id: inventory.resolveCharacterId(player),
|
||||||
|
isActive: true,
|
||||||
|
playerInventory: loadPlayerInventory(player),
|
||||||
|
},
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
function enrichPlayer(player) {
|
function enrichPlayer(player) {
|
||||||
if (!player) return null;
|
if (!player) return null;
|
||||||
|
|
||||||
@@ -120,7 +199,7 @@ app.get("/players/:uuid", (req, res) => {
|
|||||||
title: player.display_name,
|
title: player.display_name,
|
||||||
player,
|
player,
|
||||||
group,
|
group,
|
||||||
comingSoon: true,
|
comingSoon: false,
|
||||||
isOwner: req.session.playerUuid === player.uuid,
|
isOwner: req.session.playerUuid === player.uuid,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -146,12 +225,14 @@ app.get("/profile", (req, res) => {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const group = db.getGroupForPlayer(player.uuid);
|
const group = db.getGroupForPlayer(player.uuid);
|
||||||
|
const characters = loadAccountCharacters(req.session.playerUuid);
|
||||||
|
|
||||||
return res.render("profile/account", {
|
return res.render("profile/account", {
|
||||||
page: "profile",
|
page: "profile",
|
||||||
title: "Mon profil",
|
title: "Mon profil",
|
||||||
player,
|
player,
|
||||||
group,
|
group,
|
||||||
|
characters,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -256,4 +337,16 @@ app.listen(PORT, () => {
|
|||||||
console.warn(`Base SQLite introuvable : ${status.path}`);
|
console.warn(`Base SQLite introuvable : ${status.path}`);
|
||||||
console.warn("Lancez le serveur Hytale pour créer mmorpg.db à la racine du monorepo.");
|
console.warn("Lancez le serveur Hytale pour créer mmorpg.db à la racine du monorepo.");
|
||||||
}
|
}
|
||||||
|
const icons = itemIcons.getStatus();
|
||||||
|
if (!icons.available) {
|
||||||
|
console.warn("Icônes Hytale indisponibles.");
|
||||||
|
if (icons.error) {
|
||||||
|
console.warn(` → ${icons.error}`);
|
||||||
|
}
|
||||||
|
console.warn(" → Définissez HYTALE_ASSETS_ZIP dans Webapp/.env si besoin.");
|
||||||
|
} else {
|
||||||
|
console.log(
|
||||||
|
`Icônes Hytale (${icons.backend}) : ${icons.indexedIcons} PNG indexés, ${icons.indexedItems} items — ${icons.assetsZip}`,
|
||||||
|
);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -54,6 +54,69 @@ function hasCharactersTable() {
|
|||||||
return Boolean(row);
|
return Boolean(row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function hasInventoryTable() {
|
||||||
|
const connection = getDb();
|
||||||
|
if (!connection) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const row = queryOne(
|
||||||
|
"SELECT name FROM sqlite_master WHERE type = 'table' AND name = 'character_inventory'",
|
||||||
|
);
|
||||||
|
return Boolean(row);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCharacterInventory(characterId) {
|
||||||
|
if (!characterId || !hasInventoryTable()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return queryAll(
|
||||||
|
`
|
||||||
|
SELECT section_id, slot_index, item_id, quantity, durability, max_durability, metadata
|
||||||
|
FROM character_inventory
|
||||||
|
WHERE character_id = ?
|
||||||
|
ORDER BY section_id, slot_index
|
||||||
|
`,
|
||||||
|
[characterId],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getActiveCharacterId(accountUuid) {
|
||||||
|
const row = queryOne(
|
||||||
|
`SELECT active_character_id FROM player_profiles WHERE uuid = ?`,
|
||||||
|
[accountUuid],
|
||||||
|
);
|
||||||
|
return row?.active_character_id ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCharactersByAccountUuid(accountUuid) {
|
||||||
|
if (!hasCharactersTable()) {
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
return queryAll(
|
||||||
|
`
|
||||||
|
SELECT
|
||||||
|
id,
|
||||||
|
account_uuid,
|
||||||
|
name,
|
||||||
|
level,
|
||||||
|
experience,
|
||||||
|
class_id,
|
||||||
|
powers,
|
||||||
|
jobs,
|
||||||
|
guild_id,
|
||||||
|
group_id,
|
||||||
|
money,
|
||||||
|
race_id,
|
||||||
|
date_creation,
|
||||||
|
updated_at
|
||||||
|
FROM characters
|
||||||
|
WHERE account_uuid = ?
|
||||||
|
ORDER BY name COLLATE NOCASE ASC
|
||||||
|
`,
|
||||||
|
[accountUuid],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
function getDashboardStats() {
|
function getDashboardStats() {
|
||||||
const connection = getDb();
|
const connection = getDb();
|
||||||
if (!connection) {
|
if (!connection) {
|
||||||
@@ -286,6 +349,11 @@ module.exports = {
|
|||||||
getPlayerByUuid,
|
getPlayerByUuid,
|
||||||
getPlayerAuthByDisplayName,
|
getPlayerAuthByDisplayName,
|
||||||
hasPasswordColumn,
|
hasPasswordColumn,
|
||||||
|
hasCharactersTable,
|
||||||
|
hasInventoryTable,
|
||||||
|
getCharacterInventory,
|
||||||
|
getActiveCharacterId,
|
||||||
|
getCharactersByAccountUuid,
|
||||||
getGroupForPlayer,
|
getGroupForPlayer,
|
||||||
getReferenceLabel,
|
getReferenceLabel,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -0,0 +1,84 @@
|
|||||||
|
const itemIcons = require("./itemIcons");
|
||||||
|
|
||||||
|
/** Hytale inventory section ids (InventoryComponent). */
|
||||||
|
const SECTIONS = [
|
||||||
|
{ id: -1, key: "hotbar", label: "Barre rapide", slots: 9, cols: 9 },
|
||||||
|
{ id: -2, key: "storage", label: "Sac", slots: 36, cols: 9 },
|
||||||
|
{ id: -3, key: "armor", label: "Armure", slots: 4, cols: 1 },
|
||||||
|
{ id: -5, key: "utility", label: "Utilitaire", slots: 4, cols: 1 },
|
||||||
|
];
|
||||||
|
|
||||||
|
function formatItemId(itemId) {
|
||||||
|
if (!itemId) {
|
||||||
|
return "Inconnu";
|
||||||
|
}
|
||||||
|
let trimmed = itemId;
|
||||||
|
if (trimmed.startsWith("Weapon_")) {
|
||||||
|
trimmed = trimmed.slice("Weapon_".length);
|
||||||
|
} else if (trimmed.startsWith("Tool_")) {
|
||||||
|
trimmed = trimmed.slice("Tool_".length);
|
||||||
|
} else if (trimmed.startsWith("Armor_")) {
|
||||||
|
trimmed = trimmed.slice("Armor_".length);
|
||||||
|
}
|
||||||
|
return trimmed.replaceAll("_", " ");
|
||||||
|
}
|
||||||
|
|
||||||
|
function enrichSlot(row) {
|
||||||
|
const durability =
|
||||||
|
row.durability != null && row.max_durability != null
|
||||||
|
? `${Math.round(row.durability)} / ${Math.round(row.max_durability)}`
|
||||||
|
: null;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sectionId: row.section_id,
|
||||||
|
slotIndex: row.slot_index,
|
||||||
|
itemId: row.item_id,
|
||||||
|
name: formatItemId(row.item_id),
|
||||||
|
iconUrl: itemIcons.getIconUrl(row.item_id),
|
||||||
|
quantity: row.quantity,
|
||||||
|
durability,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function buildInventoryView(slots) {
|
||||||
|
const byKey = new Map();
|
||||||
|
for (const row of slots) {
|
||||||
|
const key = `${row.section_id}:${row.slot_index}`;
|
||||||
|
byKey.set(key, enrichSlot(row));
|
||||||
|
}
|
||||||
|
|
||||||
|
const sections = SECTIONS.map((section) => {
|
||||||
|
const cells = [];
|
||||||
|
for (let slot = 0; slot < section.slots; slot += 1) {
|
||||||
|
cells.push(byKey.get(`${section.id}:${slot}`) ?? null);
|
||||||
|
}
|
||||||
|
const used = cells.filter(Boolean).length;
|
||||||
|
return {
|
||||||
|
...section,
|
||||||
|
cells,
|
||||||
|
used,
|
||||||
|
capacity: section.slots,
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
const totalItems = slots.length;
|
||||||
|
|
||||||
|
return {
|
||||||
|
sections,
|
||||||
|
totalItems,
|
||||||
|
isEmpty: totalItems === 0,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveCharacterId(player) {
|
||||||
|
if (!player) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return player.character_id || player.uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
buildInventoryView,
|
||||||
|
resolveCharacterId,
|
||||||
|
formatItemId,
|
||||||
|
};
|
||||||
@@ -0,0 +1,316 @@
|
|||||||
|
const fs = require("fs");
|
||||||
|
const path = require("path");
|
||||||
|
const { execFileSync, spawnSync } = require("child_process");
|
||||||
|
|
||||||
|
const PLUGIN_ITEMS_DIR = path.resolve(
|
||||||
|
__dirname,
|
||||||
|
"../../Hytale/plugins/mmorpg/src/main/resources/Server/Item/Items",
|
||||||
|
);
|
||||||
|
const HYTALE_PROPS = path.resolve(__dirname, "../../Hytale/hytale.properties");
|
||||||
|
const GRADLE_PROPS = path.resolve(__dirname, "../../Hytale/gradle.properties");
|
||||||
|
|
||||||
|
/** @type {Map<string, string[]>} */
|
||||||
|
const iconPathIndex = new Map();
|
||||||
|
/** @type {Map<string, Buffer>} */
|
||||||
|
const iconCache = new Map();
|
||||||
|
/** @type {Set<string>} */
|
||||||
|
let zipEntries = null;
|
||||||
|
|
||||||
|
let zipBackend = null;
|
||||||
|
let assetsZipPath = null;
|
||||||
|
let indexedIcons = 0;
|
||||||
|
let lastError = null;
|
||||||
|
|
||||||
|
function readProperty(filePath, key) {
|
||||||
|
if (!fs.existsSync(filePath)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
const line = fs
|
||||||
|
.readFileSync(filePath, "utf8")
|
||||||
|
.split("\n")
|
||||||
|
.map((entry) => entry.trim())
|
||||||
|
.find((entry) => entry.startsWith(`${key}=`));
|
||||||
|
return line ? line.slice(key.length + 1).trim() : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function commandAvailable(command) {
|
||||||
|
const result = spawnSync("sh", ["-c", `command -v ${command}`], {
|
||||||
|
encoding: "utf8",
|
||||||
|
});
|
||||||
|
return result.status === 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeEntryName(name) {
|
||||||
|
return name.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveIconPath(iconField, itemId) {
|
||||||
|
const candidates = [];
|
||||||
|
if (iconField) {
|
||||||
|
const icon = iconField.replace(/\\/g, "/").replace(/^\/+/, "");
|
||||||
|
candidates.push(
|
||||||
|
icon.startsWith("Common/") ? icon : `Common/${icon}`,
|
||||||
|
icon,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
candidates.push(
|
||||||
|
`Common/Icons/ItemsGenerated/${itemId}.png`,
|
||||||
|
`Icons/ItemsGenerated/${itemId}.png`,
|
||||||
|
);
|
||||||
|
return [...new Set(candidates.map(normalizeEntryName))];
|
||||||
|
}
|
||||||
|
|
||||||
|
function findAssetsZipInHome(home) {
|
||||||
|
if (!home || !fs.existsSync(home)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const output = execFileSync(
|
||||||
|
"find",
|
||||||
|
[home, "-name", "Assets.zip", "-size", "+1M"],
|
||||||
|
{ encoding: "utf8", maxBuffer: 4 * 1024 * 1024 },
|
||||||
|
);
|
||||||
|
return (
|
||||||
|
output
|
||||||
|
.split("\n")
|
||||||
|
.map((line) => line.trim())
|
||||||
|
.find(Boolean) ?? null
|
||||||
|
);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectAssetsZip() {
|
||||||
|
if (process.env.HYTALE_ASSETS_ZIP) {
|
||||||
|
const configured = path.resolve(process.env.HYTALE_ASSETS_ZIP);
|
||||||
|
if (fs.existsSync(configured)) {
|
||||||
|
return configured;
|
||||||
|
}
|
||||||
|
lastError = `HYTALE_ASSETS_ZIP introuvable : ${configured}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const home =
|
||||||
|
process.env.HYTALE_HOME ||
|
||||||
|
readProperty(HYTALE_PROPS, "hytale.home_path") ||
|
||||||
|
readProperty(GRADLE_PROPS, "hytale.home_path");
|
||||||
|
|
||||||
|
if (!home) {
|
||||||
|
lastError = "hytale.home_path introuvable (Hytale/hytale.properties)";
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const candidates = [
|
||||||
|
path.join(home, "install/release/package/game/latest/Assets.zip"),
|
||||||
|
path.join(home, "Assets.zip"),
|
||||||
|
findAssetsZipInHome(home),
|
||||||
|
].filter(Boolean);
|
||||||
|
|
||||||
|
const found = candidates.find(
|
||||||
|
(candidate) => fs.existsSync(candidate) && fs.statSync(candidate).size > 0,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
lastError = `Assets.zip introuvable sous ${home}`;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
function listZipEntries(zipPath) {
|
||||||
|
const output = execFileSync("unzip", ["-Z1", zipPath], {
|
||||||
|
encoding: "utf8",
|
||||||
|
maxBuffer: 128 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
return output.split("\n").map(normalizeEntryName).filter(Boolean);
|
||||||
|
}
|
||||||
|
|
||||||
|
function readZipEntry(zipPath, entryPath) {
|
||||||
|
return execFileSync("unzip", ["-p", zipPath, normalizeEntryName(entryPath)], {
|
||||||
|
maxBuffer: 16 * 1024 * 1024,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function entryExists(entryPath) {
|
||||||
|
if (!zipEntries) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
const normalized = normalizeEntryName(entryPath);
|
||||||
|
return (
|
||||||
|
zipEntries.has(normalized) ||
|
||||||
|
zipEntries.has(`/${normalized}`) ||
|
||||||
|
zipEntries.has(normalized.toLowerCase())
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pickExistingEntry(candidates) {
|
||||||
|
for (const candidate of candidates) {
|
||||||
|
const normalized = normalizeEntryName(candidate);
|
||||||
|
if (zipEntries?.has(normalized)) {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
if (zipEntries?.has(`/${normalized}`)) {
|
||||||
|
return `/${normalized}`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return candidates[0] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function registerIconPaths(itemId, paths) {
|
||||||
|
const existing = iconPathIndex.get(itemId) ?? [];
|
||||||
|
iconPathIndex.set(itemId, [...new Set([...existing, ...paths])]);
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexItemJson(itemId, jsonText) {
|
||||||
|
try {
|
||||||
|
const parsed = JSON.parse(jsonText);
|
||||||
|
registerIconPaths(itemId, resolveIconPath(parsed.Icon, itemId));
|
||||||
|
} catch {
|
||||||
|
registerIconPaths(itemId, resolveIconPath(null, itemId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexPluginItems() {
|
||||||
|
if (!fs.existsSync(PLUGIN_ITEMS_DIR)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const fileName of fs.readdirSync(PLUGIN_ITEMS_DIR)) {
|
||||||
|
if (!fileName.endsWith(".json")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
const itemId = fileName.slice(0, -".json".length);
|
||||||
|
const jsonText = fs.readFileSync(path.join(PLUGIN_ITEMS_DIR, fileName), "utf8");
|
||||||
|
indexItemJson(itemId, jsonText);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function indexIconPathsFromListing(entries) {
|
||||||
|
const iconPattern = /(?:^|\/)Icons\/ItemsGenerated\/(.+)\.png$/i;
|
||||||
|
|
||||||
|
for (const entryName of entries) {
|
||||||
|
const match = entryName.match(iconPattern);
|
||||||
|
if (!match) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
registerIconPaths(match[1], [entryName]);
|
||||||
|
indexedIcons += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function loadItemDefinition(itemId) {
|
||||||
|
if (!assetsZipPath || iconPathIndex.has(itemId)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const jsonCandidates = [
|
||||||
|
`Server/Item/Items/${itemId}.json`,
|
||||||
|
`server/Item/Items/${itemId}.json`,
|
||||||
|
];
|
||||||
|
|
||||||
|
for (const jsonPath of jsonCandidates) {
|
||||||
|
if (!entryExists(jsonPath)) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
const jsonText = readZipEntry(assetsZipPath, jsonPath).toString("utf8");
|
||||||
|
indexItemJson(itemId, jsonText);
|
||||||
|
return;
|
||||||
|
} catch {
|
||||||
|
// Try next path.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
registerIconPaths(itemId, resolveIconPath(null, itemId));
|
||||||
|
}
|
||||||
|
|
||||||
|
function initAssets() {
|
||||||
|
assetsZipPath = detectAssetsZip();
|
||||||
|
if (!assetsZipPath) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!commandAvailable("unzip")) {
|
||||||
|
lastError = "Commande unzip absente — installez le paquet unzip (Arch: pacman -S unzip)";
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const entries = listZipEntries(assetsZipPath);
|
||||||
|
zipEntries = new Set(entries);
|
||||||
|
indexIconPathsFromListing(entries);
|
||||||
|
indexPluginItems();
|
||||||
|
zipBackend = "unzip-cli";
|
||||||
|
} catch (error) {
|
||||||
|
lastError = error.message;
|
||||||
|
zipBackend = null;
|
||||||
|
zipEntries = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getCandidates(itemId) {
|
||||||
|
if (!iconPathIndex.has(itemId)) {
|
||||||
|
loadItemDefinition(itemId);
|
||||||
|
}
|
||||||
|
return iconPathIndex.get(itemId) ?? resolveIconPath(null, itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconData(itemId) {
|
||||||
|
if (!itemId || !isAvailable()) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (iconCache.has(itemId)) {
|
||||||
|
return iconCache.get(itemId);
|
||||||
|
}
|
||||||
|
|
||||||
|
const entryPath = pickExistingEntry(getCandidates(itemId));
|
||||||
|
if (!entryPath) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const data = readZipEntry(assetsZipPath, entryPath);
|
||||||
|
if (!data?.length) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
iconCache.set(itemId, data);
|
||||||
|
return data;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getIconUrl(itemId) {
|
||||||
|
if (!itemId || !isAvailable()) {
|
||||||
|
return "/icons/unknown.svg";
|
||||||
|
}
|
||||||
|
return `/icons/items/${encodeURIComponent(itemId)}.png`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function isAvailable() {
|
||||||
|
return Boolean(assetsZipPath && zipBackend);
|
||||||
|
}
|
||||||
|
|
||||||
|
function getStatus() {
|
||||||
|
return {
|
||||||
|
available: isAvailable(),
|
||||||
|
backend: zipBackend,
|
||||||
|
assetsZip: assetsZipPath,
|
||||||
|
indexedIcons,
|
||||||
|
indexedItems: iconPathIndex.size,
|
||||||
|
error: lastError,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
initAssets();
|
||||||
|
|
||||||
|
module.exports = {
|
||||||
|
getIconData,
|
||||||
|
getIconUrl,
|
||||||
|
isAvailable,
|
||||||
|
getStatus,
|
||||||
|
};
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
<% if (characters.length === 0) { %>
|
||||||
|
<section class="panel">
|
||||||
|
<p class="empty">Aucun personnage trouvé pour ce compte.</p>
|
||||||
|
</section>
|
||||||
|
<% } else { %>
|
||||||
|
<% characters.forEach(function(character) { %>
|
||||||
|
<section class="panel character-inventory-block">
|
||||||
|
<div class="character-inventory-head">
|
||||||
|
<div>
|
||||||
|
<h2><%= character.display_name %></h2>
|
||||||
|
<p class="muted character-meta">
|
||||||
|
Niveau <%= character.level %>
|
||||||
|
· <%= character.classLabel %>
|
||||||
|
· <%= character.raceLabel %>
|
||||||
|
· <%= fmt.formatMoney(character.money) %> money
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<% if (character.isActive) { %>
|
||||||
|
<span class="tag tag-online">Personnage actif</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<%- include('inventory-panel', {
|
||||||
|
playerInventory: character.playerInventory,
|
||||||
|
panelTitle: null,
|
||||||
|
embedded: true
|
||||||
|
}) %>
|
||||||
|
</section>
|
||||||
|
<% }); %>
|
||||||
|
<% } %>
|
||||||
@@ -0,0 +1,99 @@
|
|||||||
|
<section class="panel inventory-panel<%= typeof embedded !== 'undefined' && embedded ? ' inventory-panel-embedded' : '' %>">
|
||||||
|
<% if (typeof embedded === 'undefined' || !embedded) { %>
|
||||||
|
<div class="panel-header">
|
||||||
|
<h2><%= typeof panelTitle !== 'undefined' && panelTitle ? panelTitle : 'Inventaire' %></h2>
|
||||||
|
<% if (!playerInventory.isEmpty) { %>
|
||||||
|
<span class="badge"><%= playerInventory.totalItems %> objet(s)</span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% } else if (!playerInventory.isEmpty) { %>
|
||||||
|
<div class="inventory-embedded-meta">
|
||||||
|
<span class="badge"><%= playerInventory.totalItems %> objet(s)</span>
|
||||||
|
</div>
|
||||||
|
<% } %>
|
||||||
|
|
||||||
|
<% if (!db.hasInventoryTable()) { %>
|
||||||
|
<p class="empty">Table inventaire absente. Redémarrez le serveur Hytale pour appliquer la migration personnages.</p>
|
||||||
|
<% } else if (playerInventory.isEmpty) { %>
|
||||||
|
<p class="empty">Inventaire vide. Les objets sont enregistrés à la déconnexion du personnage en jeu.</p>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="inventory-sections">
|
||||||
|
<% playerInventory.sections.forEach(function(section) { %>
|
||||||
|
<div class="inventory-section inventory-section--<%= section.key %>">
|
||||||
|
<div class="inventory-section-head">
|
||||||
|
<h3><%= section.label %></h3>
|
||||||
|
<span class="muted"><%= section.used %> / <%= section.capacity %></span>
|
||||||
|
</div>
|
||||||
|
<div class="inventory-grid cols-<%= section.cols %>">
|
||||||
|
<% section.cells.forEach(function(item) { %>
|
||||||
|
<% if (item) { %>
|
||||||
|
<div class="inventory-slot filled" title="<%= item.name %> · <%= item.itemId %>">
|
||||||
|
<img
|
||||||
|
class="slot-icon"
|
||||||
|
src="<%= item.iconUrl %>"
|
||||||
|
alt="<%= item.name %>"
|
||||||
|
loading="lazy"
|
||||||
|
onerror="this.onerror=null;this.src='/icons/unknown.svg';"
|
||||||
|
>
|
||||||
|
<span class="slot-name"><%= item.name %></span>
|
||||||
|
<% if (item.quantity > 1) { %>
|
||||||
|
<span class="slot-qty">×<%= item.quantity %></span>
|
||||||
|
<% } %>
|
||||||
|
<% if (item.durability) { %>
|
||||||
|
<span class="slot-dur"><%= item.durability %></span>
|
||||||
|
<% } %>
|
||||||
|
</div>
|
||||||
|
<% } else { %>
|
||||||
|
<div class="inventory-slot empty" aria-hidden="true"></div>
|
||||||
|
<% } %>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<% }); %>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<details class="inventory-list">
|
||||||
|
<summary>Liste détaillée</summary>
|
||||||
|
<table class="table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th>Section</th>
|
||||||
|
<th>Emplacement</th>
|
||||||
|
<th>Objet</th>
|
||||||
|
<th>Qté</th>
|
||||||
|
<th>Durabilité</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<% playerInventory.sections.forEach(function(section) { %>
|
||||||
|
<% section.cells.forEach(function(item, index) { %>
|
||||||
|
<% if (item) { %>
|
||||||
|
<tr>
|
||||||
|
<td><%= section.label %></td>
|
||||||
|
<td><%= index + 1 %></td>
|
||||||
|
<td>
|
||||||
|
<div class="item-row">
|
||||||
|
<img
|
||||||
|
class="item-row-icon"
|
||||||
|
src="<%= item.iconUrl %>"
|
||||||
|
alt=""
|
||||||
|
loading="lazy"
|
||||||
|
onerror="this.onerror=null;this.src='/icons/unknown.svg';"
|
||||||
|
>
|
||||||
|
<div>
|
||||||
|
<span class="player-name"><%= item.name %></span>
|
||||||
|
<small class="muted"><%= item.itemId %></small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
<td><%= item.quantity %></td>
|
||||||
|
<td><%= item.durability || '—' %></td>
|
||||||
|
</tr>
|
||||||
|
<% } %>
|
||||||
|
<% }); %>
|
||||||
|
<% }); %>
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</details>
|
||||||
|
<% } %>
|
||||||
|
</section>
|
||||||
@@ -2,7 +2,7 @@
|
|||||||
|
|
||||||
<%- include('../partials/page-header', { title: player.display_name, subtitle: 'Profil joueur' }) %>
|
<%- include('../partials/page-header', { title: player.display_name, subtitle: 'Profil joueur' }) %>
|
||||||
|
|
||||||
<%- include('../partials/player-card', { player, group, comingSoon }) %>
|
<%- include('../partials/player-card', { player, group, comingSoon: false }) %>
|
||||||
|
|
||||||
<p class="back-link"><a class="link" href="/players">← Retour à la liste</a></p>
|
<p class="back-link"><a class="link" href="/players">← Retour à la liste</a></p>
|
||||||
|
|
||||||
|
|||||||
@@ -11,9 +11,14 @@
|
|||||||
|
|
||||||
<%- include('../partials/player-card', { player, group, comingSoon: false }) %>
|
<%- include('../partials/player-card', { player, group, comingSoon: false }) %>
|
||||||
|
|
||||||
<section class="panel coming-soon">
|
<section class="panel">
|
||||||
<h2>À venir</h2>
|
<div class="panel-header">
|
||||||
<p>Inventaire, historique de combat et gestion avancée du compte seront ajoutés prochainement.</p>
|
<h2>Mes personnages</h2>
|
||||||
|
<span class="badge"><%= characters.length %></span>
|
||||||
|
</div>
|
||||||
|
<p class="muted section-intro">Inventaire sauvegardé à la déconnexion de chaque personnage en jeu.</p>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<%- include('../partials/characters-inventories', { characters }) %>
|
||||||
|
|
||||||
<%- include('../partials/page-end') %>
|
<%- include('../partials/page-end') %>
|
||||||
|
|||||||
Reference in New Issue
Block a user