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.Width > 0f ? Root.Width : 800f,
|
||||||
Root.Height > 0f ? Root.Height : 600f);
|
Root.Height > 0f ? Root.Height : 600f);
|
||||||
|
|
||||||
// Create Character belongs to a future campaign. Keep retail's
|
// Campaign CC slice CC7: gmCharacterManagementUI::ListenToElementMessage
|
||||||
// authored control in place and visibly ghosted; do not hide it or
|
// @ 0x004ed5a0 case 3 dispatches Create unconditionally on click
|
||||||
// invent an action.
|
// (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.Visible = true;
|
||||||
_create.Enabled = false;
|
_create.Enabled = false;
|
||||||
_create.OnClick = null;
|
_create.OnClick = RequestCreate;
|
||||||
_enter.OnClick = EnterSelected;
|
_enter.OnClick = EnterSelected;
|
||||||
_delete.OnClick = RequestDelete;
|
_delete.OnClick = RequestDelete;
|
||||||
_restore.OnClick = RestoreSelected;
|
_restore.OnClick = RestoreSelected;
|
||||||
|
|
@ -534,7 +538,7 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
private void ApplyButtons(RuntimeCharacterSelectionButtons buttons)
|
private void ApplyButtons(RuntimeCharacterSelectionButtons buttons)
|
||||||
{
|
{
|
||||||
_create.Visible = true;
|
_create.Visible = true;
|
||||||
_create.Enabled = false;
|
_create.Enabled = buttons.CanCreate;
|
||||||
_enter.Enabled = buttons.CanEnter;
|
_enter.Enabled = buttons.CanEnter;
|
||||||
_delete.Visible = buttons.DeleteVisible;
|
_delete.Visible = buttons.DeleteVisible;
|
||||||
_delete.Enabled = buttons.CanDelete;
|
_delete.Enabled = buttons.CanDelete;
|
||||||
|
|
@ -550,6 +554,22 @@ internal sealed class CharacterManagementUiController : IDisposable
|
||||||
InvalidateAndTick();
|
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()
|
private void EnterSelected()
|
||||||
{
|
{
|
||||||
if (_disposed)
|
if (_disposed)
|
||||||
|
|
|
||||||
|
|
@ -398,7 +398,24 @@ public sealed record CharacterSelectionRuntimeBindings(
|
||||||
Func<RuntimeCommandResult> ConfirmDelete,
|
Func<RuntimeCommandResult> ConfirmDelete,
|
||||||
Func<RuntimeCommandResult> Restore,
|
Func<RuntimeCommandResult> Restore,
|
||||||
Func<RuntimeCommandResult> Cancel,
|
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(
|
public sealed record RetailUiRuntimeBindings(
|
||||||
UiHost Host,
|
UiHost Host,
|
||||||
|
|
@ -3816,9 +3833,18 @@ public sealed class RetailUiRuntime : IDisposable
|
||||||
if (bindings is null || _characterManagementMount is not null)
|
if (bindings is null || _characterManagementMount is not null)
|
||||||
return;
|
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(
|
_characterManagementMount = new CharacterManagementUiMountCoordinator(
|
||||||
Host.Root,
|
Host.Root,
|
||||||
bindings,
|
bindings with { RequestCreate = () => CharacterCreationController?.Open() },
|
||||||
EnsureDialogFactory,
|
EnsureDialogFactory,
|
||||||
LoadCharacterManagementResources);
|
LoadCharacterManagementResources);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -64,7 +64,17 @@ public readonly record struct RuntimeCharacterSelectionButtons(
|
||||||
bool CanDelete,
|
bool CanDelete,
|
||||||
bool CanRestore,
|
bool CanRestore,
|
||||||
bool DeleteVisible,
|
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; } =
|
public static RuntimeCharacterSelectionButtons None { get; } =
|
||||||
new(false, false, false, true, false);
|
new(false, false, false, true, false);
|
||||||
|
|
@ -848,13 +858,21 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
|
|
||||||
private RuntimeCharacterSelectionButtons BuildButtons(int selectedIndex)
|
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
|
if (_operation is RuntimeCharacterSelectionOperation.DeleteRequested
|
||||||
or RuntimeCharacterSelectionOperation.DeleteAcknowledged)
|
or RuntimeCharacterSelectionOperation.DeleteAcknowledged)
|
||||||
{
|
{
|
||||||
return RuntimeCharacterSelectionButtons.None;
|
return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate };
|
||||||
}
|
}
|
||||||
if (selectedIndex < 0)
|
if (selectedIndex < 0)
|
||||||
return RuntimeCharacterSelectionButtons.None;
|
return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate };
|
||||||
|
|
||||||
RuntimeCharacterSelectionEntry selected = _entries[selectedIndex];
|
RuntimeCharacterSelectionEntry selected = _entries[selectedIndex];
|
||||||
if (selected.IsPendingDelete)
|
if (selected.IsPendingDelete)
|
||||||
|
|
@ -864,7 +882,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
CanDelete: false,
|
CanDelete: false,
|
||||||
CanRestore: !_restoreResponseArmed,
|
CanRestore: !_restoreResponseArmed,
|
||||||
DeleteVisible: false,
|
DeleteVisible: false,
|
||||||
RestoreVisible: true);
|
RestoreVisible: true,
|
||||||
|
CanCreate: canCreate);
|
||||||
}
|
}
|
||||||
|
|
||||||
return new RuntimeCharacterSelectionButtons(
|
return new RuntimeCharacterSelectionButtons(
|
||||||
|
|
@ -872,7 +891,8 @@ public sealed class RuntimeCharacterSelectionState : IDisposable
|
||||||
CanDelete: selected.CanEnter,
|
CanDelete: selected.CanEnter,
|
||||||
CanRestore: false,
|
CanRestore: false,
|
||||||
DeleteVisible: true,
|
DeleteVisible: true,
|
||||||
RestoreVisible: false);
|
RestoreVisible: false,
|
||||||
|
CanCreate: canCreate);
|
||||||
}
|
}
|
||||||
|
|
||||||
private int FindDisplayIndex(uint characterId)
|
private int FindDisplayIndex(uint characterId)
|
||||||
|
|
|
||||||
|
|
@ -61,8 +61,12 @@ public sealed class CharacterManagementUiControllerTests
|
||||||
CharacterManagementUiController.RestoreElementId);
|
CharacterManagementUiController.RestoreElementId);
|
||||||
|
|
||||||
Assert.True(create.Visible);
|
Assert.True(create.Visible);
|
||||||
Assert.False(create.Enabled);
|
// Campaign CC slice CC7: gmCharacterManagementUI::UpdateButtons @
|
||||||
Assert.Null(create.OnClick);
|
// 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(enter.Enabled);
|
||||||
Assert.True(delete.Visible);
|
Assert.True(delete.Visible);
|
||||||
Assert.True(delete.Enabled);
|
Assert.True(delete.Enabled);
|
||||||
|
|
@ -99,6 +103,45 @@ public sealed class CharacterManagementUiControllerTests
|
||||||
Assert.True(restore.Enabled);
|
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>
|
/// <summary>
|
||||||
/// Campaign LA gate round 2 finding 3: retail's UpdateWorldName@0x004ec120
|
/// Campaign LA gate round 2 finding 3: retail's UpdateWorldName@0x004ec120
|
||||||
/// / RecvNotice_WorldName@0x004ec360 both push Client::GetWorldName()
|
/// / RecvNotice_WorldName@0x004ec360 both push Client::GetWorldName()
|
||||||
|
|
@ -906,7 +949,8 @@ public sealed class CharacterManagementUiControllerTests
|
||||||
ConfirmDelete,
|
ConfirmDelete,
|
||||||
Restore,
|
Restore,
|
||||||
Cancel,
|
Cancel,
|
||||||
RequestExit);
|
RequestExit,
|
||||||
|
RequestCreate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public FakeView View { get; } = new();
|
public FakeView View { get; } = new();
|
||||||
|
|
@ -918,6 +962,14 @@ public sealed class CharacterManagementUiControllerTests
|
||||||
public int CancelCalls { get; private set; }
|
public int CancelCalls { get; private set; }
|
||||||
public int RestoreCalls { get; private set; }
|
public int RestoreCalls { get; private set; }
|
||||||
public int RequestExitCalls { 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; } =
|
public RuntimeCommandStatus RestoreStatus { get; set; } =
|
||||||
RuntimeCommandStatus.Accepted;
|
RuntimeCommandStatus.Accepted;
|
||||||
public bool ThrowOnRestore { get; set; }
|
public bool ThrowOnRestore { get; set; }
|
||||||
|
|
@ -929,7 +981,8 @@ public sealed class CharacterManagementUiControllerTests
|
||||||
RuntimeCharacterSelectionButtons buttons = operation is
|
RuntimeCharacterSelectionButtons buttons = operation is
|
||||||
RuntimeCharacterSelectionOperation.DeleteRequested
|
RuntimeCharacterSelectionOperation.DeleteRequested
|
||||||
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
|
or RuntimeCharacterSelectionOperation.DeleteAcknowledged
|
||||||
? RuntimeCharacterSelectionButtons.None
|
? RuntimeCharacterSelectionButtons.None with
|
||||||
|
{ CanCreate = View.Entries.Length < SlotCount }
|
||||||
: ButtonsFor(View.Snapshot.HighlightedCharacterId);
|
: ButtonsFor(View.Snapshot.HighlightedCharacterId);
|
||||||
Update(snapshot => snapshot with
|
Update(snapshot => snapshot with
|
||||||
{
|
{
|
||||||
|
|
@ -1022,7 +1075,8 @@ public sealed class CharacterManagementUiControllerTests
|
||||||
{
|
{
|
||||||
PendingDeleteCharacterId = 0u,
|
PendingDeleteCharacterId = 0u,
|
||||||
Operation = RuntimeCharacterSelectionOperation.DeleteRequested,
|
Operation = RuntimeCharacterSelectionOperation.DeleteRequested,
|
||||||
Buttons = RuntimeCharacterSelectionButtons.None,
|
Buttons = RuntimeCharacterSelectionButtons.None with
|
||||||
|
{ CanCreate = View.Entries.Length < SlotCount },
|
||||||
});
|
});
|
||||||
return Result(RuntimeCommandStatus.Accepted, id);
|
return Result(RuntimeCommandStatus.Accepted, id);
|
||||||
}
|
}
|
||||||
|
|
@ -1045,7 +1099,8 @@ public sealed class CharacterManagementUiControllerTests
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
false,
|
false,
|
||||||
true),
|
true,
|
||||||
|
View.Entries.Length < SlotCount),
|
||||||
});
|
});
|
||||||
AfterRestoreProjection?.Invoke();
|
AfterRestoreProjection?.Invoke();
|
||||||
return Result(RuntimeCommandStatus.Accepted, id);
|
return Result(RuntimeCommandStatus.Accepted, id);
|
||||||
|
|
@ -1065,13 +1120,22 @@ public sealed class CharacterManagementUiControllerTests
|
||||||
|
|
||||||
private void RequestExit() => RequestExitCalls++;
|
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)
|
private RuntimeCharacterSelectionButtons ButtonsFor(uint characterId)
|
||||||
{
|
{
|
||||||
|
bool canCreate = View.Entries.Length < SlotCount;
|
||||||
RuntimeCharacterSelectionEntry? selected = View.Entries
|
RuntimeCharacterSelectionEntry? selected = View.Entries
|
||||||
.Cast<RuntimeCharacterSelectionEntry?>()
|
.Cast<RuntimeCharacterSelectionEntry?>()
|
||||||
.FirstOrDefault(entry => entry?.CharacterId == characterId);
|
.FirstOrDefault(entry => entry?.CharacterId == characterId);
|
||||||
if (selected is null)
|
if (selected is null)
|
||||||
return RuntimeCharacterSelectionButtons.None;
|
return RuntimeCharacterSelectionButtons.None with { CanCreate = canCreate };
|
||||||
if (selected.Value.IsPendingDelete)
|
if (selected.Value.IsPendingDelete)
|
||||||
{
|
{
|
||||||
return new RuntimeCharacterSelectionButtons(
|
return new RuntimeCharacterSelectionButtons(
|
||||||
|
|
@ -1079,14 +1143,16 @@ public sealed class CharacterManagementUiControllerTests
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true);
|
true,
|
||||||
|
canCreate);
|
||||||
}
|
}
|
||||||
return new RuntimeCharacterSelectionButtons(
|
return new RuntimeCharacterSelectionButtons(
|
||||||
true,
|
true,
|
||||||
true,
|
true,
|
||||||
false,
|
false,
|
||||||
true,
|
true,
|
||||||
false);
|
false,
|
||||||
|
canCreate);
|
||||||
}
|
}
|
||||||
|
|
||||||
private void Update(
|
private void Update(
|
||||||
|
|
|
||||||
|
|
@ -78,6 +78,48 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
||||||
Assert.Null(environment.Host.FixedCanvasSize);
|
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 ────────────────────
|
// ── Fixture: one shared UiRoot, both controllers ────────────────────
|
||||||
|
|
||||||
private sealed class TwoControllerHarness : IDisposable
|
private sealed class TwoControllerHarness : IDisposable
|
||||||
|
|
@ -85,8 +127,15 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
||||||
public TwoControllerHarness()
|
public TwoControllerHarness()
|
||||||
{
|
{
|
||||||
Host = new UiRoot { Width = 800f, Height = 600f };
|
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);
|
Chargen = new ChargenHarness(Host);
|
||||||
|
Management = new ManagementHarness(Host, Chargen.Controller.Open);
|
||||||
}
|
}
|
||||||
|
|
||||||
public UiRoot Host { get; }
|
public UiRoot Host { get; }
|
||||||
|
|
@ -104,17 +153,17 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
||||||
{
|
{
|
||||||
private readonly RetailDialogFactory _dialogs;
|
private readonly RetailDialogFactory _dialogs;
|
||||||
|
|
||||||
public ManagementHarness(UiRoot host)
|
public ManagementHarness(UiRoot host, Action requestCreate)
|
||||||
{
|
{
|
||||||
ImportedLayout screen = BuildManagementScreen();
|
Screen = BuildManagementScreen();
|
||||||
Runtime = new ManagementFakeRuntime();
|
Runtime = new ManagementFakeRuntime(requestCreate);
|
||||||
_dialogs = new RetailDialogFactory(
|
_dialogs = new RetailDialogFactory(
|
||||||
host,
|
host,
|
||||||
type => RetailDialogFactoryTests.BuildDialogLayout(type));
|
type => RetailDialogFactoryTests.BuildDialogLayout(type));
|
||||||
Controller = Assert.IsType<CharacterManagementUiController>(
|
Controller = Assert.IsType<CharacterManagementUiController>(
|
||||||
CharacterManagementUiController.Bind(
|
CharacterManagementUiController.Bind(
|
||||||
host,
|
host,
|
||||||
screen,
|
Screen,
|
||||||
static (_, _) => BuildRow(),
|
static (_, _) => BuildRow(),
|
||||||
_dialogs,
|
_dialogs,
|
||||||
Runtime.Bindings,
|
Runtime.Bindings,
|
||||||
|
|
@ -126,9 +175,13 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
||||||
"Are you sure you want to leave?")));
|
"Are you sure you want to leave?")));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public ImportedLayout Screen { get; }
|
||||||
public ManagementFakeRuntime Runtime { get; }
|
public ManagementFakeRuntime Runtime { get; }
|
||||||
public CharacterManagementUiController Controller { get; }
|
public CharacterManagementUiController Controller { get; }
|
||||||
|
|
||||||
|
public UiButton Button(uint id) =>
|
||||||
|
Assert.IsType<UiButton>(Screen.FindElement(id));
|
||||||
|
|
||||||
public void Dispose()
|
public void Dispose()
|
||||||
{
|
{
|
||||||
Controller.Dispose();
|
Controller.Dispose();
|
||||||
|
|
@ -182,7 +235,7 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
||||||
private static readonly RuntimeGenerationToken Generation = new(11u);
|
private static readonly RuntimeGenerationToken Generation = new(11u);
|
||||||
private readonly FakeManagementView _view = new();
|
private readonly FakeManagementView _view = new();
|
||||||
|
|
||||||
public ManagementFakeRuntime()
|
public ManagementFakeRuntime(Action requestCreate)
|
||||||
{
|
{
|
||||||
_view.Entries = [new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u)];
|
_view.Entries = [new RuntimeCharacterSelectionEntry(0, 0x50000001u, "Alpha", 0u)];
|
||||||
_view.Snapshot = new RuntimeCharacterSelectionSnapshot(
|
_view.Snapshot = new RuntimeCharacterSelectionSnapshot(
|
||||||
|
|
@ -199,7 +252,7 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
||||||
LastRestoreRequestedCharacterId: 0u,
|
LastRestoreRequestedCharacterId: 0u,
|
||||||
Operation: RuntimeCharacterSelectionOperation.None,
|
Operation: RuntimeCharacterSelectionOperation.None,
|
||||||
Error: null,
|
Error: null,
|
||||||
Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false));
|
Buttons: new RuntimeCharacterSelectionButtons(true, true, false, true, false, true));
|
||||||
Bindings = new CharacterSelectionRuntimeBindings(
|
Bindings = new CharacterSelectionRuntimeBindings(
|
||||||
View: () => _view,
|
View: () => _view,
|
||||||
Highlight: _ => Result(),
|
Highlight: _ => Result(),
|
||||||
|
|
@ -208,7 +261,8 @@ public sealed class CharacterScreensFixedCanvasArbiterTests
|
||||||
ConfirmDelete: Result,
|
ConfirmDelete: Result,
|
||||||
Restore: Result,
|
Restore: Result,
|
||||||
Cancel: Result,
|
Cancel: Result,
|
||||||
RequestExit: () => { });
|
RequestExit: () => { },
|
||||||
|
RequestCreate: requestCreate);
|
||||||
}
|
}
|
||||||
|
|
||||||
public CharacterSelectionRuntimeBindings Bindings { get; }
|
public CharacterSelectionRuntimeBindings Bindings { get; }
|
||||||
|
|
|
||||||
|
|
@ -18,5 +18,12 @@
|
||||||
</ItemGroup>
|
</ItemGroup>
|
||||||
<ItemGroup>
|
<ItemGroup>
|
||||||
<ProjectReference Include="..\..\src\AcDream.Runtime\AcDream.Runtime.csproj" />
|
<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>
|
</ItemGroup>
|
||||||
</Project>
|
</Project>
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ using AcDream.Core.CharGen;
|
||||||
using AcDream.Core.Net;
|
using AcDream.Core.Net;
|
||||||
using AcDream.Core.Net.Messages;
|
using AcDream.Core.Net.Messages;
|
||||||
using AcDream.Core.Net.Packets;
|
using AcDream.Core.Net.Packets;
|
||||||
|
using AcDream.Launcher.Core.Status;
|
||||||
using AcDream.Runtime;
|
using AcDream.Runtime;
|
||||||
using AcDream.Runtime.Session;
|
using AcDream.Runtime.Session;
|
||||||
using AcDream.Runtime.Tests.CharGen;
|
using AcDream.Runtime.Tests.CharGen;
|
||||||
|
|
@ -124,6 +125,17 @@ public sealed class LiveSessionControllerCharacterCreationTests
|
||||||
public List<RuntimeCharacterCreationIdentity> Created { get; } = [];
|
public List<RuntimeCharacterCreationIdentity> Created { get; } = [];
|
||||||
public List<RuntimeCharacterCreationRejection> Failed { 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) =>
|
public LiveSessionBinding BindSession(WorldSession session) =>
|
||||||
new(session, activateCommands: () => { }, deactivateCommands: () => { }, detachEvents: () => { });
|
new(session, activateCommands: () => { }, deactivateCommands: () => { }, detachEvents: () => { });
|
||||||
public void ResetSessionState(RuntimeGenerationToken retiringGeneration) { }
|
public void ResetSessionState(RuntimeGenerationToken retiringGeneration) { }
|
||||||
|
|
@ -134,10 +146,17 @@ public sealed class LiveSessionControllerCharacterCreationTests
|
||||||
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) =>
|
public void ApplyEnteredWorld(LiveSessionCharacterSelection selection) =>
|
||||||
EnteredWorld.Add(selection);
|
EnteredWorld.Add(selection);
|
||||||
public void DetachSession(WorldSession session) { }
|
public void DetachSession(WorldSession session) { }
|
||||||
public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity) =>
|
public void ApplyCharacterCreated(RuntimeCharacterCreationIdentity identity)
|
||||||
|
{
|
||||||
Created.Add(identity);
|
Created.Add(identity);
|
||||||
public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection) =>
|
Writer?.CharacterCreated(SessionId, identity.Guid, identity.Name);
|
||||||
|
}
|
||||||
|
public void ApplyCreationFailed(RuntimeCharacterCreationRejection rejection)
|
||||||
|
{
|
||||||
Failed.Add(rejection);
|
Failed.Add(rejection);
|
||||||
|
Writer?.CreationFailed(
|
||||||
|
SessionId, rejection.RawCode, rejection.Reason, rejection.AttemptedName);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private static LiveSessionConnectOptions LiveOptions() => new(
|
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.
|
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)
|
private static void InvokeProcessDatagram(WorldSession session, byte[] datagram)
|
||||||
{
|
{
|
||||||
MethodInfo method = typeof(WorldSession).GetMethod(
|
MethodInfo method = typeof(WorldSession).GetMethod(
|
||||||
|
|
@ -490,6 +762,105 @@ public sealed class LiveSessionControllerCharacterCreationTests
|
||||||
accountName, heritage, gender, template, strength, name, numSkills, skills);
|
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)
|
private static uint ReadU32(ReadOnlySpan<byte> body, ref int pos)
|
||||||
{
|
{
|
||||||
uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
uint value = BinaryPrimitives.ReadUInt32LittleEndian(body.Slice(pos));
|
||||||
|
|
|
||||||
|
|
@ -588,8 +588,14 @@ public sealed class LiveSessionControllerTests
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
RuntimeCharacterSelectionOperation.DeleteRequested,
|
RuntimeCharacterSelectionOperation.DeleteRequested,
|
||||||
controller.CharacterSelection.Snapshot.Operation);
|
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(
|
Assert.Equal(
|
||||||
RuntimeCharacterSelectionButtons.None,
|
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
|
||||||
controller.CharacterSelection.Snapshot.Buttons);
|
controller.CharacterSelection.Snapshot.Buttons);
|
||||||
|
|
||||||
if (acknowledgeBeforeCompletion)
|
if (acknowledgeBeforeCompletion)
|
||||||
|
|
@ -607,8 +613,10 @@ public sealed class LiveSessionControllerTests
|
||||||
? RuntimeCharacterSelectionOperation.DeleteAcknowledged
|
? RuntimeCharacterSelectionOperation.DeleteAcknowledged
|
||||||
: RuntimeCharacterSelectionOperation.DeleteRequested,
|
: RuntimeCharacterSelectionOperation.DeleteRequested,
|
||||||
controller.CharacterSelection.Snapshot.Operation);
|
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(
|
Assert.Equal(
|
||||||
RuntimeCharacterSelectionButtons.None,
|
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
|
||||||
controller.CharacterSelection.Snapshot.Buttons);
|
controller.CharacterSelection.Snapshot.Buttons);
|
||||||
Assert.True(controller.CharacterSelection.TryGet(
|
Assert.True(controller.CharacterSelection.TryGet(
|
||||||
0x50000001u,
|
0x50000001u,
|
||||||
|
|
@ -625,8 +633,10 @@ public sealed class LiveSessionControllerTests
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
RuntimeCharacterSelectionOperation.DeleteAcknowledged,
|
RuntimeCharacterSelectionOperation.DeleteAcknowledged,
|
||||||
controller.CharacterSelection.Snapshot.Operation);
|
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(
|
Assert.Equal(
|
||||||
RuntimeCharacterSelectionButtons.None,
|
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
|
||||||
controller.CharacterSelection.Snapshot.Buttons);
|
controller.CharacterSelection.Snapshot.Buttons);
|
||||||
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
|
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
|
||||||
}
|
}
|
||||||
|
|
@ -665,8 +675,10 @@ public sealed class LiveSessionControllerTests
|
||||||
? RuntimeCharacterSelectionOperation.DeleteAcknowledged
|
? RuntimeCharacterSelectionOperation.DeleteAcknowledged
|
||||||
: RuntimeCharacterSelectionOperation.DeleteRequested,
|
: RuntimeCharacterSelectionOperation.DeleteRequested,
|
||||||
controller.CharacterSelection.Snapshot.Operation);
|
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(
|
Assert.Equal(
|
||||||
RuntimeCharacterSelectionButtons.None,
|
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
|
||||||
controller.CharacterSelection.Snapshot.Buttons);
|
controller.CharacterSelection.Snapshot.Buttons);
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
RuntimeCommandStatus.Rejected,
|
RuntimeCommandStatus.Rejected,
|
||||||
|
|
@ -679,8 +691,10 @@ public sealed class LiveSessionControllerTests
|
||||||
Assert.Equal(
|
Assert.Equal(
|
||||||
RuntimeCharacterSelectionOperation.DeleteAcknowledged,
|
RuntimeCharacterSelectionOperation.DeleteAcknowledged,
|
||||||
controller.CharacterSelection.Snapshot.Operation);
|
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(
|
Assert.Equal(
|
||||||
RuntimeCharacterSelectionButtons.None,
|
RuntimeCharacterSelectionButtons.None with { CanCreate = true },
|
||||||
controller.CharacterSelection.Snapshot.Buttons);
|
controller.CharacterSelection.Snapshot.Buttons);
|
||||||
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
|
Assert.Equal([("Canonical", 1)], operations.DeleteRequests);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue