feat(chargen): Campaign CC slice CC7 — end-to-end create flow + connected checklist
Create button un-ghosts: retail's exact gate (gmCharacterManagementUI:: UpdateButtons @0x004ec240, roster count < allowed slot count) ported into RuntimeCharacterSelectionButtons.CanCreate; the button's OnClick opens the chargen screen through the same CharacterCreationUiController.Open() seam the ACDREAM_OPEN_CHARGEN=1 dev path already used. Exit/Back confirm on chargen needed no new return-path code — character-management is never hidden while chargen is open on top of it — verified end-to-end by a new cross-controller test rather than left as an inspection claim. Full-flow test coverage: a new comprehensive test decodes every 0xF656 field (including the trailing checksum, recomputed via the production CharacterCreate.ComputeChecksum) against a fully populated creation (heritage/gender/all appearance slots/template/explicit skill command/ town/name); a new Theory drives the remaining six 0xF643 rejection codes through the real wire decode path, closing the gap between the already-covered isolated state-machine Theory and an actual WorldSession round trip. Launcher payload cycle: two new tests drive a real Runtime create/reject through the real SessionStatusWriter (wired exactly as LiveSessionRuntimeFactory/HeadlessSessionHost do in production) and read the result back with the real Launcher.Core StatusFileTailer/ StatusEventParser — closing the one gap CC2's own per-layer tests never reached. No gap was found in production wiring itself: GameWindow already constructs a real, non-null SessionStatusWriter for both hosts. Also fixes 4 pre-existing LiveSessionControllerTests assertions that compared a full RuntimeCharacterSelectionButtons record and would have failed once CanCreate started being computed; corrects register row AP-211 to reflect that its own predicted resolution (the Create-button gate landing) has now happened — both layers are intentionally kept as retail-matching enforcement plus defense-in-depth, not one superseding the other. Adds docs/research/2026-08-16-campaign-cc-test-script.md, the user's connected-gate script covering both the launcher and dev-shortcut launch paths, the six-page create flow, every Finish outcome, and the known cosmetic/behavioral divergences (AP-212/213/215/216/217/218/219/220/222/ 224/226/228) so they aren't mistaken for new bugs during the gate. Gates: full solution Release build green; Runtime 1735/0 (was 1726/0, +9), App 5256/3 skips (was 5254/3, +2), Headless 166/0 (unchanged), Launcher.Core 324/0, one full-solution pass across every project clean (no known flakes reproduced this run). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
parent
bb22ee8bde
commit
9cf6c52283
10 changed files with 957 additions and 37 deletions
File diff suppressed because one or more lines are too long
342
docs/research/2026-08-16-campaign-cc-test-script.md
Normal file
342
docs/research/2026-08-16-campaign-cc-test-script.md
Normal file
|
|
@ -0,0 +1,342 @@
|
|||
# Campaign CC connected-gate test script
|
||||
|
||||
**Status: the campaign is CODE-COMPLETE (CC1-CC7) and this script is its
|
||||
connected-gate contract.** Every step below is the user's own eyes on the
|
||||
running client — nothing here was run automatically. **No automated live
|
||||
character creation has been run against ACE** (see §CC-Not-Automated) —
|
||||
the first LIVE create is deliberately left to this gate.
|
||||
|
||||
This script covers TWO ways to reach the chargen screen: the real launcher
|
||||
flow (Campaign LA's product path) and the developer shortcut
|
||||
(`ACDREAM_RETAIL_UI=1` + `ACDREAM_OPEN_CHARGEN=1`, still available and still
|
||||
useful for a fast create-only iteration loop). Both land on the exact same
|
||||
screen and Runtime owner — there is no second code path being tested.
|
||||
|
||||
Local ACE connection details (per `CLAUDE.md`): host `127.0.0.1`, port
|
||||
`9000`, account `testaccount` / `testpassword`. Use a FRESH character name
|
||||
per attempt (ACE does not forget names within a session) — `CC-<yourinitials>-<n>`
|
||||
is a good scheme, e.g. `CCAB1`, `CCAB2`.
|
||||
|
||||
---
|
||||
|
||||
## §CC1 — reaching the screen
|
||||
|
||||
### Path A — the launcher (product path)
|
||||
|
||||
1. Launch `AcDream.Launcher` (already installed/updated per Campaign LA's
|
||||
own gates — this script does not re-run first-run setup or the update
|
||||
flow; see `docs/research/2026-08-14-campaign-la-test-script.md` if either
|
||||
is in question).
|
||||
2. Open **Check for updates**. Confirm it reports the client already
|
||||
current (no install prompt) — Campaign LA's own gates already proved the
|
||||
install/update mechanics; this step just confirms nothing is stale
|
||||
before the character-creation gate.
|
||||
3. Confirm (or create) a profile pointed at `127.0.0.1:9000`,
|
||||
`testaccount` / `testpassword`.
|
||||
4. Click **GUI — character select**. The retail character-management
|
||||
screen (`gmCharacterManagementUI`) opens — the flat character list, World
|
||||
name, Enter/Delete/Restore buttons, and the **Create** button.
|
||||
|
||||
### Path B — the developer shortcut
|
||||
|
||||
```powershell
|
||||
$env:ACDREAM_DAT_DIR = "$env:USERPROFILE\Documents\Asheron's Call"
|
||||
$env:ACDREAM_RETAIL_UI = "1"
|
||||
$env:ACDREAM_LIVE = "1"
|
||||
$env:ACDREAM_TEST_HOST = "127.0.0.1"
|
||||
$env:ACDREAM_TEST_PORT = "9000"
|
||||
$env:ACDREAM_TEST_USER = "testaccount"
|
||||
$env:ACDREAM_TEST_PASS = "testpassword"
|
||||
dotnet run --project src\AcDream.App\AcDream.App.csproj --no-build -c Release
|
||||
```
|
||||
|
||||
Confirm the SAME character-management screen appears. `ACDREAM_OPEN_CHARGEN=1`
|
||||
(add it to the block above) is the CC4-era interim seam that opens the
|
||||
chargen screen directly on startup, skipping the Create click — still
|
||||
useful for a fast create-only loop, but Path A/B above is now the REAL path
|
||||
and should be exercised at least once per gate.
|
||||
|
||||
### The Create button
|
||||
|
||||
1. With at least one free character slot (roster count below the account's
|
||||
allowed slot count — most test accounts have several free slots),
|
||||
confirm **Create** is ENABLED (not greyed out).
|
||||
2. **Click Create.** The chargen screen (`gmCharGenMainUI`) opens directly
|
||||
on the **Heritage** page — no confirmation, no loading screen. The
|
||||
character-management screen you were just on is not closed or hidden;
|
||||
it simply sits behind the new screen (this matters for the Exit step
|
||||
below).
|
||||
3. **If your account's roster is completely full** (rare on a fresh test
|
||||
account — every slot occupied), confirm Create is instead GREYED OUT
|
||||
and does nothing when clicked. This is retail's own gate
|
||||
(`gmCharacterManagementUI::UpdateButtons`) — a full roster ghosts
|
||||
Create exactly like Enter/Delete grey out for an unselected row.
|
||||
|
||||
### Leaving the screen (Back-at-Heritage and Exit)
|
||||
|
||||
1. On the **Heritage** page (the first page), click **Back**. Confirm a
|
||||
confirmation dialog appears asking whether to leave character creation
|
||||
(same text/shape as the Exit button below — Back-at-Heritage and Exit
|
||||
share retail's one `DoExit` confirmation).
|
||||
2. Click **Exit** (top of the screen, available on every page). Confirm
|
||||
the SAME confirmation dialog appears.
|
||||
3. **Confirm the dialog (accept).** The chargen screen closes. You are back
|
||||
at the character-management screen you started from — roster, World
|
||||
name, and any prior selection are exactly as you left them (character
|
||||
management was never hidden, so there is nothing to "restore").
|
||||
4. Click **Cancel** on a repeat Exit attempt instead — confirm the dialog
|
||||
closes and chargen stays open, untouched.
|
||||
|
||||
### What to report for §CC1
|
||||
|
||||
- Create's enabled/greyed state matching the free-slot count you actually
|
||||
have.
|
||||
- Whether clicking Create opens chargen with no lag/flash/black frame.
|
||||
- Whether returning from Exit shows the character list exactly as it was
|
||||
(no re-flicker, no lost highlight, no stale World name).
|
||||
|
||||
---
|
||||
|
||||
## §CC2 — the six-page create flow
|
||||
|
||||
**The screen does NOT open blank.** `gmCharGenMainUI`'s own constructor
|
||||
rolls a full random character (heritage, gender, appearance, clothing,
|
||||
template, start area) before the Heritage page ever draws — acdream ports
|
||||
this faithfully (AP-214, retired). **Expected retail quirk: the gender
|
||||
shown on the Appearance page is the FLIP of the roll** — the Appearance
|
||||
page's own init code reads the just-rolled gender and immediately swaps it
|
||||
to the opposite one. If you open chargen and see (say) a female Aluvian
|
||||
with the Appearance page showing "Male" selected, that is CORRECT, not a
|
||||
bug — do not report it.
|
||||
|
||||
### Heritage page
|
||||
|
||||
1. Confirm one of the 13 heritage buttons is already highlighted (the
|
||||
opening roll) — Human heritages (Aluvian/Gharu'ndim/Sho/Viamontian),
|
||||
Tumerok, Gearknight, Lugian, Empyrean, Penumbraen, Shadowbound, Undead,
|
||||
Olthoi, and OlthoiAcid should all be selectable and each show its own
|
||||
description text (starting skills, bonus-skills paragraph where retail
|
||||
has one — Lugian/Olthoi/OlthoiAcid have none, that's retail-correct).
|
||||
2. Click **Random**. Confirm the highlighted heritage changes to a
|
||||
uniformly-picked one of the 13 — this is AP-212's documented
|
||||
approximation (retail's own Heritage-page Random rolls with retail's own
|
||||
distribution; acdream picks uniformly over every installed heritage).
|
||||
Not a bug to report unless the button does nothing or crashes.
|
||||
3. Select **Olthoi** or **OlthoiAcid**. Confirm the Profession, Skills, and
|
||||
Town tabs are hidden (Olthoi variants skip straight to a fixed Custom
|
||||
template with no attribute/skill/town choices) and the screen
|
||||
auto-advances past them.
|
||||
|
||||
### Profession page
|
||||
|
||||
1. Select a NON-Olthoi heritage. Confirm 7 template buttons (Custom + 6
|
||||
presets) and 6 attribute sliders (Strength/Endurance/Coordination/
|
||||
Quickness/Focus/Self).
|
||||
2. Drag a slider. Confirm the numeric readout updates live and the
|
||||
Available-credits counter decreases/increases correspondingly.
|
||||
3. Click a preset template (not Custom). Confirm all 6 sliders jump to
|
||||
that preset's values and Available credits updates to match.
|
||||
4. Click **Random**. Confirm heritage/template selection changes (AP-212 —
|
||||
uniform pick, not retail's own weighted roll).
|
||||
|
||||
### Skills page
|
||||
|
||||
1. Confirm ONE flat listbox of skills, each row showing name, current
|
||||
level, and train/specialize costs (AP-213 — retail groups these into
|
||||
four sorted buckets; acdream's flat list is a presentation
|
||||
simplification, not a rules difference — do not report the flat
|
||||
ordering as a bug).
|
||||
2. Click a trainable row. Confirm it advances (Untrained -> Trained ->
|
||||
Specialized) and the skill-credits meter decreases; a second click on an
|
||||
already-Specialized row does nothing further (no fourth state).
|
||||
3. Confirm **Random** is disabled/greyed on this page (retail's
|
||||
`RandomizeSkills` primitive is unported — AP-212's own documented gap).
|
||||
|
||||
### Appearance page
|
||||
|
||||
1. Confirm **Face** and **Clothes** sub-tabs, nine spin controls (hair,
|
||||
eyes, nose, mouth, skin under Face; headgear, shirt, trousers, footwear
|
||||
under Clothes), nine color swatches, a shade scrollbar, zoom/rotate
|
||||
buttons, and a live 3D preview playing an idle animation loop.
|
||||
2. Click a spin's left/right arrow zones. Confirm the selected style index
|
||||
advances/retreats and the preview model updates.
|
||||
3. Click a color swatch. Confirm the swatch shows a highlighted "selected"
|
||||
ring/border (AP-215 — acdream's own selection indicator, not retail's
|
||||
overlay mechanism; functionally equivalent).
|
||||
4. Click **Zoom In**. Confirm the preview freezes its pose (idle animation
|
||||
stops) and the camera tweens closer over about half a second. Click
|
||||
**Zoom Out** — animation resumes, camera tweens back out.
|
||||
5. Click **Rotate Clockwise**/**Counter-Clockwise**. Confirm the model
|
||||
spins continuously at a steady rate (about 3 seconds per full turn);
|
||||
clicking the SAME direction again stops it, clicking the OPPOSITE
|
||||
direction reverses it.
|
||||
6. Click **Random** (on either sub-tab). Confirm hair/eyes/nose/mouth/skin
|
||||
(Face) or headgear/shirt/trousers/footwear (Clothes) all re-roll
|
||||
together — this IS retail's real `RandomizeAppearance`/
|
||||
`RandomizeClothing` primitive (CC5 ported it verbatim, not approximated).
|
||||
7. Select **Gearknight**, **Olthoi**, or **OlthoiAcid** on the Heritage
|
||||
page, then return to Appearance. Confirm the Clothes sub-tab and its
|
||||
four spins are unreachable, Nose/Mouth spins are hidden, and Eyes'
|
||||
arrows are disabled (fixed eyes for these forms).
|
||||
|
||||
Known cosmetic gaps on this page — expected, do not report as bugs unless
|
||||
noticeably worse than described: swatches show static art rather than the
|
||||
actual color they represent (AP-216); the gradient circle art next to the
|
||||
swatches never repaints to reflect the current color (AP-217); the four
|
||||
icon-only spins (hair/eyes/nose/mouth) show a plain number instead of an
|
||||
icon thumbnail, while the four clothing spins show real names (AP-215/
|
||||
AP-218); on Olthoi/OlthoiAcid/Gearknight the Skin spin does not slide up to
|
||||
close the gap left by the hidden Nose/Mouth spins (AP-219); switching
|
||||
heritage INTO or OUT OF Gearknight does not automatically re-roll
|
||||
appearance/clothing the way retail does on that exact transition (AP-220);
|
||||
the currently-selected spin shows no distinct highlighted state versus the
|
||||
other eight (AP-222 — this is a MEASURED gap in acdream's own art, not yet
|
||||
attributed to a specific missing asset; report clearly if you can visually
|
||||
compare with retail here).
|
||||
|
||||
### Town page
|
||||
|
||||
1. Confirm four town buttons: Holtburg, Shoushi, Yaraq, Sanamar (not id
|
||||
order — that's retail's own literal ordering, ported faithfully), each
|
||||
with descriptive text.
|
||||
2. Click a town. Confirm it highlights and the description text updates.
|
||||
|
||||
### Summary page
|
||||
|
||||
1. Confirm a listbox showing Profession, Gender, Heritage, and Starting
|
||||
Town lines, an "Attributes" header, then Strength/Endurance/
|
||||
Coordination/Quickness/Focus/Self/Health/Stamina/Mana/Skill Credits as
|
||||
paired rows, then Specialized and Trained skill name lists (retail also
|
||||
lists the two Untrained buckets; acdream's Summary omits them —
|
||||
AP-224, same class of cut as the Skills page's own AP-213).
|
||||
2. Confirm a static 3D preview of the character (no zoom/rotate controls
|
||||
on this page — retail has none here either).
|
||||
3. Click the **name field** and type a name. Confirm only letters, spaces,
|
||||
apostrophes, and hyphens are accepted (other characters are silently
|
||||
rejected keystroke-by-keystroke).
|
||||
4. Click **Random** on this page. Confirm a confirmation dialog appears
|
||||
first ("are you sure you want to randomize?"); confirming it re-rolls
|
||||
the ENTIRE character (heritage through name) using retail's real
|
||||
`RandomizeCharacter` primitive — the same one the screen-open roll uses.
|
||||
|
||||
### What to report for §CC2
|
||||
|
||||
- Any page that fails to render a control listed above, or where a control
|
||||
visibly does nothing when clicked.
|
||||
- Anything from the "known cosmetic gaps" list that looks MORE broken than
|
||||
described (e.g. a spin that doesn't advance at all, not just a missing
|
||||
highlight).
|
||||
- Any crash, freeze, or console error while navigating pages or the tabs.
|
||||
|
||||
---
|
||||
|
||||
## §CC3 — Finish and its dialogs
|
||||
|
||||
Use a fresh, never-before-used character name for the happy path. For every
|
||||
scenario below, watch the console/log for `[UI]`/`[CC]`-prefixed lines —
|
||||
they help distinguish "nothing happened because the click didn't register"
|
||||
from "the request went out and ACE is thinking about it."
|
||||
|
||||
### Happy path
|
||||
|
||||
1. Complete a legal character (heritage, gender, template with credits
|
||||
fully spent, at least a default set of skills, a town, a fresh name).
|
||||
2. Click **Finish** on the Summary page. Confirm the screen closes almost
|
||||
immediately (no visible "please wait" dialog for a normal accept — ACE's
|
||||
Ok reply is fast) and you land DIRECTLY in the world as the new
|
||||
character — no return to character management, no fresh character list,
|
||||
matching retail's own "log straight in" behavior.
|
||||
3. If you back out to character management instead (e.g. via a later
|
||||
logout), confirm the new character now appears in the roster alongside
|
||||
any pre-existing ones, in the correct slot.
|
||||
|
||||
### NameInUse (duplicate name)
|
||||
|
||||
1. Create a SECOND character using the EXACT name you just used above.
|
||||
2. Click Finish. Confirm the `ID_Character_Err_NameReserved` dialog appears
|
||||
("that name is in use" / similar text) and you stay on the chargen
|
||||
screen — Finish is clickable again afterward.
|
||||
3. **Expected log noise (register AD-100):** ACE sends the NameInUse
|
||||
rejection TWICE for the same request (a real ACE double-send bug, not
|
||||
an acdream defect). The FIRST reply drives the dialog above; the SECOND
|
||||
logs something like `unexpected CharacterGenerationVerificationResponse`
|
||||
in the console. That log line is EXPECTED here — do not report it as an
|
||||
error.
|
||||
|
||||
### The credit-warning confirm flow
|
||||
|
||||
1. Select the **Custom** template on the Profession page (leaves several
|
||||
attribute credits unspent) and complete the rest of the character.
|
||||
2. Click Finish. Confirm a warning dialog appears about unspent attribute
|
||||
credits, and Finish does NOT send anything yet.
|
||||
3. Confirm the dialog. Confirm the request now sends anyway, with the
|
||||
unspent credits — this is retail-correct (`DoFinish`'s own confirm-arm
|
||||
skips the credit check entirely; ACE accepts an under-spent build).
|
||||
|
||||
### The randomize warning flow
|
||||
|
||||
Already covered in §CC2's Summary-page step 4 above — confirm the warning
|
||||
appears BEFORE any randomization happens, and Cancel leaves the character
|
||||
completely untouched.
|
||||
|
||||
### The exit warning flow
|
||||
|
||||
Already covered in §CC1's "Leaving the screen" section above.
|
||||
|
||||
### NameTooLong
|
||||
|
||||
1. On the Summary page, type or paste a name longer than 32 characters and
|
||||
commit it (press Enter, or click elsewhere to move focus away from the
|
||||
field).
|
||||
2. Confirm the `ID_CharGen_NameTooLong` dialog appears and the field
|
||||
reverts to its previous (shorter) value — the field itself does not cap
|
||||
your typing at 32 characters as you go; the rejection only fires on
|
||||
commit. That is retail-correct, not a bug.
|
||||
|
||||
### What to report for §CC3
|
||||
|
||||
- Whether the happy path truly lands you in-world with no intermediate
|
||||
screen.
|
||||
- The exact dialog text shown for each rejection (useful for a later
|
||||
string-table audit even if it looks right).
|
||||
- Any case where Finish appears to do nothing at all (no dialog, no
|
||||
console line, no world entry) — that would be a real regression, not one
|
||||
of the documented cosmetic gaps above.
|
||||
|
||||
---
|
||||
|
||||
## §CC4 — ACE-side landmines (not acdream defects)
|
||||
|
||||
- **Heritage-priced skill over-deduction — LATENT, will not fire with the
|
||||
installed EoR DAT.** ACE's `PlayerFactory.CreatePlayer` has a real
|
||||
overcharge bug when specializing a skill priced by the active heritage's
|
||||
own skill list (versus the global skill table). It was measured against
|
||||
the installed EoR data and found unreachable — every heritage's one
|
||||
priced skill (Arcane Lore) has a heritage NormalCost of 0, which makes
|
||||
ACE's overcharge exactly zero. You should NOT be able to trigger a
|
||||
`FailedToSpecializeSkill` rejection from a retail-legal build during this
|
||||
gate. If you somehow do, that is worth flagging immediately — it would
|
||||
mean the installed DAT's costs differ from what was measured.
|
||||
- **Disabled-Olthoi create -> Pending -> NameDBDown dialog is
|
||||
retail-correct.** If your local ACE has Olthoi character creation
|
||||
disabled (a server config option), attempting to create an Olthoi/
|
||||
OlthoiAcid character will surface the `ID_Character_Err_NameDBDown`
|
||||
dialog via a `Pending` response code. This is retail's OWN behavior
|
||||
(ACE's `olthoi_play_disabled` branch sends `Pending`, and retail's
|
||||
dispatch has no silent branch for it) — not a "the client swallowed my
|
||||
request" bug.
|
||||
|
||||
---
|
||||
|
||||
## §CC-Not-Automated
|
||||
|
||||
No automated test in this repository has created a character against a
|
||||
LIVE ACE server. Every field-shape and response-code assertion in Campaign
|
||||
CC's test suite runs against a real `WorldSession` and hand-built response
|
||||
packets (see `tests/AcDream.Runtime.Tests/Session/LiveSessionControllerCharacterCreationTests.cs`)
|
||||
— the wire bytes and the state machine are proven byte-for-byte, but the
|
||||
FIRST character ever created against a real, running ACE process is
|
||||
whatever you create during this gate. Server state (what names exist,
|
||||
what heritages are enabled, what the account's slot count is) is entirely
|
||||
yours to observe; this script deliberately does not assume any of it in
|
||||
advance.
|
||||
|
|
@ -125,12 +125,16 @@ internal sealed class CharacterManagementUiController : IDisposable
|
|||
Root.Width > 0f ? Root.Width : 800f,
|
||||
Root.Height > 0f ? Root.Height : 600f);
|
||||
|
||||
// Create Character belongs to a future campaign. Keep retail's
|
||||
// authored control in place and visibly ghosted; do not hide it or
|
||||
// invent an action.
|
||||
// Campaign CC slice CC7: gmCharacterManagementUI::ListenToElementMessage
|
||||
// @ 0x004ed5a0 case 3 dispatches Create unconditionally on click
|
||||
// (QueueUIMode(0x1000000b) — no gate at click time); the gate lives
|
||||
// entirely in UpdateButtons @ 0x004ec240's own Enabled/ghosted state
|
||||
// (see ApplyButtons below), so the click handler is wired once here
|
||||
// and Enabled tracks the borrowed snapshot every tick. Starts
|
||||
// disabled/ghosted until the first real snapshot arrives.
|
||||
_create.Visible = true;
|
||||
_create.Enabled = false;
|
||||
_create.OnClick = null;
|
||||
_create.OnClick = RequestCreate;
|
||||
_enter.OnClick = EnterSelected;
|
||||
_delete.OnClick = RequestDelete;
|
||||
_restore.OnClick = RestoreSelected;
|
||||
|
|
@ -534,7 +538,7 @@ internal sealed class CharacterManagementUiController : IDisposable
|
|||
private void ApplyButtons(RuntimeCharacterSelectionButtons buttons)
|
||||
{
|
||||
_create.Visible = true;
|
||||
_create.Enabled = false;
|
||||
_create.Enabled = buttons.CanCreate;
|
||||
_enter.Enabled = buttons.CanEnter;
|
||||
_delete.Visible = buttons.DeleteVisible;
|
||||
_delete.Enabled = buttons.CanDelete;
|
||||
|
|
@ -550,6 +554,22 @@ internal sealed class CharacterManagementUiController : IDisposable
|
|||
InvalidateAndTick();
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC7: <c>ListenToElementMessage</c> case 3 ->
|
||||
/// <c>QueueUIMode(0x1000000b)</c>. Purely presentational — no Runtime
|
||||
/// command, no roster/state change here; <see cref="_bindings"/>'
|
||||
/// <c>RequestCreate</c> is resolved per-call (never captured) so it
|
||||
/// reflects whatever <see cref="RetailUiRuntime"/> wired at the time of
|
||||
/// the click, matching every other late-bound seam in this bindings
|
||||
/// record.
|
||||
/// </summary>
|
||||
private void RequestCreate()
|
||||
{
|
||||
if (_disposed)
|
||||
return;
|
||||
_bindings.RequestCreate?.Invoke();
|
||||
}
|
||||
|
||||
private void EnterSelected()
|
||||
{
|
||||
if (_disposed)
|
||||
|
|
|
|||
|
|
@ -398,7 +398,24 @@ public sealed record CharacterSelectionRuntimeBindings(
|
|||
Func<RuntimeCommandResult> ConfirmDelete,
|
||||
Func<RuntimeCommandResult> Restore,
|
||||
Func<RuntimeCommandResult> Cancel,
|
||||
Action RequestExit);
|
||||
Action RequestExit,
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC7: retail's Create button
|
||||
/// (<c>gmCharacterManagementUI::ListenToElementMessage @ 0x004ed5a0</c>
|
||||
/// case 3 -> <c>UIFramework::QueueUIMode(this, 0x1000000b)</c>, the
|
||||
/// <c>gmCharGenMainUI</c> mode). Wired by <see cref="RetailUiRuntime"/>
|
||||
/// itself (it alone holds both the character-management and
|
||||
/// character-creation controllers) to
|
||||
/// <c>CharacterCreationController?.Open()</c> — resolved lazily so
|
||||
/// mount order between the two screens does not matter.
|
||||
/// <see langword="null"/> when no chargen screen is mounted (e.g. a
|
||||
/// headless bot's <c>LiveCharacterSelector</c> path, where
|
||||
/// <see cref="RetailUiRuntimeBindings.CharacterCreation"/> is also
|
||||
/// null) — the button then behaves as a no-op click while its own
|
||||
/// <c>Enabled</c> gate (<see cref="RuntimeCharacterSelectionButtons.CanCreate"/>)
|
||||
/// still reflects the real roster-vs-slot state.
|
||||
/// </summary>
|
||||
Action? RequestCreate = null);
|
||||
|
||||
public sealed record RetailUiRuntimeBindings(
|
||||
UiHost Host,
|
||||
|
|
@ -3816,9 +3833,18 @@ public sealed class RetailUiRuntime : IDisposable
|
|||
if (bindings is null || _characterManagementMount is not null)
|
||||
return;
|
||||
|
||||
// Campaign CC slice CC7: RetailUiRuntime is the one object holding
|
||||
// BOTH controllers, so it supplies the cross-screen seam locally
|
||||
// rather than routing it through the externally-composed bindings
|
||||
// record (which is built before this runtime exists — see
|
||||
// CharacterSelectionRuntimeBindings.RequestCreate's own doc
|
||||
// comment). The lambda closes over `this` and reads
|
||||
// CharacterCreationController per call, so it is safe even though
|
||||
// ConfigureCharacterCreation() has not run yet at this point (see
|
||||
// its call site immediately below this method's own caller).
|
||||
_characterManagementMount = new CharacterManagementUiMountCoordinator(
|
||||
Host.Root,
|
||||
bindings,
|
||||
bindings with { RequestCreate = () => CharacterCreationController?.Open() },
|
||||
EnsureDialogFactory,
|
||||
LoadCharacterManagementResources);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -64,7 +64,17 @@ public readonly record struct RuntimeCharacterSelectionButtons(
|
|||
bool CanDelete,
|
||||
bool CanRestore,
|
||||
bool DeleteVisible,
|
||||
bool RestoreVisible)
|
||||
bool RestoreVisible,
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC7: retail's Create-character gate
|
||||
/// (<c>gmCharacterManagementUI::UpdateButtons @ 0x004ec240</c>,
|
||||
/// ~0x004ec319-0x004ec32e) — unconditional on selection, purely
|
||||
/// <c>_charSet.set_.m_num < _charSet.numAllowedCharacters_</c> (the
|
||||
/// live roster count against the allowed-slot ceiling). Mirrors
|
||||
/// <see cref="RuntimeCharacterSelectionSnapshot.RosterCount"/> <
|
||||
/// <see cref="RuntimeCharacterSelectionSnapshot.SlotCount"/>.
|
||||
/// </summary>
|
||||
bool CanCreate = false)
|
||||
{
|
||||
public static RuntimeCharacterSelectionButtons None { get; } =
|
||||
new(false, false, false, true, false);
|
||||
|
|
@ -848,13 +858,21 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
|||
|
||||
private RuntimeCharacterSelectionButtons BuildButtons(int selectedIndex)
|
||||
{
|
||||
// gmCharacterManagementUI::UpdateButtons @ 0x004ec240's Create gate
|
||||
// is unconditional on the delete/selection state below it — it only
|
||||
// ever compares the live roster count against the allowed-slot
|
||||
// ceiling (~0x004ec319-0x004ec32e:
|
||||
// `if (_charSet.set_.m_num < _charSet.numAllowedCharacters_)
|
||||
// SetState(1); else SetState(0xd);`).
|
||||
bool canCreate = _entries.Length < _slotCount;
|
||||
|
||||
if (_operation is RuntimeCharacterSelectionOperation.DeleteRequested
|
||||
or RuntimeCharacterSelectionOperation.DeleteAcknowledged)
|
||||
{
|
||||
return RuntimeCharacterSelectionButtons.None;
|
||||
return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate };
|
||||
}
|
||||
if (selectedIndex < 0)
|
||||
return RuntimeCharacterSelectionButtons.None;
|
||||
return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate };
|
||||
|
||||
RuntimeCharacterSelectionEntry selected = _entries[selectedIndex];
|
||||
if (selected.IsPendingDelete)
|
||||
|
|
@ -864,7 +882,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
|||
CanDelete: false,
|
||||
CanRestore: !_restoreResponseArmed,
|
||||
DeleteVisible: false,
|
||||
RestoreVisible: true);
|
||||
RestoreVisible: true,
|
||||
CanCreate: canCreate);
|
||||
}
|
||||
|
||||
return new RuntimeCharacterSelectionButtons(
|
||||
|
|
@ -872,7 +891,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
|||
CanDelete: selected.CanEnter,
|
||||
CanRestore: false,
|
||||
DeleteVisible: true,
|
||||
RestoreVisible: false);
|
||||
RestoreVisible: false,
|
||||
CanCreate: canCreate);
|
||||
}
|
||||
|
||||
private int FindDisplayIndex(uint characterId)
|
||||
|
|
|
|||
|
|
@ -61,8 +61,12 @@ public sealed class CharacterManagementUiControllerTests
|
|||
CharacterManagementUiController.RestoreElementId);
|
||||
|
||||
Assert.True(create.Visible);
|
||||
Assert.False(create.Enabled);
|
||||
Assert.Null(create.OnClick);
|
||||
// Campaign CC slice CC7: gmCharacterManagementUI::UpdateButtons @
|
||||
// 0x004ec240's Create gate — 3 characters against SlotCount 5.
|
||||
Assert.True(create.Enabled);
|
||||
Assert.NotNull(create.OnClick);
|
||||
create.OnClick!();
|
||||
Assert.Equal(1, environment.Runtime.RequestCreateCalls);
|
||||
Assert.True(enter.Enabled);
|
||||
Assert.True(delete.Visible);
|
||||
Assert.True(delete.Enabled);
|
||||
|
|
@ -99,6 +103,45 @@ public sealed class CharacterManagementUiControllerTests
|
|||
Assert.True(restore.Enabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC7: <c>gmCharacterManagementUI::UpdateButtons @
|
||||
/// 0x004ec240</c>'s Create gate (~0x004ec319-0x004ec32e) is purely
|
||||
/// <c>_charSet.set_.m_num < _charSet.numAllowedCharacters_</c> — a
|
||||
/// full roster (roster count == the allowed-slot ceiling) ghosts Create
|
||||
/// exactly like retail, and refilling below the ceiling un-ghosts it
|
||||
/// again on the next Tick.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateButton_GhostsWhenRosterReachesTheSlotCeiling_AndUnGhostsBelowIt()
|
||||
{
|
||||
using var environment = new EnvironmentHarness();
|
||||
CharacterManagementUiController controller = environment.Controller;
|
||||
UiButton create = environment.Button(
|
||||
CharacterManagementUiController.CreateElementId);
|
||||
|
||||
// The fixture's SlotCount is 5 — five characters exactly fills it.
|
||||
RuntimeCharacterSelectionEntry[] full = Enumerable.Range(0, 5)
|
||||
.Select(index => new RuntimeCharacterSelectionEntry(
|
||||
index,
|
||||
(uint)(0x50000200 + index),
|
||||
$"Full {index:D2}",
|
||||
0u))
|
||||
.ToArray();
|
||||
environment.Runtime.ReplaceRoster(full, highlightedCharacterId: full[0].CharacterId);
|
||||
controller.Tick();
|
||||
|
||||
Assert.True(create.Visible);
|
||||
Assert.False(create.Enabled);
|
||||
|
||||
RuntimeCharacterSelectionEntry[] belowCeiling = full[..4];
|
||||
environment.Runtime.ReplaceRoster(
|
||||
belowCeiling,
|
||||
highlightedCharacterId: belowCeiling[0].CharacterId);
|
||||
controller.Tick();
|
||||
|
||||
Assert.True(create.Enabled);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign LA gate round 2 finding 3: retail's UpdateWorldName@0x004ec120
|
||||
/// / RecvNotice_WorldName@0x004ec360 both push Client::GetWorldName()
|
||||
|
|
@ -906,7 +949,8 @@ public sealed class CharacterManagementUiControllerTests
|
|||
ConfirmDelete,
|
||||
Restore,
|
||||
Cancel,
|
||||
RequestExit);
|
||||
RequestExit,
|
||||
RequestCreate);
|
||||
}
|
||||
|
||||
public FakeView View { get; } = new();
|
||||
|
|
@ -918,6 +962,14 @@ public sealed class CharacterManagementUiControllerTests
|
|||
public int CancelCalls { get; private set; }
|
||||
public int RestoreCalls { get; private set; }
|
||||
public int RequestExitCalls { get; private set; }
|
||||
public int RequestCreateCalls { get; private set; }
|
||||
|
||||
/// <summary>Campaign CC slice CC7: the fixture's fixed allowed-slot
|
||||
/// ceiling — mirrors <c>Snapshot</c>'s own hard-coded
|
||||
/// <c>SlotCount: 5</c> so <see cref="ButtonsFor"/> computes the SAME
|
||||
/// roster-vs-slot gate the real <c>RuntimeCharacterSelectionState.BuildButtons</c>
|
||||
/// does, instead of a fixture-only shortcut.</summary>
|
||||
private const int SlotCount = 5;
|
||||
public RuntimeCommandStatus RestoreStatus { get; set; } =
|
||||
RuntimeCommandStatus.Accepted;
|
||||
public bool ThrowOnRestore { get; set; }
|
||||
|
|
@ -929,7 +981,8 @@ public sealed class CharacterManagementUiControllerTests
|
|||
RuntimeCharacterSelectionButtons buttons = operation is
|
||||
RuntimeCharacterSelectionOperation.DeleteRequested
|
||||
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
|
||||
? RuntimeCharacterSelectionButtons.None
|
||||
? RuntimeCharacterSelectionButtons.None with
|
||||
{ CanCreate = View.Entries.Length < SlotCount }
|
||||
: ButtonsFor(View.Snapshot.HighlightedCharacterId);
|
||||
Update(snapshot => snapshot with
|
||||
{
|
||||
|
|
@ -1022,7 +1075,8 @@ public sealed class CharacterManagementUiControllerTests
|
|||
{
|
||||
PendingDeleteCharacterId = 0u,
|
||||
Operation = RuntimeCharacterSelectionOperation.DeleteRequested,
|
||||
Buttons = RuntimeCharacterSelectionButtons.None,
|
||||
Buttons = RuntimeCharacterSelectionButtons.None with
|
||||
{ CanCreate = View.Entries.Length < SlotCount },
|
||||
});
|
||||
return Result(RuntimeCommandStatus.Accepted, id);
|
||||
}
|
||||
|
|
@ -1045,7 +1099,8 @@ public sealed class CharacterManagementUiControllerTests
|
|||
false,
|
||||
false,
|
||||
false,
|
||||
true),
|
||||
true,
|
||||
View.Entries.Length < SlotCount),
|
||||
});
|
||||
AfterRestoreProjection?.Invoke();
|
||||
return Result(RuntimeCommandStatus.Accepted, id);
|
||||
|
|
@ -1065,13 +1120,22 @@ public sealed class CharacterManagementUiControllerTests
|
|||
|
||||
private void RequestExit() => RequestExitCalls++;
|
||||
|
||||
private void RequestCreate() => RequestCreateCalls++;
|
||||
|
||||
/// <summary>Campaign CC slice CC7: mirrors
|
||||
/// <c>RuntimeCharacterSelectionState.BuildButtons</c>'s own
|
||||
/// unconditional <c>CanCreate</c> computation — roster length
|
||||
/// against <see cref="SlotCount"/> — so every branch below carries
|
||||
/// the SAME real gate the production state machine does, not a
|
||||
/// fixture-only shortcut.</summary>
|
||||
private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId)
|
||||
{
|
||||
bool canCreate = View.Entries.Length < SlotCount;
|
||||
RuntimeCharacterSelectionEntry? selected = View.Entries
|
||||
.Cast<RuntimeCharacterSelectionEntry?>()
|
||||
.FirstOrDefault(entry => entry?.CharacterId == characterId);
|
||||
if (selected is null)
|
||||
return RuntimeCharacterSelectionButtons.None;
|
||||
return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate };
|
||||
if (selected.Value.IsPendingDelete)
|
||||
{
|
||||
return new RuntimeCharacterSelectionButtons(
|
||||
|
|
@ -1079,14 +1143,16 @@ public sealed class CharacterManagementUiControllerTests
|
|||
false,
|
||||
true,
|
||||
false,
|
||||
true);
|
||||
true,
|
||||
canCreate);
|
||||
}
|
||||
return new RuntimeCharacterSelectionButtons(
|
||||
true,
|
||||
true,
|
||||
false,
|
||||
true,
|
||||
false);
|
||||
false,
|
||||
canCreate);
|
||||
}
|
||||
|
||||
private void Update(
|
||||
|
|
|
|||
|
|
@ -78,6 +78,48 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
|||
Assert.Null(environment.Host.FixedCanvasSize);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC7 item 1: the real Create-button wire — retail's
|
||||
/// <c>gmCharacterManagementUI::ListenToElementMessage @ 0x004ed5a0</c>
|
||||
/// case 3 -> <c>QueueUIMode(0x1000000b)</c> — and the return path on
|
||||
/// chargen Exit (<c>DoExit @ 0x004e8650</c> ->
|
||||
/// <c>QueueUIMode(0x1000000a)</c>). Character-management is never
|
||||
/// hidden by chargen opening on top of it (see the canvas-arbiter test
|
||||
/// above), so "return to character management" needs no separate
|
||||
/// Runtime action beyond chargen's own <c>Close()</c> — this proves
|
||||
/// that architecture claim end to end rather than by inspection alone.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void CreateButtonClick_OpensChargen_AndExitConfirmReturnsToManagement()
|
||||
{
|
||||
using var environment = new TwoControllerHarness();
|
||||
|
||||
// Character-management is active and visible before Create is ever
|
||||
// clicked (AttachAndTick already ran in its own harness ctor).
|
||||
Assert.True(environment.Management.Controller.Root.Visible);
|
||||
Assert.False(environment.Chargen.Controller.Root.Visible);
|
||||
|
||||
UiButton create = environment.Management.Button(
|
||||
CharacterManagementUiController.CreateElementId);
|
||||
Assert.True(create.Enabled);
|
||||
create.OnClick!();
|
||||
environment.Chargen.Controller.Tick();
|
||||
|
||||
Assert.True(environment.Chargen.Controller.Root.Visible);
|
||||
// Character-management stays active/visible underneath -- chargen
|
||||
// opening on top never deactivates or hides it.
|
||||
Assert.True(environment.Management.Controller.Root.Visible);
|
||||
|
||||
environment.Chargen.Button(CharacterCreationUiController.ExitElementId)
|
||||
.OnClick!();
|
||||
environment.Chargen.ConfirmActiveDialog(confirmed: true);
|
||||
|
||||
Assert.False(environment.Chargen.Controller.Root.Visible);
|
||||
// No separate "return" action was needed -- management was never
|
||||
// hidden, so it is simply what remains visible.
|
||||
Assert.True(environment.Management.Controller.Root.Visible);
|
||||
}
|
||||
|
||||
// ── Fixture: one shared UiRoot, both controllers ────────────────────
|
||||
|
||||
private sealed class TwoControllerHarness : IDisposable
|
||||
|
|
@ -85,8 +127,15 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
|||
public TwoControllerHarness()
|
||||
{
|
||||
Host = new UiRoot { Width = 800f, Height = 600f };
|
||||
Management = new ManagementHarness(Host);
|
||||
// Campaign CC slice CC7: chargen must exist FIRST so
|
||||
// ManagementHarness can wire its Create button straight to the
|
||||
// real CharacterCreationUiController.Open() — the same shape
|
||||
// RetailUiRuntime.ConfigureCharacterManagement() uses in
|
||||
// production (a lazily-resolved lambda closing over the OTHER
|
||||
// controller, since bindings are always built before both
|
||||
// controllers exist).
|
||||
Chargen = new ChargenHarness(Host);
|
||||
Management = new ManagementHarness(Host, Chargen.Controller.Open);
|
||||
}
|
||||
|
||||
public UiRoot Host { get; }
|
||||
|
|
@ -104,17 +153,17 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
|||
{
|
||||
private readonly RetailDialogFactory _dialogs;
|
||||
|
||||
public ManagementHarness(UiRoot host)
|
||||
public ManagementHarness(UiRoot host, Action requestCreate)
|
||||
{
|
||||
ImportedLayout screen = BuildManagementScreen();
|
||||
Runtime = new ManagementFakeRuntime();
|
||||
Screen = BuildManagementScreen();
|
||||
Runtime = new ManagementFakeRuntime(requestCreate);
|
||||
_dialogs = new RetailDialogFactory(
|
||||
host,
|
||||
type => RetailDialogFactoryTests.BuildDialogLayout(type));
|
||||
Controller = Assert.IsType<CharacterManagementUiController>(
|
||||
CharacterManagementUiController.Bind(
|
||||
host,
|
||||
screen,
|
||||
Screen,
|
||||
static (_, _) => BuildRow(),
|
||||
_dialogs,
|
||||
Runtime.Bindings,
|
||||
|
|
@ -126,9 +175,13 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
|||
"Are you sure you want to leave?")));
|
||||
}
|
||||
|
||||
public ImportedLayout Screen { get; }
|
||||
public ManagementFakeRuntime Runtime { get; }
|
||||
public CharacterManagementUiController Controller { get; }
|
||||
|
||||
public UiButton Button(uint id) =>
|
||||
Assert.IsType<UiButton>(Screen.FindElement(id));
|
||||
|
||||
public void Dispose()
|
||||
{
|
||||
Controller.Dispose();
|
||||
|
|
@ -182,7 +235,7 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
|||
private static readonly RuntimeGenerationToken Generation = new(11u);
|
||||
private readonly FakeManagementView _view = new();
|
||||
|
||||
public ManagementFakeRuntime()
|
||||
public ManagementFakeRuntime(Action requestCreate)
|
||||
{
|
||||
_view.Entries = [new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u)];
|
||||
_view.Snapshot = new RuntimeCharacterSelectionSnapshot(
|
||||
|
|
@ -199,7 +252,7 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
|||
LastRestoreRequestedCharacterId: 0u,
|
||||
Operation: RuntimeCharacterSelectionOperation.None,
|
||||
Error: null,
|
||||
Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false));
|
||||
Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false, true));
|
||||
Bindings = new CharacterSelectionRuntimeBindings(
|
||||
View: () => _view,
|
||||
Highlight: _ => Result(),
|
||||
|
|
@ -208,7 +261,8 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
|||
ConfirmDelete: Result,
|
||||
Restore: Result,
|
||||
Cancel: Result,
|
||||
RequestExit: () => { });
|
||||
RequestExit: () => { },
|
||||
RequestCreate: requestCreate);
|
||||
}
|
||||
|
||||
public CharacterSelectionRuntimeBindings Bindings { get; }
|
||||
|
|
|
|||
|
|
@ -18,5 +18,12 @@
|
|||
</ItemGroup>
|
||||
<ItemGroup>
|
||||
<ProjectReference Include="..\..\src\AcDream.Runtime\AcDream.Runtime.csproj" />
|
||||
<!-- Campaign CC slice CC7: test-only cross-assembly reference for the
|
||||
Runtime-state-transition -> SessionStatusWriter -> Launcher.Core
|
||||
StatusFileTailer end-to-end assertion (mirrors the LA1+LA3
|
||||
precedent of a cross-assembly test enforcing a shared contract).
|
||||
AcDream.Runtime itself does not, and must not, reference
|
||||
AcDream.Launcher.Core. -->
|
||||
<ProjectReference Include="..\..\src\AcDream.Launcher.Core\AcDream.Launcher.Core.csproj" />
|
||||
</ItemGroup>
|
||||
</Project>
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ using AcDream.Core.CharGen;
|
|||
using AcDream.Core.Net;
|
||||
using AcDream.Core.Net.Messages;
|
||||
using AcDream.Core.Net.Packets;
|
||||
using AcDream.Launcher.Core.Status;
|
||||
using AcDream.Runtime;
|
||||
using AcDream.Runtime.Session;
|
||||
using AcDream.Runtime.Tests.CharGen;
|
||||
|
|
@ -124,6 +125,17 @@ public sealed class LiveSessionControllerCharacterCreationTests
|
|||
public List<RuntimeCharacterCreationIdentity> Created { get; } = [];
|
||||
public List<RuntimeCharacterCreationRejection> Failed { get; } = [];
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC7 item 3: when set, forwards exactly the way
|
||||
/// production hosts do (<c>LiveSessionRuntimeFactory.Create</c>'s
|
||||
/// own <c>CharacterCreated</c>/<c>CreationFailed</c> delegates,
|
||||
/// <c>HeadlessSessionHost</c>'s identical pair) — the SAME real
|
||||
/// <see cref="SessionStatusWriter"/> a launcher-composed session
|
||||
/// would use, not a re-implemented shape.
|
||||
/// </summary>
|
||||
public SessionStatusWriter? Writer { get; set; }
|
||||
public string SessionId { get; set; } = "s1";
|
||||
|
||||
public LiveSessionBinding BindSession(WorldSession session) =>
|
||||
new(session, activateCommands: () => { }, deactivateCommands: () => { }, detachEvents: () => { });
|
||||
public void ResetSessionState(RuntimeGenerationToken retiringGeneration) { }
|
||||
|
|
@ -134,10 +146,17 @@ public sealed class LiveSessionControllerCharacterCreationTests
|
|||
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) =>
|
||||
EnteredWorld.Add(selection);
|
||||
public void DetachSession(WorldSession session) { }
|
||||
public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) =>
|
||||
public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity)
|
||||
{
|
||||
Created.Add(identity);
|
||||
public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) =>
|
||||
Writer?.CharacterCreated(SessionId, identity.Guid, identity.Name);
|
||||
}
|
||||
public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection)
|
||||
{
|
||||
Failed.Add(rejection);
|
||||
Writer?.CreationFailed(
|
||||
SessionId, rejection.RawCode, rejection.Reason, rejection.AttemptedName);
|
||||
}
|
||||
}
|
||||
|
||||
private static LiveSessionConnectOptions LiveOptions() => new(
|
||||
|
|
@ -399,6 +418,259 @@ public sealed class LiveSessionControllerCharacterCreationTests
|
|||
Assert.Equal(10u, decoded.Strength); // Custom template sits at the floor — unspent, unchanged.
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC7 item 2: the SEAMLESS end-to-end walk the
|
||||
/// campaign closeout asks for — a fully populated creation (heritage,
|
||||
/// gender, appearance across every one of the fourteen style/color
|
||||
/// slots and all six shades, template, an EXPLICIT skill command beyond
|
||||
/// what the template alone applies, an explicit town/start-area
|
||||
/// selection, name) sent through a REAL <see cref="WorldSession"/>, then
|
||||
/// decoded field-by-field — including the trailing checksum, which the
|
||||
/// pre-existing <see cref="Finish_SendsExactly55SkillSlotsAndTheCorrectAttributesAndName"/>
|
||||
/// test above never checked — against exactly the shape
|
||||
/// <c>CharacterCreateInfo.Unpack</c>/<c>Appearance.Unpack</c> parse (see
|
||||
/// <see cref="CharacterCreate"/>'s own doc comment for the ACE
|
||||
/// cross-reference). The checksum is recomputed via the SAME production
|
||||
/// <see cref="CharacterCreate.ComputeChecksum"/> formula rather than
|
||||
/// re-deriving the sum a second time by hand in the test.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Finish_SendsEveryWireFieldByteExactAgainstACEsUnpackShape()
|
||||
{
|
||||
(LiveSessionController controller, TestOperations operations, _, RuntimeGenerationToken generation) =
|
||||
StartAwaitingSelection();
|
||||
|
||||
Assert.True(controller.SelectHeritage(generation, RuntimeCharacterCreationStateFixture.AluvianId).Accepted);
|
||||
Assert.True(controller.SelectGender(generation, RuntimeCharacterCreationStateFixture.MaleGenderKey).Accepted);
|
||||
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.EyesStrip, 0u).Accepted);
|
||||
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.NoseStrip, 0u).Accepted);
|
||||
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.MouthStrip, 0u).Accepted);
|
||||
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HairStyle, 1u).Accepted);
|
||||
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HairColor, 1u).Accepted);
|
||||
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.EyeColor, 1u).Accepted);
|
||||
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HeadgearStyle, 0u).Accepted);
|
||||
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.HeadgearColor, 2u).Accepted);
|
||||
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.ShirtStyle, 0u).Accepted);
|
||||
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.ShirtColor, 1u).Accepted);
|
||||
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.TrousersStyle, 0u).Accepted);
|
||||
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.TrousersColor, 0u).Accepted);
|
||||
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.FootwearStyle, 0u).Accepted);
|
||||
Assert.True(controller.SetAppearanceIndex(generation, ChargenAppearanceSlot.FootwearColor, 2u).Accepted);
|
||||
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Skin, 0.25).Accepted);
|
||||
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Hair, 0.5).Accepted);
|
||||
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Headgear, 0.75).Accepted);
|
||||
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Shirt, 0.1).Accepted);
|
||||
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Trousers, 0.9).Accepted);
|
||||
Assert.True(controller.SetShade(generation, ChargenShadeSlot.Footwear, 0.6).Accepted);
|
||||
Assert.True(controller.SelectTemplate(generation, RuntimeCharacterCreationStateFixture.PresetTemplateIndex).Accepted);
|
||||
// Explicit skill command beyond the template's own Normal/Primary
|
||||
// lists (SkillFreeTrained costs 0 to train — no credit-budget risk).
|
||||
Assert.True(controller.TrainSkill(generation, RuntimeCharacterCreationStateFixture.SkillFreeTrained).Accepted);
|
||||
// Town: the fixture's global starter-area list is [Holtburg(0), Yaraq(1)].
|
||||
Assert.True(controller.SelectStartArea(generation, 1).Accepted);
|
||||
Assert.True(controller.SetName(generation, "FullChar").Accepted);
|
||||
|
||||
WorldSession session = operations.Sessions[0];
|
||||
byte[]? captured = null;
|
||||
session.GameMessageCapture = (body, _) => captured = body;
|
||||
|
||||
Assert.True(controller.Finish(generation).Accepted);
|
||||
Assert.NotNull(captured);
|
||||
|
||||
DecodedFullRequest decoded = DecodeCreateRequestFull(captured!);
|
||||
|
||||
Assert.Equal("testaccount", decoded.AccountName);
|
||||
Assert.Equal(1u, decoded.Constant);
|
||||
CharacterCreate.Request r = decoded.Request;
|
||||
Assert.Equal(RuntimeCharacterCreationStateFixture.AluvianId, r.Heritage);
|
||||
Assert.Equal(RuntimeCharacterCreationStateFixture.MaleGenderKey, r.Gender);
|
||||
Assert.Equal(0u, r.Appearance.EyesStrip);
|
||||
Assert.Equal(0u, r.Appearance.NoseStrip);
|
||||
Assert.Equal(0u, r.Appearance.MouthStrip);
|
||||
Assert.Equal(1u, r.Appearance.HairColor);
|
||||
Assert.Equal(1u, r.Appearance.EyeColor);
|
||||
Assert.Equal(1u, r.Appearance.HairStyle);
|
||||
Assert.Equal(0u, r.Appearance.HeadgearStyle);
|
||||
Assert.Equal(2u, r.Appearance.HeadgearColor);
|
||||
Assert.Equal(0u, r.Appearance.ShirtStyle);
|
||||
Assert.Equal(1u, r.Appearance.ShirtColor);
|
||||
Assert.Equal(0u, r.Appearance.TrousersStyle);
|
||||
Assert.Equal(0u, r.Appearance.TrousersColor);
|
||||
Assert.Equal(0u, r.Appearance.FootwearStyle);
|
||||
Assert.Equal(2u, r.Appearance.FootwearColor);
|
||||
Assert.Equal(0.25, r.Appearance.SkinShade);
|
||||
Assert.Equal(0.5, r.Appearance.HairShade);
|
||||
Assert.Equal(0.75, r.Appearance.HeadgearShade);
|
||||
Assert.Equal(0.1, r.Appearance.ShirtShade);
|
||||
Assert.Equal(0.9, r.Appearance.TrousersShade);
|
||||
Assert.Equal(0.6, r.Appearance.FootwearShade);
|
||||
Assert.Equal(RuntimeCharacterCreationStateFixture.PresetTemplateIndex, r.Template);
|
||||
Assert.Equal(16u, r.Attributes.Strength);
|
||||
Assert.Equal(10u, r.Attributes.Endurance);
|
||||
Assert.Equal(10u, r.Attributes.Coordination);
|
||||
Assert.Equal(10u, r.Attributes.Quickness);
|
||||
Assert.Equal(10u, r.Attributes.Focus);
|
||||
Assert.Equal(10u, r.Attributes.Self);
|
||||
Assert.Equal(0u, r.Slot);
|
||||
// classId: register AP-209's documented placeholder — ACE ignores
|
||||
// this field (retail's DAT DID lookup has no Core equivalent).
|
||||
Assert.Equal(0u, r.ClassId);
|
||||
Assert.Equal(
|
||||
(uint)CharacterCreate.SkillAdvancementClassCount,
|
||||
(uint)decoded.SkillAdvancementClasses.Length);
|
||||
Assert.Equal(
|
||||
(uint)ChargenSkillAdvancementClass.Trained,
|
||||
decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillTrainSpecialize]);
|
||||
Assert.Equal(
|
||||
(uint)ChargenSkillAdvancementClass.Specialized,
|
||||
decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillPresetPrimary]);
|
||||
Assert.Equal(
|
||||
(uint)ChargenSkillAdvancementClass.Trained,
|
||||
decoded.SkillAdvancementClasses[RuntimeCharacterCreationStateFixture.SkillFreeTrained]);
|
||||
Assert.Equal("FullChar", r.Name);
|
||||
Assert.Equal(1u, r.StartArea);
|
||||
Assert.False(r.IsAdmin);
|
||||
Assert.False(r.IsEnvoy);
|
||||
|
||||
// The trailing checksum (CG_Pack@0x005c74c3's final store) — never
|
||||
// read by ACE, sent for byte fidelity with a genuine retail client.
|
||||
Assert.Equal(CharacterCreate.ComputeChecksum(r), decoded.Checksum);
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC7 item 2: the remaining <c>0xF643</c> rejection
|
||||
/// codes beyond NameInUse (already covered by
|
||||
/// <see cref="Finish_ThenNameInUseResponse_SurfacesRejectionAndStaysAwaitingSelection"/>
|
||||
/// above) — proves CC5's F2 fix (Pending/Undef produce a real rejection
|
||||
/// instead of a silent reset) holds over the REAL wire decode path, not
|
||||
/// just the isolated state-machine
|
||||
/// <c>RuntimeCharacterCreationStateTests.ApplyCreationResponse_EachRejectionCode_...</c>
|
||||
/// theory.
|
||||
/// </summary>
|
||||
[Theory]
|
||||
[InlineData(CharGenVerificationResponse.Code.Pending)]
|
||||
[InlineData(CharGenVerificationResponse.Code.NameBanned)]
|
||||
[InlineData(CharGenVerificationResponse.Code.Corrupt)]
|
||||
[InlineData(CharGenVerificationResponse.Code.DatabaseDown)]
|
||||
[InlineData(CharGenVerificationResponse.Code.AdminPrivilegeDenied)]
|
||||
[InlineData(CharGenVerificationResponse.Code.Undef)]
|
||||
public void Finish_ThenEachOtherRejectionCode_ProducesTheMappedFailureWithNoRosterOrEnterSideEffect(
|
||||
CharGenVerificationResponse.Code code)
|
||||
{
|
||||
(LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) =
|
||||
StartAwaitingSelection();
|
||||
BuildReadyCharacter(controller, generation);
|
||||
WorldSession session = operations.Sessions[0];
|
||||
session.GameMessageCapture = (_, _) => { };
|
||||
|
||||
Assert.True(controller.Finish(generation).Accepted);
|
||||
|
||||
InvokeProcessDatagram(session, BuildResponsePacket((uint)code, 0u, string.Empty));
|
||||
|
||||
Assert.Single(host.Failed);
|
||||
Assert.Equal((uint)code, host.Failed[0].RawCode);
|
||||
Assert.Equal(code, host.Failed[0].Code);
|
||||
Assert.Equal(code.ToString(), host.Failed[0].Reason);
|
||||
Assert.Equal("NewChar", host.Failed[0].AttemptedName);
|
||||
Assert.Empty(host.Created);
|
||||
Assert.False(controller.IsInWorld);
|
||||
Assert.Empty(operations.EnterWorldByGuidCalls);
|
||||
Assert.Empty(host.EnteredWorld);
|
||||
Assert.DoesNotContain(host.Rosters, r => r.Entries.Any(e => e.Name == "NewChar"));
|
||||
}
|
||||
|
||||
/// <summary>
|
||||
/// Campaign CC slice CC7 item 3: the launcher payload cycle end to end —
|
||||
/// a REAL Runtime state transition (Finish -> real
|
||||
/// <see cref="WorldSession"/> -> real inbound <c>0xF643</c> Ok reply)
|
||||
/// through the REAL <see cref="SessionStatusWriter"/>, wired exactly the
|
||||
/// way <c>LiveSessionRuntimeFactory.Create</c> (App host) and
|
||||
/// <c>HeadlessSessionHost</c> wire it (see <see cref="TestHost"/>'s own
|
||||
/// doc comment), to the REAL Launcher.Core
|
||||
/// <see cref="StatusFileTailer"/>'s parsed event. This is the piece
|
||||
/// CC2's own tests never reached: <c>SessionStatusWriterTests</c> calls
|
||||
/// the writer directly and asserts raw JSON; <c>StatusEventParserTests</c>
|
||||
/// parses a hand-written JSON literal; neither drives a create through
|
||||
/// Runtime first, so a wiring gap between Runtime's own state machine
|
||||
/// and the writer (or between the writer's bytes and the tailer's
|
||||
/// parser) would not have been caught by either.
|
||||
/// </summary>
|
||||
[Fact]
|
||||
public void Finish_ThenOkResponse_WritesCharacterCreatedEvent_ParsedByTheRealLauncherTailer()
|
||||
{
|
||||
string path = Path.Combine(
|
||||
Path.GetTempPath(), $"acdream-cc7-status-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
(LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) =
|
||||
StartAwaitingSelection();
|
||||
host.Writer = new SessionStatusWriter(path);
|
||||
host.SessionId = "cc7-session";
|
||||
BuildReadyCharacter(controller, generation);
|
||||
WorldSession session = operations.Sessions[0];
|
||||
session.GameMessageCapture = (_, _) => { };
|
||||
|
||||
Assert.True(controller.Finish(generation).Accepted);
|
||||
InvokeProcessDatagram(session, BuildResponsePacket(
|
||||
(uint)CharGenVerificationResponse.Code.Ok, 0x50001234u, "NewChar"));
|
||||
|
||||
// Runtime's own side of the contract already fired.
|
||||
Assert.Single(host.Created);
|
||||
|
||||
var tailer = new StatusFileTailer(path);
|
||||
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
|
||||
CharacterCreatedStatusEvent created =
|
||||
Assert.Single(events.OfType<CharacterCreatedStatusEvent>());
|
||||
Assert.Equal("cc7-session", created.SessionId);
|
||||
Assert.Equal(0x50001234u, created.Guid);
|
||||
Assert.Equal("NewChar", created.Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Sibling of the Ok test above for the non-Ok half of the
|
||||
/// contract (<c>creationFailed{code,reason,name}</c>).</summary>
|
||||
[Fact]
|
||||
public void Finish_ThenNameInUseResponse_WritesCreationFailedEvent_ParsedByTheRealLauncherTailer()
|
||||
{
|
||||
string path = Path.Combine(
|
||||
Path.GetTempPath(), $"acdream-cc7-status-{Guid.NewGuid():N}.jsonl");
|
||||
try
|
||||
{
|
||||
(LiveSessionController controller, TestOperations operations, TestHost host, RuntimeGenerationToken generation) =
|
||||
StartAwaitingSelection();
|
||||
host.Writer = new SessionStatusWriter(path);
|
||||
host.SessionId = "cc7-session";
|
||||
BuildReadyCharacter(controller, generation);
|
||||
WorldSession session = operations.Sessions[0];
|
||||
session.GameMessageCapture = (_, _) => { };
|
||||
|
||||
Assert.True(controller.Finish(generation).Accepted);
|
||||
InvokeProcessDatagram(session, BuildResponsePacket(
|
||||
(uint)CharGenVerificationResponse.Code.NameInUse, 0u, string.Empty));
|
||||
|
||||
Assert.Single(host.Failed);
|
||||
|
||||
var tailer = new StatusFileTailer(path);
|
||||
IReadOnlyList<StatusEvent> events = tailer.ReadNewEvents();
|
||||
CreationFailedStatusEvent failed =
|
||||
Assert.Single(events.OfType<CreationFailedStatusEvent>());
|
||||
Assert.Equal("cc7-session", failed.SessionId);
|
||||
Assert.Equal((uint)CharGenVerificationResponse.Code.NameInUse, failed.Code);
|
||||
Assert.Equal("NameInUse", failed.Reason);
|
||||
Assert.Equal("NewChar", failed.Name);
|
||||
}
|
||||
finally
|
||||
{
|
||||
if (File.Exists(path))
|
||||
File.Delete(path);
|
||||
}
|
||||
}
|
||||
|
||||
private static void InvokeProcessDatagram(WorldSession session, byte[] datagram)
|
||||
{
|
||||
MethodInfo method = typeof(WorldSession).GetMethod(
|
||||
|
|
@ -490,6 +762,105 @@ public sealed class LiveSessionControllerCharacterCreationTests
|
|||
accountName, heritage, gender, template, strength, name, numSkills, skills);
|
||||
}
|
||||
|
||||
/// <summary>Campaign CC slice CC7: the FULL field set — every byte of
|
||||
/// <see cref="CharacterCreate.BuildRequestBody"/>'s layout, unlike
|
||||
/// <see cref="CapturedCreateRequest"/>/<see cref="DecodeCreateRequest"/>
|
||||
/// above which only samples a handful of fields.</summary>
|
||||
private readonly record struct DecodedFullRequest(
|
||||
string AccountName,
|
||||
uint Constant,
|
||||
CharacterCreate.Request Request,
|
||||
uint[] SkillAdvancementClasses,
|
||||
uint Checksum);
|
||||
|
||||
private static DecodedFullRequest DecodeCreateRequestFull(ReadOnlySpan<byte> body)
|
||||
{
|
||||
int pos = 0;
|
||||
uint opcode = ReadU32(body, ref pos);
|
||||
Assert.Equal(CharacterCreate.Opcode, opcode);
|
||||
string accountName = ReadString16L(body, ref pos);
|
||||
uint constant = ReadU32(body, ref pos);
|
||||
uint heritage = ReadU32(body, ref pos);
|
||||
uint gender = ReadU32(body, ref pos);
|
||||
uint eyesStrip = ReadU32(body, ref pos);
|
||||
uint noseStrip = ReadU32(body, ref pos);
|
||||
uint mouthStrip = ReadU32(body, ref pos);
|
||||
uint hairColor = ReadU32(body, ref pos);
|
||||
uint eyeColor = ReadU32(body, ref pos);
|
||||
uint hairStyle = ReadU32(body, ref pos);
|
||||
uint headgearStyle = ReadU32(body, ref pos);
|
||||
uint headgearColor = ReadU32(body, ref pos);
|
||||
uint shirtStyle = ReadU32(body, ref pos);
|
||||
uint shirtColor = ReadU32(body, ref pos);
|
||||
uint trousersStyle = ReadU32(body, ref pos);
|
||||
uint trousersColor = ReadU32(body, ref pos);
|
||||
uint footwearStyle = ReadU32(body, ref pos);
|
||||
uint footwearColor = ReadU32(body, ref pos);
|
||||
double skinShade = ReadF64(body, ref pos);
|
||||
double hairShade = ReadF64(body, ref pos);
|
||||
double headgearShade = ReadF64(body, ref pos);
|
||||
double shirtShade = ReadF64(body, ref pos);
|
||||
double trousersShade = ReadF64(body, ref pos);
|
||||
double footwearShade = ReadF64(body, ref pos);
|
||||
uint template = ReadU32(body, ref pos);
|
||||
uint strength = ReadU32(body, ref pos);
|
||||
uint endurance = ReadU32(body, ref pos);
|
||||
uint coordination = ReadU32(body, ref pos);
|
||||
uint quickness = ReadU32(body, ref pos);
|
||||
uint focus = ReadU32(body, ref pos);
|
||||
uint self = ReadU32(body, ref pos);
|
||||
uint slot = ReadU32(body, ref pos);
|
||||
uint classId = ReadU32(body, ref pos);
|
||||
uint numSkills = ReadU32(body, ref pos);
|
||||
var skills = new uint[numSkills];
|
||||
for (int i = 0; i < numSkills; i++)
|
||||
skills[i] = ReadU32(body, ref pos);
|
||||
string name = ReadString16L(body, ref pos);
|
||||
uint startArea = ReadU32(body, ref pos);
|
||||
uint isAdmin = ReadU32(body, ref pos);
|
||||
uint isEnvoy = ReadU32(body, ref pos);
|
||||
uint checksum = ReadU32(body, ref pos);
|
||||
|
||||
// Nothing left over, nothing missing — the layout is exhaustive.
|
||||
Assert.Equal(body.Length, pos);
|
||||
|
||||
var request = new CharacterCreate.Request(
|
||||
heritage,
|
||||
gender,
|
||||
new CharacterCreate.Appearance(
|
||||
eyesStrip,
|
||||
noseStrip,
|
||||
mouthStrip,
|
||||
hairColor,
|
||||
eyeColor,
|
||||
hairStyle,
|
||||
headgearStyle,
|
||||
headgearColor,
|
||||
shirtStyle,
|
||||
shirtColor,
|
||||
trousersStyle,
|
||||
trousersColor,
|
||||
footwearStyle,
|
||||
footwearColor,
|
||||
skinShade,
|
||||
hairShade,
|
||||
headgearShade,
|
||||
shirtShade,
|
||||
trousersShade,
|
||||
footwearShade),
|
||||
template,
|
||||
new CharacterCreate.Attributes(
|
||||
strength, endurance, coordination, quickness, focus, self),
|
||||
slot,
|
||||
classId,
|
||||
name,
|
||||
startArea,
|
||||
isAdmin != 0u,
|
||||
isEnvoy != 0u);
|
||||
|
||||
return new DecodedFullRequest(accountName, constant, request, skills, checksum);
|
||||
}
|
||||
|
||||
private static uint ReadU32(ReadOnlySpan<byte> body, ref int pos)
|
||||
{
|
||||
uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||
|
|
|
|||
|
|
@ -588,8 +588,14 @@ public sealed class LiveSessionControllerTests
|
|||
Assert.Equal(
|
||||
RuntimeCharacterSelectionOperation.DeleteRequested,
|
||||
controller.CharacterSelection.Snapshot.Operation);
|
||||
// Campaign CC slice CC7: this fixture's roster (2 characters) is
|
||||
// below its SlotCount (11), so retail's Create gate
|
||||
// (gmCharacterManagementUI::UpdateButtons) stays enabled through
|
||||
// the whole delete-request/acknowledge sequence below — CanCreate
|
||||
// is independent of the delete-in-flight buttons this test is
|
||||
// actually pinning.
|
||||
Assert.Equal(
|
||||
RuntimeCharacterSelectionButtons.None,
|
||||
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
|
||||
controller.CharacterSelection.Snapshot.Buttons);
|
||||
|
||||
if (acknowledgeBeforeCompletion)
|
||||
|
|
@ -607,8 +613,10 @@ public sealed class LiveSessionControllerTests
|
|||
? RuntimeCharacterSelectionOperation.DeleteAcknowledged
|
||||
: RuntimeCharacterSelectionOperation.DeleteRequested,
|
||||
controller.CharacterSelection.Snapshot.Operation);
|
||||
// CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate
|
||||
// stays true independent of the delete-in-flight buttons.
|
||||
Assert.Equal(
|
||||
RuntimeCharacterSelectionButtons.None,
|
||||
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
|
||||
controller.CharacterSelection.Snapshot.Buttons);
|
||||
Assert.True(controller.CharacterSelection.TryGet(
|
||||
0x50000001u,
|
||||
|
|
@ -625,8 +633,10 @@ public sealed class LiveSessionControllerTests
|
|||
Assert.Equal(
|
||||
RuntimeCharacterSelectionOperation.DeleteAcknowledged,
|
||||
controller.CharacterSelection.Snapshot.Operation);
|
||||
// CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate
|
||||
// stays true independent of the delete-in-flight buttons.
|
||||
Assert.Equal(
|
||||
RuntimeCharacterSelectionButtons.None,
|
||||
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
|
||||
controller.CharacterSelection.Snapshot.Buttons);
|
||||
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
|
||||
}
|
||||
|
|
@ -665,8 +675,10 @@ public sealed class LiveSessionControllerTests
|
|||
? RuntimeCharacterSelectionOperation.DeleteAcknowledged
|
||||
: RuntimeCharacterSelectionOperation.DeleteRequested,
|
||||
controller.CharacterSelection.Snapshot.Operation);
|
||||
// CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate
|
||||
// stays true independent of the delete-in-flight buttons.
|
||||
Assert.Equal(
|
||||
RuntimeCharacterSelectionButtons.None,
|
||||
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
|
||||
controller.CharacterSelection.Snapshot.Buttons);
|
||||
Assert.Equal(
|
||||
RuntimeCommandStatus.Rejected,
|
||||
|
|
@ -679,8 +691,10 @@ public sealed class LiveSessionControllerTests
|
|||
Assert.Equal(
|
||||
RuntimeCharacterSelectionOperation.DeleteAcknowledged,
|
||||
controller.CharacterSelection.Snapshot.Operation);
|
||||
// CC7: same roster(2)-below-SlotCount(11) note as above — CanCreate
|
||||
// stays true independent of the delete-in-flight buttons.
|
||||
Assert.Equal(
|
||||
RuntimeCharacterSelectionButtons.None,
|
||||
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
|
||||
controller.CharacterSelection.Snapshot.Buttons);
|
||||
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue