浏览代码

feat: create and delete characters at runtime

## Summary

0.5.0's opcode-501 handler is a stub: it returns whatever character is
already selected and never parses the request body, so the create-character
flow does nothing. Opcode-502 (delete) doesn't exist at all. This makes
both real:

- state::create_character / state::delete_character (new,
  state_account_roster_runtime.cpp) do the actual roster mutation: pick an
  unused SOID offset from the account's own primary key (reusing a freed
  SOID after a delete without ever colliding with a living character),
  build the new character, validate the whole account, and persist it --
  following the same lock/read/validate/write/unlock shape already used by
  set_selected_character and ensure_character_subclasses in this file.
- opcode501_request_codec parses the create-character request's race/
  gender/class identity triple from its bit-packed payload.
- opcode502 (new) parses the bare 64-bit delete-character target id.
- web_service_runtime.cpp wires both into the dispatch that used to just
  echo opcode501 back and never handle opcode502 at all.

## Scope note

This build has no per-character authored appearance to capture and no
settings-authored default-loadout template to seed from -- both fields dev
tooling elsewhere relies on don't exist on this CharacterState. A created
character starts with an empty loadout, the same fallback path this
account model already uses when no template matches. Capturing the
player's authored face and seeding a starting loadout are natural
follow-ups, not included here.

## Effects

- Creating a character now actually adds a row to the roster and persists
  it; deleting one now actually removes it, preserving surviving
  characters' SOIDs (an object the client already holds under an old SOID
  never gets silently renamed).
- Verified: fresh clean build (CMake + clang-cl, Release x64) links with
  zero warnings from any of the new or modified files.
T4tsuk3i 1 月之前
父节点
当前提交
9e11c57115

+ 5 - 0
Sunrise/Sunrise.vcxproj

@@ -197,7 +197,10 @@
     <ClCompile Include="src\middleware\web_service\messages\opcode702_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode406_codec.cpp" />
     <ClCompile Include="src\middleware\web_service\messages\opcode1820_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode501_request_codec.cpp" />
+    <ClCompile Include="src\middleware\web_service\messages\opcode502.cpp" />
     <ClCompile Include="src\state\runtime\state_account_runtime.cpp" />
+    <ClCompile Include="src\state\runtime\state_account_roster_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_acquisition_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_reward_grant_runtime.cpp" />
     <ClCompile Include="src\state\runtime\state_account_dismantle_runtime.cpp" />
@@ -1892,6 +1895,8 @@
     <ClInclude Include="src\middleware\web_service\messages\opcode503.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode206.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode501_codec.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode501_request_codec.h" />
+    <ClInclude Include="src\middleware\web_service\messages\opcode502.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode505\opcode505_codec.h" />
     <ClInclude Include="src\middleware\web_service\messages\opcode601\opcode601_codec.h" />
     <ClInclude Include="src\middleware\datagen\family4\account\selection_patch\account_selection_patch_encoder.h" />

+ 59 - 0
Sunrise/src/middleware/web_service/messages/opcode501_request_codec.cpp

@@ -0,0 +1,59 @@
+#include "opcode501_request_codec.h"
+
+#include "../../encoding/bit_reader.h"
+
+namespace sunrise::middleware::web_service::messages::opcode501 {
+namespace {
+
+/** The identity triple and appearance header end here; the 64-byte name follows. Only the
+ * triple is decoded; the rest is skipped as one span. */
+constexpr std::size_t kIdentityAndHeaderBits = 261;
+/** Each triple entry is a 1-bit presence flag plus an 8-bit +0x80-biased value. */
+constexpr std::uint8_t kTripleEntryBits = 9;
+constexpr std::uint64_t kByteBias = 0x80U;
+/** Bits left to skip after the triple: the appearance header and the request's trailing field. */
+constexpr std::size_t kSkipBits = kIdentityAndHeaderBits - 3U * kTripleEntryBits;
+
+/**
+ * Reads one 1-bit presence flag followed by an 8-bit +0x80-biased value.
+ * @return True when the field is present and readable. An absent field fails closed: nothing on
+ *         the wire carries the value that was skipped, so there is nothing to fall back to.
+ */
+[[nodiscard]] bool read_optional_byte(encoding::bits::Reader& reader, std::uint8_t& value) noexcept {
+    std::uint64_t present = 0;
+    if (!reader.read(1, present) || present == 0) {
+        return false;
+    }
+    std::uint64_t raw = 0;
+    if (!reader.read(8, raw)) {
+        return false;
+    }
+    value = static_cast<std::uint8_t>(raw - kByteBias);
+    return true;
+}
+
+/** Highest valid race, gender and class wire values, matching the state enums. */
+constexpr std::uint8_t kMaxRace = 2;
+constexpr std::uint8_t kMaxGender = 1;
+constexpr std::uint8_t kMaxClass = 2;
+
+} // namespace
+
+bool parse_request(const Message& message, Request& request) noexcept {
+    request = {};
+    if (message.opcode != kOpcode || message.payload.size() * 8U < kIdentityAndHeaderBits) {
+        return false;
+    }
+    encoding::bits::Reader reader(message.payload);
+    if (!read_optional_byte(reader, request.race) || !read_optional_byte(reader, request.gender)
+        || !read_optional_byte(reader, request.characterClass)) {
+        return false;
+    }
+    if (request.race > kMaxRace || request.gender > kMaxGender
+        || request.characterClass > kMaxClass) {
+        return false;
+    }
+    return reader.skip(kSkipBits);
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode501

+ 33 - 0
Sunrise/src/middleware/web_service/messages/opcode501_request_codec.h

@@ -0,0 +1,33 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../web_service_envelope.h"
+#include "opcode501_codec.h"
+
+namespace sunrise::middleware::web_service::messages::opcode501 {
+
+/** The create-character request's identity triple: race, gender and class. */
+struct Request {
+    std::uint8_t characterClass{};
+    std::uint8_t gender{};
+    std::uint8_t race{};
+};
+
+/**
+ * Parses the create-character request's identity triple.
+ *
+ * The payload is a big-endian bitstream, not a byte-aligned struct. Wire order is depth-first
+ * over `{ int8 triple[3]; CharHeader header; char name[64]; }`, where `triple` is race, gender
+ * and class in that order; each `triple` entry is a 1-bit presence flag followed by 8 value bits
+ * biased by +0x80. This build has no per-character authored appearance to capture, so the header
+ * and name that follow are consumed and discarded rather than decoded field by field.
+ *
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the parsed identity triple.
+ * @return True when the payload holds the full identity-and-header span, every presence flag is
+ *         set, and race, gender and class are all in range.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode501

+ 22 - 0
Sunrise/src/middleware/web_service/messages/opcode502.cpp

@@ -0,0 +1,22 @@
+#include "opcode502.h"
+
+#include "../../encoding/bit_reader.h"
+
+namespace sunrise::middleware::web_service::messages::opcode502 {
+namespace {
+
+constexpr std::uint8_t kCharacterSoidWidth = 64;
+constexpr std::size_t kMinimumPayloadSize = 8;
+
+} // namespace
+
+bool parse_request(const Message& message, Request& request) noexcept {
+    request = {};
+    if (message.opcode != kOpcode || message.payload.size() < kMinimumPayloadSize) {
+        return false;
+    }
+    encoding::bits::Reader reader(message.payload);
+    return reader.read(kCharacterSoidWidth, request.characterSoid);
+}
+
+} // namespace sunrise::middleware::web_service::messages::opcode502

+ 25 - 0
Sunrise/src/middleware/web_service/messages/opcode502.h

@@ -0,0 +1,25 @@
+#pragma once
+
+#include <cstdint>
+
+#include "../web_service_envelope.h"
+
+namespace sunrise::middleware::web_service::messages::opcode502 {
+
+/** Web Service opcode for the delete-character request. */
+inline constexpr std::uint16_t kOpcode = 502;
+
+/** The targeted character. */
+struct Request {
+    std::uint64_t characterSoid{};
+};
+
+/**
+ * Parses the bare 64-bit targeted-character id.
+ * @param message Parsed Web Service envelope.
+ * @param request Receives the targeted character id.
+ * @return True when the whole id is present.
+ */
+[[nodiscard]] bool parse_request(const Message& message, Request& request) noexcept;
+
+} // namespace sunrise::middleware::web_service::messages::opcode502

+ 23 - 3
Sunrise/src/server/web_service/web_service_runtime.cpp

@@ -14,6 +14,8 @@
 #include "../../middleware/web_service/messages/opcode206.h"
 #include "../../middleware/web_service/messages/opcode2400.h"
 #include "../../middleware/web_service/messages/opcode501_codec.h"
+#include "../../middleware/web_service/messages/opcode501_request_codec.h"
+#include "../../middleware/web_service/messages/opcode502.h"
 #include "../../middleware/web_service/messages/opcode503.h"
 #include "../../middleware/web_service/messages/opcode504.h"
 #include "../../middleware/web_service/messages/opcode601/opcode601_codec.h"
@@ -337,14 +339,32 @@ bool consume(std::span<const std::byte> request,
     }
 
     if (message.opcode == middleware::web_service::messages::opcode501::kOpcode) {
-        // Returns a SOID family three already publishes. The request body is not parsed.
-        const std::uint64_t characterSoid =
-            state::account::selected_character_soid(state::account_snapshot());
+        middleware::web_service::messages::opcode501::Request created{};
+        std::uint64_t characterSoid = 0;
+        if (!middleware::web_service::messages::opcode501::parse_request(message, created)
+            || !state::create_character(
+                created.characterClass, created.gender, created.race, characterSoid)) {
+            core::log::write(
+                core::log::Channel::server, core::log::Level::warn, "ev=ws501 stage=create result=fail");
+            // The client still needs an answer even when creation fails; falling back to the
+            // selected character keeps the response shape valid instead of leaving it unanswered.
+            characterSoid = state::account::selected_character_soid(state::account_snapshot());
+        }
         return middleware::web_service::messages::opcode501::encode_response(
                    message, characterSoid, response, written)
                || encode_echo(message, response, written);
     }
 
+    if (message.opcode == middleware::web_service::messages::opcode502::kOpcode) {
+        middleware::web_service::messages::opcode502::Request targeted{};
+        if (!middleware::web_service::messages::opcode502::parse_request(message, targeted)
+            || !state::delete_character(targeted.characterSoid)) {
+            core::log::write(
+                core::log::Channel::server, core::log::Level::warn, "ev=ws502 stage=delete result=fail");
+        }
+        return encode_echo(message, response, written);
+    }
+
     // The artifact vendor is answered here. Every other vendor purchase falls through to the
     // shared response-shape path, which runs the action and answers its status: an action that
     // prepared no mutation is answered with the refused code.

+ 22 - 0
Sunrise/src/state/runtime/runtime.h

@@ -473,6 +473,28 @@ void publish_sign_in_time(std::uint64_t seconds) noexcept;
  */
 [[nodiscard]] bool set_selected_character(std::uint64_t characterSoid) noexcept;
 
+/**
+ * Creates a new character in an unused slot and persists the account.
+ * @param characterClass Character class (0=titan, 1=hunter, 2=warlock).
+ * @param gender Character gender (0=male, 1=female).
+ * @param race Character race (0=human, 1=awoken, 2=exo).
+ * @param characterSoid Receives the SOID of the newly created character.
+ * @return True when the character was created, the account still validates, and the write
+ *         committed.
+ */
+[[nodiscard]] bool create_character(std::uint8_t characterClass,
+                                    std::uint8_t gender,
+                                    std::uint8_t race,
+                                    std::uint64_t& characterSoid) noexcept;
+
+/**
+ * Deletes a character by SOID and persists the account.
+ * @param characterSoid SOID of the character to delete.
+ * @return True when the character was found, removed, the account still validates, and the
+ *         write committed.
+ */
+[[nodiscard]] bool delete_character(std::uint64_t characterSoid) noexcept;
+
 /**
  * Stores the selected character's equipped title row. The caller proves the record is a claimed
  * title; this only writes it.

+ 176 - 0
Sunrise/src/state/runtime/state_account_roster_runtime.cpp

@@ -0,0 +1,176 @@
+/** Character creation and deletion logic. */
+
+#include <cstdint>
+
+#include "../../core/logging/log.h"
+#include "../account/account_state.h"
+#include "../investment/store_internal.h"
+#include "runtime.h"
+
+namespace sunrise::state {
+
+bool create_character(std::uint8_t characterClass,
+                      std::uint8_t gender,
+                      std::uint8_t race,
+                      std::uint64_t& characterSoid) noexcept {
+    characterSoid = 0;
+    if (characterClass > static_cast<std::uint8_t>(CharacterClass::warlock)
+        || gender > static_cast<std::uint8_t>(CharacterGender::female)
+        || race > static_cast<std::uint8_t>(CharacterRace::exo)) {
+        core::log::writef(core::log::Channel::state,
+                          core::log::Level::warn,
+                          "ev=create_character stage=range result=fail class=%u gender=%u race=%u",
+                          static_cast<unsigned>(characterClass),
+                          static_cast<unsigned>(gender),
+                          static_cast<unsigned>(race));
+        return false;
+    }
+
+    investment::store::g_mutex.lock();
+    AccountState candidate = investment::store::account();
+    core::log::writef(core::log::Channel::state,
+                      core::log::Level::info,
+                      "ev=create_character stage=request class=%u gender=%u race=%u chars=%zu "
+                      "cap=%zu primary=0x%016llX",
+                      static_cast<unsigned>(characterClass),
+                      static_cast<unsigned>(gender),
+                      static_cast<unsigned>(race),
+                      candidate.characterCount,
+                      candidate.characters.size(),
+                      static_cast<unsigned long long>(candidate.primarySoid));
+    if (candidate.characterCount >= candidate.characters.size() || candidate.primarySoid == 0) {
+        investment::store::g_mutex.unlock();
+        core::log::write(core::log::Channel::state,
+                         core::log::Level::warn,
+                         "ev=create_character stage=capacity_or_primary result=fail");
+        return false;
+    }
+
+    const std::size_t index = candidate.characterCount;
+    // The slot index cannot name the SOID: deletion keeps every survivor's SOID, so after
+    // deleting a middle character the next free index already belongs to a living one. The
+    // lowest unused offset is taken instead, which reuses a freed SOID without ever colliding.
+    for (std::uint64_t offset = 1U; offset <= candidate.characters.size(); ++offset) {
+        const std::uint64_t soidCandidate = candidate.primarySoid + offset;
+        bool taken = false;
+        for (std::size_t existing = 0; existing < candidate.characterCount; ++existing) {
+            if (candidate.characters[existing].soid == soidCandidate) {
+                taken = true;
+                break;
+            }
+        }
+        if (!taken) {
+            characterSoid = soidCandidate;
+            break;
+        }
+    }
+    if (characterSoid == 0) {
+        investment::store::g_mutex.unlock();
+        core::log::write(
+            core::log::Channel::state, core::log::Level::warn, "ev=create_character stage=soid result=fail");
+        return false;
+    }
+
+    CharacterState& character = candidate.characters[index];
+    character = {};
+    character.soid = characterSoid;
+    character.race = static_cast<CharacterRace>(race);
+    character.gender = static_cast<CharacterGender>(gender);
+    character.characterClass = static_cast<CharacterClass>(characterClass);
+
+    // Select the new character and deselect all others.
+    for (std::size_t i = 0; i < candidate.characterCount; ++i) {
+        candidate.characters[i].selected = false;
+    }
+    character.selected = true;
+    ++candidate.characterCount;
+
+    if (!account::valid(candidate) || !investment::store::write_account(candidate)) {
+        investment::store::g_mutex.unlock();
+        core::log::writef(core::log::Channel::state,
+                          core::log::Level::warn,
+                          "ev=create_character stage=commit result=fail primary=0x%016llX chars=%zu",
+                          static_cast<unsigned long long>(candidate.primarySoid),
+                          candidate.characterCount);
+        characterSoid = 0;
+        return false;
+    }
+    for (std::size_t i = 0; i < candidate.characters.size(); ++i) {
+        investment::store::g_session.selected[i] = candidate.characters[i].selected;
+    }
+    investment::store::g_mutex.unlock();
+    core::log::writef(core::log::Channel::state,
+                      core::log::Level::info,
+                      "ev=create_character stage=commit result=ok soid=0x%016llX chars=%zu",
+                      static_cast<unsigned long long>(characterSoid),
+                      candidate.characterCount);
+    return true;
+}
+
+bool delete_character(std::uint64_t characterSoid) noexcept {
+    if (characterSoid == 0) {
+        return false;
+    }
+
+    investment::store::g_mutex.lock();
+    AccountState candidate = investment::store::account();
+    if (candidate.characterCount == 0 || candidate.primarySoid == 0) {
+        investment::store::g_mutex.unlock();
+        return false;
+    }
+
+    std::size_t found = candidate.characterCount;
+    for (std::size_t i = 0; i < candidate.characterCount; ++i) {
+        if (candidate.characters[i].soid == characterSoid) {
+            found = i;
+            break;
+        }
+    }
+    if (found == candidate.characterCount) {
+        investment::store::g_mutex.unlock();
+        return false;
+    }
+
+    // Survivors keep the SOID they were created with. Rebasing them onto their new slot index
+    // silently renames living characters, which strands every object the client already holds
+    // under the old SOID -- its roster entry, its Family-4 body and its banner all keyed by it.
+    for (std::size_t i = found; i + 1 < candidate.characterCount; ++i) {
+        candidate.characters[i] = candidate.characters[i + 1U];
+    }
+    --candidate.characterCount;
+    candidate.characters[candidate.characterCount] = {};
+
+    // Select the first character if the deleted one was selected.
+    bool hasSelection = false;
+    for (std::size_t i = 0; i < candidate.characterCount; ++i) {
+        if (candidate.characters[i].selected) {
+            hasSelection = true;
+            break;
+        }
+    }
+    if (!hasSelection && candidate.characterCount > 0) {
+        candidate.characters[0].selected = true;
+    }
+
+    if (!account::valid(candidate) || !investment::store::write_account(candidate)) {
+        investment::store::g_mutex.unlock();
+        core::log::writef(core::log::Channel::state,
+                          core::log::Level::warn,
+                          "ev=delete_character stage=commit result=fail soid=0x%016llX",
+                          static_cast<unsigned long long>(characterSoid));
+        return false;
+    }
+    for (std::size_t i = 0; i < candidate.characters.size(); ++i) {
+        investment::store::g_session.selected[i] =
+            i < candidate.characterCount && candidate.characters[i].selected;
+    }
+    investment::store::g_mutex.unlock();
+    core::log::writef(core::log::Channel::state,
+                      core::log::Level::info,
+                      "ev=delete_character stage=commit result=ok soid=0x%016llX chars=%zu",
+                      static_cast<unsigned long long>(characterSoid),
+                      candidate.characterCount);
+    return true;
+}
+
+} // namespace sunrise::state